diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ce0e08b --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +APP_NAME=Openscreen +BUNDLE_ID=com.siddharthvaddem.openscreen + +APPLE_ID= +TEAM_ID= +SIGN_IDENTITY="Developer ID Application: Samir Patil ()" +CSC_NAME="Samir Patil ()" + +NOTARY_PROFILE=OpenScreen-notary +APPLE_APP_SPECIFIC_PASSWORD= diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..cfee36d --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @siddharthvaddem diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4cc446f..f42a92d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,6 +3,16 @@ name: Build Electron App on: workflow_dispatch: + inputs: + arch: + description: 'Architecture to build' + required: true + default: 'both' + type: choice + options: + - arm64 + - x64 + - both jobs: build-windows: @@ -36,38 +46,180 @@ jobs: build-macos: runs-on: macos-latest + strategy: + matrix: + arch: ${{ github.event.inputs.arch == 'both' && fromJSON('["arm64", "x64"]') || fromJSON(format('["{0}"]', github.event.inputs.arch)) }} + steps: + # ─── Checkout ───────────────────────────────────────────── - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 + # ─── Setup Node.js ──────────────────────────────────────── - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: '22' + node-version: 22 + cache: npm + # ─── Setup Python (needed by some native deps) ──────────── - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.11' + # ─── Install Dependencies ───────────────────────────────── - name: Install dependencies run: npm ci - - name: Install app dependencies - run: npx electron-builder install-app-deps - - - name: Build macOS app - run: npm run build:mac + # ─── Import Code Signing Certificate ────────────────────── + # This is the KEY step that makes CI signing work. + # We create a temporary keychain, import the .p12 cert into it, + # and set it as the default so codesign can find it. + - name: Import code signing certificate env: + MAC_CERTIFICATE_P12: ${{ secrets.MAC_CERTIFICATE_P12 }} + MAC_CERTIFICATE_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }} + run: | + # Create a temporary keychain + KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain-db + KEYCHAIN_PASSWORD=$(openssl rand -base64 32) + + # Create and configure keychain + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + # Decode and import certificate + echo "$MAC_CERTIFICATE_P12" | base64 --decode > $RUNNER_TEMP/certificate.p12 + security import $RUNNER_TEMP/certificate.p12 \ + -k "$KEYCHAIN_PATH" \ + -P "$MAC_CERTIFICATE_PASSWORD" \ + -T /usr/bin/codesign \ + -T /usr/bin/security + + # Allow codesign to access the keychain without UI prompt + security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + # Add to keychain search path (makes it the default) + security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"') + + # Verify the identity is available + security find-identity -v -p codesigning "$KEYCHAIN_PATH" + + # Clean up the .p12 file + rm -f $RUNNER_TEMP/certificate.p12 + + # ─── Build Vite + Electron ──────────────────────────────── + - name: Build Vite + Electron + run: npx tsc && npx vite build + + # ─── Package with electron-builder ──────────────────────── + # electron-builder handles deep codesigning the .app bundle + # "notarize: false" in electron-builder.json5 prevents it from + # trying its own notarization flow + - name: Package .app bundle + run: npx electron-builder --mac --${{ matrix.arch }} --dir + env: + CSC_NAME: "Samir Patil (N26FZ4GW28)" GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Upload macOS build + # ─── Read version from package.json ─────────────────────── + - name: Get version + id: version + run: echo "version=$(node -p 'require(\"./package.json\").version')" >> $GITHUB_OUTPUT + + # ─── Locate the .app bundle ─────────────────────────────── + - name: Find .app bundle + id: find_app + run: | + VERSION="${{ steps.version.outputs.version }}" + echo "=== Release directory contents ===" + ls -laR "release/${VERSION}/" || echo "release/${VERSION}/ not found" + echo "=== Searching for .app bundle ===" + APP_BUNDLE=$(find "release/${VERSION}" -maxdepth 4 -name "*.app" -type d | head -n1) + if [ -z "$APP_BUNDLE" ]; then + echo "::error::No .app bundle found in release/${VERSION}/" + exit 1 + fi + echo "app_bundle=$APP_BUNDLE" >> $GITHUB_OUTPUT + echo "Found: $APP_BUNDLE" + + # ─── Verify .app signature ──────────────────────────────── + - name: Verify .app code signature + run: codesign --verify --deep --strict "${{ steps.find_app.outputs.app_bundle }}" + + # ─── Create DMG ─────────────────────────────────────────── + - name: Create DMG + id: dmg + run: | + VERSION="${{ steps.version.outputs.version }}" + ARCH="${{ matrix.arch }}" + DMG_NAME="Openscreen-Mac-${ARCH}-${VERSION}.dmg" + RELEASE_DIR="release/${VERSION}" + DMG_OUTPUT="${RELEASE_DIR}/${DMG_NAME}" + STAGING="${RELEASE_DIR}/dmg-staging" + + mkdir -p "$STAGING" + cp -R "${{ steps.find_app.outputs.app_bundle }}" "$STAGING/" + ln -s /Applications "$STAGING/Applications" + + hdiutil create \ + -srcfolder "$STAGING" \ + -volname "Openscreen" \ + -fs HFS+ \ + -fsargs "-c c=64,a=16,e=16" \ + -format UDBZ \ + "$DMG_OUTPUT" + + rm -rf "$STAGING" + + echo "dmg_path=$DMG_OUTPUT" >> $GITHUB_OUTPUT + echo "dmg_name=$DMG_NAME" >> $GITHUB_OUTPUT + + # ─── Sign DMG ───────────────────────────────────────────── + - name: Sign DMG + run: | + codesign --force \ + --sign "Developer ID Application: Samir Patil (N26FZ4GW28)" \ + --timestamp \ + "${{ steps.dmg.outputs.dmg_path }}" + + # ─── Notarize DMG ──────────────────────────────────────── + # On CI we can't use keychain profiles for notarytool, so we + # pass credentials directly via env vars / flags + - name: Notarize DMG + run: | + xcrun notarytool submit "${{ steps.dmg.outputs.dmg_path }}" \ + --apple-id "${{ secrets.APPLE_ID }}" \ + --team-id "${{ secrets.APPLE_TEAM_ID }}" \ + --password "${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}" \ + --wait + timeout-minutes: 15 + + # ─── Staple ─────────────────────────────────────────────── + - name: Staple notarization ticket + run: xcrun stapler staple "${{ steps.dmg.outputs.dmg_path }}" + + # ─── Validate ───────────────────────────────────────────── + - name: Validate stapled DMG + run: | + xcrun stapler validate "${{ steps.dmg.outputs.dmg_path }}" + spctl -a -vv -t install "${{ steps.dmg.outputs.dmg_path }}" + + # ─── Upload Artifact ────────────────────────────────────── + - name: Upload notarized DMG uses: actions/upload-artifact@v4 with: - name: macos-installer - path: release/**/*.dmg + name: openscreen-mac-${{ matrix.arch }} + path: ${{ steps.dmg.outputs.dmg_path }} retention-days: 30 + # ─── Cleanup Keychain ───────────────────────────────────── + - name: Cleanup keychain + if: always() + run: security delete-keychain $RUNNER_TEMP/build.keychain-db || true + build-linux: runs-on: ubuntu-latest steps: @@ -97,4 +249,5 @@ jobs: path: | release/**/*.AppImage release/**/*.zsync + release/**/*.deb retention-days: 30 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2b04db..4194797 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,19 @@ jobs: - run: npm ci - run: npx tsc --noEmit + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run test:browser:install + - run: npm run test:browser + build: name: Build runs-on: ubuntu-latest diff --git a/.github/workflows/discord.yaml b/.github/workflows/discord.yaml new file mode 100644 index 0000000..6da25d0 --- /dev/null +++ b/.github/workflows/discord.yaml @@ -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 = //i; + const webhookUrl = (process.env.DISCORD_WEBHOOK_URL || process.env.DISCORD_PR_FORUM_WEBHOOK || "").trim(); + const botToken = (process.env.DISCORD_BOT_TOKEN || "").trim(); + const reviewerRoleId = (process.env.DISCORD_REVIEWER_ROLE_ID || "").trim(); + const alertWebhookUrl = (process.env.DISCORD_ALERT_WEBHOOK_URL || "").trim(); + + const TAGS = { + open: "1493976692967080096", + draft: "1493976782028935279", + ready: "1493976833626996756", + changes: "1493976909875515564", + approved: "1493976951038152764", + merged: "1493977049709281320", + closed: "1493977108102516786", + }; + + const labelTagMap = { + bug: "1493977562773458975", + enhancement: "1493977619216207993", + documentation: "1493978565153394830", + }; + + function cleanDescription(text, maxLen = 3500) { + if (!text) return "No description provided."; + const normalized = text + .replace(/\r\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + if (normalized.length <= maxLen) return normalized; + return `${normalized.slice(0, maxLen - 1)}…`; + } + + function trimThreadName(name) { + return name.length > 95 ? name.slice(0, 95) : name; + } + + function extractThreadId(body) { + if (!body) return null; + const match = body.match(THREAD_MARKER_REGEX); + return match ? match[1] : null; + } + + function upsertThreadMarker(body, threadId) { + const cleaned = (body || "").replace(THREAD_MARKER_REGEX, "").trim(); + return `${cleaned}\n\n`.trim(); + } + + async function discordPost(payload, options = {}) { + const endpoint = new URL(webhookUrl); + endpoint.searchParams.set("wait", "true"); + if (options.threadId) endpoint.searchParams.set("thread_id", String(options.threadId)); + + const response = await fetch(endpoint.toString(), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: WEBHOOK_USERNAME, + avatar_url: WEBHOOK_AVATAR, + allowed_mentions: { parse: [] }, + ...payload, + }) + }); + + 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}`); + } diff --git a/.gitignore b/.gitignore index 70cc387..b2be27c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,9 +12,11 @@ dist dist-electron dist-ssr *.local +.env # Editor directories and files .vscode/* +.zed/ !.vscode/extensions.json .idea .DS_Store @@ -29,4 +31,14 @@ release/** # Playwright test-results -playwright-report/ \ No newline at end of file +playwright-report/ + +# Vitest browser mode screenshots +__screenshots__/ + +# shell files +/shell.sh +# Nix +result +result-* +.direnv/ diff --git a/README.md b/README.md index b42355e..074eaa7 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,11 @@ System audio capture relies on Electron's [desktopCapturer](https://www.electron _I'm new to open source, idk what I'm doing lol. If something is wrong please raise an issue 🙏_ +## Documentation + +See the documentation here: +[OpenScreen Docs](https://deepwiki.com/siddharthvaddem/openscreen) + ## Contributing Contributions are welcome! If you’d like to help out or see what’s currently being worked on, take a look at the open issues and the [project roadmap](https://github.com/users/siddharthvaddem/projects/3) to understand the current direction of the project and find ways to contribute. diff --git a/electron-builder.json5 b/electron-builder.json5 index 40fce0a..18498df 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -20,16 +20,18 @@ "!CONTRIBUTING.md", "!LICENSE" ], - "extraResources": [ - { - "from": "public/wallpapers", - "to": "assets/wallpapers" - } - ], - "publish": [{"provider": "github"}], - - "mac": { - "hardenedRuntime": false, + "extraResources": [ + { + "from": "public/wallpapers", + "to": "assets/wallpapers" + } + ], + + "mac": { + "notarize": false, + "hardenedRuntime": true, + "entitlements": "macos.entitlements", + "entitlementsInherit": "macos.entitlements", "target": [ { "target": "dmg", @@ -38,13 +40,13 @@ ], "icon": "icons/icons/mac/icon.icns", "artifactName": "${productName}-Mac-${arch}-${version}-Installer.${ext}", - "extendInfo": { - "NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.", - "NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.", - "NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.", - "NSCameraUseContinuityCameraDeviceType": true, - "com.apple.security.device.audio-input": true - } + "extendInfo": { + "NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.", + "NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.", + "NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.", + "NSCameraUseContinuityCameraDeviceType": true, + "com.apple.security.device.audio-input": true + } }, "linux": { "target": [ @@ -54,14 +56,14 @@ "artifactName": "${productName}-Linux-${version}.${ext}", "category": "AudioVideo" }, - "win": { - "target": [ - "nsis" - ], - "icon": "icons/icons/win/icon.ico" - }, - "nsis": { - "oneClick": false, - "allowToChangeInstallationDirectory": true - } -} + "win": { + "target": [ + "nsis" + ], + "icon": "icons/icons/win/icon.ico" + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true + } +} diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 573aee8..b2a3720 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -26,6 +26,8 @@ interface Window { electronAPI: { getSources: (opts: Electron.SourcesOptions) => Promise; switchToEditor: () => Promise; + switchToHud: () => Promise; + startNewRecording: () => Promise<{ success: boolean; error?: string }>; openSourceSelector: () => Promise; selectSource: (source: ProcessedDesktopSource) => Promise; getSelectedSource: () => Promise; diff --git a/electron/i18n.ts b/electron/i18n.ts index b385008..2dfb4d3 100644 --- a/electron/i18n.ts +++ b/electron/i18n.ts @@ -5,10 +5,12 @@ import commonEn from "../src/i18n/locales/en/common.json"; import dialogsEn from "../src/i18n/locales/en/dialogs.json"; import commonEs from "../src/i18n/locales/es/common.json"; import dialogsEs from "../src/i18n/locales/es/dialogs.json"; +import commonFr from "../src/i18n/locales/fr/common.json"; +import dialogsFr from "../src/i18n/locales/fr/dialogs.json"; import commonZh from "../src/i18n/locales/zh-CN/common.json"; import dialogsZh from "../src/i18n/locales/zh-CN/dialogs.json"; -type Locale = "en" | "zh-CN" | "es"; +type Locale = "en" | "zh-CN" | "es" | "fr"; type Namespace = "common" | "dialogs"; type MessageMap = Record; @@ -16,12 +18,13 @@ const messages: Record> = { en: { common: commonEn, dialogs: dialogsEn }, "zh-CN": { common: commonZh, dialogs: dialogsZh }, es: { common: commonEs, dialogs: dialogsEs }, + fr: { common: commonFr, dialogs: dialogsFr }, }; let currentLocale: Locale = "en"; export function setMainLocale(locale: string) { - if (locale === "en" || locale === "zh-CN" || locale === "es") { + if (locale === "en" || locale === "zh-CN" || locale === "es" || locale === "fr") { currentLocale = locale; } } diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index e43f53c..be20fcd 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -355,7 +355,24 @@ export function registerIpcHandlers( getMainWindow: () => BrowserWindow | null, getSourceSelectorWindow: () => BrowserWindow | null, onRecordingStateChange?: (recording: boolean, sourceName: string) => void, + switchToHud?: () => void, ) { + ipcMain.handle("switch-to-hud", () => { + if (switchToHud) switchToHud(); + }); + ipcMain.handle("start-new-recording", async () => { + try { + setCurrentRecordingSessionState(null); + if (switchToHud) { + switchToHud(); + } + return { success: true }; + } catch (error) { + console.error("Failed to start new recording:", error); + return { success: false, error: String(error) }; + } + }); + ipcMain.handle("get-sources", async (_, opts) => { const sources = await desktopCapturer.getSources(opts); return sources.map((source) => ({ @@ -473,7 +490,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 }; @@ -484,8 +518,9 @@ export function registerIpcHandlers( }); ipcMain.handle("read-binary-file", async (_, inputPath: string) => { + let normalizedPath: string | null = null; try { - const normalizedPath = normalizeVideoSourcePath(inputPath); + normalizedPath = normalizeVideoSourcePath(inputPath); if (!normalizedPath) { return { success: false, message: "Invalid file path" }; } @@ -510,6 +545,7 @@ export function registerIpcHandlers( success: false, message: "Failed to read binary file", error: String(error), + path: normalizedPath, }; } }); @@ -599,7 +635,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); @@ -622,6 +670,16 @@ export function registerIpcHandlers( } }); + /** + * 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 { // Determine file type from extension @@ -647,11 +705,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) { @@ -663,7 +728,6 @@ export function registerIpcHandlers( }; } }); - ipcMain.handle("open-video-file-picker", async () => { try { const result = await dialog.showOpenDialog({ diff --git a/electron/main.ts b/electron/main.ts index 7e19d46..c399fd0 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -62,10 +62,12 @@ let mainWindow: BrowserWindow | null = null; let sourceSelectorWindow: BrowserWindow | null = null; let tray: Tray | null = null; let selectedSourceName = ""; +const isMac = process.platform === "darwin"; +const trayIconSize = isMac ? 16 : 24; // Tray Icons -const defaultTrayIcon = getTrayIcon("openscreen.png"); -const recordingTrayIcon = getTrayIcon("rec-button.png"); +const defaultTrayIcon = getTrayIcon("openscreen.png", trayIconSize); +const recordingTrayIcon = getTrayIcon("rec-button.png", trayIconSize); function createWindow() { mainWindow = createHudOverlayWindow(); @@ -199,12 +201,12 @@ function createTray() { }); } -function getTrayIcon(filename: string) { +function getTrayIcon(filename: string, size: number) { return nativeImage .createFromPath(path.join(process.env.VITE_PUBLIC || RENDERER_DIST, filename)) .resize({ - width: 24, - height: 24, + width: size, + height: size, quality: "best", }); } @@ -371,6 +373,16 @@ app.whenReady().then(async () => { // Ensure recordings directory exists await ensureRecordingsDir(); + function switchToHudWrapper() { + if (mainWindow) { + isForceClosing = true; + mainWindow.close(); + isForceClosing = false; + mainWindow = null; + } + showMainWindow(); + } + registerIpcHandlers( createEditorWindowWrapper, createSourceSelectorWindowWrapper, @@ -384,6 +396,7 @@ app.whenReady().then(async () => { showMainWindow(); } }, + switchToHudWrapper, ); createWindow(); }); diff --git a/electron/preload.ts b/electron/preload.ts index 8f1836b..eeca25c 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -18,6 +18,12 @@ contextBridge.exposeInMainWorld("electronAPI", { switchToEditor: () => { return ipcRenderer.invoke("switch-to-editor"); }, + switchToHud: () => { + return ipcRenderer.invoke("switch-to-hud"); + }, + startNewRecording: () => { + return ipcRenderer.invoke("start-new-recording"); + }, openSourceSelector: () => { return ipcRenderer.invoke("open-source-selector"); }, diff --git a/electron/windows.ts b/electron/windows.ts index fb9a655..dcd9f92 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -17,6 +17,11 @@ ipcMain.on("hud-overlay-hide", () => { } }); +/** + * Creates the always-on-top HUD overlay window centred at the bottom of the + * primary display. The window is frameless, transparent, and follows the user + * across macOS Spaces so it is never lost when switching virtual desktops. + */ export function createHudOverlayWindow(): BrowserWindow { const primaryDisplay = screen.getPrimaryDisplay(); const { workArea } = primaryDisplay; @@ -51,6 +56,12 @@ export function createHudOverlayWindow(): BrowserWindow { }, }); + // Follow the user across macOS Spaces (virtual desktops). + // Without this the HUD stays pinned to the Space it was first opened on. + if (process.platform === "darwin") { + win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + } + win.webContents.on("did-finish-load", () => { win?.webContents.send("main-process-message", new Date().toLocaleString()); }); @@ -74,6 +85,10 @@ export function createHudOverlayWindow(): BrowserWindow { return win; } +/** + * Creates the main editor window. Starts maximised with a hidden title bar on + * macOS. This window is not always-on-top and appears in the taskbar/dock. + */ export function createEditorWindow(): BrowserWindow { const isMac = process.platform === "darwin"; @@ -120,6 +135,10 @@ export function createEditorWindow(): BrowserWindow { return win; } +/** + * Creates the floating source-selector window used to pick a screen or window + * to record. Frameless, transparent, and follows the user across macOS Spaces. + */ export function createSourceSelectorWindow(): BrowserWindow { const { width, height } = screen.getPrimaryDisplay().workAreaSize; @@ -142,6 +161,12 @@ export function createSourceSelectorWindow(): BrowserWindow { }, }); + // Follow the user across macOS Spaces so the selector appears on the + // active desktop regardless of where the HUD was originally opened. + if (process.platform === "darwin") { + win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + } + if (VITE_DEV_SERVER_URL) { win.loadURL(VITE_DEV_SERVER_URL + "?windowType=source-selector"); } else { diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..77972fb --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1775710090, + "narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "4c1018dae018162ec878d42fec712642d214fdfa", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..7b2d328 --- /dev/null +++ b/flake.nix @@ -0,0 +1,122 @@ +{ + description = "OpenScreen — desktop screen recorder with built-in editor"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = + { self, nixpkgs }: + let + systems = [ + "x86_64-linux" + "aarch64-linux" + ]; + forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system}); + in + { + # -- Per-system outputs (packages, dev shells) -- + + packages = forAllSystems (pkgs: { + openscreen = pkgs.callPackage ./nix/package.nix { }; + default = self.packages.${pkgs.stdenv.hostPlatform.system}.openscreen; + }); + + devShells = forAllSystems ( + pkgs: + let + electron = pkgs.electron; + + # Libraries Electron needs at runtime on Linux + runtimeLibs = with pkgs; [ + # X11 + libx11 + libxcomposite + libxdamage + libxext + libxfixes + libxrandr + libxtst + libxcb + libxshmfence + + # Wayland + wayland + + # GTK / UI toolkit + gtk3 + glib + pango + cairo + gdk-pixbuf + atk + at-spi2-atk + at-spi2-core + + # Graphics + mesa + libGL + libdrm + vulkan-loader + + # Networking / crypto (NSS for Chromium) + nss + nspr + + # Audio + alsa-lib + pipewire + pulseaudio + + # System + dbus + cups + expat + libnotify + libsecret + util-linux # libuuid + ]; + in + { + default = pkgs.mkShell { + packages = with pkgs; [ + nodejs_22 + electron + + # Native module compilation + python3 + pkg-config + gcc + + # Playwright browser tests + playwright-driver.browsers + ]; + + # Electron's prebuilt binary needs these at runtime + LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath runtimeLibs; + + # Tell the npm `electron` package to use the Nix-provided binary + # instead of downloading its own. vite-plugin-electron respects this. + ELECTRON_OVERRIDE_DIST_PATH = "${electron}/libexec/electron"; + + # Playwright browser path for test:browser / test:e2e + PLAYWRIGHT_BROWSERS_PATH = "${pkgs.playwright-driver.browsers}"; + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1"; + + shellHook = '' + echo "OpenScreen dev shell — node $(node --version), electron v$(electron --version 2>/dev/null | tr -d 'v')" + ''; + }; + } + ); + + # -- System-wide outputs (modules, overlay) -- + + overlays.default = final: _prev: { + openscreen = self.packages.${final.stdenv.hostPlatform.system}.openscreen; + }; + + nixosModules.default = import ./nix/module.nix self; + homeManagerModules.default = import ./nix/hm-module.nix self; + }; +} diff --git a/icons/icons/mac/icon.icns b/icons/icons/mac/icon.icns index 7d5a493..02de106 100644 Binary files a/icons/icons/mac/icon.icns and b/icons/icons/mac/icon.icns differ diff --git a/macos.entitlements b/macos.entitlements new file mode 100644 index 0000000..5c6ddcf --- /dev/null +++ b/macos.entitlements @@ -0,0 +1,25 @@ + + + + + + com.apple.security.cs.allow-jit + + + + com.apple.security.cs.allow-unsigned-executable-memory + + + + com.apple.security.cs.disable-library-validation + + + + com.apple.security.device.audio-input + + + + com.apple.security.device.camera + + + diff --git a/nix/hm-module.nix b/nix/hm-module.nix new file mode 100644 index 0000000..b04f827 --- /dev/null +++ b/nix/hm-module.nix @@ -0,0 +1,36 @@ +# Home Manager module for OpenScreen +# Usage in flake-based Home Manager config: +# +# inputs.openscreen.url = "github:siddharthvaddem/openscreen"; +# +# { inputs, ... }: { +# imports = [ inputs.openscreen.homeManagerModules.default ]; +# programs.openscreen.enable = true; +# } +self: +{ + config, + lib, + pkgs, + ... +}: + +let + cfg = config.programs.openscreen; +in +{ + options.programs.openscreen = { + enable = lib.mkEnableOption "OpenScreen screen recorder"; + + package = lib.mkOption { + type = lib.types.package; + default = self.packages.${pkgs.stdenv.hostPlatform.system}.openscreen; + defaultText = lib.literalExpression "inputs.openscreen.packages.\${pkgs.stdenv.hostPlatform.system}.openscreen"; + description = "The OpenScreen package to use."; + }; + }; + + config = lib.mkIf cfg.enable { + home.packages = [ cfg.package ]; + }; +} diff --git a/nix/module.nix b/nix/module.nix new file mode 100644 index 0000000..3282d2d --- /dev/null +++ b/nix/module.nix @@ -0,0 +1,42 @@ +# NixOS module for OpenScreen +# Usage in flake-based NixOS config: +# +# inputs.openscreen.url = "github:siddharthvaddem/openscreen"; +# +# { inputs, ... }: { +# imports = [ inputs.openscreen.nixosModules.default ]; +# programs.openscreen.enable = true; +# } +self: +{ + config, + lib, + pkgs, + ... +}: + +let + cfg = config.programs.openscreen; +in +{ + options.programs.openscreen = { + enable = lib.mkEnableOption "OpenScreen screen recorder"; + + package = lib.mkOption { + type = lib.types.package; + default = self.packages.${pkgs.stdenv.hostPlatform.system}.openscreen; + defaultText = lib.literalExpression "inputs.openscreen.packages.\${pkgs.stdenv.hostPlatform.system}.openscreen"; + description = "The OpenScreen package to use."; + }; + }; + + config = lib.mkIf cfg.enable { + environment.systemPackages = [ cfg.package ]; + + # Screen capture on Wayland requires xdg-desktop-portal. + # We enable the base portal; users should also enable a + # desktop-specific portal (e.g. xdg-desktop-portal-gtk, + # xdg-desktop-portal-hyprland) in their DE config. + xdg.portal.enable = lib.mkDefault true; + }; +} diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 0000000..195043f --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,124 @@ +{ + lib, + buildNpmPackage, + nodejs_22, + electron, + makeWrapper, + makeDesktopItem, + copyDesktopItems, +}: + +buildNpmPackage { + nodejs = nodejs_22; + pname = "openscreen"; + version = "1.3.0"; + + src = + let + fs = lib.fileset; + # gitTracked fails when source is already a store path (path: flake inputs). + # Detect this and fall back to cleanSource which handles both cases. + isStorePath = builtins.storeDir == builtins.substring 0 (builtins.stringLength builtins.storeDir) (toString ../.); + baseFiles = if isStorePath then fs.fromSource (lib.cleanSource ../.) else fs.gitTracked ../.; + in + fs.toSource { + root = ../.; + fileset = fs.difference baseFiles ( + fs.unions [ + ../nix + ../flake.nix + ../flake.lock + (fs.fileFilter (file: file.hasExt "md") ../.) + ] + ); + }; + + npmDepsHash = "sha256-Pd6J9TuggA9vM4s/LjdoK4MoBEivSzAWc/G2+pFOM2U="; + + env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; + + # electron-builder is not needed — we wrap system electron directly + npmFlags = [ "--ignore-scripts" ]; + makeCacheWritable = true; + + # vite-plugin-electron compiles electron/ sources into dist-electron/ + # tsconfig has noEmit — tsc is type-check only + buildPhase = '' + runHook preBuild + npx vite build + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + mkdir -p "$out/lib/openscreen" + + # Renderer build output (index.html, JS chunks, copied public/ assets) + cp -r dist "$out/lib/openscreen/" + + # Main process + preload (compiled by vite-plugin-electron) + cp -r dist-electron "$out/lib/openscreen/" + + # Package manifest (electron reads "main" field to find entry point) + cp package.json "$out/lib/openscreen/" + + # Strip devDependencies (electron, vitest, biome, playwright, etc.) + npm prune --omit=dev --no-save + cp -r node_modules "$out/lib/openscreen/" + + # Asset resolution: when app.isPackaged is false, the main process resolves + # assets at /public/assets/. Mirror the electron-builder + # extraResources layout so wallpapers load correctly. + mkdir -p "$out/lib/openscreen/public/assets" + cp -r public/wallpapers "$out/lib/openscreen/public/assets/wallpapers" + + # Wrap system electron with the app directory + mkdir -p "$out/bin" + makeWrapper "${electron}/bin/electron" "$out/bin/openscreen" \ + --add-flags "$out/lib/openscreen" \ + --set ELECTRON_IS_DEV 0 + + # Install icons to hicolor theme + for size in 16 24 32 48 64 128 256 512 1024; do + icon="icons/icons/png/''${size}x''${size}.png" + if [ -f "$icon" ]; then + install -Dm644 "$icon" \ + "$out/share/icons/hicolor/''${size}x''${size}/apps/openscreen.png" + fi + done + + runHook postInstall + ''; + + nativeBuildInputs = [ + makeWrapper + copyDesktopItems + ]; + + desktopItems = [ + (makeDesktopItem { + name = "openscreen"; + desktopName = "OpenScreen"; + genericName = "Screen Recorder"; + exec = "openscreen %U"; + icon = "openscreen"; + comment = "Desktop screen recorder with built-in editor"; + categories = [ + "AudioVideo" + "Video" + "Recorder" + ]; + startupWMClass = "Openscreen"; + terminal = false; + }) + ]; + + meta = { + description = "Desktop screen recorder with built-in editor"; + homepage = "https://github.com/siddharthvaddem/openscreen"; + license = lib.licenses.mit; + mainProgram = "openscreen"; + platforms = lib.platforms.linux; + }; +} diff --git a/package-lock.json b/package-lock.json index fdbd6b9..ba40beb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,7 +51,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.3.13", - "@playwright/test": "^1.58.2", + "@playwright/test": "^1.59.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -60,6 +60,8 @@ "@types/react-dom": "^18.2.21", "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^4.2.1", + "@vitest/browser": "^4.0.16", + "@vitest/browser-playwright": "^4.0.16", "autoprefixer": "^10.4.21", "electron": "^39.2.7", "electron-builder": "^26.7.0", @@ -1898,14 +1900,13 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "netbsd" @@ -1932,14 +1933,13 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "openbsd" @@ -1966,14 +1966,13 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "openharmony" @@ -3191,12 +3190,12 @@ } }, "node_modules/@playwright/test": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", - "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", "dev": true, "dependencies": { - "playwright": "1.58.2" + "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" @@ -3205,6 +3204,12 @@ "node": ">=18" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -4471,8 +4476,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", @@ -4665,7 +4669,6 @@ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, - "license": "MIT", "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" @@ -4691,8 +4694,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/@types/dom-mediacapture-transform": { "version": "0.1.11", @@ -4946,12 +4948,1377 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/browser": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.0.16.tgz", + "integrity": "sha512-t4toy8X/YTnjYEPoY0pbDBg3EvDPg1elCDrfc+VupPHwoN/5/FNQ8Z+xBYIaEnOE2vVEyKwqYBzZ9h9rJtZVcg==", + "dev": true, + "dependencies": { + "@vitest/mocker": "4.0.16", + "@vitest/utils": "4.0.16", + "magic-string": "^0.30.21", + "pixelmatch": "7.1.0", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.0.3", + "ws": "^8.18.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.0.16" + } + }, + "node_modules/@vitest/browser-playwright": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.0.16.tgz", + "integrity": "sha512-I2Fy/ANdphi1yI46d15o0M1M4M0UJrUiVKkH5oKeRZZCdPg0fw/cfTKZzv9Ge9eobtJYp4BGblMzXdXH0vcl5g==", + "dev": true, + "dependencies": { + "@vitest/browser": "4.0.16", + "@vitest/mocker": "4.0.16", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "4.0.16" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": false + } + } + }, + "node_modules/@vitest/browser-playwright/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser-playwright/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser-playwright/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser-playwright/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser-playwright/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser-playwright/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser-playwright/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser-playwright/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser-playwright/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser-playwright/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser-playwright/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser-playwright/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser-playwright/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser-playwright/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser-playwright/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser-playwright/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser-playwright/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser-playwright/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser-playwright/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser-playwright/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser-playwright/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser-playwright/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser-playwright/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser-playwright/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser-playwright/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser-playwright/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser-playwright/node_modules/@vitest/mocker": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz", + "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==", + "dev": true, + "dependencies": { + "@vitest/spy": "4.0.16", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/browser-playwright/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/@vitest/browser-playwright/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/@vitest/browser-playwright/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@vitest/browser-playwright/node_modules/vite": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/@vitest/browser/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser/node_modules/@vitest/mocker": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz", + "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==", + "dev": true, + "dependencies": { + "@vitest/spy": "4.0.16", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/browser/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/@vitest/browser/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/@vitest/browser/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@vitest/browser/node_modules/pixelmatch": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.1.0.tgz", + "integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==", + "dev": true, + "dependencies": { + "pngjs": "^7.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, + "node_modules/@vitest/browser/node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "engines": { + "node": ">=14.19.0" + } + }, + "node_modules/@vitest/browser/node_modules/vite": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.0.16", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz", "integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==", "dev": true, - "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", @@ -4969,7 +6336,6 @@ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.16.tgz", "integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==", "dev": true, - "license": "MIT", "dependencies": { "tinyrainbow": "^3.0.3" }, @@ -4982,7 +6348,6 @@ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.16.tgz", "integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==", "dev": true, - "license": "MIT", "dependencies": { "@vitest/utils": "4.0.16", "pathe": "^2.0.3" @@ -4996,7 +6361,6 @@ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.16.tgz", "integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==", "dev": true, - "license": "MIT", "dependencies": { "@vitest/pretty-format": "4.0.16", "magic-string": "^0.30.21", @@ -5011,7 +6375,6 @@ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.16.tgz", "integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==", "dev": true, - "license": "MIT", "funding": { "url": "https://opencollective.com/vitest" } @@ -5021,7 +6384,6 @@ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz", "integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==", "dev": true, - "license": "MIT", "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" @@ -5728,7 +7090,6 @@ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, - "license": "MIT", "engines": { "node": ">=12" } @@ -6353,11 +7714,10 @@ } }, "node_modules/chai": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz", - "integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, - "license": "MIT", "engines": { "node": ">=18" } @@ -7681,8 +9041,7 @@ "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/es-object-atoms": { "version": "1.1.1", @@ -7795,7 +9154,6 @@ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, - "license": "MIT", "dependencies": { "@types/estree": "^1.0.0" } @@ -10647,6 +12005,15 @@ "node": ">=4" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -11187,8 +12554,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/pe-library": { "version": "0.4.1", @@ -11472,12 +12838,12 @@ } }, "node_modules/playwright": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", - "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", "dev": true, "dependencies": { - "playwright-core": "1.58.2" + "playwright-core": "1.59.1" }, "bin": { "playwright": "cli.js" @@ -11490,9 +12856,9 @@ } }, "node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", "dev": true, "bin": { "playwright-core": "cli.js" @@ -11548,9 +12914,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "funding": [ { "type": "opencollective", @@ -11565,7 +12931,6 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -12916,6 +14281,20 @@ "node": ">=10" } }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -13147,8 +14526,7 @@ "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/string_decoder": { "version": "1.3.0", @@ -13862,11 +15240,10 @@ } }, "node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, - "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -13941,6 +15318,15 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/tough-cookie": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", @@ -14339,7 +15725,6 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz", "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, - "license": "MIT", "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", @@ -14413,14 +15798,13 @@ } }, "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ "ppc64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "aix" @@ -14430,14 +15814,13 @@ } }, "node_modules/vitest/node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "android" @@ -14447,14 +15830,13 @@ } }, "node_modules/vitest/node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "android" @@ -14464,14 +15846,13 @@ } }, "node_modules/vitest/node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "android" @@ -14481,14 +15862,13 @@ } }, "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -14498,14 +15878,13 @@ } }, "node_modules/vitest/node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -14515,14 +15894,13 @@ } }, "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "freebsd" @@ -14532,14 +15910,13 @@ } }, "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "freebsd" @@ -14549,14 +15926,13 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -14566,14 +15942,13 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -14583,14 +15958,13 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ "ia32" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -14600,14 +15974,13 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ "loong64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -14617,14 +15990,13 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ "mips64el" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -14634,14 +16006,13 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "cpu": [ "ppc64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -14651,14 +16022,13 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ "riscv64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -14668,14 +16038,13 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ "s390x" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -14685,14 +16054,13 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -14702,14 +16070,13 @@ } }, "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "netbsd" @@ -14719,14 +16086,13 @@ } }, "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "openbsd" @@ -14736,14 +16102,13 @@ } }, "node_modules/vitest/node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "sunos" @@ -14753,14 +16118,13 @@ } }, "node_modules/vitest/node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -14770,14 +16134,13 @@ } }, "node_modules/vitest/node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ "ia32" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -14787,14 +16150,13 @@ } }, "node_modules/vitest/node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -14808,7 +16170,6 @@ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz", "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==", "dev": true, - "license": "MIT", "dependencies": { "@vitest/spy": "4.0.16", "estree-walker": "^3.0.3", @@ -14831,12 +16192,11 @@ } }, "node_modules/vitest/node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "dev": true, "hasInstallScript": true, - "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, @@ -14844,32 +16204,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" } }, "node_modules/vitest/node_modules/fdir": { @@ -14877,7 +16237,6 @@ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, - "license": "MIT", "engines": { "node": ">=12.0.0" }, @@ -14891,11 +16250,10 @@ } }, "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, - "license": "MIT", "engines": { "node": ">=12" }, @@ -14904,24 +16262,23 @@ } }, "node_modules/vitest/node_modules/vite": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", - "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, - "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -14930,14 +16287,14 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", - "less": "^4.0.0", + "less": "*", "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" @@ -15137,6 +16494,27 @@ "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xhr": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/xhr/-/xhr-2.6.0.tgz", diff --git a/package.json b/package.json index c367f9e..d41fd40 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,10 @@ "node": "22.22.1", "npm": "10.9.4" }, + "author": { + "name": "Sid", + "email": "svaddem@asu.edu" + }, "scripts": { "dev": "vite", "build": "tsc && vite build && electron-builder", @@ -18,10 +22,12 @@ "preview": "vite preview", "build:mac": "tsc && vite build && electron-builder --mac", "build:win": "tsc && vite build && electron-builder --win", - "build:linux": "tsc && vite build && electron-builder --linux", + "build:linux": "tsc && vite build && electron-builder --linux AppImage deb", "test": "vitest --run", "test:watch": "vitest", "build-vite": "tsc && vite build", + "test:browser": "vitest --config vitest.browser.config.ts --run", + "test:browser:install": "playwright install --with-deps chromium-headless-shell", "test:e2e": "playwright test", "prepare": "husky" }, @@ -69,7 +75,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.3.13", - "@playwright/test": "^1.58.2", + "@playwright/test": "^1.59.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -78,6 +84,8 @@ "@types/react-dom": "^18.2.21", "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^4.2.1", + "@vitest/browser": "^4.0.16", + "@vitest/browser-playwright": "^4.0.16", "autoprefixer": "^10.4.21", "electron": "^39.2.7", "electron-builder": "^26.7.0", diff --git a/scripts/build_macos.sh b/scripts/build_macos.sh new file mode 100755 index 0000000..bd35710 --- /dev/null +++ b/scripts/build_macos.sh @@ -0,0 +1,216 @@ +#!/bin/bash +# +# OpenScreen macOS Build Script +# Produces: release//OpenScreen-Mac--.dmg +# +# Usage: chmod +x scripts/build_macos.sh && ./scripts/build_macos.sh +# + +set -euo pipefail + +# ── Load .env ───────────────────────────────────────────────────────── +PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ENV_FILE="${PROJECT_ROOT}/.env" + +if [ -f "$ENV_FILE" ]; then + set -a + source "$ENV_FILE" + set +a +else + echo "ERROR: .env file not found at ${ENV_FILE}" + echo "Create one with APP_NAME, SIGN_IDENTITY, NOTARY_PROFILE, etc." + exit 1 +fi + +# ── Config ──────────────────────────────────────────────────────────── +VERSION=$(node -p "require('${PROJECT_ROOT}/package.json').version") +RELEASE_DIR="${PROJECT_ROOT}/release/${VERSION}" +ENTITLEMENTS="${PROJECT_ROOT}/macos.entitlements" +ARCHS=("arm64" "x64") + +# ── Colors ──────────────────────────────────────────────────────────── +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color +BOLD='\033[1m' + +print_step() { echo -e "\n${CYAN}${BOLD}▸ $1${NC}"; } +print_ok() { echo -e "${GREEN}✓ $1${NC}"; } +print_warn() { echo -e "${YELLOW}⚠ $1${NC}"; } +print_err() { echo -e "${RED}✗ $1${NC}"; } + +# ── Preflight ───────────────────────────────────────────────────────── +echo -e "\n${BOLD}╔══════════════════════════════════════════╗${NC}" +echo -e "${BOLD}║ ${APP_NAME} macOS Build Script v${VERSION} ║${NC}" +echo -e "${BOLD}╚══════════════════════════════════════════╝${NC}" + +print_step "Checking prerequisites..." + +if [[ "$(uname)" != "Darwin" ]]; then + print_err "This script must be run on macOS." + exit 1 +fi +print_ok "Running on macOS ($(uname -m))" + +if ! command -v node &> /dev/null; then + print_err "Node.js not found. Please install Node.js first." + exit 1 +fi +print_ok "Node.js found: $(node -v)" + +if ! command -v npm &> /dev/null; then + print_err "npm not found." + exit 1 +fi +print_ok "npm found: $(npm -v)" + +# Check signing identity +if ! security find-identity -v -p codesigning | grep -q "$SIGN_IDENTITY"; then + print_err "Signing identity not found: ${SIGN_IDENTITY}" + print_err "Run 'security find-identity -v -p codesigning' to see available identities." + exit 1 +fi +print_ok "Signing identity found: ${SIGN_IDENTITY}" + +# Check notary profile +if ! xcrun notarytool history --keychain-profile "$NOTARY_PROFILE" &> /dev/null; then + print_err "Notary profile '${NOTARY_PROFILE}' not found in keychain." + print_err "Run: xcrun notarytool store-credentials \"${NOTARY_PROFILE}\" --apple-id \"${APPLE_ID}\" --team-id \"${TEAM_ID}\"" + exit 1 +fi +print_ok "Notary profile found: ${NOTARY_PROFILE}" + +# Check entitlements +if [ ! -f "$ENTITLEMENTS" ]; then + print_err "Entitlements file not found: ${ENTITLEMENTS}" + exit 1 +fi +print_ok "Entitlements file found" + +# ── Clean ───────────────────────────────────────────────────────────── +cd "$PROJECT_ROOT" + +print_step "Cleaning previous build artifacts..." +rm -rf dist dist-electron "${RELEASE_DIR}" +print_ok "Clean complete" + +# ── Install Dependencies ───────────────────────────────────────────── +print_step "Installing dependencies..." +npm ci +print_ok "Dependencies installed" + +# ── Build Vite + Electron ──────────────────────────────────────────── +print_step "Building Vite + Electron... (this may take a minute)" +npx tsc && npx vite build +print_ok "Vite + Electron build complete" + +# ── Package, Sign, Notarize per Architecture ───────────────────────── +for ARCH in "${ARCHS[@]}"; do + echo "" + echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${BOLD} Building for: ${ARCH}${NC}" + echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + + # ── Package with electron-builder ───────────────────────────── + print_step "[${ARCH}] Packaging with electron-builder..." + + # Build .app only (--dir), electron-builder handles codesigning + # with hardenedRuntime + entitlements from electron-builder.json5 + CSC_NAME="$CSC_NAME" npx electron-builder --mac --${ARCH} --dir + + # Find the .app bundle + APP_BUNDLE=$(find "${RELEASE_DIR}" -maxdepth 2 -name "*.app" -type d | grep -i "${ARCH}\|mac" | head -n1) + if [ -z "$APP_BUNDLE" ]; then + # Fallback: find any .app in the output + APP_BUNDLE=$(find "${RELEASE_DIR}" -maxdepth 2 -name "*.app" -type d | head -n1) + fi + + if [ -z "$APP_BUNDLE" ]; then + print_err "[${ARCH}] Could not find .app bundle in ${RELEASE_DIR}" + exit 1 + fi + print_ok "[${ARCH}] App bundle: $(basename "$APP_BUNDLE")" + + # ── Verify codesign on .app ─────────────────────────────────── + print_step "[${ARCH}] Verifying .app code signature..." + codesign --verify --deep --strict "$APP_BUNDLE" 2>&1 || print_warn "[${ARCH}] Deep verify had warnings (may be expected pre-notarization)" + print_ok "[${ARCH}] .app signature verified" + + # ── Create DMG ──────────────────────────────────────────────── + DMG_NAME="${APP_NAME}-Mac-${ARCH}-${VERSION}.dmg" + DMG_OUTPUT="${RELEASE_DIR}/${DMG_NAME}" + DMG_STAGING="${RELEASE_DIR}/dmg-staging-${ARCH}" + + print_step "[${ARCH}] Creating DMG..." + + rm -f "$DMG_OUTPUT" + rm -rf "$DMG_STAGING" + + # Stage: app + Applications shortcut for drag-to-install + mkdir -p "$DMG_STAGING" + cp -R "$APP_BUNDLE" "$DMG_STAGING/" + ln -s /Applications "$DMG_STAGING/Applications" + + hdiutil create \ + -srcfolder "$DMG_STAGING" \ + -volname "${APP_NAME}" \ + -fs HFS+ \ + -fsargs "-c c=64,a=16,e=16" \ + -format UDBZ \ + "$DMG_OUTPUT" + + print_ok "[${ARCH}] DMG created: ${DMG_NAME}" + rm -rf "$DMG_STAGING" + + # ── Sign DMG ────────────────────────────────────────────────── + print_step "[${ARCH}] Signing DMG..." + codesign --force --sign "$SIGN_IDENTITY" --timestamp "$DMG_OUTPUT" + print_ok "[${ARCH}] DMG signed" + + # ── Notarize DMG ────────────────────────────────────────────── + print_step "[${ARCH}] Notarizing DMG with Apple... (this may take several minutes)" + xcrun notarytool submit "$DMG_OUTPUT" \ + --keychain-profile "$NOTARY_PROFILE" \ + --wait + print_ok "[${ARCH}] DMG notarized" + + # ── Staple ──────────────────────────────────────────────────── + print_step "[${ARCH}] Stapling notarization ticket..." + xcrun stapler staple "$DMG_OUTPUT" + print_ok "[${ARCH}] Ticket stapled" + + # ── Validate ────────────────────────────────────────────────── + print_step "[${ARCH}] Validating stapled DMG..." + xcrun stapler validate "$DMG_OUTPUT" + print_ok "[${ARCH}] Validation passed" + +done + +# ── Clean up unpacked dirs (keep only DMGs) ─────────────────────────── +print_step "Cleaning up intermediate directories..." +find "${RELEASE_DIR}" -maxdepth 1 -type d ! -name "$(basename "$RELEASE_DIR")" -exec rm -rf {} + 2>/dev/null || true +print_ok "Cleanup complete" + +# ── Done ────────────────────────────────────────────────────────────── +echo "" +echo -e "${GREEN}${BOLD}════════════════════════════════════════════${NC}" +echo -e "${GREEN}${BOLD} Build & Notarization Complete!${NC}" +echo -e "${GREEN}${BOLD}════════════════════════════════════════════${NC}" +echo "" + +for ARCH in "${ARCHS[@]}"; do + DMG_NAME="${APP_NAME}-Mac-${ARCH}-${VERSION}.dmg" + DMG_PATH="${RELEASE_DIR}/${DMG_NAME}" + if [ -f "$DMG_PATH" ]; then + DMG_SIZE=$(du -h "$DMG_PATH" | cut -f1) + echo -e " 📦 ${BOLD}${ARCH}:${NC} ${DMG_PATH}" + echo -e " 📏 ${BOLD}Size:${NC} ${DMG_SIZE}" + echo "" + fi +done + +echo -e " ${GREEN}All DMGs are fully signed, notarized, and stapled!${NC}" +echo -e " ${GREEN}Ready for distribution outside the Mac App Store.${NC}" +echo "" diff --git a/scripts/i18n-check.mjs b/scripts/i18n-check.mjs index 3fd0331..699ae9e 100644 --- a/scripts/i18n-check.mjs +++ b/scripts/i18n-check.mjs @@ -1,7 +1,7 @@ #!/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 */ @@ -11,7 +11,6 @@ import path from "node:path"; const LOCALES_DIR = path.resolve("src/i18n/locales"); const BASE_LOCALE = "en"; -const COMPARE_LOCALES = ["zh-CN", "es"]; function getKeys(obj, prefix = "") { const keys = []; @@ -34,12 +33,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 +83,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.`, ); } diff --git a/src/components/launch/LaunchWindow.module.css b/src/components/launch/LaunchWindow.module.css index ff68c3d..132fa0a 100644 --- a/src/components/launch/LaunchWindow.module.css +++ b/src/components/launch/LaunchWindow.module.css @@ -6,3 +6,78 @@ .electronNoDrag { -webkit-app-region: no-drag; } + +.languageMenuScroll { + max-height: 16rem; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + touch-action: pan-y; + -webkit-overflow-scrolling: touch; +} + +.languageMenuScroll::-webkit-scrollbar { + width: 8px; +} + +.languageMenuScroll::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.04); + border-radius: 999px; +} + +.languageMenuScroll::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.35), rgba(255, 255, 255, 0.2)); + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.15); +} + +.languageMenuScroll::-webkit-scrollbar-thumb:hover { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.3)); +} + +.languageMenuContainer { + position: relative; + z-index: 20; +} + +.languageMenuPanel { + position: fixed; + right: 0; + top: 0; + width: 12rem; + padding: 0.375rem; + border-radius: 0.75rem; + border: 1px solid rgba(255, 255, 255, 0.14); + background: linear-gradient(160deg, rgba(28, 29, 42, 0.98), rgba(18, 19, 28, 0.98)); + box-shadow: 0 20px 45px rgba(0, 0, 0, 0.55); + backdrop-filter: blur(14px); + pointer-events: auto; + box-sizing: border-box; +} + +.languageMenuItem { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.625rem; + border-radius: 0.5rem; + font-size: 11px; + color: rgba(255, 255, 255, 0.88); + background: transparent; + border: 0; + cursor: pointer; + transition: background-color 120ms ease, color 120ms ease; +} + +.languageMenuItem:hover, +.languageMenuItem:focus-visible { + background: rgba(255, 255, 255, 0.1); + color: #ffffff; + outline: none; +} + +.languageMenuItemActive { + background: rgba(255, 255, 255, 0.12); + color: #ffffff; +} diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 0cfef9e..2914584 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -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(null); + const languageMenuPanelRef = useRef(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); @@ -229,24 +315,41 @@ export function LaunchWindow() { return (
- {/* Language switcher — top-left, beside traffic lights */} -
- - -
+
+ {t("systemLanguagePrompt.title")} +
+
+ {t("systemLanguagePrompt.description", { + language: suggestedLanguageName, + })} +
+
+ + +
+
+ )} {/* Device selectors — fixed above HUD bar, viewport-relative, never clipped */} {(showMicControls || showWebcamControls) && ( @@ -433,104 +536,151 @@ export function LaunchWindow() { {/* Record/Stop group */} {recording && ( - - - + + + + + + + + + )} - {/* Restart recording */} - {recording && ( - - - + {!recording && ( + <> + {/* Open video file */} + + + + + {/* Open project */} + + + + )} - {/* Cancel recording */} - {recording && ( - + {/* Right sidebar controls */} +
+
- - )} +
- {/* Open video file */} - - - + {isLanguageMenuOpen + ? createPortal( +
event.stopPropagation()} + > + {availableLocales.map((loc) => ( + + ))} +
, + document.body, + ) + : null} - {/* Open project */} - - - - - {/* Window controls */} -
- - + {/* Window controls */} +
+ + +
diff --git a/src/components/launch/SourceSelector.module.css b/src/components/launch/SourceSelector.module.css index 51239ac..48d5507 100644 --- a/src/components/launch/SourceSelector.module.css +++ b/src/components/launch/SourceSelector.module.css @@ -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; } diff --git a/src/components/launch/SourceSelector.tsx b/src/components/launch/SourceSelector.tsx index 5768c3a..a2aec55 100644 --- a/src/components/launch/SourceSelector.tsx +++ b/src/components/launch/SourceSelector.tsx @@ -65,7 +65,7 @@ export function SourceSelector() { style={{ minHeight: "100vh" }} >
-
+

{t("sourceSelector.loading")}

@@ -84,10 +84,10 @@ export function SourceSelector() { {source.name} {isSelected && ( -
+
@@ -111,16 +111,16 @@ export function SourceSelector() { defaultValue={screenSources.length === 0 ? "windows" : "screens"} className="flex-1 flex flex-col" > - + {t("sourceSelector.screens", { count: String(screenSources.length) })} {t("sourceSelector.windows", { count: String(windowSources.length) })} @@ -128,14 +128,14 @@ export function SourceSelector() {
{screenSources.map(renderSourceCard)}
{windowSources.map(renderSourceCard)}
@@ -143,18 +143,18 @@ export function SourceSelector() {
-
+
diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index c15187d..f4dd29f 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -54,9 +54,11 @@ DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayNam const DropdownMenuContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, sideOffset = 4, ...props }, ref) => ( - + React.ComponentPropsWithoutRef & { + portalled?: boolean; + } +>(({ className, sideOffset = 4, portalled = true, ...props }, ref) => { + const content = ( - -)); + ); + + if (!portalled) { + return content; + } + + return {content}; +}); DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; const DropdownMenuItem = React.forwardRef< diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index 53e21e6..3326ee9 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -62,34 +62,50 @@ SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayNam const SelectContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, position = "popper", ...props }, ref) => ( - - - - & { + showScrollButtons?: boolean; + viewportClassName?: string; + } +>( + ( + { + className, + children, + position = "popper", + showScrollButtons = true, + viewportClassName, + ...props + }, + ref, + ) => ( + + - {children} - - - - -)); + {showScrollButtons ? : null} + + {children} + + {showScrollButtons ? : null} + + + ), +); SelectContent.displayName = SelectPrimitive.Content.displayName; const SelectLabel = React.forwardRef< diff --git a/src/components/video-editor/AnnotationOverlay.tsx b/src/components/video-editor/AnnotationOverlay.tsx index 11548c7..f416c32 100644 --- a/src/components/video-editor/AnnotationOverlay.tsx +++ b/src/components/video-editor/AnnotationOverlay.tsx @@ -1,8 +1,40 @@ -import { useRef } from "react"; +import { type CSSProperties, type PointerEvent, useEffect, useRef, useState } from "react"; import { Rnd } from "react-rnd"; +import { + getBlurOverlayColor, + getMosaicGridOverlayColor, + getNormalizedMosaicBlockSize, +} from "@/lib/blurEffects"; import { cn } from "@/lib/utils"; import { getArrowComponent } from "./ArrowSvgs"; -import type { AnnotationRegion } from "./types"; +import { + type AnnotationRegion, + type BlurData, + DEFAULT_BLUR_BLOCK_SIZE, + DEFAULT_BLUR_DATA, + DEFAULT_BLUR_INTENSITY, +} from "./types"; + +const FREEHAND_POINT_THRESHOLD = 1; +type PreviewCanvasSource = { + width: number; + height: number; + clientWidth?: number; + clientHeight?: number; +}; + +function buildBlurPolygonClipPath(points: Array<{ x: number; y: number }>) { + if (points.length < 3) return undefined; + const polygon = points.map((point) => `${point.x}% ${point.y}%`).join(", "); + return `polygon(${polygon})`; +} + +function buildBlurFreehandPath(points: Array<{ x: number; y: number }>, closed = true) { + if (closed ? points.length < 3 : points.length < 2) return null; + const [firstPoint, ...rest] = points; + const path = `M ${firstPoint.x} ${firstPoint.y} ${rest.map((point) => `L ${point.x} ${point.y}`).join(" ")}`; + return closed ? `${path} Z` : path; +} interface AnnotationOverlayProps { annotation: AnnotationRegion; @@ -11,9 +43,13 @@ interface AnnotationOverlayProps { containerHeight: number; onPositionChange: (id: string, position: { x: number; y: number }) => void; onSizeChange: (id: string, size: { width: number; height: number }) => void; + onBlurDataChange?: (id: string, blurData: BlurData) => void; + onBlurDataCommit?: () => void; onClick: (id: string) => void; zIndex: number; isSelectedBoost: boolean; // Boost z-index when selected for easy editing + previewSourceCanvas?: PreviewCanvasSource | null; + previewFrameVersion?: number; } export function AnnotationOverlay({ @@ -23,16 +59,130 @@ export function AnnotationOverlay({ containerHeight, onPositionChange, onSizeChange, + onBlurDataChange, + onBlurDataCommit, onClick, zIndex, isSelectedBoost, + previewSourceCanvas, + previewFrameVersion, }: AnnotationOverlayProps) { - const x = (annotation.position.x / 100) * containerWidth; - const y = (annotation.position.y / 100) * containerHeight; - const width = (annotation.size.width / 100) * containerWidth; - const height = (annotation.size.height / 100) * containerHeight; - + const committedX = (annotation.position.x / 100) * containerWidth; + const committedY = (annotation.position.y / 100) * containerHeight; + const committedWidth = (annotation.size.width / 100) * containerWidth; + const committedHeight = (annotation.size.height / 100) * containerHeight; + const blurShape = annotation.type === "blur" ? (annotation.blurData?.shape ?? "rectangle") : null; + const isSelectedFreehandBlur = isSelected && blurShape === "freehand"; const isDraggingRef = useRef(false); + const isDrawingFreehandRef = useRef(false); + const freehandPointsRef = useRef>([]); + const [isFreehandDrawing, setIsFreehandDrawing] = useState(false); + const [draftFreehandPoints, setDraftFreehandPoints] = useState>( + [], + ); + const [livePointerPoint, setLivePointerPoint] = useState<{ x: number; y: number } | null>(null); + const mosaicCanvasRef = useRef(null); + const blurType = annotation.type === "blur" ? (annotation.blurData?.type ?? "blur") : "blur"; + const blurOverlayColor = + annotation.type === "blur" ? getBlurOverlayColor(annotation.blurData) : ""; + const mosaicGridOverlayColor = + annotation.type === "blur" ? getMosaicGridOverlayColor(annotation.blurData) : ""; + const [liveRect, setLiveRect] = useState({ + x: committedX, + y: committedY, + width: committedWidth, + height: committedHeight, + }); + + useEffect(() => { + setLiveRect({ + x: committedX, + y: committedY, + width: committedWidth, + height: committedHeight, + }); + }, [committedHeight, committedWidth, committedX, committedY]); + + const { x, y, width, height } = liveRect; + + useEffect(() => { + if (annotation.type !== "blur" || blurType !== "mosaic") { + return; + } + void previewFrameVersion; + + const canvas = mosaicCanvasRef.current; + const sourceCanvas = previewSourceCanvas; + if (!canvas || !sourceCanvas) { + return; + } + + const sourceWidth = sourceCanvas.width; + const sourceHeight = sourceCanvas.height; + const sourceClientWidth = sourceCanvas.clientWidth || containerWidth || sourceWidth; + const sourceClientHeight = sourceCanvas.clientHeight || containerHeight || sourceHeight; + if ( + sourceWidth <= 0 || + sourceHeight <= 0 || + sourceClientWidth <= 0 || + sourceClientHeight <= 0 + ) { + return; + } + + const drawWidth = Math.max(1, Math.round(width)); + const drawHeight = Math.max(1, Math.round(height)); + if (drawWidth <= 0 || drawHeight <= 0) { + return; + } + + canvas.width = drawWidth; + canvas.height = drawHeight; + + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context) { + return; + } + + const scaleX = sourceWidth / sourceClientWidth; + const scaleY = sourceHeight / sourceClientHeight; + const sourceX = Math.max(0, Math.floor(x * scaleX)); + const sourceY = Math.max(0, Math.floor(y * scaleY)); + const sourceSampleWidth = Math.max(1, Math.ceil(drawWidth * scaleX)); + const sourceSampleHeight = Math.max(1, Math.ceil(drawHeight * scaleY)); + const clampedSampleWidth = Math.max(1, Math.min(sourceSampleWidth, sourceWidth - sourceX)); + const clampedSampleHeight = Math.max(1, Math.min(sourceSampleHeight, sourceHeight - sourceY)); + const blockSize = getNormalizedMosaicBlockSize(annotation.blurData); + const downscaledWidth = Math.max(1, Math.round(drawWidth / blockSize)); + const downscaledHeight = Math.max(1, Math.round(drawHeight / blockSize)); + canvas.width = downscaledWidth; + canvas.height = downscaledHeight; + + context.clearRect(0, 0, downscaledWidth, downscaledHeight); + context.imageSmoothingEnabled = true; + context.drawImage( + sourceCanvas as CanvasImageSource, + sourceX, + sourceY, + clampedSampleWidth, + clampedSampleHeight, + 0, + 0, + downscaledWidth, + downscaledHeight, + ); + }, [ + annotation, + blurType, + containerHeight, + containerWidth, + height, + previewFrameVersion, + previewSourceCanvas, + width, + x, + y, + ]); const renderArrow = () => { const direction = annotation.figureData?.arrowDirection || "right"; @@ -43,6 +193,95 @@ export function AnnotationOverlay({ return ; }; + const normalizePoint = (event: PointerEvent) => { + const rect = event.currentTarget.getBoundingClientRect(); + const x = ((event.clientX - rect.left) / rect.width) * 100; + const y = ((event.clientY - rect.top) / rect.height) * 100; + return { + x: Math.max(0, Math.min(100, x)), + y: Math.max(0, Math.min(100, y)), + }; + }; + + const appendFreehandPoint = (point: { x: number; y: number }) => { + const points = freehandPointsRef.current; + const lastPoint = points[points.length - 1]; + if (!lastPoint) { + points.push(point); + return; + } + const dx = point.x - lastPoint.x; + const dy = point.y - lastPoint.y; + // Sample freehand points in annotation-space percent units to avoid overly dense paths. + if (Math.hypot(dx, dy) >= FREEHAND_POINT_THRESHOLD) { + points.push(point); + } + }; + + const handleFreehandPointerDown = (event: PointerEvent) => { + if ( + !isSelected || + annotation.type !== "blur" || + annotation.blurData?.shape !== "freehand" || + !onBlurDataChange + ) { + return; + } + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + isDrawingFreehandRef.current = true; + setIsFreehandDrawing(true); + const point = normalizePoint(event); + freehandPointsRef.current = [point]; + setDraftFreehandPoints([point]); + setLivePointerPoint(point); + }; + + const handleFreehandPointerMove = (event: PointerEvent) => { + if (!isDrawingFreehandRef.current) return; + event.preventDefault(); + event.stopPropagation(); + const point = normalizePoint(event); + setLivePointerPoint(point); + appendFreehandPoint(point); + setDraftFreehandPoints([...freehandPointsRef.current]); + }; + + const finishFreehandPointer = (event: PointerEvent) => { + if (!isDrawingFreehandRef.current || !onBlurDataChange) return; + isDrawingFreehandRef.current = false; + setIsFreehandDrawing(false); + try { + event.currentTarget.releasePointerCapture(event.pointerId); + } catch { + // no-op if already released + } + const points = [...freehandPointsRef.current]; + if (livePointerPoint) { + const last = points[points.length - 1]; + if (!last || Math.hypot(last.x - livePointerPoint.x, last.y - livePointerPoint.y) > 0.001) { + points.push(livePointerPoint); + } + } + if (points.length >= 3) { + const closedPoints = [...points]; + const first = closedPoints[0]; + const last = closedPoints[closedPoints.length - 1]; + if (Math.hypot(last.x - first.x, last.y - first.y) > 0.001) { + closedPoints.push({ ...first }); + } + onBlurDataChange(annotation.id, { + ...(annotation.blurData || { ...DEFAULT_BLUR_DATA, shape: "freehand" }), + shape: "freehand", + freehandPoints: closedPoints, + }); + setDraftFreehandPoints(closedPoints); + onBlurDataCommit?.(); + } + setLivePointerPoint(null); + }; + const renderContent = () => { switch (annotation.type) { case "text": @@ -113,6 +352,149 @@ export function AnnotationOverlay({
{renderArrow()}
); + case "blur": { + const shape = annotation.blurData?.shape ?? "rectangle"; + const blurIntensity = Math.max( + 1, + Math.round(annotation.blurData?.intensity ?? DEFAULT_BLUR_INTENSITY), + ); + const blockSize = Math.max( + 1, + Math.round(annotation.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE), + ); + const activeFreehandPoints = + shape === "freehand" + ? isFreehandDrawing + ? draftFreehandPoints + : (annotation.blurData?.freehandPoints ?? []) + : []; + const drawingPoints = + isFreehandDrawing && livePointerPoint + ? (() => { + const last = activeFreehandPoints[activeFreehandPoints.length - 1]; + if (!last) return [livePointerPoint]; + const dx = livePointerPoint.x - last.x; + const dy = livePointerPoint.y - last.y; + return Math.hypot(dx, dy) > 0.01 + ? [...activeFreehandPoints, livePointerPoint] + : activeFreehandPoints; + })() + : activeFreehandPoints; + const clipPath = + shape === "freehand" ? buildBlurPolygonClipPath(activeFreehandPoints) : undefined; + const freehandPath = + shape === "freehand" + ? buildBlurFreehandPath( + isFreehandDrawing ? drawingPoints : activeFreehandPoints, + !isFreehandDrawing, + ) + : null; + const currentPointerPoint = isFreehandDrawing + ? livePointerPoint || drawingPoints[drawingPoints.length - 1] || null + : null; + const shapeBorderRadius = shape === "oval" ? "50%" : shape === "rectangle" ? "8px" : "0"; + const shouldShowFreehandBlurFill = + shape !== "freehand" || (!!clipPath && !isFreehandDrawing); + const shapeMaskStyle: CSSProperties = { + borderRadius: shapeBorderRadius, + clipPath: isFreehandDrawing ? undefined : clipPath, + WebkitClipPath: isFreehandDrawing ? undefined : clipPath, + }; + const isFreehandSelected = isSelectedFreehandBlur; + return ( +
+
+
+ {blurType === "mosaic" && shouldShowFreehandBlurFill && ( + + )} + {blurType === "mosaic" && shouldShowFreehandBlurFill && ( +
+ )} + {blurType === "mosaic" && ( +
+ )} + {isSelected && shape !== "freehand" && ( +
+ )} +
+ {isSelected && shape === "freehand" && freehandPath && ( + + + {currentPointerPoint && ( + + )} + + )} + {isFreehandSelected && ( +
+ )} +
+ ); + } + default: return null; } @@ -125,7 +507,19 @@ export function AnnotationOverlay({ onDragStart={() => { isDraggingRef.current = true; }} + onDrag={(_e, d) => { + setLiveRect((prev) => ({ + ...prev, + x: d.x, + y: d.y, + })); + }} onDragStop={(_e, d) => { + setLiveRect((prev) => ({ + ...prev, + x: d.x, + y: d.y, + })); const xPercent = (d.x / containerWidth) * 100; const yPercent = (d.y / containerHeight) * 100; onPositionChange(annotation.id, { x: xPercent, y: yPercent }); @@ -135,7 +529,21 @@ export function AnnotationOverlay({ isDraggingRef.current = false; }, 100); }} + onResize={(_e, _direction, ref, _delta, position) => { + setLiveRect({ + x: position.x, + y: position.y, + width: ref.offsetWidth, + height: ref.offsetHeight, + }); + }} onResizeStop={(_e, _direction, ref, _delta, position) => { + setLiveRect({ + x: position.x, + y: position.y, + width: ref.offsetWidth, + height: ref.offsetHeight, + }); const xPercent = (position.x / containerWidth) * 100; const yPercent = (position.y / containerHeight) * 100; const widthPercent = (ref.offsetWidth / containerWidth) * 100; @@ -149,18 +557,23 @@ export function AnnotationOverlay({ }} bounds="parent" className={cn( - "cursor-move transition-all", - isSelected && "ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent", + "cursor-move", + isSelected && + annotation.type !== "blur" && + "ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent", )} style={{ zIndex: isSelectedBoost ? zIndex + 1000 : zIndex, // Boost selected annotation to ensure it's on top pointerEvents: isSelected ? "auto" : "none", - border: isSelected ? "2px solid rgba(52, 178, 123, 0.8)" : "none", - backgroundColor: isSelected ? "rgba(52, 178, 123, 0.1)" : "transparent", - boxShadow: isSelected ? "0 0 0 1px rgba(52, 178, 123, 0.35)" : "none", + border: + isSelected && annotation.type !== "blur" ? "2px solid rgba(52, 178, 123, 0.8)" : "none", + backgroundColor: + isSelected && annotation.type !== "blur" ? "rgba(52, 178, 123, 0.1)" : "transparent", + boxShadow: + isSelected && annotation.type !== "blur" ? "0 0 0 1px rgba(52, 178, 123, 0.35)" : "none", }} - enableResizing={isSelected} - disableDragging={!isSelected} + enableResizing={isSelected && !isSelectedFreehandBlur} + disableDragging={!isSelected || isSelectedFreehandBlur} resizeHandleStyles={{ topLeft: { width: "12px", @@ -206,11 +619,13 @@ export function AnnotationOverlay({ >
{renderContent()} diff --git a/src/components/video-editor/AnnotationSettingsPanel.tsx b/src/components/video-editor/AnnotationSettingsPanel.tsx index b289392..4c26c88 100644 --- a/src/components/video-editor/AnnotationSettingsPanel.tsx +++ b/src/components/video-editor/AnnotationSettingsPanel.tsx @@ -5,6 +5,7 @@ import { AlignRight, Bold, ChevronDown, + Copy, Image as ImageIcon, Info, Italic, @@ -32,7 +33,12 @@ import { type CustomFont, getCustomFonts } from "@/lib/customFonts"; import { cn } from "@/lib/utils"; import { AddCustomFontDialog } from "./AddCustomFontDialog"; import { getArrowComponent } from "./ArrowSvgs"; -import type { AnnotationRegion, AnnotationType, ArrowDirection, FigureData } from "./types"; +import { + type AnnotationRegion, + type AnnotationType, + type ArrowDirection, + type FigureData, +} from "./types"; interface AnnotationSettingsPanelProps { annotation: AnnotationRegion; @@ -40,6 +46,7 @@ interface AnnotationSettingsPanelProps { onTypeChange: (type: AnnotationType) => void; onStyleChange: (style: Partial) => void; onFigureDataChange?: (figureData: FigureData) => void; + onDuplicate?: () => void; onDelete: () => void; } @@ -62,6 +69,7 @@ export function AnnotationSettingsPanel({ onTypeChange, onStyleChange, onFigureDataChange, + onDuplicate, onDelete, }: AnnotationSettingsPanelProps) { const t = useScopedT("settings"); @@ -597,15 +605,28 @@ export function AnnotationSettingsPanel({ - +
+ + + +
diff --git a/src/components/video-editor/BlurSettingsPanel.tsx b/src/components/video-editor/BlurSettingsPanel.tsx new file mode 100644 index 0000000..09bfe3a --- /dev/null +++ b/src/components/video-editor/BlurSettingsPanel.tsx @@ -0,0 +1,247 @@ +import { Info, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Slider } from "@/components/ui/slider"; +import { useScopedT } from "@/contexts/I18nContext"; +import { getBlurOverlayColor } from "@/lib/blurEffects"; +import { cn } from "@/lib/utils"; +import { + type AnnotationRegion, + type BlurColor, + type BlurData, + type BlurShape, + DEFAULT_BLUR_BLOCK_SIZE, + DEFAULT_BLUR_DATA, + MAX_BLUR_BLOCK_SIZE, + MAX_BLUR_INTENSITY, + MIN_BLUR_BLOCK_SIZE, + MIN_BLUR_INTENSITY, +} from "./types"; + +interface BlurSettingsPanelProps { + blurRegion: AnnotationRegion; + onBlurDataChange: (blurData: BlurData) => void; + onBlurDataCommit?: () => void; + onDelete: () => void; +} + +export function BlurSettingsPanel({ + blurRegion, + onBlurDataChange, + onBlurDataCommit, + onDelete, +}: BlurSettingsPanelProps) { + const t = useScopedT("settings"); + + const blurShapeOptions: Array<{ value: BlurShape; labelKey: string }> = [ + { value: "rectangle", labelKey: "blurShapeRectangle" }, + { value: "oval", labelKey: "blurShapeOval" }, + ]; + const blurColorOptions: Array<{ value: BlurColor; labelKey: string }> = [ + { value: "white", labelKey: "blurColorWhite" }, + { value: "black", labelKey: "blurColorBlack" }, + ]; + + return ( +
+
+
+ {t("annotation.blurShape")} + + {t("annotation.active")} + +
+ +
+ {blurShapeOptions.map((shape) => { + const activeShape = blurRegion.blurData?.shape || DEFAULT_BLUR_DATA.shape; + const isActive = activeShape === shape.value; + return ( + + ); + })} +
+ +
+ + +
+ +
+ +
+ {blurColorOptions.map((option) => { + const activeColor = blurRegion.blurData?.color ?? DEFAULT_BLUR_DATA.color; + const isActive = activeColor === option.value; + return ( + + ); + })} +
+
+ +
+
+ + {blurRegion.blurData?.type === "mosaic" + ? t("annotation.mosaicBlockSize") + : t("annotation.blurIntensity")} + + + {Math.round( + blurRegion.blurData?.type === "mosaic" + ? (blurRegion.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE) + : (blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity), + )} + px + +
+ { + onBlurDataChange({ + ...DEFAULT_BLUR_DATA, + ...blurRegion.blurData, + ...(blurRegion.blurData?.type === "mosaic" + ? { blockSize: values[0] } + : { intensity: values[0] }), + }); + }} + onValueCommit={() => onBlurDataCommit?.()} + min={blurRegion.blurData?.type === "mosaic" ? MIN_BLUR_BLOCK_SIZE : MIN_BLUR_INTENSITY} + max={blurRegion.blurData?.type === "mosaic" ? MAX_BLUR_BLOCK_SIZE : MAX_BLUR_INTENSITY} + step={1} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + /> +
+ + + +
+
+ + {t("annotation.shortcutsAndTips")} +
+
    +
  • {t("annotation.tipMovePlayhead")}
  • +
+
+
+
+ ); +} diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 7e556b8..4fb4193 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -42,20 +42,86 @@ import { cn } from "@/lib/utils"; import { type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils"; import { getTestId } from "@/utils/getTestId"; import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; +import { BlurSettingsPanel } from "./BlurSettingsPanel"; import { CropControl } from "./CropControl"; import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp"; import type { AnnotationRegion, AnnotationType, + BlurData, CropRegion, FigureData, PlaybackSpeed, WebcamLayoutPreset, WebcamMaskShape, + WebcamSizePreset, ZoomDepth, ZoomFocusMode, } from "./types"; -import { SPEED_OPTIONS } from "./types"; +import { DEFAULT_WEBCAM_SIZE_PRESET, MAX_PLAYBACK_SPEED, SPEED_OPTIONS } from "./types"; + +function CustomSpeedInput({ + value, + onChange, + onError, +}: { + value: number; + onChange: (val: number) => void; + onError: () => void; +}) { + const isPreset = SPEED_OPTIONS.some((o) => o.speed === value); + const [draft, setDraft] = useState(isPreset ? "" : String(Math.round(value))); + const [isFocused, setIsFocused] = useState(false); + + const prevValue = useRef(value); + if (!isFocused && prevValue.current !== value) { + prevValue.current = value; + setDraft(isPreset ? "" : String(Math.round(value))); + } + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const digits = e.target.value.replace(/\D/g, ""); + if (digits === "") { + setDraft(""); + return; + } + const num = Number(digits); + if (num > MAX_PLAYBACK_SPEED) { + onError(); + return; + } + setDraft(digits); + if (num >= 1) onChange(num); + }, + [onChange, onError], + ); + + const handleBlur = useCallback(() => { + setIsFocused(false); + if (!draft || Number(draft) < 1) { + setDraft(isPreset ? "" : String(Math.round(value))); + } + }, [draft, isPreset, value]); + + return ( +
+ setIsFocused(true)} + onChange={handleChange} + onBlur={handleBlur} + onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()} + className="w-12 bg-white/5 border border-white/10 rounded-md px-1 py-0.5 text-[11px] font-semibold text-[#d97706] text-center focus:outline-none focus:border-[#d97706]/40" + /> + × +
+ ); +} const WALLPAPER_COUNT = 18; const WALLPAPER_RELATIVE = Array.from( @@ -132,7 +198,11 @@ interface SettingsPanelProps { onGifSizePresetChange?: (preset: GifSizePreset) => void; gifOutputDimensions?: { width: number; height: number }; onExport?: () => void; - unsavedExport?: { arrayBuffer: ArrayBuffer; fileName: string; format: string } | null; + unsavedExport?: { + arrayBuffer: ArrayBuffer; + fileName: string; + format: string; + } | null; onSaveUnsavedExport?: () => void; selectedAnnotationId?: string | null; annotationRegions?: AnnotationRegion[]; @@ -140,7 +210,13 @@ interface SettingsPanelProps { onAnnotationTypeChange?: (id: string, type: AnnotationType) => void; onAnnotationStyleChange?: (id: string, style: Partial) => 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; @@ -150,6 +226,12 @@ interface SettingsPanelProps { onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void; webcamMaskShape?: import("./types").WebcamMaskShape; onWebcamMaskShapeChange?: (shape: import("./types").WebcamMaskShape) => void; + selectedZoomInDuration?: number; + selectedZoomOutDuration?: number; + onZoomDurationChange?: (zoomIn: number, zoomOut: number) => void; + webcamSizePreset?: WebcamSizePreset; + onWebcamSizePresetChange?: (size: WebcamSizePreset) => void; + onWebcamSizePresetCommit?: () => void; } export default SettingsPanel; @@ -163,6 +245,13 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [ { depth: 6, label: "5×" }, ]; +const ZOOM_SPEED_OPTIONS = [ + { label: "Instant", zoomIn: 0, zoomOut: 0 }, + { label: "Fast", zoomIn: 500, zoomOut: 350 }, + { label: "Smooth", zoomIn: 1522, zoomOut: 1015 }, + { label: "Lazy", zoomIn: 3000, zoomOut: 2000 }, +]; + export function SettingsPanel({ selected, onWallpaperChange, @@ -213,7 +302,13 @@ export function SettingsPanel({ onAnnotationTypeChange, onAnnotationStyleChange, onAnnotationFigureDataChange, + onAnnotationDuplicate, onAnnotationDelete, + selectedBlurId, + blurRegions = [], + onBlurDataChange, + onBlurDataCommit, + onBlurDelete, selectedSpeedId, selectedSpeedValue, onSpeedChange, @@ -223,6 +318,12 @@ export function SettingsPanel({ onWebcamLayoutPresetChange, webcamMaskShape = "rectangle", onWebcamMaskShapeChange, + selectedZoomInDuration, + selectedZoomOutDuration, + onZoomDurationChange, + webcamSizePreset = DEFAULT_WEBCAM_SIZE_PRESET, + onWebcamSizePresetChange, + onWebcamSizePresetCommit, }: SettingsPanelProps) { const t = useScopedT("settings"); const [wallpaperPaths, setWallpaperPaths] = useState([]); @@ -268,6 +369,7 @@ export function SettingsPanel({ const cropSnapshotRef = useRef(null); const [cropAspectLocked, setCropAspectLocked] = useState(false); const [cropAspectRatio, setCropAspectRatio] = useState(""); + const isPortraitCanvas = isPortraitAspectRatio(aspectRatio); const videoWidth = videoElement?.videoWidth || 1920; const videoHeight = videoElement?.videoHeight || 1080; @@ -446,6 +548,9 @@ export function SettingsPanel({ const selectedAnnotation = selectedAnnotationId ? annotationRegions.find((a) => a.id === selectedAnnotationId) : null; + const selectedBlur = selectedBlurId + ? blurRegions.find((region) => region.id === selectedBlurId) + : null; // If an annotation is selected, show annotation settings instead if ( @@ -466,11 +571,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 ( + onBlurDataChange(selectedBlur.id, blurData)} + onBlurDataCommit={onBlurDataCommit} + onDelete={() => onBlurDelete(selectedBlur.id)} + /> + ); + } + return (
@@ -547,6 +666,39 @@ export function SettingsPanel({ )}
)} + + {zoomEnabled && ( +
+ + {t("zoom.speed.title") || "Zoom Speed"} + +
+ {ZOOM_SPEED_OPTIONS.map((opt) => { + const isActive = + selectedZoomInDuration !== undefined && + selectedZoomOutDuration !== undefined && + Math.round(selectedZoomInDuration) === Math.round(opt.zoomIn) && + Math.round(selectedZoomOutDuration) === Math.round(opt.zoomOut); + return ( + + ); + })} +
+
+ )} {zoomEnabled && (
-
+
{SPEED_OPTIONS.map((option) => { const isActive = selectedSpeedValue === option.speed; return ( @@ -609,6 +761,29 @@ export function SettingsPanel({ ); })}
+
+
+ + {t("speed.customPlaybackSpeed")} + + {selectedSpeedId ? ( + onSpeedChange?.(val)} + onError={() => toast.error(t("speed.maxSpeedError"))} + /> + ) : ( +
+
+ -- +
+ × +
+ )} +
+
{!selectedSpeedId && (

{t("speed.selectRegion")}

)} @@ -656,15 +831,17 @@ export function SettingsPanel({ - {WEBCAM_LAYOUT_PRESETS.filter( - (preset) => - preset.value === "picture-in-picture" || - isPortraitAspectRatio(aspectRatio), - ).map((preset) => ( + {WEBCAM_LAYOUT_PRESETS.filter((preset) => { + if (preset.value === "picture-in-picture") return true; + if (preset.value === "vertical-stack") return isPortraitCanvas; + return !isPortraitCanvas; + }).map((preset) => ( {preset.value === "picture-in-picture" ? t("layout.pictureInPicture") - : t("layout.verticalStack")} + : preset.value === "vertical-stack" + ? t("layout.verticalStack") + : t("layout.dualFrame")} ))} @@ -751,6 +928,27 @@ export function SettingsPanel({
)} + {webcamLayoutPreset === "picture-in-picture" && ( +
+
+
+ {t("layout.webcamSize")} +
+
+ {webcamSizePreset}% +
+
+ onWebcamSizePresetChange?.(values[0])} + onValueCommit={() => onWebcamSizePresetCommit?.()} + min={10} + max={50} + step={1} + className="w-full" + /> +
+ )} )} @@ -879,7 +1077,7 @@ export function SettingsPanel({ - + { setGradient(g); onWallpaperChange(g); diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index a27fbb9..6d21d13 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -1,13 +1,21 @@ import type { Span } from "dnd-timeline"; -import { FolderOpen, Languages, Save } from "lucide-react"; +import { FolderOpen, Languages, Save, Video } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { useI18n, useScopedT } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; import { INITIAL_EDITOR_STATE, useEditorHistory } from "@/hooks/useEditorHistory"; -import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config"; -import { getLocaleName } from "@/i18n/loader"; +import { type Locale } from "@/i18n/config"; +import { getAvailableLocales, getLocaleName } from "@/i18n/loader"; import { calculateOutputDimensions, type ExportFormat, @@ -46,11 +54,13 @@ import { SettingsPanel } from "./SettingsPanel"; import TimelineEditor from "./timeline/TimelineEditor"; import { type AnnotationRegion, + type BlurData, type CursorTelemetryPoint, clampFocusToDepth, DEFAULT_ANNOTATION_POSITION, DEFAULT_ANNOTATION_SIZE, DEFAULT_ANNOTATION_STYLE, + DEFAULT_BLUR_DATA, DEFAULT_FIGURE_DATA, DEFAULT_PLAYBACK_SPEED, DEFAULT_ZOOM_DEPTH, @@ -64,6 +74,7 @@ import { type ZoomRegion, } from "./types"; import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback"; +import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./videoPlayback/constants"; export default function VideoEditor() { const { @@ -90,6 +101,7 @@ export default function VideoEditor() { aspectRatio, webcamLayoutPreset, webcamMaskShape, + webcamSizePreset, webcamPosition, } = editorState; @@ -113,10 +125,12 @@ export default function VideoEditor() { const [selectedTrimId, setSelectedTrimId] = useState(null); const [selectedSpeedId, setSelectedSpeedId] = useState(null); const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); + const [selectedBlurId, setSelectedBlurId] = useState(null); const [isExporting, setIsExporting] = useState(false); const [exportProgress, setExportProgress] = useState(null); const [exportError, setExportError] = useState(null); const [showExportDialog, setShowExportDialog] = useState(false); + const [showNewRecordingDialog, setShowNewRecordingDialog] = useState(false); const [exportQuality, setExportQuality] = useState("good"); const [exportFormat, setExportFormat] = useState("mp4"); const [gifFrameRate, setGifFrameRate] = useState(15); @@ -141,12 +155,22 @@ export default function VideoEditor() { const { shortcuts, isMac } = useShortcuts(); const t = useScopedT("editor"); const ts = useScopedT("settings"); + const availableLocales = getAvailableLocales(); const { locale, setLocale } = useI18n(); const nextAnnotationIdRef = useRef(1); const nextAnnotationZIndexRef = useRef(1); const exporterRef = useRef(null); + const annotationOnlyRegions = useMemo( + () => annotationRegions.filter((region) => region.type !== "blur"), + [annotationRegions], + ); + const blurRegions = useMemo( + () => annotationRegions.filter((region) => region.type === "blur"), + [annotationRegions], + ); + const currentProjectMedia = useMemo(() => { const screenVideoPath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null); if (!screenVideoPath) { @@ -206,6 +230,7 @@ export default function VideoEditor() { aspectRatio: normalizedEditor.aspectRatio, webcamLayoutPreset: normalizedEditor.webcamLayoutPreset, webcamMaskShape: normalizedEditor.webcamMaskShape, + webcamSizePreset: normalizedEditor.webcamSizePreset, webcamPosition: normalizedEditor.webcamPosition, }); setExportQuality(normalizedEditor.exportQuality); @@ -218,6 +243,7 @@ export default function VideoEditor() { setSelectedTrimId(null); setSelectedSpeedId(null); setSelectedAnnotationId(null); + setSelectedBlurId(null); nextZoomIdRef.current = deriveNextId( "zoom", @@ -416,6 +442,7 @@ export default function VideoEditor() { aspectRatio, webcamLayoutPreset, webcamMaskShape, + webcamSizePreset, webcamPosition, exportQuality, exportFormat, @@ -479,6 +506,7 @@ export default function VideoEditor() { gifSizePreset, videoPath, t, + webcamSizePreset, ], ); @@ -501,6 +529,16 @@ export default function VideoEditor() { await saveProject(true); }, [saveProject]); + const handleNewRecordingConfirm = useCallback(async () => { + const result = await window.electronAPI.startNewRecording(); + if (result.success) { + setShowNewRecordingDialog(false); + } else { + console.error("Failed to start new recording:", result.error); + setError("Failed to start new recording: " + (result.error || "Unknown error")); + } + }, []); + const handleLoadProject = useCallback(async () => { const result = await window.electronAPI.loadProjectFile(); @@ -602,7 +640,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) => { @@ -610,6 +652,7 @@ export default function VideoEditor() { if (id) { setSelectedZoomId(null); setSelectedAnnotationId(null); + setSelectedBlurId(null); } }, []); @@ -618,6 +661,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); } }, []); @@ -635,6 +689,7 @@ export default function VideoEditor() { setSelectedZoomId(id); setSelectedTrimId(null); setSelectedAnnotationId(null); + setSelectedBlurId(null); }, [pushState], ); @@ -653,6 +708,7 @@ export default function VideoEditor() { setSelectedZoomId(id); setSelectedTrimId(null); setSelectedAnnotationId(null); + setSelectedBlurId(null); }, [pushState], ); @@ -669,6 +725,7 @@ export default function VideoEditor() { setSelectedTrimId(id); setSelectedZoomId(null); setSelectedAnnotationId(null); + setSelectedBlurId(null); }, [pushState], ); @@ -678,7 +735,11 @@ export default function VideoEditor() { pushState((prev) => ({ zoomRegions: prev.zoomRegions.map((region) => region.id === id - ? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) } + ? { + ...region, + startMs: Math.round(span.start), + endMs: Math.round(span.end), + } : region, ), })); @@ -691,7 +752,11 @@ export default function VideoEditor() { pushState((prev) => ({ trimRegions: prev.trimRegions.map((region) => region.id === id - ? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) } + ? { + ...region, + startMs: Math.round(span.start), + endMs: Math.round(span.end), + } : region, ), })); @@ -717,7 +782,11 @@ export default function VideoEditor() { pushState((prev) => ({ zoomRegions: prev.zoomRegions.map((region) => region.id === selectedZoomId - ? { ...region, depth, focus: clampFocusToDepth(region.focus, depth) } + ? { + ...region, + depth, + focus: clampFocusToDepth(region.focus, depth), + } : region, ), })); @@ -739,7 +808,9 @@ export default function VideoEditor() { const handleZoomDelete = useCallback( (id: string) => { - pushState((prev) => ({ zoomRegions: prev.zoomRegions.filter((r) => r.id !== id) })); + pushState((prev) => ({ + zoomRegions: prev.zoomRegions.filter((r) => r.id !== id), + })); if (selectedZoomId === id) { setSelectedZoomId(null); } @@ -749,7 +820,9 @@ export default function VideoEditor() { const handleTrimDelete = useCallback( (id: string) => { - pushState((prev) => ({ trimRegions: prev.trimRegions.filter((r) => r.id !== id) })); + pushState((prev) => ({ + trimRegions: prev.trimRegions.filter((r) => r.id !== id), + })); if (selectedTrimId === id) { setSelectedTrimId(null); } @@ -763,6 +836,7 @@ export default function VideoEditor() { setSelectedZoomId(null); setSelectedTrimId(null); setSelectedAnnotationId(null); + setSelectedBlurId(null); } }, []); @@ -775,11 +849,14 @@ export default function VideoEditor() { endMs: Math.round(span.end), speed: DEFAULT_PLAYBACK_SPEED, }; - pushState((prev) => ({ speedRegions: [...prev.speedRegions, newRegion] })); + pushState((prev) => ({ + speedRegions: [...prev.speedRegions, newRegion], + })); setSelectedSpeedId(id); setSelectedZoomId(null); setSelectedTrimId(null); setSelectedAnnotationId(null); + setSelectedBlurId(null); }, [pushState], ); @@ -840,10 +917,54 @@ export default function VideoEditor() { style: { ...DEFAULT_ANNOTATION_STYLE }, zIndex, }; - pushState((prev) => ({ annotationRegions: [...prev.annotationRegions, newRegion] })); + pushState((prev) => ({ + annotationRegions: [...prev.annotationRegions, newRegion], + })); setSelectedAnnotationId(id); setSelectedZoomId(null); setSelectedTrimId(null); + setSelectedBlurId(null); + }, + [pushState], + ); + + const handleBlurAdded = useCallback( + (span: Span) => { + const id = `annotation-${nextAnnotationIdRef.current++}`; + const zIndex = nextAnnotationZIndexRef.current++; + const newRegion: AnnotationRegion = { + id, + startMs: Math.round(span.start), + endMs: Math.round(span.end), + type: "blur", + content: "", + position: { ...DEFAULT_ANNOTATION_POSITION }, + size: { ...DEFAULT_ANNOTATION_SIZE }, + style: { ...DEFAULT_ANNOTATION_STYLE }, + zIndex, + blurData: { ...DEFAULT_BLUR_DATA }, + }; + pushState((prev) => ({ + annotationRegions: [...prev.annotationRegions, newRegion], + })); + setSelectedBlurId(id); + setSelectedAnnotationId(null); + setSelectedZoomId(null); + setSelectedTrimId(null); + setSelectedSpeedId(null); + }, + [pushState], + ); + + const handleZoomDurationChange = useCallback( + (id: string, zoomIn: number, zoomOut: number) => { + pushState((prev) => ({ + zoomRegions: prev.zoomRegions.map((region) => + region.id === id + ? { ...region, zoomInDurationMs: zoomIn, zoomOutDurationMs: zoomOut } + : region, + ), + })); }, [pushState], ); @@ -853,7 +974,11 @@ export default function VideoEditor() { pushState((prev) => ({ annotationRegions: prev.annotationRegions.map((region) => region.id === id - ? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) } + ? { + ...region, + startMs: Math.round(span.start), + endMs: Math.round(span.end), + } : region, ), })); @@ -861,6 +986,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) => ({ @@ -869,8 +1021,11 @@ export default function VideoEditor() { if (selectedAnnotationId === id) { setSelectedAnnotationId(null); } + if (selectedBlurId === id) { + setSelectedBlurId(null); + } }, - [selectedAnnotationId, pushState], + [selectedAnnotationId, selectedBlurId, pushState], ); const handleAnnotationContentChange = useCallback( @@ -905,12 +1060,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( @@ -935,6 +1104,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) => ({ @@ -1048,11 +1262,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)) { @@ -1174,6 +1391,7 @@ export default function VideoEditor() { annotationRegions, webcamLayoutPreset, webcamMaskShape, + webcamSizePreset, webcamPosition, previewWidth, previewHeight, @@ -1307,6 +1525,7 @@ export default function VideoEditor() { annotationRegions, webcamLayoutPreset, webcamMaskShape, + webcamSizePreset, webcamPosition, previewWidth, previewHeight, @@ -1377,6 +1596,7 @@ export default function VideoEditor() { aspectRatio, webcamLayoutPreset, webcamMaskShape, + webcamSizePreset, webcamPosition, exportQuality, handleExportSaved, @@ -1482,6 +1702,34 @@ export default function VideoEditor() { return (
+ + + + {t("newRecording.title")} + {t("newRecording.description")} + + + + + + + +
- {SUPPORTED_LOCALES.map((loc) => ( + {availableLocales.map((loc) => ( ))}
+
@@ -1629,6 +1893,7 @@ export default function VideoEditor() { onZoomAdded={handleZoomAdded} onZoomSuggested={handleZoomSuggested} onZoomSpanChange={handleZoomSpanChange} + onZoomDurationChange={handleZoomDurationChange} onZoomDelete={handleZoomDelete} selectedZoomId={selectedZoomId} onSelectZoom={handleSelectZoom} @@ -1644,18 +1909,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, }) @@ -1708,11 +1980,14 @@ export default function VideoEditor() { onWebcamLayoutPresetChange={(preset) => pushState({ webcamLayoutPreset: preset, - webcamPosition: preset === "vertical-stack" ? null : webcamPosition, + webcamPosition: preset === "picture-in-picture" ? webcamPosition : null, }) } webcamMaskShape={webcamMaskShape} onWebcamMaskShapeChange={(shape) => pushState({ webcamMaskShape: shape })} + webcamSizePreset={webcamSizePreset} + onWebcamSizePresetChange={(v) => updateState({ webcamSizePreset: v })} + onWebcamSizePresetCommit={commitState} videoElement={videoPlaybackRef.current?.video || null} exportQuality={exportQuality} onExportQualityChange={setExportQuality} @@ -1739,12 +2014,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 @@ -1755,6 +2036,21 @@ export default function VideoEditor() { onSpeedDelete={handleSpeedDelete} unsavedExport={unsavedExport} onSaveUnsavedExport={handleSaveUnsavedExport} + selectedZoomInDuration={ + selectedZoomId + ? (zoomRegions.find((z) => z.id === selectedZoomId)?.zoomInDurationMs ?? + Math.round(ZOOM_IN_TRANSITION_WINDOW_MS)) + : undefined + } + selectedZoomOutDuration={ + selectedZoomId + ? (zoomRegions.find((z) => z.id === selectedZoomId)?.zoomOutDurationMs ?? + Math.round(TRANSITION_WINDOW_MS)) + : undefined + } + onZoomDurationChange={(zoomIn, zoomOut) => + selectedZoomId && handleZoomDurationChange(selectedZoomId, zoomIn, zoomOut) + } />
diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index d659afe..b798641 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -24,6 +24,7 @@ import { type Size, type StyledRenderRect, type WebcamLayoutPreset, + type WebcamSizePreset, } from "@/lib/compositeLayout"; import { getCssClipPath } from "@/lib/webcamMaskShapes"; import { @@ -34,6 +35,7 @@ import { import { AnnotationOverlay } from "./AnnotationOverlay"; import { type AnnotationRegion, + type BlurData, type SpeedRegion, type TrimRegion, ZOOM_DEPTH_SCALES, @@ -69,6 +71,7 @@ interface VideoPlaybackProps { webcamVideoPath?: string; webcamLayoutPreset: WebcamLayoutPreset; webcamMaskShape?: import("./types").WebcamMaskShape; + webcamSizePreset?: WebcamSizePreset; webcamPosition?: { cx: number; cy: number } | null; onWebcamPositionChange?: (position: { cx: number; cy: number }) => void; onWebcamPositionDragEnd?: () => void; @@ -99,6 +102,13 @@ interface VideoPlaybackProps { onSelectAnnotation?: (id: string | null) => void; onAnnotationPositionChange?: (id: string, position: { x: number; y: number }) => void; onAnnotationSizeChange?: (id: string, size: { width: number; height: number }) => void; + blurRegions?: AnnotationRegion[]; + selectedBlurId?: string | null; + onSelectBlur?: (id: string | null) => void; + onBlurPositionChange?: (id: string, position: { x: number; y: number }) => void; + onBlurSizeChange?: (id: string, size: { width: number; height: number }) => void; + onBlurDataChange?: (id: string, blurData: BlurData) => void; + onBlurDataCommit?: () => void; cursorTelemetry?: import("./types").CursorTelemetryPoint[]; } @@ -119,6 +129,7 @@ const VideoPlayback = forwardRef( webcamVideoPath, webcamLayoutPreset, webcamMaskShape, + webcamSizePreset, webcamPosition, onWebcamPositionChange, onWebcamPositionDragEnd, @@ -149,6 +160,13 @@ const VideoPlayback = forwardRef( onSelectAnnotation, onAnnotationPositionChange, onAnnotationSizeChange, + blurRegions = [], + selectedBlurId, + onSelectBlur, + onBlurPositionChange, + onBlurSizeChange, + onBlurDataChange, + onBlurDataCommit, cursorTelemetry = [], }, ref, @@ -163,6 +181,8 @@ const VideoPlayback = forwardRef( const timeUpdateAnimationRef = useRef(null); const [pixiReady, setPixiReady] = useState(false); const [videoReady, setVideoReady] = useState(false); + const [overlaySize, setOverlaySize] = useState({ width: 800, height: 600 }); + const [overlayElement, setOverlayElement] = useState(null); const overlayRef = useRef(null); const focusIndicatorRef = useRef(null); const [webcamLayout, setWebcamLayout] = useState(null); @@ -195,7 +215,10 @@ const VideoPlayback = forwardRef( const isPlayingRef = useRef(isPlaying); const isSeekingRef = useRef(false); const allowPlaybackRef = useRef(false); - const lockedVideoDimensionsRef = useRef<{ width: number; height: number } | null>(null); + const lockedVideoDimensionsRef = useRef<{ + width: number; + height: number; + } | null>(null); const layoutVideoContentRef = useRef<(() => void) | null>(null); const trimRegionsRef = useRef([]); const speedRegionsRef = useRef([]); @@ -283,6 +306,7 @@ const VideoPlayback = forwardRef( padding, webcamDimensions, webcamLayoutPreset, + webcamSizePreset, webcamPosition, webcamMaskShape, }); @@ -314,6 +338,7 @@ const VideoPlayback = forwardRef( padding, webcamDimensions, webcamLayoutPreset, + webcamSizePreset, webcamPosition, webcamMaskShape, ]); @@ -322,6 +347,11 @@ const VideoPlayback = forwardRef( layoutVideoContentRef.current = layoutVideoContent; }, [layoutVideoContent]); + const setOverlayRefs = useCallback((node: HTMLDivElement | null) => { + overlayRef.current = node; + setOverlayElement(node); + }, []); + const selectedZoom = useMemo(() => { if (!selectedZoomId) return null; return zoomRegions.find((region) => region.id === selectedZoomId) ?? null; @@ -615,7 +645,8 @@ const VideoPlayback = forwardRef( }, [selectedZoom, pixiReady, videoReady, updateOverlayForRegion]); useEffect(() => { - const overlayEl = overlayRef.current; + if (!pixiReady || !videoReady) return; + const overlayEl = overlayElement; if (!overlayEl) return; if (!selectedZoom) { overlayEl.style.cursor = "default"; @@ -624,7 +655,34 @@ const VideoPlayback = forwardRef( } overlayEl.style.cursor = isPlaying ? "not-allowed" : "grab"; overlayEl.style.pointerEvents = isPlaying ? "none" : "auto"; - }, [selectedZoom, isPlaying]); + }, [selectedZoom, isPlaying, pixiReady, videoReady, overlayElement]); + + useEffect(() => { + const overlayEl = overlayElement; + if (!overlayEl) return; + + const updateOverlaySize = () => { + const width = overlayEl.clientWidth || 800; + const height = overlayEl.clientHeight || 600; + setOverlaySize((prev) => { + if (prev.width === width && prev.height === height) return prev; + return { width, height }; + }); + }; + + updateOverlaySize(); + + if (typeof ResizeObserver !== "undefined") { + const observer = new ResizeObserver(() => { + updateOverlaySize(); + }); + observer.observe(overlayEl); + return () => observer.disconnect(); + } + + window.addEventListener("resize", updateOverlaySize); + return () => window.removeEventListener("resize", updateOverlaySize); + }, [overlayElement]); useEffect(() => { const container = containerRef.current; @@ -648,7 +706,11 @@ const VideoPlayback = forwardRef( app.ticker.maxFPS = 60; if (!mounted) { - app.destroy(true, { children: true, texture: true, textureSource: true }); + app.destroy(true, { + children: true, + texture: true, + textureSource: true, + }); return; } @@ -672,7 +734,11 @@ const VideoPlayback = forwardRef( mounted = false; setPixiReady(false); if (app && app.renderer) { - app.destroy(true, { children: true, texture: true, textureSource: true }); + app.destroy(true, { + children: true, + texture: true, + textureSource: true, + }); } appRef.current = null; cameraContainerRef.current = null; @@ -849,16 +915,13 @@ const VideoPlayback = forwardRef( }; const ticker = () => { - const bm = baseMaskRef.current; - const ss = stageSizeRef.current; - const viewportRatio = - bm.width > 0 && bm.height > 0 - ? { widthRatio: ss.width / bm.width, heightRatio: ss.height / bm.height } - : undefined; const { region, strength, blendedScale, transition } = findDominantRegion( zoomRegionsRef.current, currentTimeRef.current, - { connectZooms: true, cursorTelemetry: cursorTelemetryRef.current, viewportRatio }, + { + connectZooms: true, + cursorTelemetry: cursorTelemetryRef.current, + }, ); const defaultFocus = DEFAULT_FOCUS; @@ -1264,7 +1327,7 @@ const VideoPlayback = forwardRef( {/* Only render overlay after PIXI and video are fully initialized */} {pixiReady && videoReady && (
( style={{ display: "none", pointerEvents: "none" }} /> {(() => { - const filtered = (annotationRegions || []).filter((annotation) => { + const filteredAnnotations = (annotationRegions || []).filter((annotation) => { if (typeof annotation.startMs !== "number" || typeof annotation.endMs !== "number") return false; if (annotation.id === selectedAnnotationId) return true; const timeMs = Math.round(currentTime * 1000); - return timeMs >= annotation.startMs && timeMs <= annotation.endMs; + return timeMs >= annotation.startMs && timeMs < annotation.endMs; }); - // Sort by z-index (lowest to highest) so higher z-index renders on top - const sorted = [...filtered].sort((a, b) => a.zIndex - b.zIndex); + const filteredBlurRegions = (blurRegions || []).filter((blurRegion) => { + if (typeof blurRegion.startMs !== "number" || typeof blurRegion.endMs !== "number") + return false; + + if (blurRegion.id === selectedBlurId) return true; + + const timeMs = Math.round(currentTime * 1000); + return timeMs >= blurRegion.startMs && timeMs < blurRegion.endMs; + }); + + const sorted = [ + ...filteredAnnotations.map((annotation) => ({ + kind: "annotation" as const, + region: annotation, + })), + ...filteredBlurRegions.map((blurRegion) => ({ + kind: "blur" as const, + region: blurRegion, + })), + ].sort((a, b) => a.region.zIndex - b.region.zIndex); + const previewSnapshotCanvas = (() => { + const app = appRef.current; + if (!app?.renderer?.extract) return null; + try { + return app.renderer.extract.canvas(app.stage); + } catch { + return null; + } + })(); // Handle click-through cycling: when clicking same annotation, cycle to next const handleAnnotationClick = (clickedId: string) => { if (!onSelectAnnotation) return; // If clicking on already selected annotation and there are multiple overlapping - if (clickedId === selectedAnnotationId && sorted.length > 1) { + if (clickedId === selectedAnnotationId && filteredAnnotations.length > 1) { // Find current index and cycle to next - const currentIndex = sorted.findIndex((a) => a.id === clickedId); - const nextIndex = (currentIndex + 1) % sorted.length; - onSelectAnnotation(sorted[nextIndex].id); + const currentIndex = filteredAnnotations.findIndex((a) => a.id === clickedId); + const nextIndex = (currentIndex + 1) % filteredAnnotations.length; + onSelectAnnotation(filteredAnnotations[nextIndex].id); } else { // First click or clicking different annotation onSelectAnnotation(clickedId); } }; - return sorted.map((annotation) => ( + const handleBlurClick = (clickedId: string) => { + if (!onSelectBlur) return; + + if (clickedId === selectedBlurId && filteredBlurRegions.length > 1) { + const currentIndex = filteredBlurRegions.findIndex((a) => a.id === clickedId); + const nextIndex = (currentIndex + 1) % filteredBlurRegions.length; + onSelectBlur(filteredBlurRegions[nextIndex].id); + } else { + onSelectBlur(clickedId); + } + }; + + return sorted.map((item) => ( onAnnotationPositionChange?.(id, position)} - onSizeChange={(id, size) => onAnnotationSizeChange?.(id, size)} - onClick={handleAnnotationClick} - zIndex={annotation.zIndex} - isSelectedBoost={annotation.id === selectedAnnotationId} + key={ + item.kind === "blur" + ? `${item.region.id}-${overlaySize.width}-${overlaySize.height}-${item.region.blurData?.type ?? "blur"}-${item.region.blurData?.shape ?? "rectangle"}-${item.region.blurData?.color ?? "white"}-${Math.round(item.region.blurData?.blockSize ?? 0)}-${Math.round(item.region.blurData?.intensity ?? 0)}-${(item.region.blurData?.freehandPoints ?? []).map((p) => `${Math.round(p.x)}_${Math.round(p.y)}`).join("-")}` + : `${item.region.id}-${overlaySize.width}-${overlaySize.height}` + } + annotation={item.region} + isSelected={ + item.kind === "blur" + ? item.region.id === selectedBlurId + : item.region.id === selectedAnnotationId + } + containerWidth={overlaySize.width} + containerHeight={overlaySize.height} + onPositionChange={(id, position) => + item.kind === "blur" + ? onBlurPositionChange?.(id, position) + : onAnnotationPositionChange?.(id, position) + } + onSizeChange={(id, size) => + item.kind === "blur" + ? onBlurSizeChange?.(id, size) + : onAnnotationSizeChange?.(id, size) + } + onBlurDataChange={ + item.kind === "blur" + ? (id, blurData) => onBlurDataChange?.(id, blurData) + : undefined + } + onBlurDataCommit={item.kind === "blur" ? onBlurDataCommit : undefined} + onClick={item.kind === "blur" ? handleBlurClick : handleAnnotationClick} + zIndex={item.region.zIndex} + isSelectedBoost={ + item.kind === "blur" + ? item.region.id === selectedBlurId + : item.region.id === selectedAnnotationId + } + previewSourceCanvas={previewSnapshotCanvas} + previewFrameVersion={Math.round(currentTime * 1000)} /> )); })()} diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts index fdf5f66..14dc240 100644 --- a/src/components/video-editor/projectPersistence.test.ts +++ b/src/components/video-editor/projectPersistence.test.ts @@ -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", () => { diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index cc8b600..c085e0d 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -1,29 +1,44 @@ +import { normalizeBlurColor, normalizeBlurType } from "@/lib/blurEffects"; import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter"; import type { ProjectMedia } from "@/lib/recordingSession"; import { normalizeProjectMedia } from "@/lib/recordingSession"; -import { ASPECT_RATIOS, type AspectRatio } from "@/utils/aspectRatioUtils"; +import { ASPECT_RATIOS, type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils"; import { type AnnotationRegion, type CropRegion, + clampPlaybackSpeed, DEFAULT_ANNOTATION_POSITION, DEFAULT_ANNOTATION_SIZE, DEFAULT_ANNOTATION_STYLE, + DEFAULT_BLUR_BLOCK_SIZE, + DEFAULT_BLUR_DATA, + DEFAULT_BLUR_FREEHAND_POINTS, + DEFAULT_BLUR_INTENSITY, DEFAULT_CROP_REGION, DEFAULT_FIGURE_DATA, DEFAULT_PLAYBACK_SPEED, DEFAULT_WEBCAM_LAYOUT_PRESET, DEFAULT_WEBCAM_MASK_SHAPE, DEFAULT_WEBCAM_POSITION, + DEFAULT_WEBCAM_SIZE_PRESET, DEFAULT_ZOOM_DEPTH, + MAX_BLUR_BLOCK_SIZE, + MAX_BLUR_INTENSITY, + MAX_PLAYBACK_SPEED, + MIN_BLUR_BLOCK_SIZE, + MIN_BLUR_INTENSITY, + MIN_PLAYBACK_SPEED, type SpeedRegion, type TrimRegion, type WebcamLayoutPreset, type WebcamMaskShape, type WebcamPosition, + type WebcamSizePreset, type ZoomRegion, } from "./types"; const WALLPAPER_COUNT = 18; +const VALID_BLUR_SHAPES = new Set(["rectangle", "oval", "freehand"] as const); export const WALLPAPER_PATHS = Array.from( { length: WALLPAPER_COUNT }, @@ -47,6 +62,7 @@ export interface ProjectEditorState { aspectRatio: AspectRatio; webcamLayoutPreset: WebcamLayoutPreset; webcamMaskShape: WebcamMaskShape; + webcamSizePreset: WebcamSizePreset; webcamPosition: WebcamPosition | null; exportQuality: ExportQuality; exportFormat: ExportFormat; @@ -66,6 +82,26 @@ function isFiniteNumber(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value); } +function computeNormalizedWebcamLayoutPreset( + webcamLayoutPreset: Partial["webcamLayoutPreset"], + normalizedAspectRatio: AspectRatio, +): WebcamLayoutPreset { + switch (webcamLayoutPreset) { + case "picture-in-picture": + return webcamLayoutPreset; + case "vertical-stack": + return isPortraitAspectRatio(normalizedAspectRatio) + ? webcamLayoutPreset + : DEFAULT_WEBCAM_LAYOUT_PRESET; + case "dual-frame": + return isPortraitAspectRatio(normalizedAspectRatio) + ? DEFAULT_WEBCAM_LAYOUT_PRESET + : webcamLayoutPreset; + default: + return DEFAULT_WEBCAM_LAYOUT_PRESET; + } +} + function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } @@ -173,6 +209,26 @@ export function resolveProjectMedia( export function normalizeProjectEditor(editor: Partial): ProjectEditorState { const validAspectRatios = new Set(ASPECT_RATIOS); + const normalizedAspectRatio: AspectRatio = validAspectRatios.has( + editor.aspectRatio as AspectRatio, + ) + ? (editor.aspectRatio as AspectRatio) + : "16:9"; + const normalizedWebcamLayoutPreset = computeNormalizedWebcamLayoutPreset( + editor.webcamLayoutPreset, + normalizedAspectRatio, + ); + const normalizedWebcamPosition: WebcamPosition | null = + normalizedWebcamLayoutPreset === "picture-in-picture" && + editor.webcamPosition && + typeof editor.webcamPosition === "object" && + isFiniteNumber((editor.webcamPosition as WebcamPosition).cx) && + isFiniteNumber((editor.webcamPosition as WebcamPosition).cy) + ? { + cx: clamp((editor.webcamPosition as WebcamPosition).cx, 0, 1), + cy: clamp((editor.webcamPosition as WebcamPosition).cy, 0, 1), + } + : DEFAULT_WEBCAM_POSITION; const normalizedZoomRegions: ZoomRegion[] = Array.isArray(editor.zoomRegions) ? editor.zoomRegions @@ -223,14 +279,10 @@ export function normalizeProjectEditor(editor: Partial): Pro const endMs = Math.max(startMs + 1, rawEnd); const speed = - region.speed === 0.25 || - region.speed === 0.5 || - region.speed === 0.75 || - region.speed === 1.25 || - region.speed === 1.5 || - region.speed === 1.75 || - region.speed === 2 - ? region.speed + isFiniteNumber(region.speed) && + region.speed >= MIN_PLAYBACK_SPEED && + region.speed <= MAX_PLAYBACK_SPEED + ? clampPlaybackSpeed(region.speed) : DEFAULT_PLAYBACK_SPEED; return { @@ -252,12 +304,22 @@ export function normalizeProjectEditor(editor: Partial): Pro const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000; const startMs = Math.max(0, Math.min(rawStart, rawEnd)); const endMs = Math.max(startMs + 1, rawEnd); + const blurShape = + typeof region.blurData?.shape === "string" && + VALID_BLUR_SHAPES.has(region.blurData.shape) + ? region.blurData.shape + : DEFAULT_BLUR_DATA.shape; + const blurType = normalizeBlurType(region.blurData?.type); + const blurColor = normalizeBlurColor(region.blurData?.color); return { id: region.id, startMs, endMs, - type: region.type === "image" || region.type === "figure" ? region.type : "text", + type: + region.type === "image" || region.type === "figure" || region.type === "blur" + ? region.type + : "text", content: typeof region.content === "string" ? region.content : "", textContent: typeof region.textContent === "string" ? region.textContent : undefined, imageContent: typeof region.imageContent === "string" ? region.imageContent : undefined, @@ -304,6 +366,42 @@ export function normalizeProjectEditor(editor: Partial): Pro ...region.figureData, } : undefined, + blurData: + region.blurData && typeof region.blurData === "object" + ? { + ...DEFAULT_BLUR_DATA, + ...region.blurData, + type: blurType, + shape: blurShape, + color: blurColor, + intensity: isFiniteNumber(region.blurData.intensity) + ? clamp(region.blurData.intensity, MIN_BLUR_INTENSITY, MAX_BLUR_INTENSITY) + : DEFAULT_BLUR_INTENSITY, + blockSize: isFiniteNumber(region.blurData.blockSize) + ? clamp(region.blurData.blockSize, MIN_BLUR_BLOCK_SIZE, MAX_BLUR_BLOCK_SIZE) + : DEFAULT_BLUR_BLOCK_SIZE, + freehandPoints: Array.isArray(region.blurData.freehandPoints) + ? region.blurData.freehandPoints + .filter( + ( + point, + ): point is { + x: number; + y: number; + } => + Boolean( + point && + isFiniteNumber((point as { x?: unknown }).x) && + isFiniteNumber((point as { y?: unknown }).y), + ), + ) + .map((point) => ({ + x: clamp(point.x, 0, 100), + y: clamp(point.y, 0, 100), + })) + : DEFAULT_BLUR_FREEHAND_POINTS, + } + : undefined, }; }) : []; @@ -349,13 +447,8 @@ export function normalizeProjectEditor(editor: Partial): Pro trimRegions: normalizedTrimRegions, speedRegions: normalizedSpeedRegions, annotationRegions: normalizedAnnotationRegions, - aspectRatio: - editor.aspectRatio && validAspectRatios.has(editor.aspectRatio) ? editor.aspectRatio : "16:9", - webcamLayoutPreset: - editor.webcamLayoutPreset === "vertical-stack" || - editor.webcamLayoutPreset === "picture-in-picture" - ? editor.webcamLayoutPreset - : DEFAULT_WEBCAM_LAYOUT_PRESET, + aspectRatio: normalizedAspectRatio, + webcamLayoutPreset: normalizedWebcamLayoutPreset, webcamMaskShape: editor.webcamMaskShape === "rectangle" || editor.webcamMaskShape === "circle" || @@ -363,16 +456,11 @@ export function normalizeProjectEditor(editor: Partial): Pro editor.webcamMaskShape === "rounded" ? editor.webcamMaskShape : DEFAULT_WEBCAM_MASK_SHAPE, - webcamPosition: - editor.webcamPosition && - typeof editor.webcamPosition === "object" && - isFiniteNumber((editor.webcamPosition as WebcamPosition).cx) && - isFiniteNumber((editor.webcamPosition as WebcamPosition).cy) - ? { - cx: clamp((editor.webcamPosition as WebcamPosition).cx, 0, 1), - cy: clamp((editor.webcamPosition as WebcamPosition).cy, 0, 1), - } - : DEFAULT_WEBCAM_POSITION, + webcamSizePreset: + typeof editor.webcamSizePreset === "number" && isFiniteNumber(editor.webcamSizePreset) + ? Math.max(10, Math.min(50, editor.webcamSizePreset)) + : DEFAULT_WEBCAM_SIZE_PRESET, + webcamPosition: normalizedWebcamPosition, exportQuality: editor.exportQuality === "medium" || editor.exportQuality === "source" ? editor.exportQuality diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx index f265fe4..d89de94 100644 --- a/src/components/video-editor/timeline/Item.tsx +++ b/src/components/video-editor/timeline/Item.tsx @@ -1,8 +1,13 @@ import type { Span } from "dnd-timeline"; -import { useItem } from "dnd-timeline"; +import { useItem, useTimelineContext } from "dnd-timeline"; import { Gauge, MessageSquare, Scissors, ZoomIn } from "lucide-react"; import { useMemo } from "react"; import { cn } from "@/lib/utils"; +import { + DEFAULT_ZOOM_IN_MS, + DEFAULT_ZOOM_OUT_MS, + getDurations, +} from "../videoPlayback/zoomRegionUtils"; import glassStyles from "./ItemGlass.module.css"; interface ItemProps { @@ -13,8 +18,11 @@ interface ItemProps { isSelected?: boolean; onSelect?: () => void; zoomDepth?: number; + zoomInDurationMs?: number; + zoomOutDurationMs?: number; speedValue?: number; - variant?: "zoom" | "trim" | "annotation" | "speed"; + onZoomDurationChange?: (id: string, zoomIn: number, zoomOut: number) => void; + variant?: "zoom" | "trim" | "annotation" | "speed" | "blur"; } // Map zoom depth to multiplier labels @@ -44,10 +52,14 @@ export default function Item({ isSelected = false, onSelect, zoomDepth = 1, + zoomInDurationMs, + zoomOutDurationMs, speedValue, variant = "zoom", children, + onZoomDurationChange, }: ItemProps) { + const { pixelsToValue } = useTimelineContext(); const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({ id, span, @@ -79,6 +91,16 @@ export default function Item({ const MIN_ITEM_PX = 6; const safeItemStyle = { ...itemStyle, minWidth: MIN_ITEM_PX }; + const { zoomIn, zoomOut } = useMemo(() => { + if (!isZoom) return { zoomIn: 0, zoomOut: 0 }; + return getDurations({ + startMs: span.start, + endMs: span.end, + zoomInDurationMs, + zoomOutDurationMs, + }); + }, [isZoom, span.start, span.end, zoomInDurationMs, zoomOutDurationMs]); + return (
+ {isZoom && ( + <> + {/* Transition In Marker */} +
+ {/* Draggable handle for Transition In */} +
{ + e.stopPropagation(); + e.preventDefault(); + const target = e.currentTarget; + target.setPointerCapture(e.pointerId); + + const startX = e.clientX; + const initialZoomIn = zoomInDurationMs ?? DEFAULT_ZOOM_IN_MS; + const initialZoomOut = zoomOutDurationMs ?? DEFAULT_ZOOM_OUT_MS; + + const onPointerMove = (moveEvent: PointerEvent) => { + const deltaPx = moveEvent.clientX - startX; + const deltaMs = pixelsToValue(deltaPx); + const newDuration = Math.max( + 0, + Math.min(initialZoomIn + deltaMs, span.end - span.start - initialZoomOut), + ); + onZoomDurationChange?.(id, newDuration, initialZoomOut); + }; + + const onPointerUp = () => { + target.releasePointerCapture(e.pointerId); + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + }; + + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); + }} + /> + {/* Transition Out Marker */} +
+ {/* Draggable handle for Transition Out */} +
{ + e.stopPropagation(); + e.preventDefault(); + const target = e.currentTarget; + target.setPointerCapture(e.pointerId); + + const startX = e.clientX; + const initialZoomIn = zoomInDurationMs ?? DEFAULT_ZOOM_IN_MS; + const initialZoomOut = zoomOutDurationMs ?? DEFAULT_ZOOM_OUT_MS; + + const onPointerMove = (moveEvent: PointerEvent) => { + const deltaPx = startX - moveEvent.clientX; // Inverted because right-anchored + const deltaMs = pixelsToValue(deltaPx); + const newDuration = Math.max( + 0, + Math.min(initialZoomOut + deltaMs, span.end - span.start - initialZoomIn), + ); + onZoomDurationChange?.(id, initialZoomIn, newDuration); + }; + + const onPointerUp = () => { + target.releasePointerCapture(e.pointerId); + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + }; + + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); + }} + /> + + )}
void; onZoomSuggested?: (span: Span, focus: ZoomFocus) => void; onZoomSpanChange: (id: string, span: Span) => void; + onZoomDurationChange: (id: string, zoomIn: number, zoomOut: number) => void; onZoomDelete: (id: string) => void; selectedZoomId: string | null; onSelectZoom: (id: string | null) => void; @@ -73,6 +75,12 @@ interface TimelineEditorProps { onAnnotationDelete?: (id: string) => void; selectedAnnotationId?: string | null; onSelectAnnotation?: (id: string | null) => void; + blurRegions?: AnnotationRegion[]; + onBlurAdded?: (span: Span) => void; + onBlurSpanChange?: (id: string, span: Span) => void; + onBlurDelete?: (id: string) => void; + selectedBlurId?: string | null; + onSelectBlur?: (id: string | null) => void; speedRegions?: SpeedRegion[]; onSpeedAdded?: (span: Span) => void; onSpeedSpanChange?: (id: string, span: Span) => void; @@ -96,7 +104,9 @@ interface TimelineRenderItem { label: string; zoomDepth?: number; speedValue?: number; - variant: "zoom" | "trim" | "annotation" | "speed"; + zoomInDurationMs?: number; + zoomOutDurationMs?: number; + variant: "zoom" | "trim" | "annotation" | "speed" | "blur"; } const SCALE_CANDIDATES = [ @@ -525,11 +535,14 @@ function Timeline({ onSelectZoom, onSelectTrim, onSelectAnnotation, + onSelectBlur, onSelectSpeed, selectedZoomId, selectedTrimId, selectedAnnotationId, + selectedBlurId, selectedSpeedId, + onZoomDurationChange, keyframes = [], }: { items: TimelineRenderItem[]; @@ -540,11 +553,14 @@ function Timeline({ onSelectZoom?: (id: string | null) => void; onSelectTrim?: (id: string | null) => void; onSelectAnnotation?: (id: string | null) => void; + onSelectBlur?: (id: string | null) => void; onSelectSpeed?: (id: string | null) => void; selectedZoomId: string | null; selectedTrimId?: string | null; selectedAnnotationId?: string | null; + selectedBlurId?: string | null; selectedSpeedId?: string | null; + onZoomDurationChange: (id: string, zoomIn: number, zoomOut: number) => void; keyframes?: { id: string; time: number }[]; }) { const t = useScopedT("timeline"); @@ -568,6 +584,7 @@ function Timeline({ onSelectZoom?.(null); onSelectTrim?.(null); onSelectAnnotation?.(null); + onSelectBlur?.(null); onSelectSpeed?.(null); const rect = e.currentTarget.getBoundingClientRect(); @@ -586,6 +603,7 @@ function Timeline({ onSelectZoom, onSelectTrim, onSelectAnnotation, + onSelectBlur, onSelectSpeed, videoDurationMs, sidebarWidth, @@ -637,6 +655,7 @@ function Timeline({ const zoomItems = items.filter((item) => item.rowId === ZOOM_ROW_ID); const trimItems = items.filter((item) => item.rowId === TRIM_ROW_ID); const annotationItems = items.filter((item) => item.rowId === ANNOTATION_ROW_ID); + const blurItems = items.filter((item) => item.rowId === BLUR_ROW_ID); const speedItems = items.filter((item) => item.rowId === SPEED_ROW_ID); return ( @@ -668,6 +687,9 @@ function Timeline({ isSelected={item.id === selectedZoomId} onSelect={() => onSelectZoom?.(item.id)} zoomDepth={item.zoomDepth} + zoomInDurationMs={item.zoomInDurationMs} + zoomOutDurationMs={item.zoomOutDurationMs} + onZoomDurationChange={onZoomDurationChange} variant="zoom" > {item.label} @@ -711,6 +733,22 @@ function Timeline({ ))} + + {blurItems.map((item) => ( + onSelectBlur?.(item.id)} + variant={item.variant} + > + {item.label} + + ))} + + {speedItems.map((item) => ( { + if (!selectedBlurId || !onBlurDelete || !onSelectBlur) return; + onBlurDelete(selectedBlurId); + onSelectBlur(null); + }, [selectedBlurId, onBlurDelete, onSelectBlur]); + const deleteSelectedSpeed = useCallback(() => { if (!selectedSpeedId || !onSpeedDelete || !onSelectSpeed) return; onSpeedDelete(selectedSpeedId); @@ -908,9 +959,10 @@ export default function TimelineEditor({ const isZoomItem = zoomRegions.some((r) => r.id === excludeId); const isTrimItem = trimRegions.some((r) => r.id === excludeId); const isAnnotationItem = annotationRegions.some((r) => r.id === excludeId); + const isBlurItem = blurRegions.some((r) => r.id === excludeId); const isSpeedItem = speedRegions.some((r) => r.id === excludeId); - if (isAnnotationItem) { + if (isAnnotationItem || isBlurItem) { return false; } @@ -937,7 +989,7 @@ export default function TimelineEditor({ return false; }, - [zoomRegions, trimRegions, annotationRegions, speedRegions], + [zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions], ); // At least 5% of the timeline or 1000ms, whichever is larger, so the region @@ -1165,6 +1217,21 @@ export default function TimelineEditor({ onAnnotationAdded({ start: startPos, end: endPos }); }, [videoDuration, totalMs, currentTimeMs, onAnnotationAdded, defaultRegionDurationMs]); + const handleAddBlur = useCallback(() => { + if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onBlurAdded) { + return; + } + + const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); + if (defaultDuration <= 0) { + return; + } + + const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); + const endPos = Math.min(startPos + defaultDuration, totalMs); + onBlurAdded({ start: startPos, end: endPos }); + }, [videoDuration, totalMs, currentTimeMs, onBlurAdded, defaultRegionDurationMs]); + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { @@ -1183,6 +1250,9 @@ export default function TimelineEditor({ if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) { handleAddAnnotation(); } + if (matchesShortcut(e, keyShortcuts.addBlur, isMac)) { + handleAddBlur(); + } if (matchesShortcut(e, keyShortcuts.addSpeed, isMac)) { handleAddSpeed(); } @@ -1223,6 +1293,8 @@ export default function TimelineEditor({ deleteSelectedTrim(); } else if (selectedAnnotationId) { deleteSelectedAnnotation(); + } else if (selectedBlurId) { + deleteSelectedBlur(); } else if (selectedSpeedId) { deleteSelectedSpeed(); } @@ -1235,16 +1307,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 +1346,8 @@ export default function TimelineEditor({ span: { start: region.startMs, end: region.endMs }, label: t("labels.zoomItem", { index: String(index + 1) }), zoomDepth: region.depth, + zoomInDurationMs: region.zoomInDurationMs, + zoomOutDurationMs: region.zoomOutDurationMs, variant: "zoom", })); @@ -1304,6 +1381,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 +1398,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 +1420,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 +1429,12 @@ export default function TimelineEditor({ trimRegions, speedRegions, annotationRegions, + blurRegions, onZoomSpanChange, onTrimSpanChange, onSpeedSpanChange, onAnnotationSpanChange, + onBlurSpanChange, ], ); @@ -1403,6 +1492,25 @@ export default function TimelineEditor({ > +