Merge branch 'main' of github.com:siddharthvaddem/openscreen into feature/color-wheel
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
APP_NAME=Openscreen
|
||||
BUNDLE_ID=com.siddharthvaddem.openscreen
|
||||
|
||||
APPLE_ID=
|
||||
TEAM_ID=
|
||||
SIGN_IDENTITY="Developer ID Application: Samir Patil ()"
|
||||
CSC_NAME="Samir Patil ()"
|
||||
|
||||
NOTARY_PROFILE=OpenScreen-notary
|
||||
APPLE_APP_SPECIFIC_PASSWORD=
|
||||
@@ -0,0 +1 @@
|
||||
* @siddharthvaddem
|
||||
+164
-12
@@ -3,6 +3,16 @@ name: Build Electron App
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
arch:
|
||||
description: 'Architecture to build'
|
||||
required: true
|
||||
default: 'both'
|
||||
type: choice
|
||||
options:
|
||||
- arm64
|
||||
- x64
|
||||
- both
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
@@ -36,38 +46,180 @@ jobs:
|
||||
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
strategy:
|
||||
matrix:
|
||||
arch: ${{ github.event.inputs.arch == 'both' && fromJSON('["arm64", "x64"]') || fromJSON(format('["{0}"]', github.event.inputs.arch)) }}
|
||||
|
||||
steps:
|
||||
# ─── Checkout ─────────────────────────────────────────────
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# ─── Setup Node.js ────────────────────────────────────────
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
# ─── Setup Python (needed by some native deps) ────────────
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
# ─── Install Dependencies ─────────────────────────────────
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install app dependencies
|
||||
run: npx electron-builder install-app-deps
|
||||
|
||||
- name: Build macOS app
|
||||
run: npm run build:mac
|
||||
# ─── Import Code Signing Certificate ──────────────────────
|
||||
# This is the KEY step that makes CI signing work.
|
||||
# We create a temporary keychain, import the .p12 cert into it,
|
||||
# and set it as the default so codesign can find it.
|
||||
- name: Import code signing certificate
|
||||
env:
|
||||
MAC_CERTIFICATE_P12: ${{ secrets.MAC_CERTIFICATE_P12 }}
|
||||
MAC_CERTIFICATE_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
|
||||
run: |
|
||||
# Create a temporary keychain
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain-db
|
||||
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
|
||||
|
||||
# Create and configure keychain
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
|
||||
# Decode and import certificate
|
||||
echo "$MAC_CERTIFICATE_P12" | base64 --decode > $RUNNER_TEMP/certificate.p12
|
||||
security import $RUNNER_TEMP/certificate.p12 \
|
||||
-k "$KEYCHAIN_PATH" \
|
||||
-P "$MAC_CERTIFICATE_PASSWORD" \
|
||||
-T /usr/bin/codesign \
|
||||
-T /usr/bin/security
|
||||
|
||||
# Allow codesign to access the keychain without UI prompt
|
||||
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
|
||||
# Add to keychain search path (makes it the default)
|
||||
security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"')
|
||||
|
||||
# Verify the identity is available
|
||||
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
|
||||
|
||||
# Clean up the .p12 file
|
||||
rm -f $RUNNER_TEMP/certificate.p12
|
||||
|
||||
# ─── Build Vite + Electron ────────────────────────────────
|
||||
- name: Build Vite + Electron
|
||||
run: npx tsc && npx vite build
|
||||
|
||||
# ─── Package with electron-builder ────────────────────────
|
||||
# electron-builder handles deep codesigning the .app bundle
|
||||
# "notarize: false" in electron-builder.json5 prevents it from
|
||||
# trying its own notarization flow
|
||||
- name: Package .app bundle
|
||||
run: npx electron-builder --mac --${{ matrix.arch }} --dir
|
||||
env:
|
||||
CSC_NAME: "Samir Patil (N26FZ4GW28)"
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload macOS build
|
||||
# ─── Read version from package.json ───────────────────────
|
||||
- name: Get version
|
||||
id: version
|
||||
run: echo "version=$(node -p 'require(\"./package.json\").version')" >> $GITHUB_OUTPUT
|
||||
|
||||
# ─── Locate the .app bundle ───────────────────────────────
|
||||
- name: Find .app bundle
|
||||
id: find_app
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
echo "=== Release directory contents ==="
|
||||
ls -laR "release/${VERSION}/" || echo "release/${VERSION}/ not found"
|
||||
echo "=== Searching for .app bundle ==="
|
||||
APP_BUNDLE=$(find "release/${VERSION}" -maxdepth 4 -name "*.app" -type d | head -n1)
|
||||
if [ -z "$APP_BUNDLE" ]; then
|
||||
echo "::error::No .app bundle found in release/${VERSION}/"
|
||||
exit 1
|
||||
fi
|
||||
echo "app_bundle=$APP_BUNDLE" >> $GITHUB_OUTPUT
|
||||
echo "Found: $APP_BUNDLE"
|
||||
|
||||
# ─── Verify .app signature ────────────────────────────────
|
||||
- name: Verify .app code signature
|
||||
run: codesign --verify --deep --strict "${{ steps.find_app.outputs.app_bundle }}"
|
||||
|
||||
# ─── Create DMG ───────────────────────────────────────────
|
||||
- name: Create DMG
|
||||
id: dmg
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
ARCH="${{ matrix.arch }}"
|
||||
DMG_NAME="Openscreen-Mac-${ARCH}-${VERSION}.dmg"
|
||||
RELEASE_DIR="release/${VERSION}"
|
||||
DMG_OUTPUT="${RELEASE_DIR}/${DMG_NAME}"
|
||||
STAGING="${RELEASE_DIR}/dmg-staging"
|
||||
|
||||
mkdir -p "$STAGING"
|
||||
cp -R "${{ steps.find_app.outputs.app_bundle }}" "$STAGING/"
|
||||
ln -s /Applications "$STAGING/Applications"
|
||||
|
||||
hdiutil create \
|
||||
-srcfolder "$STAGING" \
|
||||
-volname "Openscreen" \
|
||||
-fs HFS+ \
|
||||
-fsargs "-c c=64,a=16,e=16" \
|
||||
-format UDBZ \
|
||||
"$DMG_OUTPUT"
|
||||
|
||||
rm -rf "$STAGING"
|
||||
|
||||
echo "dmg_path=$DMG_OUTPUT" >> $GITHUB_OUTPUT
|
||||
echo "dmg_name=$DMG_NAME" >> $GITHUB_OUTPUT
|
||||
|
||||
# ─── Sign DMG ─────────────────────────────────────────────
|
||||
- name: Sign DMG
|
||||
run: |
|
||||
codesign --force \
|
||||
--sign "Developer ID Application: Samir Patil (N26FZ4GW28)" \
|
||||
--timestamp \
|
||||
"${{ steps.dmg.outputs.dmg_path }}"
|
||||
|
||||
# ─── Notarize DMG ────────────────────────────────────────
|
||||
# On CI we can't use keychain profiles for notarytool, so we
|
||||
# pass credentials directly via env vars / flags
|
||||
- name: Notarize DMG
|
||||
run: |
|
||||
xcrun notarytool submit "${{ steps.dmg.outputs.dmg_path }}" \
|
||||
--apple-id "${{ secrets.APPLE_ID }}" \
|
||||
--team-id "${{ secrets.APPLE_TEAM_ID }}" \
|
||||
--password "${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}" \
|
||||
--wait
|
||||
timeout-minutes: 15
|
||||
|
||||
# ─── Staple ───────────────────────────────────────────────
|
||||
- name: Staple notarization ticket
|
||||
run: xcrun stapler staple "${{ steps.dmg.outputs.dmg_path }}"
|
||||
|
||||
# ─── Validate ─────────────────────────────────────────────
|
||||
- name: Validate stapled DMG
|
||||
run: |
|
||||
xcrun stapler validate "${{ steps.dmg.outputs.dmg_path }}"
|
||||
spctl -a -vv -t install "${{ steps.dmg.outputs.dmg_path }}"
|
||||
|
||||
# ─── Upload Artifact ──────────────────────────────────────
|
||||
- name: Upload notarized DMG
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-installer
|
||||
path: release/**/*.dmg
|
||||
name: openscreen-mac-${{ matrix.arch }}
|
||||
path: ${{ steps.dmg.outputs.dmg_path }}
|
||||
retention-days: 30
|
||||
|
||||
# ─── Cleanup Keychain ─────────────────────────────────────
|
||||
- name: Cleanup keychain
|
||||
if: always()
|
||||
run: security delete-keychain $RUNNER_TEMP/build.keychain-db || true
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
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}`);
|
||||
}
|
||||
+11
-1
@@ -12,9 +12,11 @@ dist
|
||||
dist-electron
|
||||
dist-ssr
|
||||
*.local
|
||||
.env
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
.zed/
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
@@ -25,6 +27,7 @@ dist-ssr
|
||||
*.sw?
|
||||
release/**
|
||||
*.kiro/
|
||||
.claude/
|
||||
# npx electron-builder --mac --win
|
||||
|
||||
# Playwright
|
||||
@@ -32,4 +35,11 @@ test-results
|
||||
playwright-report/
|
||||
|
||||
# Vitest browser mode screenshots
|
||||
__screenshots__/
|
||||
__screenshots__/
|
||||
|
||||
# shell files
|
||||
/shell.sh
|
||||
# Nix
|
||||
result
|
||||
result-*
|
||||
.direnv/
|
||||
|
||||
@@ -5,12 +5,15 @@
|
||||
<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>
|
||||
|
||||
<a href="https://discord.gg/yAQQhRaEeg">
|
||||
<img src="https://img.shields.io/discord/pHAUbcqNd?logo=discord&label=Discord&color=5865F2" alt="Join Discord" />
|
||||
<img src="https://dcbadge.limes.pink/api/server/https://discord.gg/yAQQhRaEeg?style=flat" alt="Join Discord" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -93,10 +96,25 @@ 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
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.13/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.12/schema.json",
|
||||
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
|
||||
"files": { "ignoreUnknown": false, "includes": ["**", "!**/*.css"] },
|
||||
"formatter": {
|
||||
|
||||
+32
-28
@@ -20,16 +20,20 @@
|
||||
"!CONTRIBUTING.md",
|
||||
"!LICENSE"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "public/wallpapers",
|
||||
"to": "assets/wallpapers"
|
||||
}
|
||||
],
|
||||
"publish": [{"provider": "github"}],
|
||||
|
||||
"mac": {
|
||||
"hardenedRuntime": false,
|
||||
// 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",
|
||||
"target": [
|
||||
{
|
||||
"target": "dmg",
|
||||
@@ -38,13 +42,13 @@
|
||||
],
|
||||
"icon": "icons/icons/mac/icon.icns",
|
||||
"artifactName": "${productName}-Mac-${arch}-${version}-Installer.${ext}",
|
||||
"extendInfo": {
|
||||
"NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.",
|
||||
"NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.",
|
||||
"NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.",
|
||||
"NSCameraUseContinuityCameraDeviceType": true,
|
||||
"com.apple.security.device.audio-input": true
|
||||
}
|
||||
"extendInfo": {
|
||||
"NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.",
|
||||
"NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.",
|
||||
"NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.",
|
||||
"NSCameraUseContinuityCameraDeviceType": true,
|
||||
"com.apple.security.device.audio-input": true
|
||||
}
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
@@ -54,14 +58,14 @@
|
||||
"artifactName": "${productName}-Linux-${version}.${ext}",
|
||||
"category": "AudioVideo"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis"
|
||||
],
|
||||
"icon": "icons/icons/win/icon.ico"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true
|
||||
}
|
||||
}
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis"
|
||||
],
|
||||
"icon": "icons/icons/win/icon.ico"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+7
-2
@@ -37,7 +37,7 @@ interface Window {
|
||||
status: string;
|
||||
error?: string;
|
||||
}>;
|
||||
getAssetBasePath: () => Promise<string | null>;
|
||||
assetBaseUrl: string;
|
||||
storeRecordedVideo: (
|
||||
videoData: ArrayBuffer,
|
||||
fileName: string,
|
||||
@@ -63,7 +63,8 @@ interface Window {
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
setRecordingState: (recording: boolean) => Promise<void>;
|
||||
setRecordingState: (recording: boolean, recordingId?: number) => Promise<void>;
|
||||
discardCursorTelemetry: (recordingId: number) => Promise<void>;
|
||||
getCursorTelemetry: (videoPath?: string) => Promise<{
|
||||
success: boolean;
|
||||
samples: CursorTelemetryPoint[];
|
||||
@@ -135,6 +136,10 @@ 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;
|
||||
|
||||
+23
-2
@@ -7,24 +7,45 @@ 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" | "es" | "fr";
|
||||
type Locale = "en" | "zh-CN" | "zh-TW" | "es" | "fr" | "ja-JP" | "ko-KR" | "tr";
|
||||
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 },
|
||||
};
|
||||
|
||||
let currentLocale: Locale = "en";
|
||||
|
||||
export function setMainLocale(locale: string) {
|
||||
if (locale === "en" || locale === "zh-CN" || locale === "es" || locale === "fr") {
|
||||
if (
|
||||
locale === "en" ||
|
||||
locale === "zh-CN" ||
|
||||
locale === "zh-TW" ||
|
||||
locale === "es" ||
|
||||
locale === "fr" ||
|
||||
locale === "ja-JP" ||
|
||||
locale === "ko-KR" ||
|
||||
locale === "tr"
|
||||
) {
|
||||
currentLocale = locale;
|
||||
}
|
||||
}
|
||||
|
||||
+235
-46
@@ -1,6 +1,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
shell,
|
||||
systemPreferences,
|
||||
} from "electron";
|
||||
import {
|
||||
type CursorTelemetryPoint,
|
||||
createCursorTelemetryBuffer,
|
||||
} from "../../src/lib/cursorTelemetryBuffer";
|
||||
import {
|
||||
normalizeProjectMedia,
|
||||
normalizeRecordingSession,
|
||||
@@ -275,14 +279,23 @@ async function storeRecordedSessionFiles(payload: StoreRecordedSessionInput) {
|
||||
currentProjectPath = null;
|
||||
|
||||
const telemetryPath = `${screenVideoPath}.cursor.json`;
|
||||
if (pendingCursorSamples.length > 0) {
|
||||
await fs.writeFile(
|
||||
telemetryPath,
|
||||
JSON.stringify({ version: CURSOR_TELEMETRY_VERSION, samples: pendingCursorSamples }, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
const pendingBatch = cursorTelemetryBuffer.takeNextBatch();
|
||||
if (pendingBatch && pendingBatch.samples.length > 0) {
|
||||
try {
|
||||
await fs.writeFile(
|
||||
telemetryPath,
|
||||
JSON.stringify(
|
||||
{ version: CURSOR_TELEMETRY_VERSION, samples: pendingBatch.samples },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
} catch (err) {
|
||||
cursorTelemetryBuffer.prependBatch(pendingBatch);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
pendingCursorSamples = [];
|
||||
|
||||
const sessionManifestPath = path.join(
|
||||
RECORDINGS_DIR,
|
||||
@@ -302,16 +315,11 @@ const CURSOR_TELEMETRY_VERSION = 1;
|
||||
const CURSOR_SAMPLE_INTERVAL_MS = 100;
|
||||
const MAX_CURSOR_SAMPLES = 60 * 60 * 10; // 1 hour @ 10Hz
|
||||
|
||||
interface CursorTelemetryPoint {
|
||||
timeMs: number;
|
||||
cx: number;
|
||||
cy: number;
|
||||
}
|
||||
|
||||
let cursorCaptureInterval: NodeJS.Timeout | null = null;
|
||||
let cursorCaptureStartTimeMs = 0;
|
||||
let activeCursorSamples: CursorTelemetryPoint[] = [];
|
||||
let pendingCursorSamples: CursorTelemetryPoint[] = [];
|
||||
const cursorTelemetryBuffer = createCursorTelemetryBuffer({
|
||||
maxActiveSamples: MAX_CURSOR_SAMPLES,
|
||||
});
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
@@ -338,29 +346,173 @@ function sampleCursorPoint() {
|
||||
const cx = clamp((cursor.x - bounds.x) / width, 0, 1);
|
||||
const cy = clamp((cursor.y - bounds.y) / height, 0, 1);
|
||||
|
||||
activeCursorSamples.push({
|
||||
cursorTelemetryBuffer.push({
|
||||
timeMs: Math.max(0, Date.now() - cursorCaptureStartTimeMs),
|
||||
cx,
|
||||
cy,
|
||||
});
|
||||
|
||||
if (activeCursorSamples.length > MAX_CURSOR_SAMPLES) {
|
||||
activeCursorSamples.shift();
|
||||
}
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(
|
||||
createEditorWindow: () => void,
|
||||
createSourceSelectorWindow: () => BrowserWindow,
|
||||
createCountdownOverlayWindow: () => BrowserWindow,
|
||||
getMainWindow: () => BrowserWindow | null,
|
||||
getSourceSelectorWindow: () => BrowserWindow | null,
|
||||
getCountdownOverlayWindow: () => BrowserWindow | null,
|
||||
onRecordingStateChange?: (recording: boolean, sourceName: string) => void,
|
||||
switchToHud?: () => void,
|
||||
) {
|
||||
const supportsWindowOpacity = process.platform !== "linux";
|
||||
const countdownOverlayState = {
|
||||
visible: false,
|
||||
value: null as number | null,
|
||||
activeRunId: null as number | null,
|
||||
hideCommitId: 0,
|
||||
hideCommitTimer: null as ReturnType<typeof setTimeout> | null,
|
||||
};
|
||||
const COUNTDOWN_OVERLAY_HIDE_DEBOUNCE_MS = 1200;
|
||||
|
||||
const clearCountdownOverlayHideCommit = () => {
|
||||
if (countdownOverlayState.hideCommitTimer) {
|
||||
clearTimeout(countdownOverlayState.hideCommitTimer);
|
||||
countdownOverlayState.hideCommitTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const commitCountdownOverlayHide = (win: BrowserWindow, hideCommitId: number) => {
|
||||
if (win.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (countdownOverlayState.visible || countdownOverlayState.hideCommitId !== hideCommitId) {
|
||||
return;
|
||||
}
|
||||
|
||||
win.hide();
|
||||
if (supportsWindowOpacity) {
|
||||
// Reset baseline opacity for the next show cycle.
|
||||
win.setOpacity(1);
|
||||
}
|
||||
};
|
||||
|
||||
const flushCountdownOverlayState = (win: BrowserWindow) => {
|
||||
if (win.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearCountdownOverlayHideCommit();
|
||||
win.webContents.send("countdown-overlay-value", countdownOverlayState.value);
|
||||
if (!countdownOverlayState.visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (win.isVisible()) {
|
||||
if (supportsWindowOpacity) {
|
||||
win.setOpacity(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (!win.isDestroyed() && countdownOverlayState.visible && !win.isVisible()) {
|
||||
if (supportsWindowOpacity) {
|
||||
win.setOpacity(0);
|
||||
}
|
||||
win.showInactive();
|
||||
|
||||
if (supportsWindowOpacity) {
|
||||
setTimeout(() => {
|
||||
if (!win.isDestroyed() && countdownOverlayState.visible && win.isVisible()) {
|
||||
win.setOpacity(1);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
}, 16);
|
||||
};
|
||||
|
||||
ipcMain.handle("countdown-overlay-show", (_, value: number, runId: number) => {
|
||||
countdownOverlayState.activeRunId = runId;
|
||||
countdownOverlayState.visible = true;
|
||||
countdownOverlayState.value = value;
|
||||
|
||||
const win = getCountdownOverlayWindow() ?? createCountdownOverlayWindow();
|
||||
if (win.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (win.webContents.isLoading()) {
|
||||
win.webContents.once("did-finish-load", () => {
|
||||
if (!win.isDestroyed()) {
|
||||
flushCountdownOverlayState(win);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
flushCountdownOverlayState(win);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("countdown-overlay-set-value", (_, value: number, runId: number) => {
|
||||
if (countdownOverlayState.activeRunId !== runId || !countdownOverlayState.visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
countdownOverlayState.value = value;
|
||||
|
||||
const win = getCountdownOverlayWindow();
|
||||
if (!win || win.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (win.webContents.isLoading()) {
|
||||
return;
|
||||
}
|
||||
|
||||
win.webContents.send("countdown-overlay-value", value);
|
||||
});
|
||||
|
||||
ipcMain.handle("countdown-overlay-hide", (_, runId: number) => {
|
||||
if (countdownOverlayState.activeRunId !== runId) {
|
||||
return;
|
||||
}
|
||||
|
||||
countdownOverlayState.visible = false;
|
||||
countdownOverlayState.hideCommitId += 1;
|
||||
const hideCommitId = countdownOverlayState.hideCommitId;
|
||||
clearCountdownOverlayHideCommit();
|
||||
|
||||
const win = getCountdownOverlayWindow();
|
||||
if (!win || win.isDestroyed()) {
|
||||
countdownOverlayState.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (supportsWindowOpacity) {
|
||||
// Hide visually immediately to avoid hide/show compositor flashes on rapid restart.
|
||||
win.setOpacity(0);
|
||||
}
|
||||
|
||||
countdownOverlayState.value = null;
|
||||
if (!win.webContents.isLoading()) {
|
||||
win.webContents.send("countdown-overlay-value", countdownOverlayState.value);
|
||||
}
|
||||
|
||||
if (!supportsWindowOpacity) {
|
||||
win.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
countdownOverlayState.hideCommitTimer = setTimeout(() => {
|
||||
countdownOverlayState.hideCommitTimer = null;
|
||||
commitCountdownOverlayHide(win, hideCommitId);
|
||||
}, COUNTDOWN_OVERLAY_HIDE_DEBOUNCE_MS);
|
||||
});
|
||||
|
||||
ipcMain.handle("switch-to-hud", () => {
|
||||
if (switchToHud) switchToHud();
|
||||
});
|
||||
ipcMain.handle("start-new-recording", async () => {
|
||||
ipcMain.handle("start-new-recording", () => {
|
||||
try {
|
||||
setCurrentRecordingSessionState(null);
|
||||
if (switchToHud) {
|
||||
@@ -490,7 +642,24 @@ export function registerIpcHandlers(
|
||||
return { success: false, message: "No recorded video found" };
|
||||
}
|
||||
|
||||
const latestVideo = videoFiles.sort().reverse()[0];
|
||||
// Sort by most recently modified to reliably get the latest recording.
|
||||
// Lexicographic sort is unreliable (e.g. recording-9.webm > recording-10.webm).
|
||||
let latestVideo: string | null = null;
|
||||
let latestMtimeMs = 0;
|
||||
for (const file of videoFiles) {
|
||||
try {
|
||||
const stat = await fs.stat(path.join(RECORDINGS_DIR, file));
|
||||
if (stat.mtimeMs > latestMtimeMs) {
|
||||
latestMtimeMs = stat.mtimeMs;
|
||||
latestVideo = file;
|
||||
}
|
||||
} catch {
|
||||
// Skip inaccessible files.
|
||||
}
|
||||
}
|
||||
if (!latestVideo) {
|
||||
return { success: false, message: "No recorded video found" };
|
||||
}
|
||||
const videoPath = path.join(RECORDINGS_DIR, latestVideo);
|
||||
|
||||
return { success: true, path: videoPath };
|
||||
@@ -531,18 +700,21 @@ export function registerIpcHandlers(
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("set-recording-state", (_, recording: boolean) => {
|
||||
ipcMain.handle("set-recording-state", (_, recording: boolean, recordingId?: number) => {
|
||||
if (recording) {
|
||||
stopCursorCapture();
|
||||
activeCursorSamples = [];
|
||||
pendingCursorSamples = [];
|
||||
// The renderer is the source of truth for the recording id (it
|
||||
// uses the same id as the saved fileName). Fall back to a
|
||||
// timestamp only if the renderer didn't supply one, so the
|
||||
// buffer always has a stable key per session.
|
||||
const id = typeof recordingId === "number" ? recordingId : Date.now();
|
||||
cursorTelemetryBuffer.startSession(id);
|
||||
cursorCaptureStartTimeMs = Date.now();
|
||||
sampleCursorPoint();
|
||||
cursorCaptureInterval = setInterval(sampleCursorPoint, CURSOR_SAMPLE_INTERVAL_MS);
|
||||
} else {
|
||||
stopCursorCapture();
|
||||
pendingCursorSamples = [...activeCursorSamples];
|
||||
activeCursorSamples = [];
|
||||
cursorTelemetryBuffer.endSession();
|
||||
}
|
||||
|
||||
const source = selectedSource || { name: "Screen" };
|
||||
@@ -551,6 +723,10 @@ export function registerIpcHandlers(
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("discard-cursor-telemetry", (_, recordingId: number) => {
|
||||
cursorTelemetryBuffer.discardBatch(recordingId);
|
||||
});
|
||||
|
||||
ipcMain.handle("get-cursor-telemetry", async (_, videoPath?: string) => {
|
||||
const targetVideoPath = normalizeVideoSourcePath(
|
||||
videoPath ?? currentRecordingSession?.screenVideoPath,
|
||||
@@ -616,7 +792,19 @@ export function registerIpcHandlers(
|
||||
|
||||
ipcMain.handle("open-external-url", async (_, url: string) => {
|
||||
try {
|
||||
await shell.openExternal(url);
|
||||
const ALLOWED_SCHEMES = ["http:", "https:", "mailto:"];
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return { success: false, error: "Invalid URL" };
|
||||
}
|
||||
|
||||
if (!ALLOWED_SCHEMES.includes(parsed.protocol)) {
|
||||
return { success: false, error: `Unsupported URL scheme: ${parsed.protocol}` };
|
||||
}
|
||||
|
||||
await shell.openExternal(parsed.toString());
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Failed to open URL:", error);
|
||||
@@ -624,20 +812,15 @@ export function registerIpcHandlers(
|
||||
}
|
||||
});
|
||||
|
||||
// Return base path for assets so renderer can resolve file:// paths in production
|
||||
ipcMain.handle("get-asset-base-path", () => {
|
||||
try {
|
||||
if (app.isPackaged) {
|
||||
const assetPath = path.join(process.resourcesPath, "assets");
|
||||
return pathToFileURL(`${assetPath}${path.sep}`).toString();
|
||||
}
|
||||
const assetPath = path.join(app.getAppPath(), "public", "assets");
|
||||
return pathToFileURL(`${assetPath}${path.sep}`).toString();
|
||||
} catch (err) {
|
||||
console.error("Failed to resolve asset base path:", err);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
/**
|
||||
* Handles saving an exported video file.
|
||||
* Shows a save dialog, normalizes the file path for the current OS,
|
||||
* ensures the directory exists, and writes the video data.
|
||||
* @param _ - Unused event parameter.
|
||||
* @param videoData - The exported video as an ArrayBuffer.
|
||||
* @param fileName - Suggested filename for the save dialog.
|
||||
* @returns Object with success status, optional file path, and error details.
|
||||
*/
|
||||
|
||||
ipcMain.handle("save-exported-video", async (_, videoData: ArrayBuffer, fileName: string) => {
|
||||
try {
|
||||
@@ -664,11 +847,18 @@ export function registerIpcHandlers(
|
||||
};
|
||||
}
|
||||
|
||||
await fs.writeFile(result.filePath, Buffer.from(videoData));
|
||||
// --- FIX: Normalize the path for Windows compatibility ---
|
||||
const normalizedPath = path.normalize(result.filePath);
|
||||
|
||||
// Ensure the parent directory exists (Windows may fail if the folder is missing)
|
||||
await fs.mkdir(path.dirname(normalizedPath), { recursive: true });
|
||||
// --- END FIX ---
|
||||
|
||||
await fs.writeFile(normalizedPath, Buffer.from(videoData));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
path: result.filePath,
|
||||
path: normalizedPath,
|
||||
message: "Video exported successfully",
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -680,7 +870,6 @@ export function registerIpcHandlers(
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("open-video-file-picker", async () => {
|
||||
try {
|
||||
const result = await dialog.showOpenDialog({
|
||||
|
||||
+39
-8
@@ -14,7 +14,12 @@ import {
|
||||
} from "electron";
|
||||
import { mainT, setMainLocale } from "./i18n";
|
||||
import { registerIpcHandlers } from "./ipc/handlers";
|
||||
import { createEditorWindow, createHudOverlayWindow, createSourceSelectorWindow } from "./windows";
|
||||
import {
|
||||
createCountdownOverlayWindow,
|
||||
createEditorWindow,
|
||||
createHudOverlayWindow,
|
||||
createSourceSelectorWindow,
|
||||
} from "./windows";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -60,12 +65,15 @@ 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");
|
||||
const recordingTrayIcon = getTrayIcon("rec-button.png");
|
||||
const defaultTrayIcon = getTrayIcon("openscreen.png", trayIconSize);
|
||||
const recordingTrayIcon = getTrayIcon("rec-button.png", trayIconSize);
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = createHudOverlayWindow();
|
||||
@@ -199,12 +207,12 @@ function createTray() {
|
||||
});
|
||||
}
|
||||
|
||||
function getTrayIcon(filename: string) {
|
||||
function getTrayIcon(filename: string, size: number) {
|
||||
return nativeImage
|
||||
.createFromPath(path.join(process.env.VITE_PUBLIC || RENDERER_DIST, filename))
|
||||
.resize({
|
||||
width: 24,
|
||||
height: 24,
|
||||
width: size,
|
||||
height: size,
|
||||
quality: "best",
|
||||
});
|
||||
}
|
||||
@@ -320,6 +328,18 @@ function createSourceSelectorWindowWrapper() {
|
||||
return sourceSelectorWindow;
|
||||
}
|
||||
|
||||
function createCountdownOverlayWindowWrapper() {
|
||||
if (countdownOverlayWindow && !countdownOverlayWindow.isDestroyed()) {
|
||||
return countdownOverlayWindow;
|
||||
}
|
||||
|
||||
countdownOverlayWindow = createCountdownOverlayWindow();
|
||||
countdownOverlayWindow.on("closed", () => {
|
||||
countdownOverlayWindow = null;
|
||||
});
|
||||
return countdownOverlayWindow;
|
||||
}
|
||||
|
||||
// On macOS, applications and their menu bar stay active until the user quits
|
||||
// explicitly with Cmd + Q.
|
||||
app.on("window-all-closed", () => {
|
||||
@@ -329,8 +349,17 @@ app.on("window-all-closed", () => {
|
||||
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.
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -384,8 +413,10 @@ app.whenReady().then(async () => {
|
||||
registerIpcHandlers(
|
||||
createEditorWindowWrapper,
|
||||
createSourceSelectorWindowWrapper,
|
||||
createCountdownOverlayWindowWrapper,
|
||||
() => mainWindow,
|
||||
() => sourceSelectorWindow,
|
||||
() => countdownOverlayWindow,
|
||||
(recording: boolean, sourceName: string) => {
|
||||
selectedSourceName = sourceName;
|
||||
if (!tray) createTray();
|
||||
|
||||
+27
-6
@@ -1,17 +1,21 @@
|
||||
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);
|
||||
},
|
||||
@@ -47,12 +51,15 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
getRecordedVideoPath: () => {
|
||||
return ipcRenderer.invoke("get-recorded-video-path");
|
||||
},
|
||||
setRecordingState: (recording: boolean) => {
|
||||
return ipcRenderer.invoke("set-recording-state", recording);
|
||||
setRecordingState: (recording: boolean, recordingId?: number) => {
|
||||
return ipcRenderer.invoke("set-recording-state", recording, recordingId);
|
||||
},
|
||||
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);
|
||||
@@ -130,6 +137,20 @@ 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 {
|
||||
|
||||
+89
-1
@@ -1,5 +1,5 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { BrowserWindow, ipcMain, screen } from "electron";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
@@ -9,6 +9,13 @@ 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", () => {
|
||||
@@ -17,6 +24,11 @@ ipcMain.on("hud-overlay-hide", () => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates the always-on-top HUD overlay window centred at the bottom of the
|
||||
* primary display. The window is frameless, transparent, and follows the user
|
||||
* across macOS Spaces so it is never lost when switching virtual desktops.
|
||||
*/
|
||||
export function createHudOverlayWindow(): BrowserWindow {
|
||||
const primaryDisplay = screen.getPrimaryDisplay();
|
||||
const { workArea } = primaryDisplay;
|
||||
@@ -45,12 +57,19 @@ 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());
|
||||
});
|
||||
@@ -74,6 +93,10 @@ export function createHudOverlayWindow(): BrowserWindow {
|
||||
return win;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the main editor window. Starts maximised with a hidden title bar on
|
||||
* macOS. This window is not always-on-top and appears in the taskbar/dock.
|
||||
*/
|
||||
export function createEditorWindow(): BrowserWindow {
|
||||
const isMac = process.platform === "darwin";
|
||||
|
||||
@@ -95,6 +118,7 @@ export function createEditorWindow(): BrowserWindow {
|
||||
show: !HEADLESS,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.mjs"),
|
||||
additionalArguments: [ASSET_BASE_URL_ARG],
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
webSecurity: false,
|
||||
@@ -120,6 +144,10 @@ export function createEditorWindow(): BrowserWindow {
|
||||
return win;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the floating source-selector window used to pick a screen or window
|
||||
* to record. Frameless, transparent, and follows the user across macOS Spaces.
|
||||
*/
|
||||
export function createSourceSelectorWindow(): BrowserWindow {
|
||||
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
|
||||
|
||||
@@ -137,11 +165,18 @@ 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 {
|
||||
@@ -152,3 +187,56 @@ 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
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1775710090,
|
||||
"narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "4c1018dae018162ec878d42fec712642d214fdfa",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
{
|
||||
description = "OpenScreen — desktop screen recorder with built-in editor";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{ self, nixpkgs }:
|
||||
let
|
||||
systems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
];
|
||||
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
|
||||
in
|
||||
{
|
||||
# -- Per-system outputs (packages, dev shells) --
|
||||
|
||||
packages = forAllSystems (pkgs: {
|
||||
openscreen = pkgs.callPackage ./nix/package.nix { };
|
||||
default = self.packages.${pkgs.stdenv.hostPlatform.system}.openscreen;
|
||||
});
|
||||
|
||||
devShells = forAllSystems (
|
||||
pkgs:
|
||||
let
|
||||
electron = pkgs.electron;
|
||||
|
||||
# Libraries Electron needs at runtime on Linux
|
||||
runtimeLibs = with pkgs; [
|
||||
# X11
|
||||
libx11
|
||||
libxcomposite
|
||||
libxdamage
|
||||
libxext
|
||||
libxfixes
|
||||
libxrandr
|
||||
libxtst
|
||||
libxcb
|
||||
libxshmfence
|
||||
|
||||
# Wayland
|
||||
wayland
|
||||
|
||||
# GTK / UI toolkit
|
||||
gtk3
|
||||
glib
|
||||
pango
|
||||
cairo
|
||||
gdk-pixbuf
|
||||
atk
|
||||
at-spi2-atk
|
||||
at-spi2-core
|
||||
|
||||
# Graphics
|
||||
mesa
|
||||
libGL
|
||||
libdrm
|
||||
vulkan-loader
|
||||
|
||||
# Networking / crypto (NSS for Chromium)
|
||||
nss
|
||||
nspr
|
||||
|
||||
# Audio
|
||||
alsa-lib
|
||||
pipewire
|
||||
pulseaudio
|
||||
|
||||
# System
|
||||
dbus
|
||||
cups
|
||||
expat
|
||||
libnotify
|
||||
libsecret
|
||||
util-linux # libuuid
|
||||
];
|
||||
in
|
||||
{
|
||||
default = pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
nodejs_22
|
||||
electron
|
||||
|
||||
# Native module compilation
|
||||
python3
|
||||
pkg-config
|
||||
gcc
|
||||
|
||||
# Playwright browser tests
|
||||
playwright-driver.browsers
|
||||
];
|
||||
|
||||
# Electron's prebuilt binary needs these at runtime
|
||||
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath runtimeLibs;
|
||||
|
||||
# Tell the npm `electron` package to use the Nix-provided binary
|
||||
# instead of downloading its own. vite-plugin-electron respects this.
|
||||
ELECTRON_OVERRIDE_DIST_PATH = "${electron}/libexec/electron";
|
||||
|
||||
# Playwright browser path for test:browser / test:e2e
|
||||
PLAYWRIGHT_BROWSERS_PATH = "${pkgs.playwright-driver.browsers}";
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
|
||||
|
||||
shellHook = ''
|
||||
echo "OpenScreen dev shell — node $(node --version), electron v$(electron --version 2>/dev/null | tr -d 'v')"
|
||||
'';
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
# -- System-wide outputs (modules, overlay) --
|
||||
|
||||
overlays.default = final: _prev: {
|
||||
openscreen = self.packages.${final.stdenv.hostPlatform.system}.openscreen;
|
||||
};
|
||||
|
||||
nixosModules.default = import ./nix/module.nix self;
|
||||
homeManagerModules.default = import ./nix/hm-module.nix self;
|
||||
};
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,25 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,36 @@
|
||||
# Home Manager module for OpenScreen
|
||||
# Usage in flake-based Home Manager config:
|
||||
#
|
||||
# inputs.openscreen.url = "github:siddharthvaddem/openscreen";
|
||||
#
|
||||
# { inputs, ... }: {
|
||||
# imports = [ inputs.openscreen.homeManagerModules.default ];
|
||||
# programs.openscreen.enable = true;
|
||||
# }
|
||||
self:
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
|
||||
let
|
||||
cfg = config.programs.openscreen;
|
||||
in
|
||||
{
|
||||
options.programs.openscreen = {
|
||||
enable = lib.mkEnableOption "OpenScreen screen recorder";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = self.packages.${pkgs.stdenv.hostPlatform.system}.openscreen;
|
||||
defaultText = lib.literalExpression "inputs.openscreen.packages.\${pkgs.stdenv.hostPlatform.system}.openscreen";
|
||||
description = "The OpenScreen package to use.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
home.packages = [ cfg.package ];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# NixOS module for OpenScreen
|
||||
# Usage in flake-based NixOS config:
|
||||
#
|
||||
# inputs.openscreen.url = "github:siddharthvaddem/openscreen";
|
||||
#
|
||||
# { inputs, ... }: {
|
||||
# imports = [ inputs.openscreen.nixosModules.default ];
|
||||
# programs.openscreen.enable = true;
|
||||
# }
|
||||
self:
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
|
||||
let
|
||||
cfg = config.programs.openscreen;
|
||||
in
|
||||
{
|
||||
options.programs.openscreen = {
|
||||
enable = lib.mkEnableOption "OpenScreen screen recorder";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = self.packages.${pkgs.stdenv.hostPlatform.system}.openscreen;
|
||||
defaultText = lib.literalExpression "inputs.openscreen.packages.\${pkgs.stdenv.hostPlatform.system}.openscreen";
|
||||
description = "The OpenScreen package to use.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
environment.systemPackages = [ cfg.package ];
|
||||
|
||||
# Screen capture on Wayland requires xdg-desktop-portal.
|
||||
# We enable the base portal; users should also enable a
|
||||
# desktop-specific portal (e.g. xdg-desktop-portal-gtk,
|
||||
# xdg-desktop-portal-hyprland) in their DE config.
|
||||
xdg.portal.enable = lib.mkDefault true;
|
||||
};
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
{
|
||||
lib,
|
||||
buildNpmPackage,
|
||||
nodejs_22,
|
||||
electron,
|
||||
makeWrapper,
|
||||
makeDesktopItem,
|
||||
copyDesktopItems,
|
||||
}:
|
||||
|
||||
buildNpmPackage {
|
||||
nodejs = nodejs_22;
|
||||
pname = "openscreen";
|
||||
version = "1.3.0";
|
||||
|
||||
src =
|
||||
let
|
||||
fs = lib.fileset;
|
||||
# gitTracked fails when source is already a store path (path: flake inputs).
|
||||
# Detect this and fall back to cleanSource which handles both cases.
|
||||
isStorePath = builtins.storeDir == builtins.substring 0 (builtins.stringLength builtins.storeDir) (toString ../.);
|
||||
baseFiles = if isStorePath then fs.fromSource (lib.cleanSource ../.) else fs.gitTracked ../.;
|
||||
in
|
||||
fs.toSource {
|
||||
root = ../.;
|
||||
fileset = fs.difference baseFiles (
|
||||
fs.unions [
|
||||
../nix
|
||||
../flake.nix
|
||||
../flake.lock
|
||||
(fs.fileFilter (file: file.hasExt "md") ../.)
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
npmDepsHash = "sha256-Pd6J9TuggA9vM4s/LjdoK4MoBEivSzAWc/G2+pFOM2U=";
|
||||
|
||||
env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
|
||||
|
||||
# electron-builder is not needed — we wrap system electron directly
|
||||
npmFlags = [ "--ignore-scripts" ];
|
||||
makeCacheWritable = true;
|
||||
|
||||
# vite-plugin-electron compiles electron/ sources into dist-electron/
|
||||
# tsconfig has noEmit — tsc is type-check only
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
npx vite build
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p "$out/lib/openscreen"
|
||||
|
||||
# Renderer build output (index.html, JS chunks, copied public/ assets)
|
||||
cp -r dist "$out/lib/openscreen/"
|
||||
|
||||
# Main process + preload (compiled by vite-plugin-electron)
|
||||
cp -r dist-electron "$out/lib/openscreen/"
|
||||
|
||||
# Package manifest (electron reads "main" field to find entry point)
|
||||
cp package.json "$out/lib/openscreen/"
|
||||
|
||||
# Strip devDependencies (electron, vitest, biome, playwright, etc.)
|
||||
npm prune --omit=dev --no-save
|
||||
cp -r node_modules "$out/lib/openscreen/"
|
||||
|
||||
# Asset resolution: when app.isPackaged is false, the main process resolves
|
||||
# assets at <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;
|
||||
};
|
||||
}
|
||||
Generated
+2044
-7126
File diff suppressed because it is too large
Load Diff
+37
-39
@@ -40,70 +40,68 @@
|
||||
"@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.3",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@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.9.2",
|
||||
"@uiw/react-color-block": "^2.9.2",
|
||||
"@uiw/color-convert": "^2.10.1",
|
||||
"@uiw/react-color-block": "^2.10.1",
|
||||
"@uiw/react-color-colorful": "^2.9.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dnd-timeline": "^2.2.0",
|
||||
"emoji-picker-react": "^4.16.1",
|
||||
"dnd-timeline": "^2.4.0",
|
||||
"emoji-picker-react": "^4.18.0",
|
||||
"fix-webm-duration": "^1.0.6",
|
||||
"gif.js": "^0.2.0",
|
||||
"gsap": "^3.13.0",
|
||||
"gsap": "^3.15.0",
|
||||
"lucide-react": "^0.545.0",
|
||||
"mediabunny": "^1.25.1",
|
||||
"motion": "^12.23.24",
|
||||
"mp4box": "^2.2.0",
|
||||
"mediabunny": "^1.40.1",
|
||||
"motion": "^12.38.0",
|
||||
"mp4box": "^2.3.0",
|
||||
"pixi-filters": "^6.1.5",
|
||||
"pixi.js": "^8.14.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"pixi.js": "^8.18.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-rnd": "^10.5.2",
|
||||
"react-rnd": "^10.5.3",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uuid": "^13.0.0",
|
||||
"web-demuxer": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.13",
|
||||
"@biomejs/biome": "^2.4.12",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@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",
|
||||
"@vitest/browser": "^4.0.16",
|
||||
"@vitest/browser-playwright": "^4.0.16",
|
||||
"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",
|
||||
"@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",
|
||||
"husky": "^9.1.7",
|
||||
"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"
|
||||
"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"
|
||||
},
|
||||
"main": "dist-electron/main.js",
|
||||
"lint-staged": {
|
||||
|
||||
Executable
+216
@@ -0,0 +1,216 @@
|
||||
#!/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 ""
|
||||
+10
-5
@@ -1,17 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Validates that all locale translation files have identical key structures.
|
||||
* Compares zh-CN and es against the en baseline for every namespace.
|
||||
* Compares all locale folders (except en) 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", "tr"];
|
||||
|
||||
function getKeys(obj, prefix = "") {
|
||||
const keys = [];
|
||||
@@ -34,12 +32,19 @@ 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 COMPARE_LOCALES) {
|
||||
for (const locale of compareLocales) {
|
||||
const localePath = path.join(LOCALES_DIR, locale, `${namespace}.json`);
|
||||
|
||||
if (!fs.existsSync(localePath)) {
|
||||
@@ -77,6 +82,6 @@ if (hasErrors) {
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(
|
||||
`i18n check PASSED — all ${COMPARE_LOCALES.length} locales match ${BASE_LOCALE} across ${namespaces.length} namespaces.`,
|
||||
`i18n check PASSED — all ${compareLocales.length} locales match ${BASE_LOCALE} across ${namespaces.length} namespaces.`,
|
||||
);
|
||||
}
|
||||
|
||||
+14
-5
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
@@ -9,18 +10,24 @@ import { ShortcutsProvider } from "./contexts/ShortcutsContext";
|
||||
import { loadAllCustomFonts } from "./lib/customFonts";
|
||||
|
||||
export default function App() {
|
||||
const [windowType, setWindowType] = useState("");
|
||||
const [windowType, setWindowType] = useState(
|
||||
() => new URLSearchParams(window.location.search).get("windowType") || "",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const type = params.get("windowType") || "";
|
||||
setWindowType(type);
|
||||
if (type === "hud-overlay" || type === "source-selector") {
|
||||
const type = new URLSearchParams(window.location.search).get("windowType") || "";
|
||||
if (type !== windowType) {
|
||||
setWindowType(type);
|
||||
}
|
||||
|
||||
if (type === "hud-overlay" || type === "source-selector" || type === "countdown-overlay") {
|
||||
document.body.style.background = "transparent";
|
||||
document.documentElement.style.background = "transparent";
|
||||
document.getElementById("root")?.style.setProperty("background", "transparent");
|
||||
}
|
||||
}, [windowType]);
|
||||
|
||||
useEffect(() => {
|
||||
// Load custom fonts on app initialization
|
||||
loadAllCustomFonts().catch((error) => {
|
||||
console.error("Failed to load custom fonts:", error);
|
||||
@@ -33,6 +40,8 @@ export default function App() {
|
||||
return <LaunchWindow />;
|
||||
case "source-selector":
|
||||
return <SourceSelector />;
|
||||
case "countdown-overlay":
|
||||
return <CountdownOverlay />;
|
||||
case "editor":
|
||||
return (
|
||||
<ShortcutsProvider>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
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,3 +6,78 @@
|
||||
.electronNoDrag {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.languageMenuScroll {
|
||||
max-height: 16rem;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior: contain;
|
||||
touch-action: pan-y;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.languageMenuScroll::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.languageMenuScroll::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.languageMenuScroll::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.35), rgba(255, 255, 255, 0.2));
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.languageMenuScroll::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.3));
|
||||
}
|
||||
|
||||
.languageMenuContainer {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.languageMenuPanel {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 12rem;
|
||||
padding: 0.375rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: linear-gradient(160deg, rgba(28, 29, 42, 0.98), rgba(18, 19, 28, 0.98));
|
||||
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(14px);
|
||||
pointer-events: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.languageMenuItem {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.languageMenuItem:hover,
|
||||
.languageMenuItem:focus-visible {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #ffffff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.languageMenuItemActive {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ChevronDown, Languages } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Check, ChevronDown, Languages } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs";
|
||||
import { FaRegStopCircle } from "react-icons/fa";
|
||||
import { FaFolderOpen } from "react-icons/fa6";
|
||||
@@ -18,9 +19,7 @@ import {
|
||||
} from "react-icons/md";
|
||||
import { RxDragHandleDots2 } from "react-icons/rx";
|
||||
import { useI18n, useScopedT } from "@/contexts/I18nContext";
|
||||
import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config";
|
||||
import { getLocaleName } from "@/i18n/loader";
|
||||
import { isMac as getIsMac } from "@/utils/platformUtils";
|
||||
import { getAvailableLocales, getLocaleName } from "@/i18n/loader";
|
||||
import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
|
||||
import { useCameraDevices } from "../../hooks/useCameraDevices";
|
||||
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
|
||||
@@ -28,6 +27,7 @@ import { useScreenRecorder } from "../../hooks/useScreenRecorder";
|
||||
import { requestCameraAccess } from "../../lib/requestCameraAccess";
|
||||
import { formatTimePadded } from "../../utils/timeUtils";
|
||||
import { AudioLevelMeter } from "../ui/audio-level-meter";
|
||||
import { Button } from "../ui/button";
|
||||
import { Tooltip } from "../ui/tooltip";
|
||||
import styles from "./LaunchWindow.module.css";
|
||||
|
||||
@@ -67,17 +67,26 @@ const hudGroupClasses =
|
||||
const hudIconBtnClasses =
|
||||
"flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer text-white hover:bg-white/10 hover:scale-[1.08] active:scale-95";
|
||||
|
||||
const hudAuxIconBtnClasses =
|
||||
"flex items-center justify-center p-1.5 rounded-full transition-colors duration-150 text-white/55 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed";
|
||||
|
||||
const windowBtnClasses =
|
||||
"flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08]";
|
||||
|
||||
const hudSidebarClasses = "ml-0.5 pl-1.5 border-l border-white/10 flex items-center gap-0.5";
|
||||
|
||||
export function LaunchWindow() {
|
||||
const t = useScopedT("launch");
|
||||
const { locale, setLocale } = useI18n();
|
||||
const [isMac, setIsMac] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getIsMac().then(setIsMac);
|
||||
}, []);
|
||||
const availableLocales = getAvailableLocales();
|
||||
const {
|
||||
locale,
|
||||
setLocale,
|
||||
systemLocaleSuggestion,
|
||||
acceptSystemLocaleSuggestion,
|
||||
dismissSystemLocaleSuggestion,
|
||||
resolveSystemLocaleSuggestion,
|
||||
} = useI18n();
|
||||
const suggestedLanguageName = systemLocaleSuggestion ? getLocaleName(systemLocaleSuggestion) : "";
|
||||
|
||||
const {
|
||||
recording,
|
||||
@@ -109,6 +118,18 @@ export function LaunchWindow() {
|
||||
const [isWebcamHovered, setIsWebcamHovered] = useState(false);
|
||||
const [isWebcamFocused, setIsWebcamFocused] = useState(false);
|
||||
const webcamExpanded = isWebcamHovered || isWebcamFocused;
|
||||
const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false);
|
||||
const languageTriggerRef = useRef<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,
|
||||
@@ -162,6 +183,71 @@ export function LaunchWindow() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLanguageMenuOpen) return;
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node;
|
||||
const clickedTrigger = languageTriggerRef.current?.contains(target);
|
||||
const clickedMenu = languageMenuPanelRef.current?.contains(target);
|
||||
if (!clickedTrigger && !clickedMenu) {
|
||||
setIsLanguageMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setIsLanguageMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("pointerdown", handlePointerDown);
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("pointerdown", handlePointerDown);
|
||||
window.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [isLanguageMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLanguageMenuOpen || !languageTriggerRef.current) return;
|
||||
|
||||
const updatePosition = () => {
|
||||
if (!languageTriggerRef.current) return;
|
||||
const rect = languageTriggerRef.current.getBoundingClientRect();
|
||||
const gap = 8;
|
||||
const viewportPadding = 8;
|
||||
const availableHeight = Math.max(80, rect.top - viewportPadding - gap);
|
||||
const top = Math.max(viewportPadding, rect.top - gap - availableHeight);
|
||||
|
||||
setLanguageMenuStyle({
|
||||
right: Math.max(viewportPadding, window.innerWidth - rect.right),
|
||||
top,
|
||||
maxHeight: availableHeight,
|
||||
});
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
window.addEventListener("resize", updatePosition);
|
||||
window.addEventListener("scroll", updatePosition, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", updatePosition);
|
||||
window.removeEventListener("scroll", updatePosition, true);
|
||||
};
|
||||
}, [isLanguageMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLanguageMenuOpen || !languageMenuPanelRef.current) return;
|
||||
const id = requestAnimationFrame(() => {
|
||||
if (languageMenuPanelRef.current) {
|
||||
languageMenuPanelRef.current.scrollTop = 0;
|
||||
}
|
||||
});
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [isLanguageMenuOpen]);
|
||||
|
||||
const [selectedSource, setSelectedSource] = useState("Screen");
|
||||
const [hasSelectedSource, setHasSelectedSource] = useState(false);
|
||||
|
||||
@@ -228,25 +314,42 @@ export function LaunchWindow() {
|
||||
};
|
||||
|
||||
return (
|
||||
<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={`w-screen h-screen overflow-x-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}`}
|
||||
>
|
||||
{SUPPORTED_LOCALES.map((loc) => (
|
||||
<option key={loc} value={loc} className="bg-[#1c1c24] text-white">
|
||||
{getLocaleName(loc)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Device selectors — fixed above HUD bar, viewport-relative, never clipped */}
|
||||
{(showMicControls || showWebcamControls) && (
|
||||
@@ -423,6 +526,7 @@ export function LaunchWindow() {
|
||||
onClick={async () => {
|
||||
await setWebcamEnabled(!webcamEnabled);
|
||||
}}
|
||||
disabled={recording}
|
||||
title={webcamEnabled ? t("webcam.disableWebcam") : t("webcam.enableWebcam")}
|
||||
>
|
||||
{webcamEnabled
|
||||
@@ -433,104 +537,151 @@ export function LaunchWindow() {
|
||||
|
||||
{/* Record/Stop group */}
|
||||
<button
|
||||
className={`flex items-center gap-0.5 rounded-full p-2 transition-colors duration-150 ${styles.electronNoDrag} ${
|
||||
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} ${
|
||||
recording
|
||||
? paused
|
||||
? "bg-amber-500/10 hover:bg-amber-500/15"
|
||||
: "animate-record-pulse bg-red-500/10"
|
||||
: "bg-red-500/12 hover:bg-red-500/16"
|
||||
: "bg-white/5 hover:bg-white/[0.08]"
|
||||
}`}
|
||||
onClick={toggleRecording}
|
||||
disabled={!hasSelectedSource && !recording}
|
||||
style={{ flex: "0 0 auto" }}
|
||||
>
|
||||
{recording ? (
|
||||
<>
|
||||
{getIcon("stop", paused ? "text-amber-400" : "text-red-400")}
|
||||
<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 && (
|
||||
<span
|
||||
className={`${paused ? "text-amber-400" : "text-red-400"} text-xs font-semibold tabular-nums`}
|
||||
className={`${paused ? "text-amber-400" : "text-red-400"} inline-block w-[34px] text-left text-xs font-semibold tabular-nums`}
|
||||
>
|
||||
{formatTimePadded(elapsedSeconds)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
getIcon("record", hasSelectedSource ? "text-white/80" : "text-white/30")
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{recording && (
|
||||
<Tooltip content={paused ? t("tooltips.resumeRecording") : t("tooltips.pauseRecording")}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={togglePaused}
|
||||
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
|
||||
<Tooltip
|
||||
content={paused ? t("tooltips.resumeRecording") : t("tooltips.pauseRecording")}
|
||||
>
|
||||
{getIcon(paused ? "resume" : "pause", paused ? "text-amber-400" : "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Restart recording */}
|
||||
{recording && (
|
||||
<Tooltip content={t("tooltips.restartRecording")}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={restartRecording}
|
||||
>
|
||||
{getIcon("restart", "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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Cancel recording */}
|
||||
{recording && (
|
||||
<Tooltip content={t("tooltips.cancelRecording")}>
|
||||
{/* Right sidebar controls */}
|
||||
<div className={`${hudSidebarClasses} ${styles.electronNoDrag}`}>
|
||||
<div className={`${styles.languageMenuContainer} ${styles.electronNoDrag}`}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={cancelRecording}
|
||||
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}`}
|
||||
>
|
||||
{getIcon("cancel", "text-white/60")}
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<Languages size={13} className="text-white/75" />
|
||||
</div>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Open video file */}
|
||||
<Tooltip content={t("tooltips.openVideoFile")}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={openVideoFile}
|
||||
disabled={recording}
|
||||
>
|
||||
{getIcon("videoFile", "text-white/60")}
|
||||
</button>
|
||||
</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 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>
|
||||
{/* 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,15 +2,21 @@
|
||||
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: 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);
|
||||
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);
|
||||
}
|
||||
|
||||
.sourceCard {
|
||||
border-radius: 12px;
|
||||
corner-shape: squircle;
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(120deg, rgba(38, 38, 48, 0.98) 0%, rgba(24, 24, 32, 0.96) 100%);
|
||||
border: 1px solid rgba(60, 60, 80, 0.22);
|
||||
box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.18);
|
||||
@@ -28,7 +34,7 @@
|
||||
}
|
||||
|
||||
.selected {
|
||||
border: 2px solid #34b27b;
|
||||
border: 1.5px 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),
|
||||
@@ -70,30 +76,27 @@
|
||||
}
|
||||
|
||||
/* scrollbar */
|
||||
.sourceGridScroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(52, 178, 123, 0.5) rgba(40, 40, 50, 0.6);
|
||||
}
|
||||
|
||||
.sourceGridScroll::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
width: 3px;
|
||||
}
|
||||
|
||||
.sourceGridScroll::-webkit-scrollbar-track {
|
||||
background: rgba(30, 30, 38, 0.5);
|
||||
background: rgba(30, 30, 38, 0.3);
|
||||
border-radius: 4px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.sourceGridScroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(80, 80, 100, 0.6);
|
||||
border-radius: 4px;
|
||||
background: rgba(52, 178, 123, 0.5);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export function SourceSelector() {
|
||||
style={{ minHeight: "100vh" }}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[#34B27B] mx-auto mb-2" />
|
||||
<div className="animate-spin duration-500 rounded-[50%] h-6 w-6 border-2 border-b-transparent 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-lg"
|
||||
className="w-full aspect-video object-cover rounded-xl [corner-shape:squircle] "
|
||||
/>
|
||||
{isSelected && (
|
||||
<div className="absolute -top-1.5 -right-1.5">
|
||||
<div className="absolute -top-1 -right-1">
|
||||
<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-full">
|
||||
<TabsList className="grid grid-cols-2 mb-3 bg-white/5 rounded-[14px] squircle ">
|
||||
<TabsTrigger
|
||||
value="screens"
|
||||
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-full text-xs py-1 transition-all"
|
||||
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"
|
||||
>
|
||||
{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-full text-xs py-1 transition-all"
|
||||
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"
|
||||
>
|
||||
{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 pr-1 auto-rows-min ${styles.sourceGridScroll}`}
|
||||
className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pt-1 pr-1.5 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 pr-1 auto-rows-min ${styles.sourceGridScroll}`}
|
||||
className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pt-1 pr-1.5 auto-rows-min ${styles.sourceGridScroll}`}
|
||||
>
|
||||
{windowSources.map(renderSourceCard)}
|
||||
</div>
|
||||
@@ -143,18 +143,18 @@ export function SourceSelector() {
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="p-3 flex justify-center gap-2">
|
||||
<div className="p-3 justify-center flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => window.close()}
|
||||
className="px-5 py-1 text-xs text-zinc-400 hover:text-white hover:bg-white/5 rounded-full"
|
||||
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"
|
||||
>
|
||||
{tc("actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleShare}
|
||||
disabled={!selectedSource}
|
||||
className="px-5 py-1 text-xs bg-[#34B27B] text-white hover:bg-[#34B27B]/80 disabled:opacity-30 disabled:bg-zinc-700 rounded-full"
|
||||
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"
|
||||
>
|
||||
{tc("actions.share")}
|
||||
</Button>
|
||||
|
||||
@@ -52,4 +52,4 @@ const AccordionContent = React.forwardRef<
|
||||
));
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
|
||||
|
||||
@@ -52,4 +52,4 @@ const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDiv
|
||||
);
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
|
||||
|
||||
@@ -90,13 +90,13 @@ DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
|
||||
@@ -54,9 +54,11 @@ DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayNam
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
|
||||
portalled?: boolean;
|
||||
}
|
||||
>(({ className, sideOffset = 4, portalled = true, ...props }, ref) => {
|
||||
const content = (
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
@@ -67,8 +69,14 @@ const DropdownMenuContent = React.forwardRef<
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
);
|
||||
|
||||
if (!portalled) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return <DropdownMenuPrimitive.Portal>{content}</DropdownMenuPrimitive.Portal>;
|
||||
});
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
@@ -169,18 +177,18 @@ DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuTrigger,
|
||||
};
|
||||
|
||||
@@ -57,4 +57,4 @@ function PopoverArrow({
|
||||
);
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverArrow };
|
||||
export { Popover, PopoverAnchor, PopoverArrow, PopoverContent, PopoverTrigger };
|
||||
|
||||
@@ -62,34 +62,50 @@ SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayNam
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
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
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> & {
|
||||
showScrollButtons?: boolean;
|
||||
viewportClassName?: string;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
showScrollButtons = true,
|
||||
viewportClassName,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"p-1",
|
||||
"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" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
{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>
|
||||
),
|
||||
);
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
@@ -141,13 +157,13 @@ SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
|
||||
@@ -50,4 +50,4 @@ const TabsContent = React.forwardRef<
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
export { Tabs, TabsContent, TabsList, TabsTrigger };
|
||||
|
||||
@@ -67,4 +67,4 @@ function Tooltip({
|
||||
);
|
||||
}
|
||||
|
||||
export { TooltipProvider, TooltipRoot, TooltipTrigger, TooltipContent, Tooltip };
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger };
|
||||
|
||||
@@ -1,8 +1,40 @@
|
||||
import { useRef } from "react";
|
||||
import { type CSSProperties, type PointerEvent, useEffect, useRef, useState } from "react";
|
||||
import { Rnd } from "react-rnd";
|
||||
import {
|
||||
getBlurOverlayColor,
|
||||
getMosaicGridOverlayColor,
|
||||
getNormalizedMosaicBlockSize,
|
||||
} from "@/lib/blurEffects";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getArrowComponent } from "./ArrowSvgs";
|
||||
import type { AnnotationRegion } from "./types";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type BlurData,
|
||||
DEFAULT_BLUR_BLOCK_SIZE,
|
||||
DEFAULT_BLUR_DATA,
|
||||
DEFAULT_BLUR_INTENSITY,
|
||||
} from "./types";
|
||||
|
||||
const FREEHAND_POINT_THRESHOLD = 1;
|
||||
type PreviewCanvasSource = {
|
||||
width: number;
|
||||
height: number;
|
||||
clientWidth?: number;
|
||||
clientHeight?: number;
|
||||
};
|
||||
|
||||
function buildBlurPolygonClipPath(points: Array<{ x: number; y: number }>) {
|
||||
if (points.length < 3) return undefined;
|
||||
const polygon = points.map((point) => `${point.x}% ${point.y}%`).join(", ");
|
||||
return `polygon(${polygon})`;
|
||||
}
|
||||
|
||||
function buildBlurFreehandPath(points: Array<{ x: number; y: number }>, closed = true) {
|
||||
if (closed ? points.length < 3 : points.length < 2) return null;
|
||||
const [firstPoint, ...rest] = points;
|
||||
const path = `M ${firstPoint.x} ${firstPoint.y} ${rest.map((point) => `L ${point.x} ${point.y}`).join(" ")}`;
|
||||
return closed ? `${path} Z` : path;
|
||||
}
|
||||
|
||||
interface AnnotationOverlayProps {
|
||||
annotation: AnnotationRegion;
|
||||
@@ -11,9 +43,13 @@ interface AnnotationOverlayProps {
|
||||
containerHeight: number;
|
||||
onPositionChange: (id: string, position: { x: number; y: number }) => void;
|
||||
onSizeChange: (id: string, size: { width: number; height: number }) => void;
|
||||
onBlurDataChange?: (id: string, blurData: BlurData) => void;
|
||||
onBlurDataCommit?: () => void;
|
||||
onClick: (id: string) => void;
|
||||
zIndex: number;
|
||||
isSelectedBoost: boolean; // Boost z-index when selected for easy editing
|
||||
previewSourceCanvas?: PreviewCanvasSource | null;
|
||||
previewFrameVersion?: number;
|
||||
}
|
||||
|
||||
export function AnnotationOverlay({
|
||||
@@ -23,16 +59,130 @@ export function AnnotationOverlay({
|
||||
containerHeight,
|
||||
onPositionChange,
|
||||
onSizeChange,
|
||||
onBlurDataChange,
|
||||
onBlurDataCommit,
|
||||
onClick,
|
||||
zIndex,
|
||||
isSelectedBoost,
|
||||
previewSourceCanvas,
|
||||
previewFrameVersion,
|
||||
}: AnnotationOverlayProps) {
|
||||
const x = (annotation.position.x / 100) * containerWidth;
|
||||
const y = (annotation.position.y / 100) * containerHeight;
|
||||
const width = (annotation.size.width / 100) * containerWidth;
|
||||
const height = (annotation.size.height / 100) * containerHeight;
|
||||
|
||||
const committedX = (annotation.position.x / 100) * containerWidth;
|
||||
const committedY = (annotation.position.y / 100) * containerHeight;
|
||||
const committedWidth = (annotation.size.width / 100) * containerWidth;
|
||||
const committedHeight = (annotation.size.height / 100) * containerHeight;
|
||||
const blurShape = annotation.type === "blur" ? (annotation.blurData?.shape ?? "rectangle") : null;
|
||||
const isSelectedFreehandBlur = isSelected && blurShape === "freehand";
|
||||
const isDraggingRef = useRef(false);
|
||||
const isDrawingFreehandRef = useRef(false);
|
||||
const freehandPointsRef = useRef<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";
|
||||
@@ -43,6 +193,95 @@ 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":
|
||||
@@ -113,6 +352,149 @@ 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;
|
||||
}
|
||||
@@ -125,7 +507,19 @@ export function AnnotationOverlay({
|
||||
onDragStart={() => {
|
||||
isDraggingRef.current = true;
|
||||
}}
|
||||
onDrag={(_e, d) => {
|
||||
setLiveRect((prev) => ({
|
||||
...prev,
|
||||
x: d.x,
|
||||
y: d.y,
|
||||
}));
|
||||
}}
|
||||
onDragStop={(_e, d) => {
|
||||
setLiveRect((prev) => ({
|
||||
...prev,
|
||||
x: d.x,
|
||||
y: d.y,
|
||||
}));
|
||||
const xPercent = (d.x / containerWidth) * 100;
|
||||
const yPercent = (d.y / containerHeight) * 100;
|
||||
onPositionChange(annotation.id, { x: xPercent, y: yPercent });
|
||||
@@ -135,7 +529,21 @@ export function AnnotationOverlay({
|
||||
isDraggingRef.current = false;
|
||||
}, 100);
|
||||
}}
|
||||
onResize={(_e, _direction, ref, _delta, position) => {
|
||||
setLiveRect({
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
width: ref.offsetWidth,
|
||||
height: ref.offsetHeight,
|
||||
});
|
||||
}}
|
||||
onResizeStop={(_e, _direction, ref, _delta, position) => {
|
||||
setLiveRect({
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
width: ref.offsetWidth,
|
||||
height: ref.offsetHeight,
|
||||
});
|
||||
const xPercent = (position.x / containerWidth) * 100;
|
||||
const yPercent = (position.y / containerHeight) * 100;
|
||||
const widthPercent = (ref.offsetWidth / containerWidth) * 100;
|
||||
@@ -149,18 +557,23 @@ export function AnnotationOverlay({
|
||||
}}
|
||||
bounds="parent"
|
||||
className={cn(
|
||||
"cursor-move transition-all",
|
||||
isSelected && "ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent",
|
||||
"cursor-move",
|
||||
isSelected &&
|
||||
annotation.type !== "blur" &&
|
||||
"ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent",
|
||||
)}
|
||||
style={{
|
||||
zIndex: isSelectedBoost ? zIndex + 1000 : zIndex, // Boost selected annotation to ensure it's on top
|
||||
pointerEvents: isSelected ? "auto" : "none",
|
||||
border: isSelected ? "2px solid rgba(52, 178, 123, 0.8)" : "none",
|
||||
backgroundColor: isSelected ? "rgba(52, 178, 123, 0.1)" : "transparent",
|
||||
boxShadow: isSelected ? "0 0 0 1px rgba(52, 178, 123, 0.35)" : "none",
|
||||
border:
|
||||
isSelected && annotation.type !== "blur" ? "2px solid rgba(52, 178, 123, 0.8)" : "none",
|
||||
backgroundColor:
|
||||
isSelected && annotation.type !== "blur" ? "rgba(52, 178, 123, 0.1)" : "transparent",
|
||||
boxShadow:
|
||||
isSelected && annotation.type !== "blur" ? "0 0 0 1px rgba(52, 178, 123, 0.35)" : "none",
|
||||
}}
|
||||
enableResizing={isSelected}
|
||||
disableDragging={!isSelected}
|
||||
enableResizing={isSelected && !isSelectedFreehandBlur}
|
||||
disableDragging={!isSelected || isSelectedFreehandBlur}
|
||||
resizeHandleStyles={{
|
||||
topLeft: {
|
||||
width: "12px",
|
||||
@@ -206,11 +619,13 @@ export function AnnotationOverlay({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full h-full rounded-lg",
|
||||
"w-full h-full",
|
||||
annotation.type !== "blur" && "rounded-lg",
|
||||
annotation.type === "text" && "bg-transparent",
|
||||
annotation.type === "image" && "bg-transparent",
|
||||
annotation.type === "figure" && "bg-transparent",
|
||||
isSelected && "shadow-lg",
|
||||
annotation.type === "blur" && "bg-transparent",
|
||||
isSelected && annotation.type !== "blur" && "shadow-lg",
|
||||
)}
|
||||
>
|
||||
{renderContent()}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
AlignRight,
|
||||
Bold,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Image as ImageIcon,
|
||||
Info,
|
||||
Italic,
|
||||
@@ -33,7 +34,12 @@ import { cn } from "@/lib/utils";
|
||||
import ColorPicker from "../ui/color-picker";
|
||||
import { AddCustomFontDialog } from "./AddCustomFontDialog";
|
||||
import { getArrowComponent } from "./ArrowSvgs";
|
||||
import type { AnnotationRegion, AnnotationType, ArrowDirection, FigureData } from "./types";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type AnnotationType,
|
||||
type ArrowDirection,
|
||||
type FigureData,
|
||||
} from "./types";
|
||||
|
||||
interface AnnotationSettingsPanelProps {
|
||||
annotation: AnnotationRegion;
|
||||
@@ -41,6 +47,7 @@ interface AnnotationSettingsPanelProps {
|
||||
onTypeChange: (type: AnnotationType) => void;
|
||||
onStyleChange: (style: Partial<AnnotationRegion["style"]>) => void;
|
||||
onFigureDataChange?: (figureData: FigureData) => void;
|
||||
onDuplicate?: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
@@ -63,6 +70,7 @@ export function AnnotationSettingsPanel({
|
||||
onTypeChange,
|
||||
onStyleChange,
|
||||
onFigureDataChange,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
}: AnnotationSettingsPanelProps) {
|
||||
const t = useScopedT("settings");
|
||||
@@ -593,15 +601,28 @@ export function AnnotationSettingsPanel({
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<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-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>
|
||||
|
||||
<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">
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { Info, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import { getBlurOverlayColor } from "@/lib/blurEffects";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type BlurColor,
|
||||
type BlurData,
|
||||
type BlurShape,
|
||||
DEFAULT_BLUR_BLOCK_SIZE,
|
||||
DEFAULT_BLUR_DATA,
|
||||
MAX_BLUR_BLOCK_SIZE,
|
||||
MAX_BLUR_INTENSITY,
|
||||
MIN_BLUR_BLOCK_SIZE,
|
||||
MIN_BLUR_INTENSITY,
|
||||
} from "./types";
|
||||
|
||||
interface BlurSettingsPanelProps {
|
||||
blurRegion: AnnotationRegion;
|
||||
onBlurDataChange: (blurData: BlurData) => void;
|
||||
onBlurDataCommit?: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function BlurSettingsPanel({
|
||||
blurRegion,
|
||||
onBlurDataChange,
|
||||
onBlurDataCommit,
|
||||
onDelete,
|
||||
}: BlurSettingsPanelProps) {
|
||||
const t = useScopedT("settings");
|
||||
|
||||
const blurShapeOptions: Array<{ value: BlurShape; labelKey: string }> = [
|
||||
{ value: "rectangle", labelKey: "blurShapeRectangle" },
|
||||
{ value: "oval", labelKey: "blurShapeOval" },
|
||||
];
|
||||
const blurColorOptions: Array<{ value: BlurColor; labelKey: string }> = [
|
||||
{ value: "white", labelKey: "blurColorWhite" },
|
||||
{ value: "black", labelKey: "blurColorBlack" },
|
||||
];
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Accordion,
|
||||
@@ -33,20 +33,22 @@ 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,
|
||||
@@ -121,11 +123,6 @@ function CustomSpeedInput({
|
||||
);
|
||||
}
|
||||
|
||||
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%)",
|
||||
@@ -208,7 +205,13 @@ 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;
|
||||
@@ -284,7 +287,13 @@ export function SettingsPanel({
|
||||
onAnnotationTypeChange,
|
||||
onAnnotationStyleChange,
|
||||
onAnnotationFigureDataChange,
|
||||
onAnnotationDuplicate,
|
||||
onAnnotationDelete,
|
||||
selectedBlurId,
|
||||
blurRegions = [],
|
||||
onBlurDataChange,
|
||||
onBlurDataCommit,
|
||||
onBlurDelete,
|
||||
selectedSpeedId,
|
||||
selectedSpeedValue,
|
||||
onSpeedChange,
|
||||
@@ -299,24 +308,12 @@ export function SettingsPanel({
|
||||
onWebcamSizePresetCommit,
|
||||
}: SettingsPanelProps) {
|
||||
const t = useScopedT("settings");
|
||||
const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]);
|
||||
// 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 [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",
|
||||
@@ -342,6 +339,7 @@ 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;
|
||||
@@ -498,7 +496,7 @@ export function SettingsPanel({
|
||||
setCustomImages((prev) => prev.filter((img) => img !== imageUrl));
|
||||
// If the removed image was selected, clear selection
|
||||
if (selected === imageUrl) {
|
||||
onWallpaperChange(wallpaperPaths[0] || WALLPAPER_RELATIVE[0]);
|
||||
onWallpaperChange(WALLPAPER_PATHS[0]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -520,6 +518,9 @@ export function SettingsPanel({
|
||||
const selectedAnnotation = selectedAnnotationId
|
||||
? annotationRegions.find((a) => a.id === selectedAnnotationId)
|
||||
: null;
|
||||
const selectedBlur = selectedBlurId
|
||||
? blurRegions.find((region) => region.id === selectedBlurId)
|
||||
: null;
|
||||
|
||||
// If an annotation is selected, show annotation settings instead
|
||||
if (
|
||||
@@ -540,11 +541,25 @@ 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">
|
||||
@@ -753,15 +768,17 @@ export function SettingsPanel({
|
||||
<SelectValue placeholder={t("layout.selectPreset")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{WEBCAM_LAYOUT_PRESETS.filter(
|
||||
(preset) =>
|
||||
preset.value === "picture-in-picture" ||
|
||||
isPortraitAspectRatio(aspectRatio),
|
||||
).map((preset) => (
|
||||
{WEBCAM_LAYOUT_PRESETS.filter((preset) => {
|
||||
if (preset.value === "picture-in-picture") return true;
|
||||
if (preset.value === "vertical-stack") return isPortraitCanvas;
|
||||
return !isPortraitCanvas;
|
||||
}).map((preset) => (
|
||||
<SelectItem key={preset.value} value={preset.value} className="text-xs">
|
||||
{preset.value === "picture-in-picture"
|
||||
? t("layout.pictureInPicture")
|
||||
: t("layout.verticalStack")}
|
||||
: preset.value === "vertical-stack"
|
||||
? t("layout.verticalStack")
|
||||
: t("layout.dualFrame")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -1066,26 +1083,12 @@ export function SettingsPanel({
|
||||
);
|
||||
})}
|
||||
|
||||
{(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;
|
||||
})();
|
||||
{WALLPAPER_PATHS.map((canonicalPath, i) => {
|
||||
const previewUrl = wallpaperPreviewUrls[i] ?? canonicalPath;
|
||||
const isSelected = selected === canonicalPath;
|
||||
return (
|
||||
<div
|
||||
key={path}
|
||||
key={canonicalPath}
|
||||
className={cn(
|
||||
"aspect-square w-9 h-9 rounded-md border-2 overflow-hidden cursor-pointer transition-all duration-200 shadow-sm",
|
||||
isSelected
|
||||
@@ -1093,11 +1096,11 @@ export function SettingsPanel({
|
||||
: "border-white/10 hover:border-[#34B27B]/40 opacity-80 hover:opacity-100 bg-white/5",
|
||||
)}
|
||||
style={{
|
||||
backgroundImage: `url(${path})`,
|
||||
backgroundImage: `url(${previewUrl})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
}}
|
||||
onClick={() => onWallpaperChange(path)}
|
||||
onClick={() => onWallpaperChange(canonicalPath)}
|
||||
role="button"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
import { useI18n, useScopedT } from "@/contexts/I18nContext";
|
||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||
import { INITIAL_EDITOR_STATE, useEditorHistory } from "@/hooks/useEditorHistory";
|
||||
import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config";
|
||||
import { getLocaleName } from "@/i18n/loader";
|
||||
import { type Locale } from "@/i18n/config";
|
||||
import { getAvailableLocales, getLocaleName } from "@/i18n/loader";
|
||||
import {
|
||||
calculateOutputDimensions,
|
||||
type ExportFormat,
|
||||
@@ -32,6 +32,7 @@ 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,
|
||||
@@ -54,11 +55,13 @@ import { SettingsPanel } from "./SettingsPanel";
|
||||
import TimelineEditor from "./timeline/TimelineEditor";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type BlurData,
|
||||
type CursorTelemetryPoint,
|
||||
clampFocusToDepth,
|
||||
DEFAULT_ANNOTATION_POSITION,
|
||||
DEFAULT_ANNOTATION_SIZE,
|
||||
DEFAULT_ANNOTATION_STYLE,
|
||||
DEFAULT_BLUR_DATA,
|
||||
DEFAULT_FIGURE_DATA,
|
||||
DEFAULT_PLAYBACK_SPEED,
|
||||
DEFAULT_ZOOM_DEPTH,
|
||||
@@ -122,6 +125,7 @@ export default function VideoEditor() {
|
||||
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);
|
||||
@@ -149,14 +153,24 @@ export default function VideoEditor() {
|
||||
const nextSpeedIdRef = useRef(1);
|
||||
|
||||
const { shortcuts, isMac } = useShortcuts();
|
||||
const { locale, setLocale, t: rawT } = useI18n();
|
||||
const t = useScopedT("editor");
|
||||
const ts = useScopedT("settings");
|
||||
const { locale, setLocale } = useI18n();
|
||||
const availableLocales = getAvailableLocales();
|
||||
|
||||
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) {
|
||||
@@ -229,6 +243,7 @@ export default function VideoEditor() {
|
||||
setSelectedTrimId(null);
|
||||
setSelectedSpeedId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
|
||||
nextZoomIdRef.current = deriveNextId(
|
||||
"zoom",
|
||||
@@ -307,7 +322,6 @@ export default function VideoEditor() {
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
exportQuality,
|
||||
exportFormat,
|
||||
@@ -347,7 +361,10 @@ export default function VideoEditor() {
|
||||
setLastSavedSnapshot(
|
||||
createProjectSnapshot(
|
||||
webcamSourcePath
|
||||
? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath }
|
||||
? {
|
||||
screenVideoPath: sourcePath,
|
||||
webcamVideoPath: webcamSourcePath,
|
||||
}
|
||||
: { screenVideoPath: sourcePath },
|
||||
INITIAL_EDITOR_STATE,
|
||||
),
|
||||
@@ -484,7 +501,6 @@ export default function VideoEditor() {
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
exportQuality,
|
||||
exportFormat,
|
||||
@@ -493,6 +509,7 @@ export default function VideoEditor() {
|
||||
gifSizePreset,
|
||||
videoPath,
|
||||
t,
|
||||
webcamSizePreset,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -533,18 +550,18 @@ export default function VideoEditor() {
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(result.message || "Failed to load project");
|
||||
toast.error(result.message || t("project.failedToLoad"));
|
||||
return;
|
||||
}
|
||||
|
||||
const restored = await applyLoadedProject(result.project, result.path ?? null);
|
||||
if (!restored) {
|
||||
toast.error("Invalid project file format");
|
||||
toast.error(t("project.invalidFormat"));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(`Project loaded from ${result.path}`);
|
||||
}, [applyLoadedProject]);
|
||||
toast.success(t("project.loadedFrom", { path: result.path ?? "" }));
|
||||
}, [applyLoadedProject, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const removeLoadListener = window.electronAPI.onMenuLoadProject(handleLoadProject);
|
||||
@@ -626,7 +643,11 @@ export default function VideoEditor() {
|
||||
|
||||
const handleSelectZoom = useCallback((id: string | null) => {
|
||||
setSelectedZoomId(id);
|
||||
if (id) setSelectedTrimId(null);
|
||||
if (id) {
|
||||
setSelectedTrimId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSelectTrim = useCallback((id: string | null) => {
|
||||
@@ -634,6 +655,7 @@ export default function VideoEditor() {
|
||||
if (id) {
|
||||
setSelectedZoomId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -642,6 +664,17 @@ export default function VideoEditor() {
|
||||
if (id) {
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedBlurId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSelectBlur = useCallback((id: string | null) => {
|
||||
setSelectedBlurId(id);
|
||||
if (id) {
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedSpeedId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -659,6 +692,7 @@ export default function VideoEditor() {
|
||||
setSelectedZoomId(id);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
},
|
||||
[pushState],
|
||||
);
|
||||
@@ -677,6 +711,7 @@ export default function VideoEditor() {
|
||||
setSelectedZoomId(id);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
},
|
||||
[pushState],
|
||||
);
|
||||
@@ -693,6 +728,7 @@ export default function VideoEditor() {
|
||||
setSelectedTrimId(id);
|
||||
setSelectedZoomId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
},
|
||||
[pushState],
|
||||
);
|
||||
@@ -803,6 +839,7 @@ export default function VideoEditor() {
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -822,6 +859,7 @@ export default function VideoEditor() {
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
},
|
||||
[pushState],
|
||||
);
|
||||
@@ -888,6 +926,35 @@ export default function VideoEditor() {
|
||||
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],
|
||||
);
|
||||
@@ -909,6 +976,33 @@ 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) => ({
|
||||
@@ -917,8 +1011,11 @@ export default function VideoEditor() {
|
||||
if (selectedAnnotationId === id) {
|
||||
setSelectedAnnotationId(null);
|
||||
}
|
||||
if (selectedBlurId === id) {
|
||||
setSelectedBlurId(null);
|
||||
}
|
||||
},
|
||||
[selectedAnnotationId, pushState],
|
||||
[selectedAnnotationId, selectedBlurId, pushState],
|
||||
);
|
||||
|
||||
const handleAnnotationContentChange = useCallback(
|
||||
@@ -953,12 +1050,26 @@ export default function VideoEditor() {
|
||||
if (!region.figureData) {
|
||||
updatedRegion.figureData = { ...DEFAULT_FIGURE_DATA };
|
||||
}
|
||||
} else if (type === "blur") {
|
||||
updatedRegion.content = "";
|
||||
if (!region.blurData) {
|
||||
updatedRegion.blurData = { ...DEFAULT_BLUR_DATA };
|
||||
}
|
||||
}
|
||||
return updatedRegion;
|
||||
}),
|
||||
}));
|
||||
|
||||
if (type === "blur" && selectedAnnotationId === id) {
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(id);
|
||||
setSelectedSpeedId(null);
|
||||
} else if (type !== "blur" && selectedBlurId === id) {
|
||||
setSelectedBlurId(null);
|
||||
setSelectedAnnotationId(id);
|
||||
}
|
||||
},
|
||||
[pushState],
|
||||
[pushState, selectedAnnotationId, selectedBlurId],
|
||||
);
|
||||
|
||||
const handleAnnotationStyleChange = useCallback(
|
||||
@@ -983,6 +1094,51 @@ export default function VideoEditor() {
|
||||
[pushState],
|
||||
);
|
||||
|
||||
const handleBlurDataPreviewChange = useCallback(
|
||||
(id: string, blurData: BlurData) => {
|
||||
updateState((prev) => ({
|
||||
annotationRegions: prev.annotationRegions.map((region) =>
|
||||
region.id === id
|
||||
? {
|
||||
...region,
|
||||
blurData,
|
||||
// Freehand drawing area is the full video surface.
|
||||
...(blurData.shape === "freehand"
|
||||
? {
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 100, height: 100 },
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: region,
|
||||
),
|
||||
}));
|
||||
},
|
||||
[updateState],
|
||||
);
|
||||
|
||||
const handleBlurDataPanelChange = useCallback(
|
||||
(id: string, blurData: BlurData) => {
|
||||
pushState((prev) => ({
|
||||
annotationRegions: prev.annotationRegions.map((region) =>
|
||||
region.id === id
|
||||
? {
|
||||
...region,
|
||||
blurData,
|
||||
...(blurData.shape === "freehand"
|
||||
? {
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 100, height: 100 },
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: region,
|
||||
),
|
||||
}));
|
||||
},
|
||||
[pushState],
|
||||
);
|
||||
|
||||
const handleAnnotationPositionChange = useCallback(
|
||||
(id: string, position: { x: number; y: number }) => {
|
||||
pushState((prev) => ({
|
||||
@@ -1096,11 +1252,14 @@ export default function VideoEditor() {
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedAnnotationId &&
|
||||
!annotationRegions.some((region) => region.id === selectedAnnotationId)
|
||||
!annotationOnlyRegions.some((region) => region.id === selectedAnnotationId)
|
||||
) {
|
||||
setSelectedAnnotationId(null);
|
||||
}
|
||||
}, [selectedAnnotationId, annotationRegions]);
|
||||
if (selectedBlurId && !blurRegions.some((region) => region.id === selectedBlurId)) {
|
||||
setSelectedBlurId(null);
|
||||
}
|
||||
}, [selectedAnnotationId, selectedBlurId, annotationOnlyRegions, blurRegions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSpeedId && !speedRegions.some((region) => region.id === selectedSpeedId)) {
|
||||
@@ -1126,17 +1285,22 @@ export default function VideoEditor() {
|
||||
const handleExportSaved = useCallback(
|
||||
(formatLabel: "GIF" | "Video", filePath: string) => {
|
||||
setExportedFilePath(filePath);
|
||||
toast.success(`${formatLabel} exported successfully`, {
|
||||
description: filePath,
|
||||
action: {
|
||||
label: "Show in Folder",
|
||||
onClick: () => {
|
||||
void handleShowExportedFile(filePath);
|
||||
toast.success(
|
||||
t("export.exportedSuccessfully", {
|
||||
format: formatLabel,
|
||||
}),
|
||||
{
|
||||
description: filePath,
|
||||
action: {
|
||||
label: rawT("common.actions.showInFolder"),
|
||||
onClick: () => {
|
||||
void handleShowExportedFile(filePath);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
},
|
||||
[handleShowExportedFile],
|
||||
[handleShowExportedFile, t, rawT],
|
||||
);
|
||||
|
||||
const handleSaveUnsavedExport = useCallback(async () => {
|
||||
@@ -1240,6 +1404,12 @@ 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) {
|
||||
@@ -1374,6 +1544,12 @@ 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) {
|
||||
@@ -1397,9 +1573,15 @@ export default function VideoEditor() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Export error:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
setExportError(errorMessage);
|
||||
toast.error(`Export failed: ${errorMessage}`);
|
||||
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 }));
|
||||
}
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
exporterRef.current = null;
|
||||
@@ -1432,6 +1614,7 @@ export default function VideoEditor() {
|
||||
exportQuality,
|
||||
handleExportSaved,
|
||||
cursorTelemetry,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1510,7 +1693,7 @@ export default function VideoEditor() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-background">
|
||||
<div className="text-foreground">Loading video...</div>
|
||||
<div className="text-foreground">{t("loadingVideo")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1524,7 +1707,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"
|
||||
>
|
||||
Load Project File
|
||||
{ts("project.load")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1579,7 +1762,7 @@ export default function VideoEditor() {
|
||||
className="bg-transparent text-[11px] font-medium outline-none cursor-pointer appearance-none pr-1"
|
||||
style={{ color: "inherit" }}
|
||||
>
|
||||
{SUPPORTED_LOCALES.map((loc) => (
|
||||
{availableLocales.map((loc) => (
|
||||
<option key={loc} value={loc} className="bg-[#09090b] text-white">
|
||||
{getLocaleName(loc)}
|
||||
</option>
|
||||
@@ -1675,11 +1858,18 @@ export default function VideoEditor() {
|
||||
cropRegion={cropRegion}
|
||||
trimRegions={trimRegions}
|
||||
speedRegions={speedRegions}
|
||||
annotationRegions={annotationRegions}
|
||||
annotationRegions={annotationOnlyRegions}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
onSelectAnnotation={handleSelectAnnotation}
|
||||
onAnnotationPositionChange={handleAnnotationPositionChange}
|
||||
onAnnotationSizeChange={handleAnnotationSizeChange}
|
||||
blurRegions={blurRegions}
|
||||
selectedBlurId={selectedBlurId}
|
||||
onSelectBlur={handleSelectBlur}
|
||||
onBlurPositionChange={handleAnnotationPositionChange}
|
||||
onBlurSizeChange={handleAnnotationSizeChange}
|
||||
onBlurDataChange={handleBlurDataPreviewChange}
|
||||
onBlurDataCommit={commitState}
|
||||
cursorTelemetry={cursorTelemetry}
|
||||
/>
|
||||
</div>
|
||||
@@ -1732,18 +1922,25 @@ export default function VideoEditor() {
|
||||
onSpeedDelete={handleSpeedDelete}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
onSelectSpeed={handleSelectSpeed}
|
||||
annotationRegions={annotationRegions}
|
||||
annotationRegions={annotationOnlyRegions}
|
||||
onAnnotationAdded={handleAnnotationAdded}
|
||||
onAnnotationSpanChange={handleAnnotationSpanChange}
|
||||
onAnnotationDelete={handleAnnotationDelete}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
onSelectAnnotation={handleSelectAnnotation}
|
||||
blurRegions={blurRegions}
|
||||
onBlurAdded={handleBlurAdded}
|
||||
onBlurSpanChange={handleAnnotationSpanChange}
|
||||
onBlurDelete={handleAnnotationDelete}
|
||||
selectedBlurId={selectedBlurId}
|
||||
onSelectBlur={handleSelectBlur}
|
||||
aspectRatio={aspectRatio}
|
||||
onAspectRatioChange={(ar) =>
|
||||
pushState({
|
||||
aspectRatio: ar,
|
||||
webcamLayoutPreset:
|
||||
!isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack"
|
||||
(isPortraitAspectRatio(ar) && webcamLayoutPreset === "dual-frame") ||
|
||||
(!isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack")
|
||||
? "picture-in-picture"
|
||||
: webcamLayoutPreset,
|
||||
})
|
||||
@@ -1796,7 +1993,7 @@ export default function VideoEditor() {
|
||||
onWebcamLayoutPresetChange={(preset) =>
|
||||
pushState({
|
||||
webcamLayoutPreset: preset,
|
||||
webcamPosition: preset === "vertical-stack" ? null : webcamPosition,
|
||||
webcamPosition: preset === "picture-in-picture" ? webcamPosition : null,
|
||||
})
|
||||
}
|
||||
webcamMaskShape={webcamMaskShape}
|
||||
@@ -1830,12 +2027,18 @@ export default function VideoEditor() {
|
||||
)}
|
||||
onExport={handleOpenExportDialog}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
annotationRegions={annotationRegions}
|
||||
annotationRegions={annotationOnlyRegions}
|
||||
onAnnotationContentChange={handleAnnotationContentChange}
|
||||
onAnnotationTypeChange={handleAnnotationTypeChange}
|
||||
onAnnotationStyleChange={handleAnnotationStyleChange}
|
||||
onAnnotationFigureDataChange={handleAnnotationFigureDataChange}
|
||||
onAnnotationDuplicate={handleAnnotationDuplicate}
|
||||
onAnnotationDelete={handleAnnotationDelete}
|
||||
selectedBlurId={selectedBlurId}
|
||||
blurRegions={blurRegions}
|
||||
onBlurDataChange={handleBlurDataPanelChange}
|
||||
onBlurDataCommit={commitState}
|
||||
onBlurDelete={handleAnnotationDelete}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
selectedSpeedValue={
|
||||
selectedSpeedId
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { getAssetPath } from "@/lib/assetPath";
|
||||
import {
|
||||
getWebcamLayoutCssBoxShadow,
|
||||
type Size,
|
||||
@@ -26,6 +25,7 @@ import {
|
||||
type WebcamLayoutPreset,
|
||||
type WebcamSizePreset,
|
||||
} from "@/lib/compositeLayout";
|
||||
import { classifyWallpaper, DEFAULT_WALLPAPER, resolveImageWallpaperUrl } from "@/lib/wallpaper";
|
||||
import { getCssClipPath } from "@/lib/webcamMaskShapes";
|
||||
import {
|
||||
type AspectRatio,
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import { AnnotationOverlay } from "./AnnotationOverlay";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type BlurData,
|
||||
type SpeedRegion,
|
||||
type TrimRegion,
|
||||
ZOOM_DEPTH_SCALES,
|
||||
@@ -101,6 +102,13 @@ interface VideoPlaybackProps {
|
||||
onSelectAnnotation?: (id: string | null) => void;
|
||||
onAnnotationPositionChange?: (id: string, position: { x: number; y: number }) => void;
|
||||
onAnnotationSizeChange?: (id: string, size: { width: number; height: number }) => void;
|
||||
blurRegions?: AnnotationRegion[];
|
||||
selectedBlurId?: string | null;
|
||||
onSelectBlur?: (id: string | null) => void;
|
||||
onBlurPositionChange?: (id: string, position: { x: number; y: number }) => void;
|
||||
onBlurSizeChange?: (id: string, size: { width: number; height: number }) => void;
|
||||
onBlurDataChange?: (id: string, blurData: BlurData) => void;
|
||||
onBlurDataCommit?: () => void;
|
||||
cursorTelemetry?: import("./types").CursorTelemetryPoint[];
|
||||
}
|
||||
|
||||
@@ -152,6 +160,13 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
onSelectAnnotation,
|
||||
onAnnotationPositionChange,
|
||||
onAnnotationSizeChange,
|
||||
blurRegions = [],
|
||||
selectedBlurId,
|
||||
onSelectBlur,
|
||||
onBlurPositionChange,
|
||||
onBlurSizeChange,
|
||||
onBlurDataChange,
|
||||
onBlurDataCommit,
|
||||
cursorTelemetry = [],
|
||||
},
|
||||
ref,
|
||||
@@ -166,7 +181,10 @@ 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 [webcamLayout, setWebcamLayout] = useState<StyledRenderRect | null>(null);
|
||||
const [webcamDimensions, setWebcamDimensions] = useState<Size | null>(null);
|
||||
@@ -330,6 +348,11 @@ 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;
|
||||
@@ -346,7 +369,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
if (!vid) return;
|
||||
try {
|
||||
allowPlaybackRef.current = true;
|
||||
await vid.play();
|
||||
await vid.play().catch((err) => {
|
||||
console.log("PLAY ERROR:", err);
|
||||
throw err;
|
||||
});
|
||||
} catch (error) {
|
||||
allowPlaybackRef.current = false;
|
||||
throw error;
|
||||
@@ -519,84 +545,24 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
|
||||
useEffect(() => {
|
||||
if (!pixiReady || !videoReady) return;
|
||||
const el = overlayRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const app = appRef.current;
|
||||
const cameraContainer = cameraContainerRef.current;
|
||||
const video = videoRef.current;
|
||||
// Seed immediately so overlays never start at 800×600
|
||||
setOverlaySize({ width: el.clientWidth, height: el.clientHeight });
|
||||
|
||||
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();
|
||||
}
|
||||
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 };
|
||||
});
|
||||
});
|
||||
}, [pixiReady, videoReady, layoutVideoContent]);
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [pixiReady, videoReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pixiReady || !videoReady) return;
|
||||
@@ -623,7 +589,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
}, [selectedZoom, pixiReady, videoReady, updateOverlayForRegion]);
|
||||
|
||||
useEffect(() => {
|
||||
const overlayEl = overlayRef.current;
|
||||
if (!pixiReady || !videoReady) return;
|
||||
const overlayEl = overlayElement;
|
||||
if (!overlayEl) return;
|
||||
if (!selectedZoom) {
|
||||
overlayEl.style.cursor = "default";
|
||||
@@ -632,7 +599,34 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
}
|
||||
overlayEl.style.cursor = isPlaying ? "not-allowed" : "grab";
|
||||
overlayEl.style.pointerEvents = isPlaying ? "none" : "auto";
|
||||
}, [selectedZoom, isPlaying]);
|
||||
}, [selectedZoom, isPlaying, pixiReady, videoReady, overlayElement]);
|
||||
|
||||
useEffect(() => {
|
||||
const overlayEl = overlayElement;
|
||||
if (!overlayEl) return;
|
||||
|
||||
const updateOverlaySize = () => {
|
||||
const width = overlayEl.clientWidth || 800;
|
||||
const height = overlayEl.clientHeight || 600;
|
||||
setOverlaySize((prev) => {
|
||||
if (prev.width === width && prev.height === height) return prev;
|
||||
return { width, height };
|
||||
});
|
||||
};
|
||||
|
||||
updateOverlaySize();
|
||||
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
const observer = new ResizeObserver(() => {
|
||||
updateOverlaySize();
|
||||
});
|
||||
observer.observe(overlayEl);
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
|
||||
window.addEventListener("resize", updateOverlaySize);
|
||||
return () => window.removeEventListener("resize", updateOverlaySize);
|
||||
}, [overlayElement]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -759,7 +753,6 @@ 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;
|
||||
|
||||
@@ -807,7 +800,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
videoContainer.mask = null;
|
||||
maskGraphicsRef.current = null;
|
||||
if (blurFilterRef.current) {
|
||||
videoContainer.filters = [];
|
||||
videoContainer.filters = null;
|
||||
blurFilterRef.current.destroy();
|
||||
blurFilterRef.current = null;
|
||||
}
|
||||
@@ -864,23 +857,14 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
state.appliedScale = appliedTransform.scale;
|
||||
};
|
||||
|
||||
let lastMotionBlurActive: boolean | null = null;
|
||||
const ticker = () => {
|
||||
const bm = baseMaskRef.current;
|
||||
const ss = stageSizeRef.current;
|
||||
const viewportRatio =
|
||||
bm.width > 0 && bm.height > 0
|
||||
? {
|
||||
widthRatio: ss.width / bm.width,
|
||||
heightRatio: ss.height / bm.height,
|
||||
}
|
||||
: undefined;
|
||||
const { region, strength, blendedScale, transition } = findDominantRegion(
|
||||
zoomRegionsRef.current,
|
||||
currentTimeRef.current,
|
||||
{
|
||||
connectZooms: true,
|
||||
cursorTelemetry: cursorTelemetryRef.current,
|
||||
viewportRatio,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1031,6 +1015,23 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
motionIntensity,
|
||||
motionVector,
|
||||
);
|
||||
|
||||
const isMotionBlurActive = (motionBlurAmountRef.current || 0) > 0 && isPlayingRef.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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
app.ticker.add(ticker);
|
||||
@@ -1068,7 +1069,17 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
videoReadyRafRef.current = requestAnimationFrame(waitForRenderableFrame);
|
||||
};
|
||||
|
||||
const [resolvedWallpaper, setResolvedWallpaper] = useState<string | null>(null);
|
||||
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 webcamCssBoxShadow = useMemo(
|
||||
() => getWebcamLayoutCssBoxShadow(webcamLayoutPreset),
|
||||
[webcamLayoutPreset],
|
||||
@@ -1136,58 +1147,6 @@ 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) {
|
||||
@@ -1287,9 +1246,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
{/* Only render overlay after PIXI and video are fully initialized */}
|
||||
{pixiReady && videoReady && (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
ref={setOverlayRefs}
|
||||
className="absolute inset-0 select-none"
|
||||
style={{ pointerEvents: "none", zIndex: 30 }}
|
||||
style={{ pointerEvents: "auto", zIndex: 30 }}
|
||||
onPointerDown={handleOverlayPointerDown}
|
||||
onPointerMove={handleOverlayPointerMove}
|
||||
onPointerUp={handleOverlayPointerUp}
|
||||
@@ -1301,47 +1260,117 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
style={{ display: "none", pointerEvents: "none" }}
|
||||
/>
|
||||
{(() => {
|
||||
const filtered = (annotationRegions || []).filter((annotation) => {
|
||||
const filteredAnnotations = (annotationRegions || []).filter((annotation) => {
|
||||
if (typeof annotation.startMs !== "number" || typeof annotation.endMs !== "number")
|
||||
return false;
|
||||
|
||||
if (annotation.id === selectedAnnotationId) return true;
|
||||
|
||||
const timeMs = Math.round(currentTime * 1000);
|
||||
return timeMs >= annotation.startMs && timeMs <= annotation.endMs;
|
||||
return timeMs >= annotation.startMs && timeMs < annotation.endMs;
|
||||
});
|
||||
|
||||
// Sort by z-index (lowest to highest) so higher z-index renders on top
|
||||
const sorted = [...filtered].sort((a, b) => a.zIndex - b.zIndex);
|
||||
const filteredBlurRegions = (blurRegions || []).filter((blurRegion) => {
|
||||
if (typeof blurRegion.startMs !== "number" || typeof blurRegion.endMs !== "number")
|
||||
return false;
|
||||
|
||||
if (blurRegion.id === selectedBlurId) return true;
|
||||
|
||||
const timeMs = Math.round(currentTime * 1000);
|
||||
return timeMs >= blurRegion.startMs && timeMs < blurRegion.endMs;
|
||||
});
|
||||
|
||||
const sorted = [
|
||||
...filteredAnnotations.map((annotation) => ({
|
||||
kind: "annotation" as const,
|
||||
region: annotation,
|
||||
})),
|
||||
...filteredBlurRegions.map((blurRegion) => ({
|
||||
kind: "blur" as const,
|
||||
region: blurRegion,
|
||||
})),
|
||||
].sort((a, b) => a.region.zIndex - b.region.zIndex);
|
||||
const previewSnapshotCanvas =
|
||||
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 && sorted.length > 1) {
|
||||
if (clickedId === selectedAnnotationId && filteredAnnotations.length > 1) {
|
||||
// Find current index and cycle to next
|
||||
const currentIndex = sorted.findIndex((a) => a.id === clickedId);
|
||||
const nextIndex = (currentIndex + 1) % sorted.length;
|
||||
onSelectAnnotation(sorted[nextIndex].id);
|
||||
const currentIndex = filteredAnnotations.findIndex((a) => a.id === clickedId);
|
||||
const nextIndex = (currentIndex + 1) % filteredAnnotations.length;
|
||||
onSelectAnnotation(filteredAnnotations[nextIndex].id);
|
||||
} else {
|
||||
// First click or clicking different annotation
|
||||
onSelectAnnotation(clickedId);
|
||||
}
|
||||
};
|
||||
|
||||
return sorted.map((annotation) => (
|
||||
const handleBlurClick = (clickedId: string) => {
|
||||
if (!onSelectBlur) return;
|
||||
|
||||
if (clickedId === selectedBlurId && filteredBlurRegions.length > 1) {
|
||||
const currentIndex = filteredBlurRegions.findIndex((a) => a.id === clickedId);
|
||||
const nextIndex = (currentIndex + 1) % filteredBlurRegions.length;
|
||||
onSelectBlur(filteredBlurRegions[nextIndex].id);
|
||||
} else {
|
||||
onSelectBlur(clickedId);
|
||||
}
|
||||
};
|
||||
|
||||
return sorted.map((item) => (
|
||||
<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}
|
||||
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)}
|
||||
/>
|
||||
));
|
||||
})()}
|
||||
|
||||
@@ -44,6 +44,7 @@ describe("projectPersistence media compatibility", () => {
|
||||
aspectRatio: "16:9",
|
||||
webcamLayoutPreset: "picture-in-picture",
|
||||
webcamMaskShape: "circle",
|
||||
webcamPosition: null,
|
||||
exportQuality: "good",
|
||||
exportFormat: "mp4",
|
||||
gifFrameRate: 15,
|
||||
@@ -66,6 +67,99 @@ 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", () => {
|
||||
@@ -103,3 +197,62 @@ 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { normalizeBlurColor, normalizeBlurType } from "@/lib/blurEffects";
|
||||
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
|
||||
import type { ProjectMedia } from "@/lib/recordingSession";
|
||||
import { normalizeProjectMedia } from "@/lib/recordingSession";
|
||||
import { ASPECT_RATIOS, type AspectRatio } from "@/utils/aspectRatioUtils";
|
||||
import { DEFAULT_WALLPAPER, WALLPAPER_PATHS } from "@/lib/wallpaper";
|
||||
import { ASPECT_RATIOS, type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type CropRegion,
|
||||
@@ -9,6 +11,10 @@ import {
|
||||
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,
|
||||
@@ -17,7 +23,11 @@ import {
|
||||
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,
|
||||
@@ -28,12 +38,23 @@ import {
|
||||
type ZoomRegion,
|
||||
} from "./types";
|
||||
|
||||
const WALLPAPER_COUNT = 18;
|
||||
const VALID_BLUR_SHAPES = new Set(["rectangle", "oval", "freehand"] as const);
|
||||
|
||||
export const WALLPAPER_PATHS = Array.from(
|
||||
{ length: WALLPAPER_COUNT },
|
||||
(_, i) => `/wallpapers/wallpaper${i + 1}.jpg`,
|
||||
);
|
||||
// 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 PROJECT_VERSION = 2;
|
||||
|
||||
@@ -72,6 +93,26 @@ 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));
|
||||
}
|
||||
@@ -179,6 +220,26 @@ 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
|
||||
@@ -254,12 +315,22 @@ 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 : "text",
|
||||
type:
|
||||
region.type === "image" || region.type === "figure" || region.type === "blur"
|
||||
? region.type
|
||||
: "text",
|
||||
content: typeof region.content === "string" ? region.content : "",
|
||||
textContent: typeof region.textContent === "string" ? region.textContent : undefined,
|
||||
imageContent: typeof region.imageContent === "string" ? region.imageContent : undefined,
|
||||
@@ -306,6 +377,42 @@ 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,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
@@ -329,7 +436,10 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
const cropHeight = clamp(rawCropHeight, 0.01, 1 - cropY);
|
||||
|
||||
return {
|
||||
wallpaper: typeof editor.wallpaper === "string" ? editor.wallpaper : WALLPAPER_PATHS[0],
|
||||
wallpaper:
|
||||
typeof editor.wallpaper === "string"
|
||||
? normalizeWallpaperValue(editor.wallpaper)
|
||||
: DEFAULT_WALLPAPER,
|
||||
shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0,
|
||||
showBlur: typeof editor.showBlur === "boolean" ? editor.showBlur : false,
|
||||
motionBlurAmount: isFiniteNumber(editor.motionBlurAmount)
|
||||
@@ -351,13 +461,8 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
trimRegions: normalizedTrimRegions,
|
||||
speedRegions: normalizedSpeedRegions,
|
||||
annotationRegions: normalizedAnnotationRegions,
|
||||
aspectRatio:
|
||||
editor.aspectRatio && validAspectRatios.has(editor.aspectRatio) ? editor.aspectRatio : "16:9",
|
||||
webcamLayoutPreset:
|
||||
editor.webcamLayoutPreset === "vertical-stack" ||
|
||||
editor.webcamLayoutPreset === "picture-in-picture"
|
||||
? editor.webcamLayoutPreset
|
||||
: DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||
aspectRatio: normalizedAspectRatio,
|
||||
webcamLayoutPreset: normalizedWebcamLayoutPreset,
|
||||
webcamMaskShape:
|
||||
editor.webcamMaskShape === "rectangle" ||
|
||||
editor.webcamMaskShape === "circle" ||
|
||||
@@ -369,16 +474,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
typeof editor.webcamSizePreset === "number" && isFiniteNumber(editor.webcamSizePreset)
|
||||
? Math.max(10, Math.min(50, editor.webcamSizePreset))
|
||||
: DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
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,
|
||||
webcamPosition: normalizedWebcamPosition,
|
||||
exportQuality:
|
||||
editor.exportQuality === "medium" || editor.exportQuality === "source"
|
||||
? editor.exportQuality
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Span } from "dnd-timeline";
|
||||
import { useItem } from "dnd-timeline";
|
||||
import { Gauge, MessageSquare, Scissors, ZoomIn } from "lucide-react";
|
||||
import { Gauge, MessageSquare, MousePointer2, 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";
|
||||
|
||||
@@ -14,7 +15,8 @@ interface ItemProps {
|
||||
onSelect?: () => void;
|
||||
zoomDepth?: number;
|
||||
speedValue?: number;
|
||||
variant?: "zoom" | "trim" | "annotation" | "speed";
|
||||
isAutoFocus?: boolean;
|
||||
variant?: "zoom" | "trim" | "annotation" | "speed" | "blur";
|
||||
}
|
||||
|
||||
// Map zoom depth to multiplier labels
|
||||
@@ -45,9 +47,11 @@ 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,
|
||||
@@ -132,19 +136,25 @@ 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">
|
||||
Trim
|
||||
{t("labels.trim")}
|
||||
</span>
|
||||
</>
|
||||
) : isSpeed ? (
|
||||
<>
|
||||
<Gauge className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
|
||||
{speedValue !== undefined ? `${speedValue}×` : "Speed"}
|
||||
{speedValue !== undefined ? `${speedValue}×` : t("labels.speed")}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -44,6 +44,7 @@ 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;
|
||||
@@ -73,6 +74,12 @@ interface TimelineEditorProps {
|
||||
onAnnotationDelete?: (id: string) => void;
|
||||
selectedAnnotationId?: string | null;
|
||||
onSelectAnnotation?: (id: string | null) => void;
|
||||
blurRegions?: AnnotationRegion[];
|
||||
onBlurAdded?: (span: Span) => void;
|
||||
onBlurSpanChange?: (id: string, span: Span) => void;
|
||||
onBlurDelete?: (id: string) => void;
|
||||
selectedBlurId?: string | null;
|
||||
onSelectBlur?: (id: string | null) => void;
|
||||
speedRegions?: SpeedRegion[];
|
||||
onSpeedAdded?: (span: Span) => void;
|
||||
onSpeedSpanChange?: (id: string, span: Span) => void;
|
||||
@@ -96,7 +103,8 @@ interface TimelineRenderItem {
|
||||
label: string;
|
||||
zoomDepth?: number;
|
||||
speedValue?: number;
|
||||
variant: "zoom" | "trim" | "annotation" | "speed";
|
||||
isAutoFocus?: boolean;
|
||||
variant: "zoom" | "trim" | "annotation" | "speed" | "blur";
|
||||
}
|
||||
|
||||
const SCALE_CANDIDATES = [
|
||||
@@ -525,10 +533,12 @@ function Timeline({
|
||||
onSelectZoom,
|
||||
onSelectTrim,
|
||||
onSelectAnnotation,
|
||||
onSelectBlur,
|
||||
onSelectSpeed,
|
||||
selectedZoomId,
|
||||
selectedTrimId,
|
||||
selectedAnnotationId,
|
||||
selectedBlurId,
|
||||
selectedSpeedId,
|
||||
keyframes = [],
|
||||
}: {
|
||||
@@ -540,10 +550,12 @@ 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 }[];
|
||||
}) {
|
||||
@@ -568,6 +580,7 @@ function Timeline({
|
||||
onSelectZoom?.(null);
|
||||
onSelectTrim?.(null);
|
||||
onSelectAnnotation?.(null);
|
||||
onSelectBlur?.(null);
|
||||
onSelectSpeed?.(null);
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
@@ -586,6 +599,7 @@ function Timeline({
|
||||
onSelectZoom,
|
||||
onSelectTrim,
|
||||
onSelectAnnotation,
|
||||
onSelectBlur,
|
||||
onSelectSpeed,
|
||||
videoDurationMs,
|
||||
sidebarWidth,
|
||||
@@ -637,6 +651,7 @@ function Timeline({
|
||||
const zoomItems = items.filter((item) => item.rowId === ZOOM_ROW_ID);
|
||||
const trimItems = items.filter((item) => item.rowId === TRIM_ROW_ID);
|
||||
const annotationItems = items.filter((item) => item.rowId === ANNOTATION_ROW_ID);
|
||||
const blurItems = items.filter((item) => item.rowId === BLUR_ROW_ID);
|
||||
const speedItems = items.filter((item) => item.rowId === SPEED_ROW_ID);
|
||||
|
||||
return (
|
||||
@@ -668,6 +683,7 @@ function Timeline({
|
||||
isSelected={item.id === selectedZoomId}
|
||||
onSelect={() => onSelectZoom?.(item.id)}
|
||||
zoomDepth={item.zoomDepth}
|
||||
isAutoFocus={item.isAutoFocus}
|
||||
variant="zoom"
|
||||
>
|
||||
{item.label}
|
||||
@@ -711,6 +727,22 @@ 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
|
||||
@@ -755,6 +787,12 @@ export default function TimelineEditor({
|
||||
onAnnotationDelete,
|
||||
selectedAnnotationId,
|
||||
onSelectAnnotation,
|
||||
blurRegions = [],
|
||||
onBlurAdded,
|
||||
onBlurSpanChange,
|
||||
onBlurDelete,
|
||||
selectedBlurId,
|
||||
onSelectBlur,
|
||||
speedRegions = [],
|
||||
onSpeedAdded,
|
||||
onSpeedSpanChange,
|
||||
@@ -839,6 +877,12 @@ 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);
|
||||
@@ -908,9 +952,10 @@ export default function TimelineEditor({
|
||||
const isZoomItem = zoomRegions.some((r) => r.id === excludeId);
|
||||
const isTrimItem = trimRegions.some((r) => r.id === excludeId);
|
||||
const isAnnotationItem = annotationRegions.some((r) => r.id === excludeId);
|
||||
const isBlurItem = blurRegions.some((r) => r.id === excludeId);
|
||||
const isSpeedItem = speedRegions.some((r) => r.id === excludeId);
|
||||
|
||||
if (isAnnotationItem) {
|
||||
if (isAnnotationItem || isBlurItem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -937,7 +982,7 @@ export default function TimelineEditor({
|
||||
|
||||
return false;
|
||||
},
|
||||
[zoomRegions, trimRegions, annotationRegions, speedRegions],
|
||||
[zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions],
|
||||
);
|
||||
|
||||
// At least 5% of the timeline or 1000ms, whichever is larger, so the region
|
||||
@@ -1165,6 +1210,21 @@ export default function TimelineEditor({
|
||||
onAnnotationAdded({ start: startPos, end: endPos });
|
||||
}, [videoDuration, totalMs, currentTimeMs, onAnnotationAdded, defaultRegionDurationMs]);
|
||||
|
||||
const handleAddBlur = useCallback(() => {
|
||||
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onBlurAdded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
|
||||
if (defaultDuration <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
|
||||
const endPos = Math.min(startPos + defaultDuration, totalMs);
|
||||
onBlurAdded({ start: startPos, end: endPos });
|
||||
}, [videoDuration, totalMs, currentTimeMs, onBlurAdded, defaultRegionDurationMs]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
@@ -1183,6 +1243,9 @@ export default function TimelineEditor({
|
||||
if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) {
|
||||
handleAddAnnotation();
|
||||
}
|
||||
if (matchesShortcut(e, keyShortcuts.addBlur, isMac)) {
|
||||
handleAddBlur();
|
||||
}
|
||||
if (matchesShortcut(e, keyShortcuts.addSpeed, isMac)) {
|
||||
handleAddSpeed();
|
||||
}
|
||||
@@ -1223,6 +1286,8 @@ export default function TimelineEditor({
|
||||
deleteSelectedTrim();
|
||||
} else if (selectedAnnotationId) {
|
||||
deleteSelectedAnnotation();
|
||||
} else if (selectedBlurId) {
|
||||
deleteSelectedBlur();
|
||||
} else if (selectedSpeedId) {
|
||||
deleteSelectedSpeed();
|
||||
}
|
||||
@@ -1235,16 +1300,19 @@ export default function TimelineEditor({
|
||||
handleAddZoom,
|
||||
handleAddTrim,
|
||||
handleAddAnnotation,
|
||||
handleAddBlur,
|
||||
handleAddSpeed,
|
||||
deleteSelectedKeyframe,
|
||||
deleteSelectedZoom,
|
||||
deleteSelectedTrim,
|
||||
deleteSelectedAnnotation,
|
||||
deleteSelectedBlur,
|
||||
deleteSelectedSpeed,
|
||||
selectedKeyframeId,
|
||||
selectedZoomId,
|
||||
selectedTrimId,
|
||||
selectedAnnotationId,
|
||||
selectedBlurId,
|
||||
selectedSpeedId,
|
||||
annotationRegions,
|
||||
currentTime,
|
||||
@@ -1271,6 +1339,7 @@ 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",
|
||||
}));
|
||||
|
||||
@@ -1304,6 +1373,14 @@ export default function TimelineEditor({
|
||||
};
|
||||
});
|
||||
|
||||
const blurs: TimelineRenderItem[] = blurRegions.map((region, index) => ({
|
||||
id: region.id,
|
||||
rowId: BLUR_ROW_ID,
|
||||
span: { start: region.startMs, end: region.endMs },
|
||||
label: t("labels.blurItem", { index: String(index + 1) }),
|
||||
variant: "blur",
|
||||
}));
|
||||
|
||||
const speeds: TimelineRenderItem[] = speedRegions.map((region, index) => ({
|
||||
id: region.id,
|
||||
rowId: SPEED_ROW_ID,
|
||||
@@ -1313,8 +1390,8 @@ export default function TimelineEditor({
|
||||
variant: "speed",
|
||||
}));
|
||||
|
||||
return [...zooms, ...trims, ...annotations, ...speeds];
|
||||
}, [zoomRegions, trimRegions, annotationRegions, speedRegions, t]);
|
||||
return [...zooms, ...trims, ...annotations, ...blurs, ...speeds];
|
||||
}, [zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions, t]);
|
||||
|
||||
// Flat list of all non-annotation region spans for neighbour-clamping during drag/resize
|
||||
const allRegionSpans = useMemo(() => {
|
||||
@@ -1335,6 +1412,8 @@ export default function TimelineEditor({
|
||||
onSpeedSpanChange?.(id, span);
|
||||
} else if (annotationRegions.some((r) => r.id === id)) {
|
||||
onAnnotationSpanChange?.(id, span);
|
||||
} else if (blurRegions.some((r) => r.id === id)) {
|
||||
onBlurSpanChange?.(id, span);
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -1342,10 +1421,12 @@ export default function TimelineEditor({
|
||||
trimRegions,
|
||||
speedRegions,
|
||||
annotationRegions,
|
||||
blurRegions,
|
||||
onZoomSpanChange,
|
||||
onTrimSpanChange,
|
||||
onSpeedSpanChange,
|
||||
onAnnotationSpanChange,
|
||||
onBlurSpanChange,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1403,6 +1484,25 @@ 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"
|
||||
@@ -1489,10 +1589,12 @@ 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}
|
||||
/>
|
||||
|
||||
@@ -47,7 +47,7 @@ export interface TrimRegion {
|
||||
endMs: number;
|
||||
}
|
||||
|
||||
export type AnnotationType = "text" | "image" | "figure";
|
||||
export type AnnotationType = "text" | "image" | "figure" | "blur";
|
||||
|
||||
export type ArrowDirection =
|
||||
| "up"
|
||||
@@ -65,6 +65,27 @@ export interface FigureData {
|
||||
strokeWidth: number;
|
||||
}
|
||||
|
||||
export type BlurShape = "rectangle" | "oval" | "freehand";
|
||||
export type BlurType = "blur" | "mosaic";
|
||||
export type BlurColor = "white" | "black";
|
||||
|
||||
export const MIN_BLUR_INTENSITY = 2;
|
||||
export const MAX_BLUR_INTENSITY = 40;
|
||||
export const DEFAULT_BLUR_INTENSITY = 12;
|
||||
export const MIN_BLUR_BLOCK_SIZE = 4;
|
||||
export const MAX_BLUR_BLOCK_SIZE = 48;
|
||||
export const DEFAULT_BLUR_BLOCK_SIZE = 12;
|
||||
|
||||
export interface BlurData {
|
||||
type: BlurType;
|
||||
shape: BlurShape;
|
||||
color: BlurColor;
|
||||
intensity: number;
|
||||
blockSize: number;
|
||||
// Points are normalized (0-100) within the annotation bounds.
|
||||
freehandPoints?: Array<{ x: number; y: number }>;
|
||||
}
|
||||
|
||||
export interface AnnotationPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -99,6 +120,7 @@ export interface AnnotationRegion {
|
||||
style: AnnotationTextStyle;
|
||||
zIndex: number;
|
||||
figureData?: FigureData;
|
||||
blurData?: BlurData;
|
||||
}
|
||||
|
||||
export const DEFAULT_ANNOTATION_POSITION: AnnotationPosition = {
|
||||
@@ -128,6 +150,27 @@ export const DEFAULT_FIGURE_DATA: FigureData = {
|
||||
strokeWidth: 4,
|
||||
};
|
||||
|
||||
export const DEFAULT_BLUR_FREEHAND_POINTS: Array<{ x: number; y: number }> = [
|
||||
{ x: 10, y: 30 },
|
||||
{ x: 25, y: 10 },
|
||||
{ x: 55, y: 8 },
|
||||
{ x: 82, y: 20 },
|
||||
{ x: 90, y: 45 },
|
||||
{ x: 78, y: 72 },
|
||||
{ x: 52, y: 90 },
|
||||
{ x: 22, y: 84 },
|
||||
{ x: 8, y: 58 },
|
||||
];
|
||||
|
||||
export const DEFAULT_BLUR_DATA: BlurData = {
|
||||
type: "blur",
|
||||
shape: "rectangle",
|
||||
color: "white",
|
||||
intensity: DEFAULT_BLUR_INTENSITY,
|
||||
blockSize: DEFAULT_BLUR_BLOCK_SIZE,
|
||||
freehandPoints: DEFAULT_BLUR_FREEHAND_POINTS,
|
||||
};
|
||||
|
||||
export interface CropRegion {
|
||||
x: number;
|
||||
y: number;
|
||||
|
||||
@@ -140,7 +140,7 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
||||
screenRect.y,
|
||||
screenRect.width,
|
||||
screenRect.height,
|
||||
compositeLayout.screenCover ? 0 : borderRadius,
|
||||
compositeLayout.screenBorderRadius ?? (compositeLayout.screenCover ? 0 : borderRadius),
|
||||
);
|
||||
maskGraphics.fill({ color: 0xffffff });
|
||||
|
||||
|
||||
@@ -90,8 +90,10 @@ export function computeZoomTransform({
|
||||
}
|
||||
|
||||
const progress = Math.min(1, Math.max(0, zoomProgress));
|
||||
const focusStagePxX = baseMask.x + focusX * baseMask.width;
|
||||
const focusStagePxY = baseMask.y + focusY * baseMask.height;
|
||||
// Focus coordinates are stage-normalized (0-1 of full canvas),
|
||||
// so map directly to stage pixels, not through baseMask.
|
||||
const focusStagePxX = focusX * stageSize.width;
|
||||
const focusStagePxY = focusY * stageSize.height;
|
||||
const stageCenterX = stageSize.width / 2;
|
||||
const stageCenterY = stageSize.height / 2;
|
||||
const scale = 1 + (zoomScale - 1) * progress;
|
||||
@@ -128,8 +130,8 @@ export function computeFocusFromTransform({
|
||||
const focusStagePxY = (stageCenterY - y) / zoomScale;
|
||||
|
||||
return {
|
||||
cx: (focusStagePxX - baseMask.x) / baseMask.width,
|
||||
cy: (focusStagePxY - baseMask.y) / baseMask.height,
|
||||
cx: focusStagePxX / stageSize.width,
|
||||
cy: focusStagePxY / stageSize.height,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+112
-10
@@ -5,16 +5,11 @@ import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
type I18nNamespace,
|
||||
LOCALE_STORAGE_KEY,
|
||||
type Locale,
|
||||
SUPPORTED_LOCALES,
|
||||
} from "@/i18n/config";
|
||||
import { translate } from "@/i18n/loader";
|
||||
import { DEFAULT_LOCALE, type I18nNamespace, LOCALE_STORAGE_KEY, type Locale } from "@/i18n/config";
|
||||
import { getAvailableLocales, translate } from "@/i18n/loader";
|
||||
|
||||
type TranslateVars = Record<string, string | number>;
|
||||
|
||||
@@ -22,8 +17,14 @@ interface I18nContextValue {
|
||||
locale: Locale;
|
||||
setLocale: (locale: Locale) => void;
|
||||
t: (qualifiedKey: string, vars?: TranslateVars) => string;
|
||||
systemLocaleSuggestion: Locale | null;
|
||||
acceptSystemLocaleSuggestion: () => void;
|
||||
dismissSystemLocaleSuggestion: () => void;
|
||||
resolveSystemLocaleSuggestion: () => void;
|
||||
}
|
||||
|
||||
const SYSTEM_LANGUAGE_PROMPT_SEEN_KEY = "openscreen-system-language-prompt-seen";
|
||||
|
||||
const I18nContext = createContext<I18nContextValue | null>(null);
|
||||
|
||||
export function useI18n(): I18nContextValue {
|
||||
@@ -41,7 +42,37 @@ export function useScopedT(namespace: I18nNamespace) {
|
||||
}
|
||||
|
||||
function isSupportedLocale(value: string): value is Locale {
|
||||
return (SUPPORTED_LOCALES as readonly string[]).includes(value);
|
||||
return getAvailableLocales().includes(value);
|
||||
}
|
||||
|
||||
function getSupportedSystemLocale(): Locale | null {
|
||||
if (typeof navigator === "undefined") return null;
|
||||
const availableLocales = getAvailableLocales();
|
||||
|
||||
const candidates =
|
||||
Array.isArray(navigator.languages) && navigator.languages.length > 0
|
||||
? navigator.languages
|
||||
: [navigator.language];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue;
|
||||
if (isSupportedLocale(candidate)) return candidate;
|
||||
|
||||
const exactMatch = availableLocales.find(
|
||||
(locale) => locale.toLowerCase() === candidate.toLowerCase(),
|
||||
);
|
||||
if (exactMatch) return exactMatch;
|
||||
|
||||
const baseLanguage = candidate.split("-")[0]?.toLowerCase();
|
||||
if (!baseLanguage) continue;
|
||||
|
||||
if (baseLanguage === "zh" && availableLocales.includes("zh-CN")) return "zh-CN";
|
||||
|
||||
const baseMatch = availableLocales.find((locale) => locale.toLowerCase() === baseLanguage);
|
||||
if (baseMatch) return baseMatch;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getInitialLocale(): Locale {
|
||||
@@ -56,6 +87,16 @@ function getInitialLocale(): Locale {
|
||||
|
||||
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
const [locale, setLocaleState] = useState<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);
|
||||
@@ -73,6 +114,48 @@ export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
document.documentElement.lang = locale;
|
||||
}, [locale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasRunSystemLocaleCheckRef.current) return;
|
||||
hasRunSystemLocaleCheckRef.current = true;
|
||||
|
||||
let hasStoredLocale = false;
|
||||
let hasHandledSystemPrompt = false;
|
||||
try {
|
||||
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||
hasStoredLocale = Boolean(stored && isSupportedLocale(stored));
|
||||
hasHandledSystemPrompt = localStorage.getItem(SYSTEM_LANGUAGE_PROMPT_SEEN_KEY) === "1";
|
||||
} catch {
|
||||
// localStorage may be unavailable
|
||||
}
|
||||
|
||||
if (hasStoredLocale || hasHandledSystemPrompt) return;
|
||||
|
||||
const detectedSystemLocale = getSupportedSystemLocale();
|
||||
if (!detectedSystemLocale || detectedSystemLocale === DEFAULT_LOCALE) {
|
||||
markPromptAsHandled();
|
||||
return;
|
||||
}
|
||||
|
||||
setSystemLocaleSuggestion(detectedSystemLocale);
|
||||
}, [markPromptAsHandled]);
|
||||
|
||||
const acceptSystemLocaleSuggestion = useCallback(() => {
|
||||
if (!systemLocaleSuggestion) return;
|
||||
setLocale(systemLocaleSuggestion);
|
||||
setSystemLocaleSuggestion(null);
|
||||
markPromptAsHandled();
|
||||
}, [markPromptAsHandled, setLocale, systemLocaleSuggestion]);
|
||||
|
||||
const dismissSystemLocaleSuggestion = useCallback(() => {
|
||||
setSystemLocaleSuggestion(null);
|
||||
markPromptAsHandled();
|
||||
}, [markPromptAsHandled]);
|
||||
|
||||
const resolveSystemLocaleSuggestion = useCallback(() => {
|
||||
setSystemLocaleSuggestion(null);
|
||||
markPromptAsHandled();
|
||||
}, [markPromptAsHandled]);
|
||||
|
||||
const t = useCallback(
|
||||
(qualifiedKey: string, vars?: TranslateVars): string => {
|
||||
const dotIndex = qualifiedKey.indexOf(".");
|
||||
@@ -84,7 +167,26 @@ export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
[locale],
|
||||
);
|
||||
|
||||
const value = useMemo<I18nContextValue>(() => ({ locale, setLocale, t }), [locale, setLocale, t]);
|
||||
const value = useMemo<I18nContextValue>(
|
||||
() => ({
|
||||
locale,
|
||||
setLocale,
|
||||
t,
|
||||
systemLocaleSuggestion,
|
||||
acceptSystemLocaleSuggestion,
|
||||
dismissSystemLocaleSuggestion,
|
||||
resolveSystemLocaleSuggestion,
|
||||
}),
|
||||
[
|
||||
locale,
|
||||
setLocale,
|
||||
t,
|
||||
systemLocaleSuggestion,
|
||||
acceptSystemLocaleSuggestion,
|
||||
dismissSystemLocaleSuggestion,
|
||||
resolveSystemLocaleSuggestion,
|
||||
],
|
||||
);
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
DEFAULT_WEBCAM_POSITION,
|
||||
DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
} from "@/components/video-editor/types";
|
||||
import { DEFAULT_WALLPAPER } from "@/lib/wallpaper";
|
||||
import type { AspectRatio } from "@/utils/aspectRatioUtils";
|
||||
|
||||
// Undoable state — selection IDs are intentionally excluded (undoing a
|
||||
@@ -46,7 +47,7 @@ export const INITIAL_EDITOR_STATE: EditorState = {
|
||||
speedRegions: [],
|
||||
annotationRegions: [],
|
||||
cropRegion: DEFAULT_CROP_REGION,
|
||||
wallpaper: "/wallpapers/wallpaper1.jpg",
|
||||
wallpaper: DEFAULT_WALLPAPER,
|
||||
shadowIntensity: 0,
|
||||
showBlur: false,
|
||||
motionBlurAmount: 0,
|
||||
|
||||
+276
-38
@@ -110,6 +110,10 @@ 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 =
|
||||
@@ -118,11 +122,15 @@ 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=av1",
|
||||
"video/webm;codecs=h264",
|
||||
"video/webm;codecs=vp9",
|
||||
"video/webm;codecs=vp8",
|
||||
"video/webm;codecs=vp9",
|
||||
"video/webm;codecs=av1",
|
||||
"video/webm",
|
||||
];
|
||||
|
||||
@@ -158,10 +166,6 @@ 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.
|
||||
@@ -194,6 +198,85 @@ 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,
|
||||
@@ -225,6 +308,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
try {
|
||||
const screenBlob = await activeScreenRecorder.recordedBlobPromise;
|
||||
if (discardRecordingId.current === activeRecordingId) {
|
||||
window.electronAPI?.discardCursorTelemetry(activeRecordingId);
|
||||
return;
|
||||
}
|
||||
if (screenBlob.size === 0) {
|
||||
@@ -334,7 +418,10 @@ 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;
|
||||
@@ -365,7 +452,117 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
};
|
||||
}, [teardownMedia]);
|
||||
|
||||
const startRecording = async () => {
|
||||
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) => {
|
||||
try {
|
||||
const selectedSource = await window.electronAPI.getSelectedSource();
|
||||
if (!selectedSource) {
|
||||
@@ -373,6 +570,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isCountdownRunActive(countdownRunToken)) {
|
||||
teardownMedia();
|
||||
return;
|
||||
}
|
||||
|
||||
let screenMediaStream: MediaStream;
|
||||
|
||||
const videoConstraints = {
|
||||
@@ -413,6 +615,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
}
|
||||
screenStream.current = screenMediaStream;
|
||||
|
||||
if (!isCountdownRunActive(countdownRunToken)) {
|
||||
teardownMedia();
|
||||
return;
|
||||
}
|
||||
|
||||
if (microphoneEnabled) {
|
||||
try {
|
||||
microphoneStream.current = await navigator.mediaDevices.getUserMedia({
|
||||
@@ -437,32 +644,35 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
}
|
||||
}
|
||||
|
||||
if (!isCountdownRunActive(countdownRunToken)) {
|
||||
teardownMedia();
|
||||
return;
|
||||
}
|
||||
|
||||
if (webcamEnabled) {
|
||||
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 (!webcamReady.current) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const interval = setInterval(() => {
|
||||
if (webcamReady.current) {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}
|
||||
}, 50);
|
||||
setTimeout(() => {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}, 5000);
|
||||
});
|
||||
} 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 (!webcamStream.current) {
|
||||
webcamAcquireId.current++;
|
||||
setWebcamEnabledState(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isCountdownRunActive(countdownRunToken)) {
|
||||
teardownMedia();
|
||||
return;
|
||||
}
|
||||
|
||||
stream.current = new MediaStream();
|
||||
@@ -505,6 +715,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
);
|
||||
}
|
||||
|
||||
if (!isCountdownRunActive(countdownRunToken)) {
|
||||
teardownMedia();
|
||||
return;
|
||||
}
|
||||
|
||||
let {
|
||||
width = DEFAULT_WIDTH,
|
||||
height = DEFAULT_HEIGHT,
|
||||
@@ -524,6 +739,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
);
|
||||
|
||||
const hasAudio = stream.current.getAudioTracks().length > 0;
|
||||
if (!isCountdownRunActive(countdownRunToken)) {
|
||||
teardownMedia();
|
||||
return;
|
||||
}
|
||||
|
||||
screenRecorder.current = createRecorderHandle(stream.current, {
|
||||
mimeType,
|
||||
videoBitsPerSecond,
|
||||
@@ -553,7 +773,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
setRecording(true);
|
||||
setPaused(false);
|
||||
setElapsedSeconds(0);
|
||||
window.electronAPI?.setRecordingState(true);
|
||||
window.electronAPI?.setRecordingState(true, recordingId.current);
|
||||
|
||||
const activeScreenRecorder = screenRecorder.current;
|
||||
const activeWebcamRecorder = webcamRecorder.current;
|
||||
@@ -635,7 +855,17 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
};
|
||||
|
||||
const toggleRecording = () => {
|
||||
recording ? stopRecording.current() : startRecording();
|
||||
if (recording) {
|
||||
stopRecording.current();
|
||||
return;
|
||||
}
|
||||
|
||||
if (countdownActive) {
|
||||
cancelCountdown();
|
||||
return;
|
||||
}
|
||||
|
||||
void startRecordCountdown();
|
||||
};
|
||||
|
||||
const restartRecording = async () => {
|
||||
@@ -649,7 +879,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
|
||||
restarting.current = true;
|
||||
discardRecordingId.current = activeRecordingId;
|
||||
allowAutoFinalize.current = false;
|
||||
|
||||
const stopPromises = [
|
||||
new Promise<void>((resolve) => {
|
||||
@@ -700,13 +929,22 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
|
||||
const cancelRecording = () => {
|
||||
const activeScreenRecorder = screenRecorder.current;
|
||||
if (!activeScreenRecorder || activeScreenRecorder.recorder.state !== "recording") return;
|
||||
if (
|
||||
activeScreenRecorder?.recorder.state === "recording" ||
|
||||
activeScreenRecorder?.recorder.state === "paused"
|
||||
) {
|
||||
const activeRecordingId = recordingId.current;
|
||||
discardRecordingId.current = activeRecordingId;
|
||||
allowAutoFinalize.current = false;
|
||||
|
||||
const activeRecordingId = recordingId.current;
|
||||
discardRecordingId.current = activeRecordingId;
|
||||
allowAutoFinalize.current = false;
|
||||
stopRecording.current();
|
||||
return;
|
||||
}
|
||||
|
||||
stopRecording.current();
|
||||
if (countdownActive) {
|
||||
cancelCountdown();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config";
|
||||
import enDialogs from "@/i18n/locales/en/dialogs.json";
|
||||
import esDialogs from "@/i18n/locales/es/dialogs.json";
|
||||
import frDialogs from "@/i18n/locales/fr/dialogs.json";
|
||||
import koKRDialogs from "@/i18n/locales/ko-KR/dialogs.json";
|
||||
import trDialogs from "@/i18n/locales/tr/dialogs.json";
|
||||
import zhCNDialogs from "@/i18n/locales/zh-CN/dialogs.json";
|
||||
|
||||
const tutorialHelpKeys = [
|
||||
"triggerLabel",
|
||||
"title",
|
||||
"description",
|
||||
"explanationBefore",
|
||||
"remove",
|
||||
"explanationMiddle",
|
||||
"covered",
|
||||
"explanationAfter",
|
||||
"visualExample",
|
||||
"removed",
|
||||
"kept",
|
||||
"part1",
|
||||
"part2",
|
||||
"part3",
|
||||
"finalVideo",
|
||||
"step1Title",
|
||||
"step1DescriptionBefore",
|
||||
"step1DescriptionAfter",
|
||||
"step2Title",
|
||||
"step2Description",
|
||||
] as const;
|
||||
|
||||
const keysThatMayBeEmpty = new Set<(typeof tutorialHelpKeys)[number]>(["step1DescriptionBefore"]);
|
||||
|
||||
const dialogsByLocale = {
|
||||
en: enDialogs,
|
||||
"zh-CN": zhCNDialogs,
|
||||
es: esDialogs,
|
||||
fr: frDialogs,
|
||||
tr: trDialogs,
|
||||
"ko-KR": koKRDialogs,
|
||||
} satisfies Record<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
+11
-2
@@ -1,5 +1,14 @@
|
||||
export const DEFAULT_LOCALE = "en" as const;
|
||||
export const SUPPORTED_LOCALES = ["en", "zh-CN", "es", , "fr", "tr"] as const;
|
||||
export const SUPPORTED_LOCALES = [
|
||||
"en",
|
||||
"zh-CN",
|
||||
"zh-TW",
|
||||
"es",
|
||||
"fr",
|
||||
"tr",
|
||||
"ko-KR",
|
||||
"ja-JP",
|
||||
] as const;
|
||||
export const I18N_NAMESPACES = [
|
||||
"common",
|
||||
"dialogs",
|
||||
@@ -10,7 +19,7 @@ export const I18N_NAMESPACES = [
|
||||
"timeline",
|
||||
] as const;
|
||||
|
||||
export type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||
export type Locale = string;
|
||||
export type I18nNamespace = (typeof I18N_NAMESPACES)[number];
|
||||
|
||||
export const LOCALE_STORAGE_KEY = "openscreen-locale";
|
||||
|
||||
+71
-6
@@ -1,6 +1,10 @@
|
||||
import { DEFAULT_LOCALE, type I18nNamespace, type Locale } from "./config";
|
||||
import { DEFAULT_LOCALE, I18N_NAMESPACES, type I18nNamespace, type Locale } from "./config";
|
||||
|
||||
type MessageMap = Record<string, unknown>;
|
||||
type LocaleValidationError = {
|
||||
locale: string;
|
||||
missingNamespaces: I18nNamespace[];
|
||||
};
|
||||
|
||||
const modules = import.meta.glob("./locales/**/*.json", { eager: true }) as Record<
|
||||
string,
|
||||
@@ -18,6 +22,62 @@ for (const [path, mod] of Object.entries(modules)) {
|
||||
messages[locale][namespace] = mod.default;
|
||||
}
|
||||
|
||||
const REQUIRED_NAMESPACES = new Set<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;
|
||||
@@ -34,15 +94,18 @@ function interpolate(str: string, vars?: Record<string, string | number>): strin
|
||||
}
|
||||
|
||||
export function getMessages(locale: Locale, namespace: I18nNamespace): MessageMap {
|
||||
return messages[locale]?.[namespace] ?? {};
|
||||
const resolvedLocale = isAvailableLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||
return messages[resolvedLocale]?.[namespace] ?? {};
|
||||
}
|
||||
|
||||
export function getLocaleName(locale: Locale): string {
|
||||
return getMessageValue(messages[locale]?.common, "locale.name") ?? locale;
|
||||
const resolvedLocale = isAvailableLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||
return getMessageValue(messages[resolvedLocale]?.common, "locale.name") ?? locale;
|
||||
}
|
||||
|
||||
export function getLocaleShort(locale: Locale): string {
|
||||
return getMessageValue(messages[locale]?.common, "locale.short") ?? locale;
|
||||
const resolvedLocale = isAvailableLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||
return getMessageValue(messages[resolvedLocale]?.common, "locale.short") ?? locale;
|
||||
}
|
||||
|
||||
export function translate(
|
||||
@@ -52,8 +115,10 @@ export function translate(
|
||||
vars?: Record<string, string | number>,
|
||||
): string {
|
||||
const value =
|
||||
getMessageValue(messages[locale]?.[namespace], key) ??
|
||||
getMessageValue(messages[DEFAULT_LOCALE]?.[namespace], key);
|
||||
getMessageValue(
|
||||
messages[isAvailableLocale(locale) ? locale : DEFAULT_LOCALE]?.[namespace],
|
||||
key,
|
||||
) ?? getMessageValue(messages[DEFAULT_LOCALE]?.[namespace], key);
|
||||
|
||||
if (value == null) return `${namespace}.${key}`;
|
||||
return interpolate(value, vars);
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"open": "Open",
|
||||
"upload": "Upload",
|
||||
"export": "Export",
|
||||
"showInFolder": "Show in Folder",
|
||||
"file": "File",
|
||||
"edit": "Edit",
|
||||
"view": "View",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm"
|
||||
},
|
||||
"loadingVideo": "Loading video...",
|
||||
"errors": {
|
||||
"noVideoLoaded": "No video loaded",
|
||||
"videoNotReady": "Video not ready",
|
||||
@@ -14,6 +15,7 @@
|
||||
"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}}"
|
||||
@@ -36,6 +38,8 @@
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,5 +33,11 @@
|
||||
"recording": {
|
||||
"selectSource": "Please select a source to record"
|
||||
},
|
||||
"language": "Language"
|
||||
"language": "Language",
|
||||
"systemLanguagePrompt": {
|
||||
"title": "Use your system language?",
|
||||
"description": "We detected {{language}} as your system language. Do you want to switch OpenScreen to {{language}}?",
|
||||
"switch": "Switch to {{language}}",
|
||||
"keepDefault": "Keep current language"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"selectPreset": "Select preset",
|
||||
"pictureInPicture": "Picture in Picture",
|
||||
"verticalStack": "Vertical Stack",
|
||||
"dualFrame": "Dual Frame",
|
||||
"webcamShape": "Camera Shape",
|
||||
"webcamSize": "Webcam Size"
|
||||
},
|
||||
@@ -103,6 +104,7 @@
|
||||
"typeText": "Text",
|
||||
"typeImage": "Image",
|
||||
"typeArrow": "Arrow",
|
||||
"typeBlur": "Blur",
|
||||
"textContent": "Text Content",
|
||||
"textPlaceholder": "Enter your text...",
|
||||
"fontStyle": "Font Style",
|
||||
@@ -121,6 +123,18 @@
|
||||
"arrowDirection": "Arrow Direction",
|
||||
"strokeWidth": "Stroke Width: {{width}}px",
|
||||
"arrowColor": "Arrow Color",
|
||||
"blurType": "Blur Type",
|
||||
"blurTypeBlur": "Blur",
|
||||
"blurTypeMosaic": "Mosaic Blur",
|
||||
"blurColor": "Blur Color",
|
||||
"blurColorWhite": "White",
|
||||
"blurColorBlack": "Black",
|
||||
"blurShape": "Blur Shape",
|
||||
"blurIntensity": "Blur Intensity",
|
||||
"mosaicBlockSize": "Mosaic Block Size",
|
||||
"blurShapeRectangle": "Rectangle",
|
||||
"blurShapeOval": "Oval",
|
||||
"blurShapeFreehand": "Freehand",
|
||||
"deleteAnnotation": "Delete Annotation",
|
||||
"shortcutsAndTips": "Shortcuts & Tips",
|
||||
"tipMovePlayhead": "Move playhead to overlapping annotation section and select an item.",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"addTrim": "Add Trim",
|
||||
"addSpeed": "Add Speed",
|
||||
"addAnnotation": "Add Annotation",
|
||||
"addBlur": "Add Blur",
|
||||
"addKeyframe": "Add Keyframe",
|
||||
"deleteSelected": "Delete Selected",
|
||||
"playPause": "Play / Pause"
|
||||
|
||||
@@ -4,21 +4,26 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"open": "Abrir",
|
||||
"upload": "Subir",
|
||||
"export": "Exportar",
|
||||
"showInFolder": "Mostrar en carpeta",
|
||||
"file": "Archivo",
|
||||
"edit": "Editar",
|
||||
"view": "Vista",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"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}}"
|
||||
@@ -30,6 +31,8 @@
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,5 +33,11 @@
|
||||
"recording": {
|
||||
"selectSource": "Por favor selecciona una fuente para grabar"
|
||||
},
|
||||
"language": "Idioma"
|
||||
"language": "Idioma",
|
||||
"systemLanguagePrompt": {
|
||||
"title": "¿Usar el idioma del sistema?",
|
||||
"description": "Detectamos {{language}} como idioma de tu sistema. ¿Quieres cambiar OpenScreen a {{language}}?",
|
||||
"switch": "Cambiar a {{language}}",
|
||||
"keepDefault": "Mantener idioma actual"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,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"
|
||||
},
|
||||
@@ -103,6 +104,7 @@
|
||||
"typeText": "Texto",
|
||||
"typeImage": "Imagen",
|
||||
"typeArrow": "Flecha",
|
||||
"typeBlur": "Desenfoque",
|
||||
"textContent": "Contenido de texto",
|
||||
"textPlaceholder": "Escribe tu texto...",
|
||||
"fontStyle": "Estilo de fuente",
|
||||
@@ -121,6 +123,18 @@
|
||||
"arrowDirection": "Dirección de la flecha",
|
||||
"strokeWidth": "Grosor del trazo: {{width}}px",
|
||||
"arrowColor": "Color de la flecha",
|
||||
"blurType": "Tipo de desenfoque",
|
||||
"blurTypeBlur": "Desenfoque",
|
||||
"blurTypeMosaic": "Desenfoque mosaico",
|
||||
"blurColor": "Color del desenfoque",
|
||||
"blurColorWhite": "Blanco",
|
||||
"blurColorBlack": "Negro",
|
||||
"blurShape": "Forma del desenfoque",
|
||||
"blurIntensity": "Intensidad del desenfoque",
|
||||
"mosaicBlockSize": "Tamano del bloque mosaico",
|
||||
"blurShapeRectangle": "Rectángulo",
|
||||
"blurShapeOval": "Óvalo",
|
||||
"blurShapeFreehand": "Mano alzada",
|
||||
"deleteAnnotation": "Eliminar anotación",
|
||||
"shortcutsAndTips": "Atajos y consejos",
|
||||
"tipMovePlayhead": "Mueve el cabezal de reproducción a la sección de anotación superpuesta y selecciona un elemento.",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"addTrim": "Agregar recorte",
|
||||
"addSpeed": "Agregar velocidad",
|
||||
"addAnnotation": "Agregar anotación",
|
||||
"addBlur": "Agregar desenfoque",
|
||||
"addKeyframe": "Agregar fotograma clave",
|
||||
"deleteSelected": "Eliminar seleccionado",
|
||||
"playPause": "Reproducir / Pausar"
|
||||
|
||||
@@ -4,23 +4,28 @@
|
||||
"suggestZooms": "Sugerir zooms desde el cursor",
|
||||
"addTrim": "Agregar recorte (T)",
|
||||
"addAnnotation": "Agregar anotación (A)",
|
||||
"addSpeed": "Agregar velocidad (S)"
|
||||
"addSpeed": "Agregar velocidad (S)",
|
||||
"addBlur": "Agregar desenfoque (B)"
|
||||
},
|
||||
"hints": {
|
||||
"pressZoom": "Presiona Z para agregar zoom",
|
||||
"pressTrim": "Presiona T para agregar recorte",
|
||||
"pressAnnotation": "Presiona A para agregar anotación",
|
||||
"pressSpeed": "Presiona S para agregar velocidad"
|
||||
"pressSpeed": "Presiona S para agregar velocidad",
|
||||
"pressBlur": "Presiona B para agregar una región de desenfoque"
|
||||
},
|
||||
"labels": {
|
||||
"pan": "Desplazar",
|
||||
"zoom": "Zoom",
|
||||
"trim": "Recortar",
|
||||
"speed": "Velocidad",
|
||||
"zoomItem": "Zoom {{index}}",
|
||||
"trimItem": "Recorte {{index}}",
|
||||
"speedItem": "Velocidad {{index}}",
|
||||
"annotationItem": "Anotación",
|
||||
"imageItem": "Imagen",
|
||||
"emptyText": "Texto vacío"
|
||||
"emptyText": "Texto vacío",
|
||||
"blurItem": "Desenfoque {{index}}"
|
||||
},
|
||||
"emptyState": {
|
||||
"noVideo": "No hay video cargado",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"open": "Ouvrir",
|
||||
"upload": "Téléverser",
|
||||
"export": "Exporter",
|
||||
"showInFolder": "Afficher dans le dossier",
|
||||
"file": "Fichier",
|
||||
"edit": "Éditer",
|
||||
"view": "Affichage",
|
||||
|
||||
@@ -27,10 +27,11 @@
|
||||
"triggerLabel": "Comment fonctionne la coupe",
|
||||
"title": "Comment fonctionne la coupe",
|
||||
"description": "Comprendre comment supprimer les parties indésirables de votre vidéo.",
|
||||
"explanation": "L'outil Coupe fonctionne en définissant les segments que vous souhaitez",
|
||||
"explanationRemove": "supprimer",
|
||||
"explanationCovered": "couvert",
|
||||
"explanationEnd": "par un segment de coupe rouge sera coupé lors de l'export.",
|
||||
"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é",
|
||||
@@ -39,7 +40,8 @@
|
||||
"part3": "Partie 3",
|
||||
"finalVideo": "Vidéo finale",
|
||||
"step1Title": "1. Ajouter une coupe",
|
||||
"step1Description": "Appuyez sur T ou cliquez sur l'icône ciseaux pour marquer une section à supprimer.",
|
||||
"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."
|
||||
},
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
{
|
||||
"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",
|
||||
@@ -8,6 +14,7 @@
|
||||
"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}}"
|
||||
@@ -30,6 +37,8 @@
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,5 +33,11 @@
|
||||
"recording": {
|
||||
"selectSource": "Veuillez sélectionner une source à enregistrer"
|
||||
},
|
||||
"language": "Langue"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,21 @@
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
"playbackSpeed": "Vitesse de lecture",
|
||||
"selectRegion": "Sélectionnez une région de vitesse à ajuster",
|
||||
"deleteRegion": "Supprimer la région de vitesse"
|
||||
"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"
|
||||
@@ -24,7 +33,9 @@
|
||||
"selectPreset": "Choisir un préréglage",
|
||||
"pictureInPicture": "Incrustation d'image",
|
||||
"verticalStack": "Empilement vertical",
|
||||
"webcamShape": "Forme de la caméra"
|
||||
"dualFrame": "Double cadre",
|
||||
"webcamShape": "Forme de la caméra",
|
||||
"webcamSize": "Taille de la caméra"
|
||||
},
|
||||
"effects": {
|
||||
"title": "Effets vidéo",
|
||||
@@ -100,6 +111,7 @@
|
||||
"typeText": "Texte",
|
||||
"typeImage": "Image",
|
||||
"typeArrow": "Flèche",
|
||||
"typeBlur": "Flou",
|
||||
"textContent": "Contenu du texte",
|
||||
"textPlaceholder": "Saisissez votre texte...",
|
||||
"fontStyle": "Style de police",
|
||||
@@ -118,6 +130,18 @@
|
||||
"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.",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"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"
|
||||
|
||||
@@ -4,23 +4,28 @@
|
||||
"suggestZooms": "Suggérer des zooms depuis le curseur",
|
||||
"addTrim": "Ajouter une coupe (T)",
|
||||
"addAnnotation": "Ajouter une annotation (A)",
|
||||
"addSpeed": "Ajouter une vitesse (S)"
|
||||
"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"
|
||||
"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"
|
||||
"emptyText": "Texte vide",
|
||||
"blurItem": "Flou {{index}}"
|
||||
},
|
||||
"emptyState": {
|
||||
"noVideo": "Aucune vidéo chargée",
|
||||
@@ -28,20 +33,20 @@
|
||||
},
|
||||
"errors": {
|
||||
"cannotPlaceZoom": "Impossible de placer le zoom ici",
|
||||
"zoomExistsAtLocation": "Un zoom existe déjà à cet emplacement ou l'espace disponible est insuffisant.",
|
||||
"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'abord un screencast pour générer des suggestions basées sur le curseur.",
|
||||
"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'enregistrement ne contient pas suffisamment de données de mouvement du curseur.",
|
||||
"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'espace disponible est insuffisant.",
|
||||
"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'espace disponible est insuffisant."
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"export": {
|
||||
"complete": "エクスポート完了",
|
||||
"yourFormatReady": "あなたの{{format}}が準備できました",
|
||||
"showInFolder": "フォルダで表示",
|
||||
"finalizingVideo": "ビデオのエクスポートを最終処理中...",
|
||||
"compilingGifProgress": "GIFをコンパイル中... {{progress}}%",
|
||||
"compilingGifWait": "GIFをコンパイル中... しばらくお待ちください",
|
||||
"takeMoment": "少々お待ちください...",
|
||||
"failed": "エクスポートに失敗しました",
|
||||
"tryAgain": "もう一度お試しください",
|
||||
"finalizingVideoTitle": "ビデオの最終処理",
|
||||
"compilingGif": "GIFをコンパイル中",
|
||||
"exportingFormat": "{{format}}をエクスポート中",
|
||||
"compiling": "コンパイル中",
|
||||
"renderingFrames": "フレームをレンダリング中",
|
||||
"processing": "処理中...",
|
||||
"finalizing": "最終処理中...",
|
||||
"compilingStatus": "コンパイル中...",
|
||||
"status": "ステータス",
|
||||
"format": "フォーマット",
|
||||
"frames": "フレーム",
|
||||
"cancelExport": "エクスポートをキャンセル",
|
||||
"savedSuccessfully": "{{format}}を正常に保存しました!"
|
||||
},
|
||||
"tutorial": {
|
||||
"triggerLabel": "トリミングの仕組み",
|
||||
"title": "トリミングの仕組み",
|
||||
"description": "動画の不要な部分を削除する方法について解説します。",
|
||||
"explanationBefore": "トリムツールは、動画から",
|
||||
"remove": "「削除したい部分」",
|
||||
"explanationMiddle": "を指定することで機能します。エクスポート時には、赤い枠で",
|
||||
"covered": "囲まれた部分",
|
||||
"explanationAfter": "がすべてカットされます。",
|
||||
"visualExample": "視覚的な例",
|
||||
"removed": "削除",
|
||||
"kept": "保持",
|
||||
"part1": "パート 1",
|
||||
"part2": "パート 2",
|
||||
"part3": "パート 3",
|
||||
"finalVideo": "完成動画",
|
||||
"step1Title": "1. 削除範囲を追加",
|
||||
"step1DescriptionBefore": "キーボードの",
|
||||
"step1DescriptionAfter": "、またはハサミのアイコンをクリックして、削除したい範囲を指定します。",
|
||||
|
||||
"step2Title": "2. 範囲を調整",
|
||||
"step2Description": "赤い領域の両端をドラッグして、削除したい範囲を正確に調整します。"
|
||||
},
|
||||
"unsavedChanges": {
|
||||
"title": "未保存の変更",
|
||||
"message": "未保存の変更があります。",
|
||||
"detail": "閉じる前にプロジェクトを保存しますか?",
|
||||
"saveAndClose": "保存して閉じる",
|
||||
"discardAndClose": "破棄して閉じる",
|
||||
"loadProject": "プロジェクトを読み込む…",
|
||||
"saveProject": "プロジェクトを保存…",
|
||||
"saveProjectAs": "プロジェクトを名前を付けて保存…"
|
||||
},
|
||||
"fileDialogs": {
|
||||
"saveGif": "エクスポートしたGIFを保存",
|
||||
"saveVideo": "エクスポートしたビデオを保存",
|
||||
"selectVideo": "ビデオファイルを選択",
|
||||
"saveProject": "OpenScreen プロジェクトを保存",
|
||||
"openProject": "OpenScreen プロジェクトを開く",
|
||||
"gifImage": "GIF 画像",
|
||||
"mp4Video": "MP4 ビデオ",
|
||||
"videoFiles": "ビデオファイル",
|
||||
"openscreenProject": "OpenScreen プロジェクト",
|
||||
"allFiles": "すべてのファイル"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"newRecording": {
|
||||
"title": "レコーダーに戻る",
|
||||
"description": "現在の作業内容が保存されました。",
|
||||
"cancel": "キャンセル",
|
||||
"confirm": "確認"
|
||||
},
|
||||
"loadingVideo": "ビデオを読み込み中...",
|
||||
"errors": {
|
||||
"noVideoLoaded": "ビデオが読み込まれていません",
|
||||
"videoNotReady": "ビデオが準備できていません",
|
||||
"unableToDetermineSourcePath": "ソースビデオのパスを特定できません",
|
||||
"failedToSaveGif": "GIFの保存に失敗しました",
|
||||
"gifExportFailed": "GIFのエクスポートに失敗しました",
|
||||
"failedToSaveVideo": "ビデオの保存に失敗しました",
|
||||
"exportFailed": "エクスポートに失敗しました",
|
||||
"exportFailedWithError": "エクスポートに失敗しました: {{error}}",
|
||||
"failedToSaveExport": "エクスポートの保存に失敗しました",
|
||||
"failedToSaveExportedVideo": "エクスポートしたビデオの保存に失敗しました",
|
||||
"failedToRevealInFolder": "フォルダの表示に失敗しました: {{error}}"
|
||||
},
|
||||
"export": {
|
||||
"canceled": "エクスポートがキャンセルされました",
|
||||
"exportedSuccessfully": "{{format}}を正常にエクスポートしました"
|
||||
},
|
||||
"project": {
|
||||
"saveCanceled": "プロジェクトの保存がキャンセルされました",
|
||||
"failedToSave": "プロジェクトの保存に失敗しました",
|
||||
"savedTo": "プロジェクトを保存しました: {{path}}",
|
||||
"failedToLoad": "プロジェクトの読み込みに失敗しました",
|
||||
"invalidFormat": "無効なプロジェクトファイル形式です",
|
||||
"loadedFrom": "プロジェクトを読み込みました: {{path}}"
|
||||
},
|
||||
"recording": {
|
||||
"failedCameraAccess": "カメラのアクセス要求に失敗しました。",
|
||||
"cameraBlocked": "カメラのアクセスがブロックされています。システム設定で有効にして、ウェブカメラを使用してください。",
|
||||
"systemAudioUnavailable": "システムオーディオが利用できません。システムオーディオなしで録画します。",
|
||||
"microphoneDenied": "マイクのアクセスが拒否されました。オーディオなしで録画を続行します。",
|
||||
"cameraDenied": "カメラのアクセスが拒否されました。ウェブカメラなしで録画を続行します。",
|
||||
"permissionDenied": "録画の権限が拒否されました。画面録画を許可してください。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"tooltips": {
|
||||
"hideHUD": "HUDを隠す",
|
||||
"closeApp": "アプリを閉じる",
|
||||
"restartRecording": "録画を再開",
|
||||
"cancelRecording": "録画をキャンセル",
|
||||
"pauseRecording": "録画を一時停止",
|
||||
"resumeRecording": "録画を再開",
|
||||
"openVideoFile": "ビデオファイルを開く",
|
||||
"openProject": "プロジェクトを開く"
|
||||
},
|
||||
"audio": {
|
||||
"enableSystemAudio": "システムオーディオを有効にする",
|
||||
"disableSystemAudio": "システムオーディオを無効にする",
|
||||
"enableMicrophone": "マイクを有効にする",
|
||||
"disableMicrophone": "マイクを無効にする",
|
||||
"defaultMicrophone": "デフォルトのマイク"
|
||||
},
|
||||
"webcam": {
|
||||
"enableWebcam": "ウェブカメラを有効にする",
|
||||
"disableWebcam": "ウェブカメラを無効にする",
|
||||
"defaultCamera": "デフォルトのカメラ",
|
||||
"searching": "検索中...",
|
||||
"noneFound": "カメラが見つかりません",
|
||||
"unavailable": "カメラが利用できません"
|
||||
},
|
||||
"sourceSelector": {
|
||||
"loading": "ソースを読み込み中...",
|
||||
"screens": "画面 ({{count}})",
|
||||
"windows": "ウィンドウ ({{count}})",
|
||||
"defaultSourceName": "画面"
|
||||
},
|
||||
"recording": {
|
||||
"selectSource": "録画するソースを選択してください"
|
||||
},
|
||||
"language": "言語",
|
||||
"systemLanguagePrompt": {
|
||||
"title": "システム言語を使用しますか?",
|
||||
"description": "システム言語として{{language}}が検出されました。OpenScreenを{{language}}に切り替えますか?",
|
||||
"switch": "{{language}}に切り替え",
|
||||
"keepDefault": "現在の言語を保持"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
{
|
||||
"zoom": {
|
||||
"level": "ズーム倍率",
|
||||
"selectRegion": "ズーム範囲を選択して調整",
|
||||
"deleteZoom": "ズームを削除",
|
||||
"focusMode": {
|
||||
"title": "フォーカスモード",
|
||||
"manual": "手動",
|
||||
"auto": "自動",
|
||||
"autoDescription": "カメラが録画中のカーソル位置に追従します"
|
||||
},
|
||||
"speed": {
|
||||
"title": "ズーム速度",
|
||||
"instant": "即時",
|
||||
"fast": "高速",
|
||||
"smooth": "滑らか",
|
||||
"lazy": "遅延"
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
"playbackSpeed": "再生速度",
|
||||
"selectRegion": "速度範囲を選択して調整",
|
||||
"deleteRegion": "速度範囲を削除",
|
||||
"customPlaybackSpeed": "カスタム再生速度",
|
||||
"maxSpeedError": "速度は16×を超えることはできません"
|
||||
},
|
||||
"trim": {
|
||||
"deleteRegion": "トリム範囲を削除"
|
||||
},
|
||||
"layout": {
|
||||
"title": "レイアウト",
|
||||
"preset": "プリセット",
|
||||
"selectPreset": "プリセットを選択",
|
||||
"pictureInPicture": "ピクチャーインピクチャー",
|
||||
"verticalStack": "縦積み",
|
||||
"dualFrame": "デュアルフレーム",
|
||||
"webcamShape": "カメラの形状",
|
||||
"webcamSize": "カメラのサイズ"
|
||||
},
|
||||
"effects": {
|
||||
"title": "ビデオ効果",
|
||||
"blurBg": "背景をぼかす",
|
||||
"motionBlur": "モーションブラー",
|
||||
"off": "オフ",
|
||||
"shadow": "影",
|
||||
"roundness": "丸み",
|
||||
"padding": "余白"
|
||||
},
|
||||
"background": {
|
||||
"title": "背景",
|
||||
"image": "画像",
|
||||
"color": "色",
|
||||
"gradient": "グラデーション",
|
||||
"uploadCustom": "カスタムをアップロード",
|
||||
"gradientLabel": "グラデーション {{index}}"
|
||||
},
|
||||
"crop": {
|
||||
"title": "クロップ",
|
||||
"cropVideo": "ビデオをクロップ",
|
||||
"dragInstruction": "各辺をドラッグしてクロップ範囲を調整",
|
||||
"ratio": "比率",
|
||||
"free": "自由",
|
||||
"done": "完了",
|
||||
"lockAspectRatio": "アスペクト比を固定",
|
||||
"unlockAspectRatio": "アスペクト比の固定を解除"
|
||||
},
|
||||
"exportFormat": {
|
||||
"mp4": "MP4",
|
||||
"gif": "GIF",
|
||||
"mp4Video": "MP4 ビデオ",
|
||||
"mp4Description": "高品質のビデオファイル",
|
||||
"gifAnimation": "GIF アニメーション",
|
||||
"gifDescription": "共有用のアニメーション画像"
|
||||
},
|
||||
"exportQuality": {
|
||||
"title": "エクスポート品質",
|
||||
"low": "低画質",
|
||||
"medium": "中画質",
|
||||
"high": "高画質"
|
||||
},
|
||||
"gifSettings": {
|
||||
"frameRate": "GIF フレームレート",
|
||||
"size": "GIF サイズ",
|
||||
"loop": "GIF をループする"
|
||||
},
|
||||
"project": {
|
||||
"save": "プロジェクトを保存",
|
||||
"load": "プロジェクトを読み込む"
|
||||
},
|
||||
"export": {
|
||||
"videoButton": "ビデオをエクスポート",
|
||||
"gifButton": "GIF をエクスポート",
|
||||
"chooseSaveLocation": "保存場所を選択"
|
||||
},
|
||||
"links": {
|
||||
"reportBug": "バグを報告",
|
||||
"starOnGithub": "GitHub でスターを付ける"
|
||||
},
|
||||
"imageUpload": {
|
||||
"invalidFileType": "無効なファイル形式",
|
||||
"jpgOnly": "JPG または JPEG 画像ファイルをアップロードしてください。",
|
||||
"uploadSuccess": "カスタム画像が正常にアップロードされました!",
|
||||
"failedToUpload": "画像のアップロードに失敗しました",
|
||||
"errorReading": "ファイルの読み取り中にエラーが発生しました。"
|
||||
},
|
||||
"annotation": {
|
||||
"title": "注釈設定",
|
||||
"active": "アクティブ",
|
||||
"typeText": "テキスト",
|
||||
"typeImage": "画像",
|
||||
"typeArrow": "矢印",
|
||||
"typeBlur": "ぼかし",
|
||||
"textContent": "テキスト内容",
|
||||
"textPlaceholder": "テキストを入力してください...",
|
||||
"fontStyle": "フォントスタイル",
|
||||
"selectStyle": "スタイルを選択",
|
||||
"size": "サイズ",
|
||||
"customFonts": "カスタムフォント",
|
||||
"textColor": "文字色",
|
||||
"background": "背景",
|
||||
"none": "なし",
|
||||
"color": "色",
|
||||
"clearBackground": "背景をクリア",
|
||||
"uploadImage": "画像をアップロード",
|
||||
"supportedFormats": "サポートされている形式: JPG, PNG, GIF, WebP",
|
||||
"arrowDirection": "矢印の方向",
|
||||
"strokeWidth": "線の太さ: {{width}}px",
|
||||
"arrowColor": "矢印の色",
|
||||
"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": "言語"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"title": "キーボードショートカット",
|
||||
"customize": "カスタマイズ",
|
||||
"configurable": "設定可能",
|
||||
"fixed": "固定",
|
||||
"pressKey": "キーを押してください…",
|
||||
"clickToChange": "クリックして変更",
|
||||
"pressEscToCancel": "Escキーを押してキャンセル",
|
||||
"helpText": "ショートカットをクリックして新しいキーの組み合わせを押します。Escキーを押してキャンセルします。",
|
||||
"resetToDefaults": "デフォルトにリセット",
|
||||
"alreadyUsedBy": "すでに {{action}} に使用されています",
|
||||
"swap": "入れ替え",
|
||||
"reservedShortcut": "このショートカットは \"{{label}}\" に予約されており、再割り当てできません。",
|
||||
"savedToast": "キーボードショートカットが保存されました",
|
||||
"resetToast": "デフォルトのショートカットにリセット — 保存をクリックして適用",
|
||||
"actions": {
|
||||
"addZoom": "ズームを追加",
|
||||
"addTrim": "トリムを追加",
|
||||
"addSpeed": "速度を追加",
|
||||
"addAnnotation": "注釈を追加",
|
||||
"addBlur": "ぼかしを追加",
|
||||
"addKeyframe": "キーフレームを追加",
|
||||
"deleteSelected": "選択を削除",
|
||||
"playPause": "再生 / 一時停止"
|
||||
},
|
||||
"fixedActions": {
|
||||
"undo": "元に戻す",
|
||||
"redo": "やり直す",
|
||||
"cycleAnnotationsForward": "注釈を順に切り替え",
|
||||
"cycleAnnotationsBackward": "注釈を逆順に切り替え",
|
||||
"deleteSelectedAlt": "選択を削除 (alt)",
|
||||
"panTimeline": "タイムラインをパン",
|
||||
"zoomTimeline": "タイムラインをズーム",
|
||||
"frameBack": "フレームを戻す",
|
||||
"frameForward": "フレームを進める"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"buttons": {
|
||||
"addZoom": "ズームを追加 (Z)",
|
||||
"suggestZooms": "カーソル位置からズームを提案",
|
||||
"addTrim": "トリムを追加 (T)",
|
||||
"addAnnotation": "注釈を追加 (A)",
|
||||
"addBlur": "ぼかしを追加 (B)",
|
||||
"addSpeed": "速度を追加 (S)"
|
||||
},
|
||||
"hints": {
|
||||
"pressZoom": "Zキーを押してズームを追加",
|
||||
"pressTrim": "Tキーを押してトリムを追加",
|
||||
"pressAnnotation": "Aキーを押して注釈を追加",
|
||||
"pressBlur": "Bキーを押してぼかしを追加",
|
||||
"pressSpeed": "Sキーを押して速度を追加"
|
||||
},
|
||||
"labels": {
|
||||
"pan": "移動",
|
||||
"zoom": "ズーム",
|
||||
"trim": "トリム",
|
||||
"speed": "速度",
|
||||
"zoomItem": "ズーム {{index}}",
|
||||
"trimItem": "トリム {{index}}",
|
||||
"speedItem": "速度 {{index}}",
|
||||
"annotationItem": "注釈",
|
||||
"blurItem": "ぼかし {{index}}",
|
||||
"imageItem": "画像",
|
||||
"emptyText": "空のテキスト"
|
||||
},
|
||||
"emptyState": {
|
||||
"noVideo": "ビデオが読み込まれていません",
|
||||
"dragAndDrop": "ビデオをドラッグアンドドロップして編集を開始してください"
|
||||
},
|
||||
"errors": {
|
||||
"cannotPlaceZoom": "ここにズームを配置できません",
|
||||
"zoomExistsAtLocation": "この場所にはすでにズームが存在するか、十分なスペースがありません。",
|
||||
"zoomSuggestionUnavailable": "ズームの自動提案機能が利用できません",
|
||||
"noCursorTelemetry": "カーソルの動きが記録されていません",
|
||||
"noCursorTelemetryDescription": "まず画面収録を行い、カーソルに基づく提案を生成してください。",
|
||||
"noUsableTelemetry": "使用可能なカーソルの動きデータがありません",
|
||||
"noUsableTelemetryDescription": "録画には十分なカーソルの動きデータが含まれていません。",
|
||||
"noDwellMoments": "カーソルが静止したポイントが見つかりません",
|
||||
"noDwellMomentsDescription": "強調したい操作の際に、カーソルを一時停止させて録画してみてください。",
|
||||
"noAutoZoomSlots": "自動ズームを適用できる箇所がありません",
|
||||
"noAutoZoomSlotsDescription": "検出された滞留ポイントが既存のズーム領域と重なっています。",
|
||||
"cannotPlaceTrim": "ここに切り取りを配置できません",
|
||||
"trimExistsAtLocation": "この場所にはすでに切り取りが存在するか、十分なスペースがありません。",
|
||||
"cannotPlaceSpeed": "ここに速度を配置できません",
|
||||
"speedExistsAtLocation": "この場所にはすでに速度が存在するか、十分なスペースがありません。"
|
||||
},
|
||||
"success": {
|
||||
"addedZoomSuggestions": "カーソルに基づくズーム提案を {{count}} 件追加しました",
|
||||
"addedZoomSuggestionsPlural": "カーソルに基づくズーム提案を {{count}} 件追加しました"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"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": "KO"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"export": {
|
||||
"complete": "내보내기 완료",
|
||||
"yourFormatReady": "{{format}} 파일이 준비되었습니다",
|
||||
"showInFolder": "폴더에서 보기",
|
||||
"finalizingVideo": "비디오 내보내기 마무리 중...",
|
||||
"compilingGifProgress": "GIF 생성 중... {{progress}}%",
|
||||
"compilingGifWait": "GIF 생성 중... 잠시 시간이 걸릴 수 있습니다",
|
||||
"takeMoment": "잠시 기다려 주세요...",
|
||||
"failed": "내보내기 실패",
|
||||
"tryAgain": "다시 시도해 주세요",
|
||||
"finalizingVideoTitle": "비디오 마무리 중",
|
||||
"compilingGif": "GIF 생성 중",
|
||||
"exportingFormat": "{{format}} 내보내는 중",
|
||||
"compiling": "생성 중...",
|
||||
"renderingFrames": "프레임 렌더링 중",
|
||||
"processing": "처리 중...",
|
||||
"finalizing": "마무리 중...",
|
||||
"compilingStatus": "생성 중...",
|
||||
"status": "상태",
|
||||
"format": "형식",
|
||||
"frames": "프레임",
|
||||
"cancelExport": "내보내기 취소",
|
||||
"savedSuccessfully": "{{format}} 저장이 완료되었습니다!"
|
||||
},
|
||||
"tutorial": {
|
||||
"triggerLabel": "트리밍 사용법",
|
||||
"title": "트리밍 사용법",
|
||||
"description": "비디오에서 불필요한 부분을 잘라내는 방법을 알아보세요.",
|
||||
"explanationBefore": "트림 도구는 제거할 구간을",
|
||||
"remove": "지정",
|
||||
"explanationMiddle": "하는 방식으로 동작합니다 —",
|
||||
"covered": "빨간 트림 구간으로 덮인",
|
||||
"explanationAfter": "부분은 내보낼 때 잘려나갑니다.",
|
||||
"visualExample": "화면 예시",
|
||||
"removed": "제거됨",
|
||||
"kept": "유지됨",
|
||||
"part1": "파트 1",
|
||||
"part2": "파트 2",
|
||||
"part3": "파트 3",
|
||||
"finalVideo": "최종 비디오",
|
||||
"step1Title": "1. 트림 추가",
|
||||
"step1DescriptionBefore": "",
|
||||
"step1DescriptionAfter": "키를 누르거나 가위 아이콘을 클릭해 제거할 구간을 표시하세요.",
|
||||
"step2Title": "2. 조정",
|
||||
"step2Description": "빨간 구간의 가장자리를 드래그해 잘라낼 범위를 설정하세요."
|
||||
},
|
||||
"unsavedChanges": {
|
||||
"title": "저장되지 않은 변경 사항",
|
||||
"message": "저장되지 않은 변경 사항이 있습니다.",
|
||||
"detail": "닫기 전에 프로젝트를 저장하시겠습니까?",
|
||||
"saveAndClose": "저장 후 닫기",
|
||||
"discardAndClose": "저장하지 않고 닫기",
|
||||
"loadProject": "프로젝트 불러오기...",
|
||||
"saveProject": "프로젝트 저장...",
|
||||
"saveProjectAs": "다른 이름으로 프로젝트 저장..."
|
||||
},
|
||||
"fileDialogs": {
|
||||
"saveGif": "내보낸 GIF 저장",
|
||||
"saveVideo": "내보낸 비디오 저장",
|
||||
"selectVideo": "비디오 파일 선택",
|
||||
"saveProject": "OpenScreen 프로젝트 저장",
|
||||
"openProject": "OpenScreen 프로젝트 열기",
|
||||
"gifImage": "GIF 이미지",
|
||||
"mp4Video": "MP4 비디오",
|
||||
"videoFiles": "비디오 파일",
|
||||
"openscreenProject": "OpenScreen 프로젝트",
|
||||
"allFiles": "모든 파일"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"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": "카메라 접근이 거부되었습니다. 웹캠 없이 녹화를 계속합니다.",
|
||||
"permissionDenied": "녹화 권한이 거부되었습니다. 화면 녹화를 허용해 주세요."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"tooltips": {
|
||||
"hideHUD": "HUD 숨기기",
|
||||
"closeApp": "앱 닫기",
|
||||
"restartRecording": "녹화 다시 시작",
|
||||
"cancelRecording": "녹화 취소",
|
||||
"pauseRecording": "녹화 일시정지",
|
||||
"resumeRecording": "녹화 재개",
|
||||
"openVideoFile": "비디오 파일 열기",
|
||||
"openProject": "프로젝트 열기"
|
||||
},
|
||||
"audio": {
|
||||
"enableSystemAudio": "시스템 오디오 활성화",
|
||||
"disableSystemAudio": "시스템 오디오 비활성화",
|
||||
"enableMicrophone": "마이크 활성화",
|
||||
"disableMicrophone": "마이크 비활성화",
|
||||
"defaultMicrophone": "기본 마이크"
|
||||
},
|
||||
"webcam": {
|
||||
"enableWebcam": "웹캠 활성화",
|
||||
"disableWebcam": "웹캠 비활성화",
|
||||
"defaultCamera": "기본 카메라",
|
||||
"searching": "검색 중...",
|
||||
"noneFound": "카메라를 찾을 수 없음",
|
||||
"unavailable": "카메라를 사용할 수 없음"
|
||||
},
|
||||
"sourceSelector": {
|
||||
"loading": "소스 불러오는 중...",
|
||||
"screens": "화면 ({{count}}개)",
|
||||
"windows": "창 ({{count}}개)",
|
||||
"defaultSourceName": "화면"
|
||||
},
|
||||
"recording": {
|
||||
"selectSource": "녹화할 소스를 선택해 주세요"
|
||||
},
|
||||
"language": "언어"
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
{
|
||||
"zoom": {
|
||||
"level": "줌 레벨",
|
||||
"selectRegion": "조정할 줌 구간을 선택하세요",
|
||||
"deleteZoom": "줌 삭제",
|
||||
"focusMode": {
|
||||
"title": "포커스 모드",
|
||||
"manual": "수동",
|
||||
"auto": "자동",
|
||||
"autoDescription": "녹화된 커서 위치를 따라 카메라가 이동합니다"
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
"playbackSpeed": "재생 속도",
|
||||
"selectRegion": "조정할 속도 구간을 선택하세요",
|
||||
"deleteRegion": "속도 구간 삭제",
|
||||
"customPlaybackSpeed": "재생 속도 직접 입력",
|
||||
"maxSpeedError": "속도는 16×를 초과할 수 없습니다"
|
||||
},
|
||||
"trim": {
|
||||
"deleteRegion": "트림 구간 삭제"
|
||||
},
|
||||
"layout": {
|
||||
"title": "레이아웃",
|
||||
"preset": "프리셋",
|
||||
"selectPreset": "프리셋 선택",
|
||||
"pictureInPicture": "화면 속 화면",
|
||||
"verticalStack": "세로 배치",
|
||||
"webcamShape": "카메라 모양",
|
||||
"webcamSize": "웹캠 크기"
|
||||
},
|
||||
"effects": {
|
||||
"title": "비디오 효과",
|
||||
"blurBg": "배경 흐림",
|
||||
"motionBlur": "모션 블러",
|
||||
"off": "끄기",
|
||||
"shadow": "그림자",
|
||||
"roundness": "모서리 둥글기",
|
||||
"padding": "여백"
|
||||
},
|
||||
"background": {
|
||||
"title": "배경",
|
||||
"image": "이미지",
|
||||
"color": "색상",
|
||||
"gradient": "그라디언트",
|
||||
"uploadCustom": "직접 업로드",
|
||||
"gradientLabel": "그라디언트 {{index}}"
|
||||
},
|
||||
"crop": {
|
||||
"title": "자르기",
|
||||
"cropVideo": "비디오 자르기",
|
||||
"dragInstruction": "각 면을 드래그해 자르기 영역을 조정하세요",
|
||||
"ratio": "비율",
|
||||
"free": "자유",
|
||||
"done": "완료",
|
||||
"lockAspectRatio": "화면 비율 고정",
|
||||
"unlockAspectRatio": "화면 비율 해제"
|
||||
},
|
||||
"exportFormat": {
|
||||
"mp4": "MP4",
|
||||
"gif": "GIF",
|
||||
"mp4Video": "MP4 비디오",
|
||||
"mp4Description": "고화질 비디오 파일",
|
||||
"gifAnimation": "GIF 애니메이션",
|
||||
"gifDescription": "공유용 애니메이션 이미지"
|
||||
},
|
||||
"exportQuality": {
|
||||
"title": "내보내기 품질",
|
||||
"low": "낮음",
|
||||
"medium": "보통",
|
||||
"high": "높음"
|
||||
},
|
||||
"gifSettings": {
|
||||
"frameRate": "GIF 프레임 속도",
|
||||
"size": "GIF 크기",
|
||||
"loop": "GIF 반복"
|
||||
},
|
||||
"project": {
|
||||
"save": "프로젝트 저장",
|
||||
"load": "프로젝트 불러오기"
|
||||
},
|
||||
"export": {
|
||||
"videoButton": "비디오 내보내기",
|
||||
"gifButton": "GIF 내보내기",
|
||||
"chooseSaveLocation": "저장 위치 선택"
|
||||
},
|
||||
"links": {
|
||||
"reportBug": "버그 신고",
|
||||
"starOnGithub": "GitHub에 Star 남기기"
|
||||
},
|
||||
"imageUpload": {
|
||||
"invalidFileType": "지원하지 않는 파일 형식입니다",
|
||||
"jpgOnly": "JPG 또는 JPEG 이미지 파일을 업로드해 주세요.",
|
||||
"uploadSuccess": "커스텀 이미지가 성공적으로 업로드되었습니다!",
|
||||
"failedToUpload": "이미지 업로드에 실패했습니다",
|
||||
"errorReading": "파일을 읽는 중 오류가 발생했습니다."
|
||||
},
|
||||
"annotation": {
|
||||
"title": "주석 설정",
|
||||
"active": "활성",
|
||||
"typeText": "텍스트",
|
||||
"typeImage": "이미지",
|
||||
"typeArrow": "화살표",
|
||||
"textContent": "텍스트 내용",
|
||||
"textPlaceholder": "텍스트를 입력하세요...",
|
||||
"fontStyle": "폰트 스타일",
|
||||
"selectStyle": "스타일 선택",
|
||||
"size": "크기",
|
||||
"customFonts": "커스텀 폰트",
|
||||
"textColor": "텍스트 색상",
|
||||
"background": "배경",
|
||||
"none": "없음",
|
||||
"color": "색상",
|
||||
"clearBackground": "배경 지우기",
|
||||
"uploadImage": "이미지 업로드",
|
||||
"supportedFormats": "지원 형식: JPG, PNG, GIF, WebP",
|
||||
"arrowDirection": "화살표 방향",
|
||||
"strokeWidth": "선 두께: {{width}}px",
|
||||
"arrowColor": "화살표 색상",
|
||||
"deleteAnnotation": "주석 삭제",
|
||||
"shortcutsAndTips": "단축키 및 팁",
|
||||
"tipMovePlayhead": "재생 헤드를 주석 구간으로 옮겨 항목을 선택하세요.",
|
||||
"tipTabCycle": "Tab 키로 겹치는 항목을 순환할 수 있습니다.",
|
||||
"tipShiftTabCycle": "Shift+Tab으로 역방향 순환할 수 있습니다.",
|
||||
"invalidImageType": "지원하지 않는 파일 형식입니다",
|
||||
"imageFormatsOnly": "JPG, PNG, GIF 또는 WebP 이미지 파일을 업로드해 주세요.",
|
||||
"imageUploadSuccess": "이미지가 성공적으로 업로드되었습니다!",
|
||||
"failedImageUpload": "이미지 업로드에 실패했습니다"
|
||||
},
|
||||
"fontStyles": {
|
||||
"classic": "클래식",
|
||||
"editor": "에디터",
|
||||
"strong": "강조",
|
||||
"typewriter": "타자기",
|
||||
"deco": "데코",
|
||||
"simple": "심플",
|
||||
"modern": "모던",
|
||||
"clean": "클린"
|
||||
},
|
||||
"customFont": {
|
||||
"dialogTitle": "Google 폰트 추가",
|
||||
"urlLabel": "Google Fonts 가져오기 URL",
|
||||
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
|
||||
"urlHelp": "Google Fonts에서 폰트 선택 → \"폰트 가져오기\" 클릭 → @import URL 복사",
|
||||
"nameLabel": "표시 이름",
|
||||
"namePlaceholder": "내 커스텀 폰트",
|
||||
"nameHelp": "폰트 선택기에서 표시될 이름입니다",
|
||||
"addButton": "폰트 추가",
|
||||
"addingButton": "추가 중...",
|
||||
"errorEmptyUrl": "Google Fonts 가져오기 URL을 입력해 주세요",
|
||||
"errorInvalidUrl": "유효한 Google Fonts URL을 입력해 주세요",
|
||||
"errorEmptyName": "폰트 이름을 입력해 주세요",
|
||||
"errorExtractFailed": "URL에서 폰트 패밀리를 추출할 수 없습니다",
|
||||
"successMessage": "\"{{fontName}}\" 폰트가 성공적으로 추가되었습니다",
|
||||
"failedToAdd": "폰트 추가에 실패했습니다",
|
||||
"errorTimeout": "폰트 로딩 시간이 초과되었습니다. URL을 확인하고 다시 시도해 주세요.",
|
||||
"errorLoadFailed": "폰트를 불러올 수 없습니다. Google Fonts URL이 올바른지 확인해 주세요."
|
||||
},
|
||||
"language": {
|
||||
"title": "언어"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"title": "키보드 단축키",
|
||||
"customize": "사용자 지정",
|
||||
"configurable": "변경 가능",
|
||||
"fixed": "고정",
|
||||
"pressKey": "키를 누르세요...",
|
||||
"clickToChange": "클릭해서 변경",
|
||||
"pressEscToCancel": "Esc를 눌러 취소",
|
||||
"helpText": "단축키를 클릭한 후 새 키 조합을 누르세요. 취소하려면 Esc를 누르세요.",
|
||||
"resetToDefaults": "기본값으로 초기화",
|
||||
"alreadyUsedBy": "이미 {{action}}에서 사용 중입니다",
|
||||
"swap": "교체",
|
||||
"reservedShortcut": "이 단축키는 \"{{label}}\"에 예약되어 있어 변경할 수 없습니다.",
|
||||
"savedToast": "키보드 단축키가 저장되었습니다",
|
||||
"resetToast": "기본 단축키로 초기화되었습니다 — 저장을 클릭해 적용하세요",
|
||||
"actions": {
|
||||
"addZoom": "줌 추가",
|
||||
"addTrim": "트림 추가",
|
||||
"addSpeed": "속도 추가",
|
||||
"addAnnotation": "주석 추가",
|
||||
"addKeyframe": "키프레임 추가",
|
||||
"deleteSelected": "선택 항목 삭제",
|
||||
"playPause": "재생 / 일시정지"
|
||||
},
|
||||
"fixedActions": {
|
||||
"undo": "실행 취소",
|
||||
"redo": "다시 실행",
|
||||
"cycleAnnotationsForward": "주석 앞으로 순환",
|
||||
"cycleAnnotationsBackward": "주석 뒤로 순환",
|
||||
"deleteSelectedAlt": "선택 항목 삭제 (대체)",
|
||||
"panTimeline": "타임라인 이동",
|
||||
"zoomTimeline": "타임라인 확대/축소",
|
||||
"frameBack": "이전 프레임",
|
||||
"frameForward": "다음 프레임"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"buttons": {
|
||||
"addZoom": "줌 추가 (Z)",
|
||||
"suggestZooms": "커서 기반 줌 제안",
|
||||
"addTrim": "트림 추가 (T)",
|
||||
"addAnnotation": "주석 추가 (A)",
|
||||
"addSpeed": "속도 추가 (S)"
|
||||
},
|
||||
"hints": {
|
||||
"pressZoom": "Z를 눌러 줌 추가",
|
||||
"pressTrim": "T를 눌러 트림 추가",
|
||||
"pressAnnotation": "A를 눌러 주석 추가",
|
||||
"pressSpeed": "S를 눌러 속도 추가"
|
||||
},
|
||||
"labels": {
|
||||
"pan": "이동",
|
||||
"zoom": "줌",
|
||||
"trim": "트림",
|
||||
"speed": "속도",
|
||||
"zoomItem": "줌 {{index}}",
|
||||
"trimItem": "트림 {{index}}",
|
||||
"speedItem": "속도 {{index}}",
|
||||
"annotationItem": "주석",
|
||||
"imageItem": "이미지",
|
||||
"emptyText": "빈 텍스트"
|
||||
},
|
||||
"emptyState": {
|
||||
"noVideo": "불러온 비디오 없음",
|
||||
"dragAndDrop": "비디오를 드래그 앤 드롭해서 편집을 시작하세요"
|
||||
},
|
||||
"errors": {
|
||||
"cannotPlaceZoom": "이 위치에 줌을 추가할 수 없습니다",
|
||||
"zoomExistsAtLocation": "이 위치에 이미 줌이 있거나 공간이 부족합니다.",
|
||||
"zoomSuggestionUnavailable": "줌 제안 기능을 사용할 수 없습니다",
|
||||
"noCursorTelemetry": "커서 데이터가 없습니다",
|
||||
"noCursorTelemetryDescription": "커서 기반 제안을 생성하려면 먼저 화면을 녹화해 주세요.",
|
||||
"noUsableTelemetry": "사용 가능한 커서 데이터가 없습니다",
|
||||
"noUsableTelemetryDescription": "녹화에 충분한 커서 이동 데이터가 포함되어 있지 않습니다.",
|
||||
"noDwellMoments": "명확한 커서 정지 구간을 찾을 수 없습니다",
|
||||
"noDwellMomentsDescription": "중요한 동작에서 커서를 천천히 멈추며 녹화해 보세요.",
|
||||
"noAutoZoomSlots": "자동 줌 슬롯이 없습니다",
|
||||
"noAutoZoomSlotsDescription": "감지된 정지 지점이 기존 줌 구간과 겹칩니다.",
|
||||
"cannotPlaceTrim": "이 위치에 트림을 추가할 수 없습니다",
|
||||
"trimExistsAtLocation": "이 위치에 이미 트림이 있거나 공간이 부족합니다.",
|
||||
"cannotPlaceSpeed": "이 위치에 속도를 추가할 수 없습니다",
|
||||
"speedExistsAtLocation": "이 위치에 이미 속도 구간이 있거나 공간이 부족합니다."
|
||||
},
|
||||
"success": {
|
||||
"addedZoomSuggestions": "커서 기반 줌 제안 {{count}}개가 추가되었습니다",
|
||||
"addedZoomSuggestionsPlural": "커서 기반 줌 제안 {{count}}개가 추가되었습니다"
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
"open": "Aç",
|
||||
"upload": "Yükle",
|
||||
"export": "Dışa Aktar",
|
||||
"showInFolder": "Klasörde Göster",
|
||||
"file": "Dosya",
|
||||
"edit": "Düzenle",
|
||||
"view": "Görünüm",
|
||||
|
||||
@@ -27,10 +27,11 @@
|
||||
"triggerLabel": "Kırpma nasıl çalışır",
|
||||
"title": "Kırpma Nasıl Çalışır",
|
||||
"description": "Videonuzun istenmeyen bölümlerini nasıl keseceğinizi anlayın.",
|
||||
"explanation": "Kırpma aracı, kaldırmak istediğiniz bölümleri tanımlayarak çalışır.",
|
||||
"explanationRemove": "kaldırmak",
|
||||
"explanationCovered": "kaplanan",
|
||||
"explanationEnd": "kırmızı kırpma bölgesi ile işaretlenen kısımlar dışa aktarımda kesilecektir.",
|
||||
"explanationBefore": "Kırpma aracı, istediğiniz bölümleri",
|
||||
"remove": "kaldırmak",
|
||||
"explanationMiddle": " için kullanılır; kırmızı kırpma bölgesiyle",
|
||||
"covered": "kaplanan",
|
||||
"explanationAfter": "her şey dışa aktarımda kesilecektir.",
|
||||
"visualExample": "Görsel Örnek",
|
||||
"removed": "KALDIRILDI",
|
||||
"kept": "Korundu",
|
||||
@@ -39,7 +40,8 @@
|
||||
"part3": "Bölüm 3",
|
||||
"finalVideo": "Son Video",
|
||||
"step1Title": "1. Kırpma Ekle",
|
||||
"step1Description": "Kaldırılacak bölümü işaretlemek için T tuşuna basın veya makas simgesine tıklayın.",
|
||||
"step1DescriptionBefore": "Kaldırılacak bölümü işaretlemek için ",
|
||||
"step1DescriptionAfter": " tuşuna basın veya makas simgesine tıklayın.",
|
||||
"step2Title": "2. Ayarla",
|
||||
"step2Description": "Kesmek istediğiniz kısmı tam olarak kaplamak için kırmızı bölgenin kenarlarını sürükleyin."
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"failedToSaveVideo": "Video kaydedilemedi",
|
||||
"exportFailed": "Dışa aktarım başarısız oldu",
|
||||
"exportFailedWithError": "Dışa aktarım başarısız: {{error}}",
|
||||
"exportBackgroundLoadFailed": "Dışa aktarım başarısız: arka plan görüntüsü yüklenemedi ({{url}})",
|
||||
"failedToSaveExport": "Dışa aktarım kaydedilemedi",
|
||||
"failedToSaveExportedVideo": "Dışa aktarılan video kaydedilemedi",
|
||||
"failedToRevealInFolder": "Klasörde gösterme hatası: {{error}}"
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"typeText": "Metin",
|
||||
"typeImage": "Görüntü",
|
||||
"typeArrow": "Ok",
|
||||
"typeBlur": "Bulanık",
|
||||
"textContent": "Metin İçeriği",
|
||||
"textPlaceholder": "Metninizi girin...",
|
||||
"fontStyle": "Yazı Tipi Stili",
|
||||
@@ -118,6 +119,11 @@
|
||||
"arrowDirection": "Ok Yönü",
|
||||
"strokeWidth": "Çizgi Kalınlığı: {{width}}px",
|
||||
"arrowColor": "Ok Rengi",
|
||||
"blurShape": "Bulanık Şekli",
|
||||
"blurIntensity": "Bulanıklık Yoğunluğu",
|
||||
"blurShapeRectangle": "Dikdörtgen",
|
||||
"blurShapeOval": "Oval",
|
||||
"blurShapeFreehand": "Serbest",
|
||||
"deleteAnnotation": "Açıklamayı Sil",
|
||||
"shortcutsAndTips": "Kısayollar ve İpuçları",
|
||||
"tipMovePlayhead": "Oynatma imlecini çakışan açıklama bölümüne taşıyın ve bir öğe seçin.",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"addTrim": "Kırpma Ekle",
|
||||
"addSpeed": "Hız Ekle",
|
||||
"addAnnotation": "Açıklama Ekle",
|
||||
"addBlur": "Bulanik Ekle",
|
||||
"addKeyframe": "Anahtar Kare Ekle",
|
||||
"deleteSelected": "Seçileni Sil",
|
||||
"playPause": "Oynat / Duraklat"
|
||||
|
||||
@@ -4,23 +4,28 @@
|
||||
"suggestZooms": "İmleçten Yakınlaştırma Öner",
|
||||
"addTrim": "Kırpma Ekle (T)",
|
||||
"addAnnotation": "Açıklama Ekle (A)",
|
||||
"addSpeed": "Hız Ekle (S)"
|
||||
"addSpeed": "Hız Ekle (S)",
|
||||
"addBlur": "Bulanık ekle (B)"
|
||||
},
|
||||
"hints": {
|
||||
"pressZoom": "Yakınlaştırma eklemek için Z tuşuna basın",
|
||||
"pressTrim": "Kırpma eklemek için T tuşuna basın",
|
||||
"pressAnnotation": "Açıklama eklemek için A tuşuna basın",
|
||||
"pressSpeed": "Hız eklemek için S tuşuna basın"
|
||||
"pressSpeed": "Hız eklemek için S tuşuna basın",
|
||||
"pressBlur": "Bulanık bölge eklemek için B tuşuna basın"
|
||||
},
|
||||
"labels": {
|
||||
"pan": "Kaydır",
|
||||
"zoom": "Yakınlaştır",
|
||||
"trim": "Kırp",
|
||||
"speed": "Hız",
|
||||
"zoomItem": "Yakınlaştırma {{index}}",
|
||||
"trimItem": "Kırpma {{index}}",
|
||||
"speedItem": "Hız {{index}}",
|
||||
"annotationItem": "Açıklama",
|
||||
"imageItem": "Görüntü",
|
||||
"emptyText": "Boş metin"
|
||||
"emptyText": "Boş metin",
|
||||
"blurItem": "Bulanık {{index}}"
|
||||
},
|
||||
"emptyState": {
|
||||
"noVideo": "Video Yüklenmedi",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"open": "打开",
|
||||
"upload": "上传",
|
||||
"export": "导出",
|
||||
"showInFolder": "在文件夹中显示",
|
||||
"file": "文件",
|
||||
"edit": "编辑",
|
||||
"view": "视图",
|
||||
@@ -23,7 +24,7 @@
|
||||
"exitFullscreen": "退出全屏"
|
||||
},
|
||||
"locale": {
|
||||
"name": "中文",
|
||||
"short": "中文"
|
||||
"name": "简体中文",
|
||||
"short": "简中"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"newRecording": {
|
||||
"title": "返回录屏",
|
||||
"description": "当前会话已保存。",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认"
|
||||
},
|
||||
"loadingVideo": "正在加载视频...",
|
||||
"errors": {
|
||||
"noVideoLoaded": "未加载视频",
|
||||
"videoNotReady": "视频未就绪",
|
||||
@@ -8,6 +15,7 @@
|
||||
"failedToSaveVideo": "保存视频失败",
|
||||
"exportFailed": "导出失败",
|
||||
"exportFailedWithError": "导出失败:{{error}}",
|
||||
"exportBackgroundLoadFailed": "导出失败:无法加载背景图片({{url}})",
|
||||
"failedToSaveExport": "保存导出文件失败",
|
||||
"failedToSaveExportedVideo": "保存导出的视频失败",
|
||||
"failedToRevealInFolder": "在文件夹中显示时出错:{{error}}"
|
||||
@@ -30,6 +38,8 @@
|
||||
"systemAudioUnavailable": "系统音频不可用。将在无系统音频的情况下录制。",
|
||||
"microphoneDenied": "麦克风权限被拒绝。录制将继续,但不包含音频。",
|
||||
"cameraDenied": "摄像头权限被拒绝。录制将继续,但不包含摄像头画面。",
|
||||
"cameraDisconnected": "摄像头已断开连接。",
|
||||
"cameraNotFound": "未找到摄像头。",
|
||||
"permissionDenied": "录屏权限被拒绝。请允许屏幕录制。"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user