diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index cfee36d65c..2a3000ef82 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @siddharthvaddem +* @EtienneLescot diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 0000000000..d42bbd6607 --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,11 @@ +name: Setup Node.js +description: Install Node 22, restore npm cache, run npm ci +runs: + using: composite + steps: + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + shell: bash diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 79f39d4de3..9d8ea49e15 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,43 +1,36 @@ -# Pull Request Template - -## Description - - -## Motivation - - -## Type of Change -- [ ] New Feature -- [ ] Bug Fix -- [ ] Refactor / Code Cleanup -- [ ] Documentation Update -- [ ] Other (please specify) - -## Related Issue(s) - - -## Screenshots / Video - - -**Screenshot** (if applicable): - -```markdown -![Screenshot Description](path/to/screenshot.png) -``` - -**Video** (if applicable): - -```html - -``` +## Summary + + +## Related issue + + + +Fixes # + +## Type of change +- [ ] Bug fix +- [ ] Feature +- [ ] Enhancement +- [ ] Documentation +- [ ] Refactor / maintenance +- [ ] Performance +- [ ] Security + +## Release impact +- [ ] Patch +- [ ] Minor +- [ ] Major / breaking change +- [ ] No release note needed + +## Desktop impact +- [ ] Windows +- [ ] macOS +- [ ] Linux +- [ ] Installer / packaging +- [ ] Not platform-specific + +## Screenshots / video + ## Testing - - -## Checklist -- [ ] I have performed a self-review of my code. -- [ ] I have added any necessary screenshots or videos. -- [ ] I have linked related issue(s) and updated the changelog if applicable. - ---- -*Thank you for contributing!* + diff --git a/.github/scripts/discord-bot-api.mjs b/.github/scripts/discord-bot-api.mjs new file mode 100644 index 0000000000..94e2248fe3 --- /dev/null +++ b/.github/scripts/discord-bot-api.mjs @@ -0,0 +1,51 @@ +import { warning } from "@actions/core"; + +const API_BASE = "https://discord.com/api/v10"; +const DEFAULT_TIMEOUT_MS = 5_000; + +async function callDiscord(botToken, method, path, body, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + let res; + try { + res = await fetch(`${API_BASE}${path}`, { + method, + headers: { + Authorization: `Bot ${botToken}`, + "Content-Type": "application/json", + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } + + if (res.status === 429) { + const txt = await res.text(); + warning(`Discord rate-limited (429) on ${method} ${path}: ${txt}`); + throw new Error(`Discord rate-limited (429) on ${method} ${path}`); + } + + if (!res.ok) { + const txt = await res.text(); + throw new Error(`Discord API ${method} ${path} failed ${res.status}: ${txt}`); + } + + if (res.status === 204) return null; + return res.json(); +} + +export async function createForumThread({ botToken, forumChannelId, payload, timeoutMs }) { + return callDiscord(botToken, "POST", `/channels/${forumChannelId}/threads`, payload, { + timeoutMs, + }); +} + +export async function postChannelMessage({ botToken, channelId, payload, timeoutMs }) { + return callDiscord(botToken, "POST", `/channels/${channelId}/messages`, payload, { timeoutMs }); +} + +export async function patchChannel({ botToken, channelId, payload, timeoutMs }) { + return callDiscord(botToken, "PATCH", `/channels/${channelId}`, payload, { timeoutMs }); +} diff --git a/.github/scripts/discord-bot-api.test.mjs b/.github/scripts/discord-bot-api.test.mjs new file mode 100644 index 0000000000..fb56f42830 --- /dev/null +++ b/.github/scripts/discord-bot-api.test.mjs @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createForumThread, patchChannel, postChannelMessage } from "./discord-bot-api.mjs"; + +const botToken = "test-token"; + +beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function mockResponse({ status = 200, body = { id: "x" } } = {}) { + vi.mocked(fetch).mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + text: vi.fn().mockResolvedValue(JSON.stringify(body)), + json: vi.fn().mockResolvedValue(body), + }); +} + +const happyCases = [ + { + name: "createForumThread", + call: (args) => createForumThread(args), + args: { forumChannelId: "forum-1", payload: { name: "PR #1" } }, + expectUrl: "https://discord.com/api/v10/channels/forum-1/threads", + expectMethod: "POST", + expectBody: { name: "PR #1" }, + }, + { + name: "postChannelMessage", + call: (args) => postChannelMessage(args), + args: { channelId: "thread-1", payload: { content: "hello" } }, + expectUrl: "https://discord.com/api/v10/channels/thread-1/messages", + expectMethod: "POST", + expectBody: { content: "hello" }, + }, + { + name: "patchChannel", + call: (args) => patchChannel(args), + args: { channelId: "thread-1", payload: { archived: true } }, + expectUrl: "https://discord.com/api/v10/channels/thread-1", + expectMethod: "PATCH", + expectBody: { archived: true }, + }, +]; + +describe.each(happyCases)("$name", ({ call, args, expectUrl, expectMethod, expectBody }) => { + it("calls Discord with the right URL, method, bot auth, and payload", async () => { + mockResponse(); + + await call({ ...args, botToken }); + + const [url, init] = vi.mocked(fetch).mock.calls[0]; + expect(url).toBe(expectUrl); + expect(init.method).toBe(expectMethod); + expect(init.headers.Authorization).toBe(`Bot ${botToken}`); + expect(JSON.parse(init.body)).toEqual(expectBody); + expect(init.signal).toBeDefined(); + }); + + it("throws on 429 with rate-limit message", async () => { + mockResponse({ status: 429, body: { retry_after: 1 } }); + await expect(call({ ...args, botToken })).rejects.toThrow(/rate-limited \(429\)/); + }); + + it("throws on non-ok responses with status in message", async () => { + mockResponse({ status: 403, body: { message: "Missing Permissions" } }); + await expect(call({ ...args, botToken })).rejects.toThrow(/failed 403/); + }); +}); + +describe("callDiscord timeout", () => { + it("aborts the request after timeoutMs when the fetch hangs", async () => { + // fetch that never resolves until aborted + vi.mocked(fetch).mockImplementation( + (_url, init) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener("abort", () => { + reject(new DOMException("aborted", "AbortError")); + }); + }), + ); + await expect( + postChannelMessage({ + botToken, + channelId: "x", + payload: {}, + timeoutMs: 10, + }), + ).rejects.toThrow(); + }); +}); diff --git a/.github/scripts/discord-pr-sync.mjs b/.github/scripts/discord-pr-sync.mjs new file mode 100644 index 0000000000..a40e34f4be --- /dev/null +++ b/.github/scripts/discord-pr-sync.mjs @@ -0,0 +1,439 @@ +import { info, warning } from "@actions/core"; +import { context, getOctokit } from "@actions/github"; +import { createForumThread, patchChannel, postChannelMessage } from "./discord-bot-api.mjs"; +import { validateThreadChannel } from "./discord-thread-validator.mjs"; + +const botToken = (process.env.DISCORD_BOT_TOKEN || "").trim(); +const reviewerRoleId = (process.env.DISCORD_REVIEWER_ROLE_ID || "").trim(); +const forumChannelId = (process.env.DISCORD_PR_FORUM_CHANNEL_ID || "").trim(); +const alertChannelId = (process.env.DISCORD_ALERT_CHANNEL_ID || "").trim(); + +const THREAD_MARKER_REGEX = //i; + +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(); +} + +const NO_MENTIONS = { allowed_mentions: { parse: [] } }; + +async function safePatchChannel(args, contextLabel) { + try { + await patchChannel(args); + } catch (err) { + warning( + `Discord thread patch failed for ${contextLabel} (continuing): ${err && err.message ? err.message : err}`, + ); + } +} + +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(octokit) { + 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 octokit.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: issue.number, + }); + return data; + } + return null; +} + +async function getReviewState(octokit, owner, repo, pullNumber) { + const { data } = await octokit.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 main() { + try { + const octokit = getOctokit(process.env.GITHUB_TOKEN); + + const pr = await getPullRequest(octokit); + if (!pr) { + info("No PR context found. Skipping."); + return; + } + + if (!botToken || !forumChannelId) { + warning( + `Discord sync skipped: bot token or forum channel id unavailable for event '${context.eventName}'. ` + + "Set DISCORD_BOT_TOKEN (secret) and DISCORD_PR_FORUM_CHANNEL_ID (variable).", + ); + 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(octokit, 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))].slice(0, 5); + + const createPayload = { + name: trimThreadName(`PR #${number} - ${title}`), + auto_archive_duration: 4320, + applied_tags: appliedTags, + message: { + content: + action === "ready_for_review" + ? "🔔 PR is now ready for review" + : "🔔 New pull request opened", + 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(), + }, + ], + allowed_mentions: { parse: [] }, + }, + }; + + const thread = await createForumThread({ + botToken, + forumChannelId, + payload: createPayload, + }); + const createdThreadId = thread?.id || null; + if (createdThreadId) { + const updatedBody = upsertThreadMarker(body, createdThreadId); + await octokit.rest.pulls.update({ + owner, + repo, + pull_number: number, + body: updatedBody, + }); + info(`Created Discord thread ${createdThreadId} and stored mapping.`); + } else { + warning("Discord thread created but id missing in response."); + } + return; + } + + if (!threadId) { + info("No mapped Discord thread ID found; skipping update event."); + return; + } + + if (!(await validateThreadChannel(threadId, number, { botToken, forumChannelId }))) { + info("Thread ID in PR body failed channel validation; ignoring marker."); + 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))].slice(0, 5); + await safePatchChannel( + { + botToken, + channelId: threadId, + payload: { + name: trimThreadName(`PR #${number} - ${title}`), + ...(appliedTags.length ? { applied_tags: appliedTags } : {}), + }, + }, + `tag refresh on ${action}`, + ); + } + + let updateMessage = null; + let updateEmbed = null; + + if (context.eventName === "pull_request_target") { + if (action === "synchronize") { + const { data: commits } = await octokit.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))].slice( + 0, + 5, + ); + await safePatchChannel( + { + botToken, + channelId: threadId, + payload: { + ...(appliedTags.length ? { applied_tags: appliedTags } : {}), + ...(isMerged ? { archived: true, locked: true } : {}), + }, + }, + `close (${isMerged ? "merged" : "closed without merge"})`, + ); + + 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))].slice( + 0, + 5, + ); + await safePatchChannel( + { + botToken, + channelId: threadId, + payload: { + ...(appliedTags.length ? { applied_tags: appliedTags } : {}), + }, + }, + `review ${state}`, + ); + } + } + } 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) { + info("No Discord update message for this event/action. Skipping."); + return; + } + + const payload = { content: updateMessage || "", ...NO_MENTIONS }; + if (updateEmbed) payload.embeds = [updateEmbed]; + await postChannelMessage({ botToken, channelId: threadId, payload }); + info(`Posted update to Discord thread ${threadId}.`); + } catch (err) { + const msg = err && err.message ? err.message : String(err); + warning( + `Discord sync failed, but this optional automation will not block PR validation: ${msg}`, + ); + + if (alertChannelId) { + try { + await postChannelMessage({ + botToken, + channelId: alertChannelId, + payload: { + content: `⚠️ PR->Discord sync failed\n${msg}\nRun: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, + ...NO_MENTIONS, + }, + }); + } catch (alertErr) { + warning( + `Failed to send alert message: ${alertErr && alertErr.message ? alertErr.message : alertErr}`, + ); + } + } + } +} + +main(); diff --git a/.github/scripts/discord-release-announce.mjs b/.github/scripts/discord-release-announce.mjs new file mode 100644 index 0000000000..c4bdc5813c --- /dev/null +++ b/.github/scripts/discord-release-announce.mjs @@ -0,0 +1,141 @@ +import { info, warning } from "@actions/core"; +import { context, getOctokit } from "@actions/github"; +import { createForumThread, postChannelMessage } from "./discord-bot-api.mjs"; + +const botToken = (process.env.DISCORD_BOT_TOKEN || "").trim(); +const channelId = ( + process.env.DISCORD_RC_TESTING_CHANNEL_ID || + process.env.DISCORD_RELEASE_CHANNEL_ID || + "" +).trim(); + +const kind = (process.env.KIND || "stable").trim(); +const stableTag = (process.env.STABLE_TAG || "").trim(); +const rcTag = (process.env.RC_TAG || "").trim(); +const extra = (process.env.EXTRA || "").trim(); + +if (!stableTag) { + warning("STABLE_TAG missing; skipping."); + process.exit(0); +} +if (!botToken || !channelId) { + info("Discord announce skipped: set DISCORD_BOT_TOKEN and a channel id variable."); + process.exit(0); +} + +const owner = context.repo.owner; +const repo = context.repo.repo; +const releaseUrl = `${context.serverUrl}/${owner}/${repo}/releases/tag/${stableTag}`; +const stableVersion = stableTag.replace(/^v/, "").replace(/-.*$/, ""); + +let closedIssues = []; +if (process.env.GITHUB_TOKEN) { + try { + const octokit = getOctokit(process.env.GITHUB_TOKEN); + const versionTitle = `v${stableVersion}`; + const milestones = await octokit.paginate(octokit.rest.issues.listMilestones, { + owner, + repo, + state: "closed", + per_page: 100, + }); + const m = milestones.find((x) => x.title === versionTitle); + if (m) { + const issues = await octokit.paginate(octokit.rest.issues.listForRepo, { + owner, + repo, + milestone: `${m.number}`, + state: "closed", + per_page: 100, + }); + closedIssues = issues + .filter((i) => !i.pull_request) + .slice(0, 20) + .map((i) => `• [#${i.number}](${i.html_url}) ${i.title}`); + } + } catch (err) { + warning(`Failed to fetch closed issues: ${err?.message ?? err}`); + } +} + +const isRc = kind === "rc"; +const embedTitle = isRc + ? `🧪 ${stableTag} release candidate ready for testing` + : `🚀 ${stableTag} released`; +const threadName = (isRc ? `${stableTag} RC — testing` : `${stableTag} released`).slice(0, 100); +const color = isRc ? 15844367 : 5814783; + +const description = [ + extra ? `> ${extra}\n` : "", + `📦 **Download:** [${stableTag}](${releaseUrl})`, + isRc && rcTag ? `_Promoted from \`${rcTag}\`_` : "", + closedIssues.length > 0 ? `\n**Closed issues in this release:**\n${closedIssues.join("\n")}` : "", +] + .filter(Boolean) + .join("\n"); + +const embed = { + title: embedTitle, + url: releaseUrl, + description, + color, + timestamp: new Date().toISOString(), +}; + +// Discord channel types that require a thread wrapper (no top-level messages). +const FORUM_LIKE_TYPES = new Set([15, 16]); // 15 = GUILD_FORUM, 16 = GUILD_MEDIA + +async function fetchChannelType() { + const res = await fetch(`https://discord.com/api/v10/channels/${channelId}`, { + headers: { Authorization: `Bot ${botToken}` }, + }); + if (!res.ok) { + const txt = await res.text(); + warning(`Discord channel fetch failed ${res.status}: ${txt}`); + return null; + } + return res.json(); +} + +async function announceToForum() { + const thread = await createForumThread({ + botToken, + forumChannelId: channelId, + payload: { + name: threadName, + auto_archive_duration: 4320, + message: { + embeds: [embed], + allowed_mentions: { parse: [] }, + }, + }, + }); + info(`📣 ${kind} announcement posted to forum thread ${thread.id}.`); +} + +async function announceToText() { + const result = await postChannelMessage({ + botToken, + channelId, + payload: { + embeds: [embed], + allowed_mentions: { parse: [] }, + }, + }); + info(`📣 ${kind} announcement posted to text channel (id=${result.id}).`); +} + +const channel = await fetchChannelType(); +if (!channel) { + process.exit(0); +} + +try { + if (FORUM_LIKE_TYPES.has(channel.type)) { + await announceToForum(); + } else { + await announceToText(); + } +} catch (err) { + warning(`Discord announce failed: ${err?.message ?? err}`); +} diff --git a/.github/scripts/discord-release-announce.test.mjs b/.github/scripts/discord-release-announce.test.mjs new file mode 100644 index 0000000000..a5f23ddfbe --- /dev/null +++ b/.github/scripts/discord-release-announce.test.mjs @@ -0,0 +1,187 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockCreateForumThread = vi.fn(); +const mockPostChannelMessage = vi.fn(); +const mockListMilestones = vi.fn(); +const mockListForRepo = vi.fn(); + +vi.mock("@actions/core", () => ({ + info: vi.fn(), + warning: vi.fn(), +})); +vi.mock("@actions/github", () => ({ + context: { + repo: { owner: "acme", repo: "widget" }, + serverUrl: "https://github.com", + }, + getOctokit: () => ({ + paginate: async (fn, opts) => { + if (fn === mockListMilestones) return mockListMilestones(opts); + if (fn === mockListForRepo) return mockListForRepo(opts); + return []; + }, + rest: { + issues: { + listMilestones: mockListMilestones, + listForRepo: mockListForRepo, + }, + }, + }), +})); +vi.mock("./discord-bot-api.mjs", () => ({ + createForumThread: mockCreateForumThread, + postChannelMessage: mockPostChannelMessage, +})); + +async function loadScript(env) { + vi.resetModules(); + vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit unexpectedly called with "${code}"`); + }); + for (const [k, v] of Object.entries(env)) { + process.env[k] = v; + } + return import("./discord-release-announce.mjs"); +} + +const BASE_ENV = { + DISCORD_BOT_TOKEN: "test-token", + GITHUB_TOKEN: "test-github", + STABLE_TAG: "v1.5.0", +}; + +beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + mockCreateForumThread.mockReset(); + mockPostChannelMessage.mockReset(); + mockListMilestones.mockReset(); + mockListForRepo.mockReset(); + mockListMilestones.mockResolvedValue([]); + mockListForRepo.mockResolvedValue([]); + for (const k of Object.keys(process.env)) { + if ( + k.startsWith("DISCORD_") || + k === "GITHUB_TOKEN" || + k === "STABLE_TAG" || + k === "RC_TAG" || + k === "KIND" || + k === "EXTRA" + ) { + delete process.env[k]; + } + } +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("discord-release-announce", () => { + it("skips when STABLE_TAG is missing", async () => { + await expect( + loadScript({ DISCORD_BOT_TOKEN: "t", DISCORD_RC_TESTING_CHANNEL_ID: "c" }), + ).rejects.toThrow(/process\.exit.*"0"/); + expect(mockCreateForumThread).not.toHaveBeenCalled(); + expect(mockPostChannelMessage).not.toHaveBeenCalled(); + }); + + it("skips when bot token or channel id is missing", async () => { + await expect(loadScript({ STABLE_TAG: "v1.0.0" })).rejects.toThrow(/process\.exit.*"0"/); + expect(mockCreateForumThread).not.toHaveBeenCalled(); + expect(mockPostChannelMessage).not.toHaveBeenCalled(); + }); + + it("posts a forum thread when the channel is a forum (type 15)", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ type: 15, name: "rc-testing" }), + }); + mockCreateForumThread.mockResolvedValue({ id: "thread-1" }); + + await loadScript({ + ...BASE_ENV, + DISCORD_RC_TESTING_CHANNEL_ID: "1521416826146263051", + KIND: "rc", + RC_TAG: "v1.4.5-rc.3", + }); + + expect(mockCreateForumThread).toHaveBeenCalledTimes(1); + const args = mockCreateForumThread.mock.calls[0][0]; + expect(args.botToken).toBe("test-token"); + expect(args.forumChannelId).toBe("1521416826146263051"); + expect(args.payload.name).toBe("v1.5.0 RC — testing".slice(0, 100)); + expect(args.payload.message.embeds[0].title).toContain("release candidate"); + expect(args.payload.message.embeds[0].description).toContain( + "https://github.com/acme/widget/releases/tag/v1.5.0", + ); + expect(args.payload.message.embeds[0].description).toContain("Promoted from `v1.4.5-rc.3`"); + expect(mockPostChannelMessage).not.toHaveBeenCalled(); + }); + + it("posts a media thread when the channel is media (type 16)", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ type: 16 }), + }); + mockCreateForumThread.mockResolvedValue({ id: "thread-2" }); + + await loadScript({ ...BASE_ENV, DISCORD_RELEASE_CHANNEL_ID: "1493594372409917512" }); + + expect(mockCreateForumThread).toHaveBeenCalledTimes(1); + expect(mockPostChannelMessage).not.toHaveBeenCalled(); + }); + + it("posts a regular message when the channel is text (type 0)", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ type: 0 }), + }); + mockPostChannelMessage.mockResolvedValue({ id: "msg-1" }); + + await loadScript({ ...BASE_ENV, DISCORD_RELEASE_CHANNEL_ID: "123" }); + + expect(mockPostChannelMessage).toHaveBeenCalledTimes(1); + const args = mockPostChannelMessage.mock.calls[0][0]; + expect(args.channelId).toBe("123"); + expect(args.payload.embeds[0].title).toContain("released"); + expect(mockCreateForumThread).not.toHaveBeenCalled(); + }); + + it("includes closed issues from the versioned milestone", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ type: 0 }), + }); + mockPostChannelMessage.mockResolvedValue({ id: "msg-2" }); + mockListMilestones.mockResolvedValue([{ number: 7, title: "v1.5.0" }]); + mockListForRepo.mockResolvedValue([ + { number: 42, title: "fix bug", html_url: "https://x/42", pull_request: null }, + { number: 43, title: "add feature", html_url: "https://x/43", pull_request: { url: "x" } }, + ]); + + await loadScript({ ...BASE_ENV, DISCORD_RELEASE_CHANNEL_ID: "123" }); + + const args = mockPostChannelMessage.mock.calls[0][0]; + const desc = args.payload.embeds[0].description; + expect(desc).toContain("Closed issues in this release"); + expect(desc).toContain("#42"); + expect(desc).toContain("fix bug"); + expect(desc).not.toContain("add feature"); + }); + + it("handles 4xx gracefully without throwing", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ type: 0 }), + }); + mockPostChannelMessage.mockRejectedValue(new Error("failed 403: Missing Permissions")); + + await loadScript({ ...BASE_ENV, DISCORD_RELEASE_CHANNEL_ID: "123" }); + expect(mockPostChannelMessage).toHaveBeenCalledTimes(1); + }); +}); diff --git a/.github/scripts/discord-roadmap-sync.mjs b/.github/scripts/discord-roadmap-sync.mjs new file mode 100644 index 0000000000..4bb608c3e0 --- /dev/null +++ b/.github/scripts/discord-roadmap-sync.mjs @@ -0,0 +1,222 @@ +import { info, warning } from "@actions/core"; +import { context, getOctokit } from "@actions/github"; + +const ROADMAP_PATTERN = /(^|\/)ROADMAP\.md$|(^|\/)docs\/roadmap\.md$/i; +const ROADMAP_EMBED_TITLE = "🗺️ OpenScreen Roadmap"; + +const botToken = (process.env.DISCORD_BOT_TOKEN || "").trim(); +const channelId = (process.env.DISCORD_ROADMAP_CHANNEL_ID || "").trim(); +const overrideMessageId = (process.env.DISCORD_ROADMAP_MESSAGE_ID || "").trim(); + +async function main() { + try { + if (!botToken || !channelId) { + info( + "DISCORD_BOT_TOKEN or DISCORD_ROADMAP_CHANNEL_ID not set; skipping. " + + "Configure both as repo secret / variable to enable #🗺️・roadmap auto-sync.", + ); + return; + } + + const octokit = getOctokit(process.env.GITHUB_TOKEN); + + // 0. Resolve the message id to update + let existingMessageId = overrideMessageId; + if (!existingMessageId) { + try { + const pinRes = await fetch( + `https://discord.com/api/v10/channels/${channelId}/messages/pins`, + { headers: { Authorization: `Bot ${botToken}` } }, + ); + if (pinRes.ok) { + const data = await pinRes.json(); + const pins = (data.items || []).map((item) => item.message).filter(Boolean); + const existing = pins.find((m) => m.embeds?.[0]?.title === ROADMAP_EMBED_TITLE); + if (existing) { + existingMessageId = existing.id; + info(`Found existing pinned roadmap message ${existingMessageId}.`); + } else { + info("No existing pinned roadmap message found; will create one."); + } + } else { + const txt = await pinRes.text(); + warning(`Failed to fetch pins (${pinRes.status}): ${txt}; falling back to POST.`); + } + } catch (err) { + warning( + `Pin lookup threw: ${err && err.message ? err.message : err}; falling back to POST.`, + ); + } + } + + // 1. Detect which files changed in this event + let changedFiles = []; + try { + if (context.eventName === "pull_request_target") { + const pr = context.payload.pull_request; + if (!pr) { + info("No PR context; skipping."); + return; + } + const res = await octokit.rest.pulls.listFiles({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + per_page: 100, + }); + changedFiles = res.data; + } else if (context.eventName === "push") { + const sha = context.payload.after || context.payload.head_commit?.id || context.sha; + const res = await octokit.rest.repos.getCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: sha, + }); + changedFiles = res.data.files || []; + } + } catch (err) { + warning(`Failed to list changed files: ${err && err.message ? err.message : err}`); + return; + } + + const roadmapFiles = changedFiles.filter((f) => ROADMAP_PATTERN.test(f.filename)); + if (roadmapFiles.length === 0) { + info("No roadmap files in event; skipping."); + return; + } + + // 2. Fetch the current ROADMAP.md content from main + let content; + try { + const res = await octokit.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: "ROADMAP.md", + ref: "main", + }); + if (Array.isArray(res.data) || res.data.type !== "file" || !res.data.content) { + warning("ROADMAP.md is not a readable file; skipping."); + return; + } + content = Buffer.from(res.data.content, "base64").toString("utf-8"); + } catch (err) { + warning(`Failed to fetch ROADMAP.md: ${err && err.message ? err.message : err}`); + return; + } + + // 3. Truncate if it exceeds Discord's embed description limit + const rawUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/blob/main/ROADMAP.md`; + const truncationNote = `\n\n… *(truncated, see [full file on GitHub](${rawUrl}))*`; + const maxContentLength = 4096 - truncationNote.length; + let description = content; + let truncated = false; + if (content.length > maxContentLength) { + description = content.slice(0, maxContentLength) + truncationNote; + truncated = true; + } + + // 4. Build the embed payload + const syncedAt = new Date().toISOString().split("T")[0]; + const payload = { + embeds: [ + { + title: ROADMAP_EMBED_TITLE, + url: rawUrl, + description, + color: 1998671, + footer: { + text: `${context.repo.owner}/${context.repo.repo} • Last synced ${syncedAt}`, + }, + timestamp: new Date().toISOString(), + }, + ], + allowed_mentions: { parse: [] }, + }; + if (truncated) { + payload.content = `⚠️ Roadmap exceeds Discord embed limit; truncated. See the [full file on GitHub](${rawUrl}) for the complete version.`; + } + + // 5. PATCH the existing message, or POST a new one + let messageId = existingMessageId; + try { + if (messageId) { + const res = await fetch( + `https://discord.com/api/v10/channels/${channelId}/messages/${messageId}`, + { + method: "PATCH", + headers: { + Authorization: `Bot ${botToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }, + ); + if (res.status === 404) { + warning( + `Existing message ${messageId} not found in Discord (was it deleted?). Falling back to POST.`, + ); + messageId = ""; + } else if (!res.ok) { + const txt = await res.text(); + warning(`Roadmap Discord PATCH failed ${res.status}: ${txt}`); + return; + } else { + info(`Roadmap Discord message ${messageId} updated.`); + } + } + + if (!messageId) { + const res = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, { + method: "POST", + headers: { + Authorization: `Bot ${botToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const txt = await res.text(); + warning(`Roadmap Discord POST failed ${res.status}: ${txt}`); + return; + } + const data = await res.json(); + messageId = data.id; + info(`🆕 New roadmap message created with id ${messageId}.`); + info( + `👉 Set DISCORD_ROADMAP_MESSAGE_ID=${messageId} as a repo variable to update this message on future changes.`, + ); + info( + ` gh variable set DISCORD_ROADMAP_MESSAGE_ID --body "${messageId}" --repo ${context.repo.owner}/${context.repo.repo}`, + ); + } + } catch (err) { + warning(`Roadmap Discord sync threw: ${err && err.message ? err.message : err}`); + return; + } + + // 6. Pin the message + try { + const pinRes = await fetch( + `https://discord.com/api/v10/channels/${channelId}/messages/pins/${messageId}`, + { method: "PUT", headers: { Authorization: `Bot ${botToken}` } }, + ); + if (pinRes.status === 204 || pinRes.ok) { + info(`Message ${messageId} pinned.`); + } else if (pinRes.status === 403) { + warning( + "Cannot pin message: bot lacks 'Manage Messages' on the channel. Add it via Discord channel permissions.", + ); + } else { + const txt = await pinRes.text(); + warning(`Pin failed ${pinRes.status}: ${txt}`); + } + } catch (err) { + warning(`Pin threw: ${err && err.message ? err.message : err}`); + } + } catch (err) { + const msg = err && err.message ? err.message : String(err); + warning(`Roadmap Discord sync failed: ${msg}`); + } +} + +main(); diff --git a/.github/scripts/discord-thread-validator.mjs b/.github/scripts/discord-thread-validator.mjs new file mode 100644 index 0000000000..88ae9c7ea6 --- /dev/null +++ b/.github/scripts/discord-thread-validator.mjs @@ -0,0 +1,42 @@ +import { warning } from "@actions/core"; + +export async function validateThreadChannel(threadId, prNumber, { botToken, forumChannelId } = {}) { + if (!botToken) { + warning( + "DISCORD_BOT_TOKEN not set; cannot validate thread channel ownership. Rejecting marker.", + ); + return false; + } + const VALIDATION_TIMEOUT_MS = 5_000; + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), VALIDATION_TIMEOUT_MS); + const res = await fetch(`https://discord.com/api/v10/channels/${threadId}`, { + headers: { Authorization: `Bot ${botToken}` }, + signal: controller.signal, + }); + clearTimeout(timeout); + if (!res.ok) { + warning(`Thread validation failed: channel ${threadId} returned ${res.status}`); + return false; + } + const channel = await res.json(); + if (forumChannelId && channel.parent_id !== forumChannelId) { + warning( + `Thread ${threadId} parent_id=${channel.parent_id} does not match expected forum ${forumChannelId}; treating marker as untrusted.`, + ); + return false; + } + const expectedPrefix = `PR #${prNumber} -`; + if (!channel.name || !channel.name.startsWith(expectedPrefix)) { + warning( + `Thread ${threadId} name "${channel.name}" does not match expected prefix "${expectedPrefix}"; treating marker as untrusted.`, + ); + return false; + } + return true; + } catch (err) { + warning(`Thread validation threw: ${err && err.message ? err.message : err}`); + return false; + } +} diff --git a/.github/scripts/discord-thread-validator.test.mjs b/.github/scripts/discord-thread-validator.test.mjs new file mode 100644 index 0000000000..f2c686b852 --- /dev/null +++ b/.github/scripts/discord-thread-validator.test.mjs @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { validateThreadChannel } from "./discord-thread-validator.mjs"; + +beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("validateThreadChannel", () => { + const botToken = "bot-token"; + const number = 42; + + it("fails closed when botToken is unset", async () => { + const result = await validateThreadChannel("123", number, { botToken: "" }); + expect(result).toBe(false); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("rejects a forged marker pointing at a random thread (wrong parent)", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + id: "111", + parent_id: "999999999999999999", + name: `PR #${number} - Some PR`, + }), + }); + + const result = await validateThreadChannel("111", number, { + botToken, + forumChannelId: "888888888888888888", + }); + + expect(result).toBe(false); + }); + + it("rejects a marker pointing at a sibling PR thread in the same forum", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + id: "222", + parent_id: "888888888888888888", + name: `PR #99 - Other PR`, + }), + }); + + const result = await validateThreadChannel("222", number, { + botToken, + forumChannelId: "888888888888888888", + }); + + expect(result).toBe(false); + }); + + it("accepts a valid bot-created thread for PR #N", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + id: "333", + parent_id: "888888888888888888", + name: `PR #${number} - My feature`, + }), + }); + + const result = await validateThreadChannel("333", number, { + botToken, + forumChannelId: "888888888888888888", + }); + + expect(result).toBe(true); + }); + + it("returns false when Discord API returns non-ok", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 404, + }); + + const result = await validateThreadChannel("404", number, { botToken }); + expect(result).toBe(false); + }); + + it("passes an AbortSignal with timeout to fetch", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + id: "777", + parent_id: "888888888888888888", + name: `PR #${number} - gated`, + }), + }); + + await validateThreadChannel("777", number, { + botToken, + forumChannelId: "888888888888888888", + }); + + const call = vi.mocked(fetch).mock.calls[0]; + expect(call[1].signal).toBeInstanceOf(AbortSignal); + }); + + it("returns false when fetch throws", async () => { + vi.mocked(fetch).mockRejectedValue(new Error("network error")); + + const result = await validateThreadChannel("500", number, { botToken }); + expect(result).toBe(false); + }); +}); diff --git a/.github/scripts/discord-weekly-leaderboard.mjs b/.github/scripts/discord-weekly-leaderboard.mjs new file mode 100644 index 0000000000..0bfe180a4a --- /dev/null +++ b/.github/scripts/discord-weekly-leaderboard.mjs @@ -0,0 +1,75 @@ +import { info, warning } from "@actions/core"; +import { context, getOctokit } from "@actions/github"; +import { postChannelMessage } from "./discord-bot-api.mjs"; + +const botToken = (process.env.DISCORD_BOT_TOKEN || "").trim(); +const spotlightChannelId = (process.env.DISCORD_SPOTLIGHT_CHANNEL_ID || "").trim(); + +async function main() { + if (!botToken || !spotlightChannelId) { + info("DISCORD_BOT_TOKEN or DISCORD_SPOTLIGHT_CHANNEL_ID missing. Skipping leaderboard post."); + return; + } + + const octokit = getOctokit(process.env.GITHUB_TOKEN); + 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)}`; + + let allItems = []; + try { + allItems = await octokit.paginate(octokit.rest.search.issuesAndPullRequests, { + q, + per_page: 100, + }); + } catch (err) { + warning(`Search API failed: ${err && err.message ? err.message : err}`); + return; + } + const counter = new Map(); + for (const item of allItems) { + 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 = allItems.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 = { + 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: [] }, + }; + + try { + await postChannelMessage({ + botToken, + channelId: spotlightChannelId, + payload, + }); + } catch (err) { + warning(`Leaderboard post failed: ${err && err.message ? err.message : err}`); + } +} + +main(); diff --git a/.github/scripts/release-milestone-close.mjs b/.github/scripts/release-milestone-close.mjs new file mode 100644 index 0000000000..829cbee349 --- /dev/null +++ b/.github/scripts/release-milestone-close.mjs @@ -0,0 +1,42 @@ +import { info, warning } from "@actions/core"; +import { getOctokit } from "@actions/github"; + +const token = (process.env.TOKEN || "").trim(); +const stable = (process.env.STABLE_VERSION || "").trim(); + +if (!token || !stable) { + warning("TOKEN or STABLE_VERSION missing; skipping milestone close."); + process.exit(0); +} + +const title = `v${stable}`; +const owner = process.env.GITHUB_REPOSITORY_OWNER || ""; +const repoFull = process.env.GITHUB_REPOSITORY || ""; +const repo = repoFull.includes("/") ? repoFull.split("/")[1] : repoFull; + +if (!owner || !repo) { + warning("GITHUB_REPOSITORY not set; skipping."); + process.exit(0); +} + +const octokit = getOctokit(token); + +const open = await octokit.paginate(octokit.rest.issues.listMilestones, { + owner, + repo, + state: "open", + per_page: 100, +}); +const m = open.find((x) => x.title === title); +if (!m) { + info(`Open milestone "${title}" not found; nothing to close.`); + process.exit(0); +} + +await octokit.rest.issues.updateMilestone({ + owner, + repo, + milestone_number: m.number, + state: "closed", +}); +info(`Closed milestone "${title}" (#${m.number}).`); diff --git a/.github/scripts/release-milestone-migrate.mjs b/.github/scripts/release-milestone-migrate.mjs new file mode 100644 index 0000000000..19f296fafa --- /dev/null +++ b/.github/scripts/release-milestone-migrate.mjs @@ -0,0 +1,119 @@ +import { info, warning } from "@actions/core"; +import { getOctokit } from "@actions/github"; + +const ROLLING_NAME = "Next Release"; +const MARKER_PREFIX = "`; + const comments = await octokit.paginate(octokit.rest.issues.listComments, { + owner, + repo, + issue_number: issueNumber, + per_page: 100, + }); + return comments.some((c) => c.body && c.body.includes(marker)); +} + +async function main() { + const rolling = await findMilestone(ROLLING_NAME); + if (!rolling) { + info(`Rolling milestone "${ROLLING_NAME}" not found; nothing to migrate.`); + return; + } + + const versioned = await ensureMilestone(versionedName); + info(`Target milestone: ${versionedName} (#${versioned.number}).`); + + const items = await listMilestoneItems(rolling.number); + info(`Found ${items.length} item(s) in "${ROLLING_NAME}".`); + + let moved = 0; + let skipped = 0; + for (const item of items) { + if (await hasMarker(item.number, versionedName)) { + skipped++; + continue; + } + await octokit.rest.issues.update({ + owner, + repo, + issue_number: item.number, + milestone: versioned.number, + }); + const tag = `${MARKER_PREFIX}${versionedName} -->`; + await octokit.rest.issues.createComment({ + owner, + repo, + issue_number: item.number, + body: `${tag}\nMoved into \`${versionedName}\` as part of the pre-release cut.`, + }); + moved++; + } + info(`Migrated ${moved}, skipped ${skipped} (already tagged).`); +} + +await main(); diff --git a/.github/workflows/aur-publish.yml b/.github/workflows/aur-publish.yml new file mode 100644 index 0000000000..03a1b3c519 --- /dev/null +++ b/.github/workflows/aur-publish.yml @@ -0,0 +1,162 @@ +name: Publish to AUR + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Release tag to publish (e.g. v1.5.0)" + required: true + type: string + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + if: (github.event_name == 'workflow_dispatch' || !github.event.release.prerelease) && vars.AUR_PACKAGE_NAME != '' + steps: + - name: Resolve tag and version + id: meta + env: + GH_EVENT_TAG: ${{ github.event.release.tag_name }} + INPUT_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + TAG="${GH_EVENT_TAG:-$INPUT_TAG}" + if [[ -z "$TAG" ]]; then + echo "::error::No tag resolved from release event or workflow input" + exit 1 + fi + VERSION="${TAG#v}" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Check AUR secrets + id: aur_secret + env: + AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + run: | + if [[ -z "$AUR_SSH_PRIVATE_KEY" ]]; then + echo "AUR_SSH_PRIVATE_KEY secret not set; skipping." + echo "configured=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "configured=true" >> "$GITHUB_OUTPUT" + + - name: Find .pacman asset + if: steps.aur_secret.outputs.configured == 'true' + id: asset + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.meta.outputs.tag }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + NAMES=$(gh release view "$TAG" --repo "$REPO" --json assets --jq '.assets[].name') + PACMAN_NAME=$(echo "$NAMES" | grep -iE '\.pacman$' | head -n1 || true) + if [[ -z "$PACMAN_NAME" ]]; then + echo "::error::No .pacman asset found in release $TAG" + echo "Available assets:" + echo "$NAMES" + exit 1 + fi + echo "name=$PACMAN_NAME" >> "$GITHUB_OUTPUT" + echo "Found pacman asset: $PACMAN_NAME" + + - name: Download and compute sha256 + if: steps.aur_secret.outputs.configured == 'true' + id: sha + env: + REPO: ${{ github.repository }} + TAG: ${{ steps.meta.outputs.tag }} + ASSET: ${{ steps.asset.outputs.name }} + run: | + set -euo pipefail + BASE="https://github.com/${REPO}/releases/download/${TAG}" + curl -fsSL --retry 3 -o /tmp/pkg.pacman "${BASE}/${ASSET}" + PKG_SHA=$(sha256sum /tmp/pkg.pacman | awk '{print $1}') + echo "sha256=$PKG_SHA" >> "$GITHUB_OUTPUT" + + - name: Setup SSH for AUR + if: steps.aur_secret.outputs.configured == 'true' + env: + AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + AUR_KNOWN_HOSTS: ${{ vars.AUR_KNOWN_HOSTS }} + run: | + set -euo pipefail + if [[ -z "$AUR_KNOWN_HOSTS" ]]; then + echo "::error::AUR_KNOWN_HOSTS variable is required for secure AUR SSH" + exit 1 + fi + mkdir -p ~/.ssh + echo "$AUR_SSH_PRIVATE_KEY" > ~/.ssh/aur_key + chmod 600 ~/.ssh/aur_key + printf '%s\n' "$AUR_KNOWN_HOSTS" > ~/.ssh/aur_known_hosts + cat >> ~/.ssh/config <<'SSHCONF' + Host aur.archlinux.org + HostName aur.archlinux.org + User aur + IdentityFile ~/.ssh/aur_key + StrictHostKeyChecking yes + UserKnownHostsFile ~/.ssh/aur_known_hosts + SSHCONF + + - name: Clone AUR repository + if: steps.aur_secret.outputs.configured == 'true' + env: + PACKAGE: ${{ vars.AUR_PACKAGE_NAME }} + run: | + set -euo pipefail + git clone "ssh://aur@aur.archlinux.org/${PACKAGE}.git" aur-repo + + - name: Install makepkg + if: steps.aur_secret.outputs.configured == 'true' + run: | + set -euo pipefail + sudo apt-get update -qq + sudo apt-get install -y -qq pacman-package-manager 2>/dev/null || \ + sudo apt-get install -y -qq makepkg 2>/dev/null || { + echo "::error::Unable to install makepkg. Install pacman-package-manager or makepkg." + exit 1 + } + command -v makepkg >/dev/null || { + echo "::error::makepkg still missing after install." + exit 1 + } + + - name: Update PKGBUILD and .SRCINFO + if: steps.aur_secret.outputs.configured == 'true' + working-directory: aur-repo + env: + VERSION: ${{ steps.meta.outputs.version }} + SHA256: ${{ steps.sha.outputs.sha256 }} + ASSET: ${{ steps.asset.outputs.name }} + REPO: ${{ github.repository }} + TAG: ${{ steps.meta.outputs.tag }} + run: | + set -euo pipefail + sed -i -E "s|^pkgver=.*|pkgver=${VERSION}|" PKGBUILD + sed -i -E "s|^pkgrel=.*|pkgrel=1|" PKGBUILD + sed -i -E "s|^sha256sums=\('[^']*'|sha256sums=('${SHA256}'|" PKGBUILD + makepkg --printsrcinfo > .SRCINFO + echo "Updated .SRCINFO" + + - name: Commit and push + if: steps.aur_secret.outputs.configured == 'true' + working-directory: aur-repo + env: + VERSION: ${{ steps.meta.outputs.version }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add PKGBUILD .SRCINFO + if git diff --cached --quiet; then + echo "PKGBUILD already up to date for ${VERSION} — nothing to commit." + exit 0 + fi + git commit -m "Bump to ${VERSION}" + git push diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1f85736c2a..6487cbb174 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,152 +1,167 @@ - name: Build Electron App on: + push: + tags: + - "v*" workflow_dispatch: inputs: arch: - description: 'Architecture to build' + description: "macOS architecture to build" required: true - default: 'both' + default: "both" type: choice options: - arm64 - x64 - both + release_tag: + description: "Optional release tag to create or update, e.g. v1.5.0" + required: false + type: string + +permissions: + contents: write + +concurrency: + group: build-${{ github.ref_name }}-${{ github.event.inputs.release_tag || 'artifacts' }} + cancel-in-progress: false jobs: build-windows: + name: Windows installer runs-on: windows-latest steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Node.js - uses: actions/setup-node@v3 - with: - node-version: '22' + uses: ./.github/actions/setup - - name: Install dependencies - run: npm ci + - name: Cache caption assets + uses: actions/cache@v4 + with: + path: caption-assets + key: caption-assets-${{ runner.os }}-${{ hashFiles('scripts/fetch-caption-model.mjs') }} - name: Build Windows app - run: npm run build:win - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Upload Windows build + run: npm run build:win -- --publish never + + - name: Upload Windows installer uses: actions/upload-artifact@v4 with: - name: windows-installer - path: release/**/*.exe + name: openscreen-windows + path: release/**/Openscreen.Setup.*.exe + if-no-files-found: error retention-days: 30 build-macos: + name: macOS ${{ matrix.arch }} DMG runs-on: macos-latest strategy: + fail-fast: false matrix: - arch: ${{ github.event.inputs.arch == 'both' && fromJSON('["arm64", "x64"]') || fromJSON(format('["{0}"]', github.event.inputs.arch)) }} - + arch: ${{ fromJSON((github.event_name == 'workflow_dispatch' && github.event.inputs.arch != 'both') && format('["{0}"]', github.event.inputs.arch) || '["arm64", "x64"]') }} steps: - # ─── Checkout ───────────────────────────────────────────── - name: Checkout code uses: actions/checkout@v4 - # ─── Setup Node.js ──────────────────────────────────────── - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm + uses: ./.github/actions/setup - # ─── Setup Python (needed by some native deps) ──────────── - name: Setup Python uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: "3.11" - # ─── Install Dependencies ───────────────────────────────── - - name: Install dependencies - run: npm ci + - name: Ensure sharp prebuilt + run: npm rebuild sharp + env: + npm_config_build_from_source: "false" + + - name: Cache caption assets + uses: actions/cache@v4 + with: + path: caption-assets + key: caption-assets-${{ runner.os }}-${{ hashFiles('scripts/fetch-caption-model.mjs') }} + + - name: Resolve macOS signing + id: signing + env: + MAC_CERTIFICATE_P12: ${{ secrets.MAC_CERTIFICATE_P12 }} + MAC_CERTIFICATE_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }} + MAC_CSC_NAME: ${{ secrets.MAC_CSC_NAME }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + run: | + if [[ -n "$MAC_CERTIFICATE_P12" && -n "$MAC_CERTIFICATE_PASSWORD" && -n "$MAC_CSC_NAME" && -n "$APPLE_ID" && -n "$APPLE_TEAM_ID" && -n "$APPLE_APP_SPECIFIC_PASSWORD" ]]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + fi - # ─── 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 + if: steps.signing.outputs.enabled == 'true' 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) + 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 \ + 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" + rm -f "$RUNNER_TEMP/certificate.p12" - # 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: Build native macOS helpers + run: npm run build:native:mac + env: + OPENSCREEN_MAC_HELPER_ARCHS: ${{ matrix.arch }} + - name: Package .app bundle - run: npx electron-builder --mac --${{ matrix.arch }} --dir + run: npx electron-builder --mac --${{ matrix.arch }} --dir --publish never env: - CSC_NAME: "Samir Patil (N26FZ4GW28)" - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CSC_NAME: ${{ secrets.MAC_CSC_NAME }} + CSC_IDENTITY_AUTO_DISCOVERY: ${{ steps.signing.outputs.enabled == 'true' && 'true' || 'false' }} - # ─── Read version from package.json ─────────────────────── - name: Get version id: version - run: echo "version=$(node -p 'require(\"./package.json\").version')" >> $GITHUB_OUTPUT + run: | + VERSION="$(node -e "console.log(require('./package.json').version)")" + echo "version=$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 + 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}/" + find "release/${VERSION}" -maxdepth 4 -print || true exit 1 fi - echo "app_bundle=$APP_BUNDLE" >> $GITHUB_OUTPUT - echo "Found: $APP_BUNDLE" + echo "app_bundle=$APP_BUNDLE" >> "$GITHUB_OUTPUT" - # ─── Verify .app signature ──────────────────────────────── - name: Verify .app code signature + if: steps.signing.outputs.enabled == 'true' run: codesign --verify --deep --strict "${{ steps.find_app.outputs.app_bundle }}" - # ─── Create DMG ─────────────────────────────────────────── - name: Create DMG id: dmg run: | @@ -157,6 +172,8 @@ jobs: DMG_OUTPUT="${RELEASE_DIR}/${DMG_NAME}" STAGING="${RELEASE_DIR}/dmg-staging" + rm -rf "$STAGING" + rm -f "$DMG_OUTPUT" mkdir -p "$STAGING" cp -R "${{ steps.find_app.outputs.app_bundle }}" "$STAGING/" ln -s /Applications "$STAGING/Applications" @@ -170,22 +187,18 @@ jobs: "$DMG_OUTPUT" rm -rf "$STAGING" + echo "dmg_path=$DMG_OUTPUT" >> "$GITHUB_OUTPUT" - echo "dmg_path=$DMG_OUTPUT" >> $GITHUB_OUTPUT - echo "dmg_name=$DMG_NAME" >> $GITHUB_OUTPUT - - # ─── Sign DMG ───────────────────────────────────────────── - name: Sign DMG + if: steps.signing.outputs.enabled == 'true' && !contains(github.ref_name, '-') run: | codesign --force \ - --sign "Developer ID Application: Samir Patil (N26FZ4GW28)" \ + --sign "${{ secrets.MAC_CSC_NAME }}" \ --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 + if: steps.signing.outputs.enabled == 'true' && !contains(github.ref_name, '-') run: | xcrun notarytool submit "${{ steps.dmg.outputs.dmg_path }}" \ --apple-id "${{ secrets.APPLE_ID }}" \ @@ -194,60 +207,193 @@ jobs: --wait timeout-minutes: 15 - # ─── Staple ─────────────────────────────────────────────── - name: Staple notarization ticket + if: steps.signing.outputs.enabled == 'true' && !contains(github.ref_name, '-') run: xcrun stapler staple "${{ steps.dmg.outputs.dmg_path }}" - # ─── Validate ───────────────────────────────────────────── - name: Validate stapled DMG + if: steps.signing.outputs.enabled == 'true' && !contains(github.ref_name, '-') run: | xcrun stapler validate "${{ steps.dmg.outputs.dmg_path }}" spctl -a -vv -t install "${{ steps.dmg.outputs.dmg_path }}" - # ─── Upload Artifact ────────────────────────────────────── - - name: Upload notarized DMG + - name: Upload macOS DMG uses: actions/upload-artifact@v4 with: name: openscreen-mac-${{ matrix.arch }} path: ${{ steps.dmg.outputs.dmg_path }} + if-no-files-found: error retention-days: 30 - # ─── Cleanup Keychain ───────────────────────────────────── - name: Cleanup keychain - if: always() - run: security delete-keychain $RUNNER_TEMP/build.keychain-db || true + if: always() && steps.signing.outputs.enabled == 'true' + run: security delete-keychain "$RUNNER_TEMP/build.keychain-db" || true build-linux: + name: Linux packages runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Node.js - uses: actions/setup-node@v3 - with: - node-version: '22' - - - name: Install dependencies - run: npm ci + uses: ./.github/actions/setup - # bsdtar (from libarchive-tools) is required by fpm to build pacman - # packages. AppImage and deb don't need it; ubuntu-latest doesn't ship it. - name: Install pacman build dependencies run: sudo apt-get update && sudo apt-get install -y libarchive-tools + - name: Cache caption assets + uses: actions/cache@v4 + with: + path: caption-assets + key: caption-assets-${{ runner.os }}-${{ hashFiles('scripts/fetch-caption-model.mjs') }} + - name: Build Linux app - run: npm run build:linux - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npm run build:linux -- --publish never - - name: Upload Linux build + - name: Upload Linux packages uses: actions/upload-artifact@v4 with: - name: linux-installer + name: openscreen-linux path: | release/**/*.AppImage release/**/*.zsync release/**/*.deb release/**/*.pacman + if-no-files-found: error retention-days: 30 + + publish-release: + name: Publish GitHub release + runs-on: ubuntu-latest + needs: + - build-windows + - build-macos + - build-linux + if: ${{ (github.event_name == 'push' && github.ref_type == 'tag') || (github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag != '') }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Resolve release tag + id: release + env: + INPUT_TAG: ${{ github.event.inputs.release_tag }} + run: | + if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then + TAG="${GITHUB_REF_NAME}" + else + TAG="${INPUT_TAG}" + fi + + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(rc|beta|alpha)\.[0-9]+)?$ ]]; then + echo "::error::Release tag must look like v1.5.0 or v1.5.0-rc.1; got '${TAG}'" + exit 1 + fi + + VERSION="${TAG#v}" + # For an RC tag (e.g. v1.5.0-rc.1) package.json is at the pre-release version + # (1.5.0-rc.1), not the stable version (1.5.0). Compare against the full tag version. + PACKAGE_VERSION="$(node -p 'require("./package.json").version')" + if [[ "$PACKAGE_VERSION" != "$VERSION" ]]; then + echo "::error::package.json version ${PACKAGE_VERSION} does not match ${VERSION} from tag ${TAG}" + exit 1 + fi + + if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-(rc|beta|alpha)\.[0-9]+$ ]]; then + PRERELEASE_FLAG="--prerelease" + IS_PRERELEASE="true" + else + PRERELEASE_FLAG="" + IS_PRERELEASE="false" + fi + + # Compute the previous stable tag for auto-generated release notes. We don't use + # GitHub's "most recent prior release by date" because the fork carries re-published + # upstream releases whose published_at is more recent than the fork's own first release. + # For SemVer X.Y.Z: previous is vX.Y.(Z-1) if Z>0, else vX.(Y-1).0, else v(X-1).0.0. + STABLE_VERSION="${VERSION%%-*}" + IFS='.' read -r PX PY PZ <<< "$STABLE_VERSION" + if (( PZ > 0 )); then + NOTES_START_TAG="v${PX}.${PY}.$((PZ - 1))" + elif (( PY > 0 )); then + NOTES_START_TAG="v${PX}.$((PY - 1)).0" + else + NOTES_START_TAG="v$((PX - 1)).0.0" + fi + echo "Computed notes_start_tag=${NOTES_START_TAG} for tag=${TAG}" + + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "stable_version=$STABLE_VERSION" >> "$GITHUB_OUTPUT" + echo "is_prerelease=$IS_PRERELEASE" >> "$GITHUB_OUTPUT" + echo "prerelease_flag=$PRERELEASE_FLAG" >> "$GITHUB_OUTPUT" + echo "notes_start_tag=$NOTES_START_TAG" >> "$GITHUB_OUTPUT" + + - name: Download Windows installer + uses: actions/download-artifact@v4 + with: + name: openscreen-windows + path: artifacts/windows + + - name: Download macOS arm64 DMG + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: openscreen-mac-arm64 + path: artifacts/mac-arm64 + + - name: Download macOS x64 DMG + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: openscreen-mac-x64 + path: artifacts/mac-x64 + + - name: Download Linux packages + uses: actions/download-artifact@v4 + with: + name: openscreen-linux + path: artifacts/linux + + - name: Publish release assets + env: + GH_TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }} + TAG: ${{ steps.release.outputs.tag }} + PRERELEASE_FLAG: ${{ steps.release.outputs.prerelease_flag }} + NOTES_START_TAG: ${{ steps.release.outputs.notes_start_tag }} + run: | + mapfile -t FILES < <(find artifacts -type f | sort) + if [[ "${#FILES[@]}" -eq 0 ]]; then + echo "::error::No installer artifacts were downloaded" + exit 1 + fi + + if gh release view "$TAG" >/dev/null 2>&1; then + gh release upload "$TAG" "${FILES[@]}" --clobber + else + # --notes-start-tag controls which previous tag GitHub compares against + # when auto-generating the release notes. Default behaviour (most recent + # prior release by date) doesn't work for this fork because the v1.4.0 + # release in the fork was re-published after v1.5.0, which makes GitHub + # pick v1.4.0 as the "previous" for any v1.5.x release. + # shellcheck disable=SC2086 + gh release create "$TAG" "${FILES[@]}" \ + --target "$GITHUB_SHA" \ + --title "$TAG" \ + --generate-notes \ + --notes-start-tag "$NOTES_START_TAG" \ + $PRERELEASE_FLAG + fi + + if [[ -n "$PRERELEASE_FLAG" ]]; then + gh release edit "$TAG" \ + --draft=false \ + --latest=false \ + --title "$TAG" + else + gh release edit "$TAG" \ + --draft=false \ + --latest \ + --title "$TAG" + fi diff --git a/.github/workflows/bump-nix-package.yml b/.github/workflows/bump-nix-package.yml index 5ff3c73e63..57e48e51a4 100644 --- a/.github/workflows/bump-nix-package.yml +++ b/.github/workflows/bump-nix-package.yml @@ -111,7 +111,7 @@ jobs: - \`version\` → \`${VERSION}\` - \`npmDepsHash\` → \`${HASH}\` (computed via \`prefetch-npm-deps package-lock.json\`) - Merge this so Nix users (NixOS, Home Manager, \`nix run github:siddharthvaddem/openscreen\`) pick up the new release. + Merge this so Nix users (NixOS, Home Manager, \`nix run github:${{ github.repository }}\`) pick up the new release. > Note: PRs opened by \`GITHUB_TOKEN\` don't auto-trigger CI. The diff is two lines — review the change here, then merge. If you want CI to run, push an empty commit to this branch or close-and-reopen the PR. EOF diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c9e8ef188..3c65e41b6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,11 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - run: npm ci + - uses: ./.github/actions/setup - run: npm run lint typecheck: @@ -24,11 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - run: npm ci + - uses: ./.github/actions/setup - run: npx tsc --noEmit test: @@ -36,11 +28,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - run: npm ci + - uses: ./.github/actions/setup - run: npm run test - run: npm run test:browser:install - run: npm run test:browser @@ -50,9 +38,28 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - run: npm ci + - uses: ./.github/actions/setup - run: npx vite build + + semantic-pr: + name: Validate PR title (semantic) + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + feat + fix + chore + refactor + perf + docs + test + build + ci + style + revert + requireScope: false diff --git a/.github/workflows/diagnostic-artifact.yml b/.github/workflows/diagnostic-artifact.yml new file mode 100644 index 0000000000..bdf9bde9d5 --- /dev/null +++ b/.github/workflows/diagnostic-artifact.yml @@ -0,0 +1,106 @@ +name: Diagnostic artifact + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build-windows: + name: Windows x64 diagnostic bundle + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: ./.github/actions/setup + + - name: Build native helper + run: npm run build:native:win + + - name: Bundle diagnostic tool + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $bundle = "openscreen-diagnostic-windows-x64" + $staging = New-Item -ItemType Directory -Path "$bundle" + Copy-Item scripts/diagnostic-tool/diagnostic.mjs -Destination $staging + Copy-Item scripts/diagnostic-tool/diagnostic.bat -Destination $staging + Copy-Item scripts/diagnostic-tool/README.md -Destination $staging + New-Item -ItemType Directory -Path "$staging/helpers/win32-x64" | Out-Null + Copy-Item electron/native/bin/win32-x64/wgc-capture.exe -Destination "$staging/helpers/win32-x64/" + Compress-Archive -Path "$staging" -DestinationPath "$bundle.zip" + + - name: Smoke-test the bundle + shell: pwsh + # The actual capture path is exercised by users with a real desktop + # session. A non-interactive runner has no display, so WGC cannot + # capture frames and the helper exits before emitting [stop-timing]. + # Validate bundle structure + CLI parser instead of running capture. + run: | + $ErrorActionPreference = "Stop" + Expand-Archive -Path openscreen-diagnostic-windows-x64.zip -DestinationPath smoke + $smoke = Resolve-Path "smoke/openscreen-diagnostic-windows-x64" + foreach ($rel in @( + "diagnostic.mjs", + "diagnostic.bat", + "README.md", + "helpers/win32-x64/wgc-capture.exe" + )) { + $p = Join-Path $smoke $rel + if (-not (Test-Path $p)) { throw "Smoke test: missing $rel" } + } + & "$smoke/diagnostic.bat" --help | Out-Null + if ($LASTEXITCODE -ne 0) { throw "diagnostic.bat --help exited $LASTEXITCODE" } + + - name: Upload Windows diagnostic bundle + uses: actions/upload-artifact@v4 + with: + name: openscreen-diagnostic-windows-x64 + path: openscreen-diagnostic-windows-x64.zip + if-no-files-found: error + retention-days: 14 + + build-macos: + name: macOS ${{ matrix.arch }} diagnostic bundle + runs-on: macos-latest + strategy: + fail-fast: false + matrix: + arch: [arm64, x64] + steps: + - uses: actions/checkout@v4 + + - uses: ./.github/actions/setup + + - name: Build native helper + run: npm run build:native:mac + env: + OPENSCREEN_MAC_HELPER_ARCHS: ${{ matrix.arch }} + + - name: Bundle diagnostic tool + run: | + set -euo pipefail + arch="${{ matrix.arch }}" + arch_tag="darwin-$([ "$arch" = "x64" ] && echo x64 || echo arm64)" + bundle="openscreen-diagnostic-macos-$arch" + rm -rf "$bundle" "$bundle.tar.gz" + mkdir -p "$bundle/helpers/$arch_tag" + cp scripts/diagnostic-tool/diagnostic.mjs "$bundle/" + cp scripts/diagnostic-tool/diagnostic.sh "$bundle/" + cp scripts/diagnostic-tool/README.md "$bundle/" + cp "electron/native/bin/$arch_tag/openscreen-screencapturekit-helper" "$bundle/helpers/$arch_tag/" + chmod +x "$bundle/diagnostic.sh" "$bundle/helpers/$arch_tag/openscreen-screencapturekit-helper" + tar -czf "$bundle.tar.gz" "$bundle" + + - name: Upload macOS diagnostic bundle + uses: actions/upload-artifact@v4 + with: + name: openscreen-diagnostic-macos-${{ matrix.arch }} + path: openscreen-diagnostic-macos-${{ matrix.arch }}.tar.gz + if-no-files-found: error + retention-days: 14 \ No newline at end of file diff --git a/.github/workflows/discord-pr-notify.yml b/.github/workflows/discord-pr-notify.yml new file mode 100644 index 0000000000..20ed1c47c4 --- /dev/null +++ b/.github/workflows/discord-pr-notify.yml @@ -0,0 +1,40 @@ +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] + +permissions: + contents: read + pull-requests: write + issues: read + +jobs: + notify: + name: Sync PR activity to Discord + if: | + 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: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: ./.github/actions/setup + + - name: Sync PR activity to Discord forum thread + continue-on-error: true + env: + DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }} + DISCORD_REVIEWER_ROLE_ID: ${{ secrets.DISCORD_REVIEWER_ROLE_ID }} + DISCORD_PR_FORUM_CHANNEL_ID: ${{ vars.DISCORD_PR_FORUM_CHANNEL_ID }} + DISCORD_ALERT_CHANNEL_ID: ${{ vars.DISCORD_ALERT_CHANNEL_ID }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node .github/scripts/discord-pr-sync.mjs diff --git a/.github/workflows/discord-roadmap-sync.yml b/.github/workflows/discord-roadmap-sync.yml new file mode 100644 index 0000000000..bbada3e0e0 --- /dev/null +++ b/.github/workflows/discord-roadmap-sync.yml @@ -0,0 +1,40 @@ +name: Discord Roadmap Sync + +on: + pull_request_target: + types: [closed] + push: + branches: [main] + +permissions: + contents: read + pull-requests: read + +jobs: + roadmap-sync: + name: Sync ROADMAP.md to Discord + if: | + (github.event_name == 'pull_request_target' && + github.event.action == 'closed' && + github.event.pull_request.merged == true && + github.event.pull_request.base.ref == 'main') || + (github.event_name == 'push' && github.ref == 'refs/heads/main') + concurrency: + group: discord-roadmap-sync-${{ github.repository }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: ./.github/actions/setup + + - name: Sync ROADMAP.md to pinned Discord message + continue-on-error: true + env: + DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }} + DISCORD_ROADMAP_CHANNEL_ID: ${{ vars.DISCORD_ROADMAP_CHANNEL_ID }} + DISCORD_ROADMAP_MESSAGE_ID: ${{ vars.DISCORD_ROADMAP_MESSAGE_ID }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node .github/scripts/discord-roadmap-sync.mjs diff --git a/.github/workflows/discord-weekly-leaderboard.yml b/.github/workflows/discord-weekly-leaderboard.yml new file mode 100644 index 0000000000..9724e36dec --- /dev/null +++ b/.github/workflows/discord-weekly-leaderboard.yml @@ -0,0 +1,27 @@ +name: Discord Weekly Leaderboard + +on: + schedule: + - cron: "0 12 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + leaderboard: + name: Post weekly contributor leaderboard + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: ./.github/actions/setup + + - name: Post weekly leaderboard to Discord + env: + DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }} + DISCORD_SPOTLIGHT_CHANNEL_ID: ${{ vars.DISCORD_SPOTLIGHT_CHANNEL_ID }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node .github/scripts/discord-weekly-leaderboard.mjs diff --git a/.github/workflows/discord.yaml b/.github/workflows/discord.yaml deleted file mode 100644 index 3708110a26..0000000000 --- a/.github/workflows/discord.yaml +++ /dev/null @@ -1,515 +0,0 @@ -name: PR to Discord Forum - -on: - pull_request_target: - types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, edited, labeled, unlabeled, closed] - pull_request_review: - types: [submitted] - issue_comment: - types: [created] - schedule: - - cron: "0 12 * * 1" - workflow_dispatch: - -permissions: - contents: read - pull-requests: write - issues: read - -jobs: - notify: - if: github.event_name != 'schedule' && github.actor != 'github-actions[bot]' - concurrency: - group: discord-pr-sync-${{ github.repository }}-${{ github.event.pull_request.number || github.event.issue.number || github.run_id }} - cancel-in-progress: false - runs-on: ubuntu-latest - steps: - - name: Sync PR activity to Discord forum thread - id: sync - continue-on-error: true - 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) { - core.warning( - `Discord sync skipped: webhook secret unavailable for event '${context.eventName}'. ` + - "Set either DISCORD_WEBHOOK_URL or DISCORD_PR_FORUM_WEBHOOK in repository secrets.", - ); - 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/.github/workflows/merged-pr-bookkeeping.yml b/.github/workflows/merged-pr-bookkeeping.yml new file mode 100644 index 0000000000..8b0671325d --- /dev/null +++ b/.github/workflows/merged-pr-bookkeeping.yml @@ -0,0 +1,252 @@ +name: Merged PR issue bookkeeping + +on: + pull_request_target: + types: [closed] + +permissions: + contents: read + issues: write + pull-requests: read + +jobs: + mark-linked-issues-fixed: + name: Mark linked issues fixed in main + if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main' + runs-on: ubuntu-latest + steps: + - name: Update closing issues + uses: actions/github-script@v7 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const pullRequest = context.payload.pull_request; + const pullNumber = pullRequest.number; + + const fixedInMainLabel = { + name: "status: fixed in main", + color: "0E8A16", + description: "Work is merged into main but may not be in a downloadable release yet.", + }; + const pendingReleaseLabel = { + name: "status: pending release", + color: "FBCA04", + description: "Merged change is waiting for a packaged desktop release.", + }; + const labelsToRemove = ["status: in progress", "status: needs triage"]; + const nextReleaseMilestoneTitle = "Next Release"; + + async function ensureLabel(label) { + try { + await github.rest.issues.getLabel({ + owner, + repo, + name: label.name, + }); + } catch (error) { + if (error.status !== 404) throw error; + try { + await github.rest.issues.createLabel({ + owner, + repo, + name: label.name, + color: label.color, + description: label.description, + }); + core.info(`Created label '${label.name}'.`); + } catch (createError) { + if (createError.status !== 422) throw createError; + core.info(`Label '${label.name}' already exists.`); + } + } + } + + async function ensureMilestone(title) { + const milestones = await github.paginate(github.rest.issues.listMilestones, { + owner, + repo, + state: "all", + per_page: 100, + }); + const existing = milestones.find((milestone) => milestone.title === title); + if (existing?.state === "open") return existing; + if (existing) { + const reopened = await github.rest.issues.updateMilestone({ + owner, + repo, + milestone_number: existing.number, + state: "open", + }); + core.info(`Reopened milestone '${title}'.`); + return reopened.data; + } + + try { + const created = await github.rest.issues.createMilestone({ + owner, + repo, + title, + description: "Merged changes queued for the next packaged desktop release.", + }); + core.info(`Created milestone '${title}'.`); + return created.data; + } catch (error) { + if (error.status !== 422) throw error; + const refreshed = await github.paginate(github.rest.issues.listMilestones, { + owner, + repo, + state: "all", + per_page: 100, + }); + const milestone = refreshed.find((item) => item.title === title); + if (!milestone) throw error; + return milestone; + } + } + + async function getClosingIssueRefs() { + const query = ` + query($owner: String!, $repo: String!, $pullNumber: Int!, $cursor: String) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $pullNumber) { + closingIssuesReferences(first: 100, after: $cursor) { + nodes { + number + repository { + name + owner { + login + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + } + `; + + const issueRefs = new Map(); + let cursor = null; + let hasNextPage = true; + + while (hasNextPage) { + const result = await github.graphql(query, { + owner, + repo, + pullNumber, + cursor, + }); + const refs = result.repository.pullRequest.closingIssuesReferences; + for (const issue of refs.nodes) { + const issueOwner = issue.repository.owner.login; + const issueRepo = issue.repository.name; + issueRefs.set(`${issueOwner}/${issueRepo}#${issue.number}`, { + owner: issueOwner, + repo: issueRepo, + number: issue.number, + }); + } + hasNextPage = refs.pageInfo.hasNextPage; + cursor = refs.pageInfo.endCursor; + } + + return [...issueRefs.values()]; + } + + async function hasBookkeepingComment(issueNumber, marker) { + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: issueNumber, + per_page: 100, + }); + return comments.some((comment) => comment.body && comment.body.includes(marker)); + } + + await ensureLabel(fixedInMainLabel); + await ensureLabel(pendingReleaseLabel); + const fallbackMilestone = pullRequest.milestone || await ensureMilestone(nextReleaseMilestoneTitle); + + const issueRefs = await getClosingIssueRefs(); + if (issueRefs.length === 0) { + core.info(`PR #${pullNumber} did not declare closing issue references. Nothing to update.`); + return; + } + + for (const issueRef of issueRefs) { + if ( + issueRef.owner.toLowerCase() !== owner.toLowerCase() || + issueRef.repo.toLowerCase() !== repo.toLowerCase() + ) { + core.warning( + `Skipping cross-repository closing reference ${issueRef.owner}/${issueRef.repo}#${issueRef.number}; ` + + `this workflow only updates issues in ${owner}/${repo}.`, + ); + continue; + } + + const issueNumber = issueRef.number; + const issueResponse = await github.rest.issues.get({ + owner, + repo, + issue_number: issueNumber, + }); + const issue = issueResponse.data; + const existingLabels = issue.labels.map((label) => + typeof label === "string" ? label : label.name, + ); + const milestoneTitle = issue.milestone?.title || fallbackMilestone.title; + const milestoneNumber = issue.milestone?.number || fallbackMilestone.number; + + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: issueNumber, + labels: [fixedInMainLabel.name, pendingReleaseLabel.name], + }); + + for (const label of labelsToRemove) { + if (!existingLabels.includes(label)) continue; + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: issueNumber, + name: label, + }); + } catch (error) { + if (error.status !== 404) throw error; + } + } + + await github.rest.issues.update({ + owner, + repo, + issue_number: issueNumber, + milestone: milestoneNumber, + state: "closed", + state_reason: "completed", + }); + + const marker = ``; + if (!(await hasBookkeepingComment(issueNumber, marker))) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: [ + marker, + `Fixed by #${pullNumber} and merged into \`main\`.`, + "", + `This change is assigned to the \`${milestoneTitle}\` release milestone and is not necessarily available in the latest downloadable desktop release yet. It is currently marked as \`${pendingReleaseLabel.name}\` until a packaged release containing it is published.`, + ].join("\n"), + }); + } + + core.info(`Updated issue #${issueNumber} for merged PR #${pullNumber}.`); + } diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml new file mode 100644 index 0000000000..a592488c7f --- /dev/null +++ b/.github/workflows/prerelease.yml @@ -0,0 +1,160 @@ +name: Cut a release candidate + +on: + workflow_dispatch: + inputs: + bump: + description: "Semver bump type from current package.json version" + required: true + type: choice + options: [patch, minor, major] + default: minor + rc_number: + description: "Pre-release counter (rc.1, rc.2, ...)" + required: true + type: number + default: 1 + target_version: + description: "Override the auto-bumped next version (e.g. 2.0.0). Leave empty to bump from package.json." + required: false + type: string + +permissions: + contents: write + issues: write + pull-requests: write + +concurrency: + group: prerelease-${{ github.ref }} + cancel-in-progress: false + +jobs: + prerelease: + name: Cut pre-release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: ./.github/actions/setup + + - name: Calculate next version and RC tag + id: version + env: + BUMP: ${{ inputs.bump }} + RC_NUMBER: ${{ inputs.rc_number }} + TARGET_VERSION: ${{ inputs.target_version }} + run: | + set -euo pipefail + CURRENT=$(node -p "require('./package.json').version") + if [[ -n "$TARGET_VERSION" ]]; then + NEXT="$TARGET_VERSION" + else + IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT" + case "$BUMP" in + patch) PATCH=$((PATCH + 1)) ;; + minor) MINOR=$((MINOR + 1)); PATCH=0 ;; + major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; + esac + NEXT="${MAJOR}.${MINOR}.${PATCH}" + fi + PRERELEASE_VERSION="${NEXT}-rc.${RC_NUMBER}" + RC_TAG="v${PRERELEASE_VERSION}" + echo "current=$CURRENT" >> "$GITHUB_OUTPUT" + echo "next=$NEXT" >> "$GITHUB_OUTPUT" + echo "prerelease=$PRERELEASE_VERSION" >> "$GITHUB_OUTPUT" + echo "rc_tag=$RC_TAG" >> "$GITHUB_OUTPUT" + echo "Will cut ${RC_TAG} from ${CURRENT}" + + - name: Migrate issues from "Next Release" to versioned milestone + env: + TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }} + NEXT: ${{ steps.version.outputs.next }} + run: node .github/scripts/release-milestone-migrate.mjs + + - name: Bump package.json to pre-release version + env: + PRERELEASE: ${{ steps.version.outputs.prerelease }} + run: | + set -euo pipefail + sed -i -E "s|(\"version\"[[:space:]]*:[[:space:]]*\")[^\"]*(\")|\1${PRERELEASE}\2|" package.json + echo "package.json version:" + grep '"version"' package.json + + - name: Commit package.json bump on a release branch + env: + TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }} + PRERELEASE: ${{ steps.version.outputs.prerelease }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + # The release branch is FROZEN between RC cut and stable promotion. The bump + # commit lives on release/v${PRERELEASE} only; nothing is merged into main + # until promote.yml publishes the stable tag, so any features merged into + # main after this step are NOT in the RC build. + BRANCH="release/v${PRERELEASE}" + # Delete remote branch first so the push below is always fast-forward (idempotent on rerun). + git push "https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}.git" ":${BRANCH}" 2>/dev/null || true + git checkout -b "$BRANCH" + git add package.json + git commit -m "chore(release): bump to ${PRERELEASE} [skip ci]" + git push "https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$BRANCH" + + - name: Push RC tag on the release branch + env: + # Use GITHUB_TOKEN for the tag push: a tag is a ref, not a file change, so + # the workflows:write permission isn't needed. + # Note: GITHUB_TOKEN tag pushes do NOT trigger build.yml in this org's setup, + # so we explicitly trigger it via gh workflow run right after. + RC_TAG: ${{ steps.version.outputs.rc_tag }} + PRERELEASE: ${{ steps.version.outputs.prerelease }} + run: | + set -euo pipefail + BRANCH="release/v${PRERELEASE}" + git fetch origin "$BRANCH" + git checkout "$BRANCH" + git reset --hard "origin/${BRANCH}" + # Delete remote tag first (idempotent on rerun) and any local tag. + git push origin ":${RC_TAG}" 2>/dev/null || true + git tag -d "$RC_TAG" 2>/dev/null || true + git tag "$RC_TAG" + git push origin "$RC_TAG" + + - name: Trigger build workflow + env: + GH_TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }} + RC_TAG: ${{ steps.version.outputs.rc_tag }} + run: | + set -euo pipefail + # GITHUB_TOKEN tag pushes don't fire the build.yml trigger in this setup, + # so dispatch it explicitly. The PAT ensures the build's release creation + # propagates to Tier 3 (homebrew/winget/nix/aur) via release: published. + gh workflow run build.yml \ + -f release_tag="${RC_TAG}" \ + -f arch=both \ + --repo "$GITHUB_REPOSITORY" + + - name: Announce RC on Discord (#rc-testing) + if: success() + env: + DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }} + DISCORD_RC_TESTING_CHANNEL_ID: ${{ vars.DISCORD_RC_TESTING_CHANNEL_ID }} + GITHUB_TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }} + STABLE_TAG: ${{ steps.version.outputs.rc_tag }} + KIND: rc + run: node .github/scripts/discord-release-announce.mjs + + - name: Workflow summary + run: | + { + echo "## Pre-release cut" + echo "" + echo "- RC tag: \`${{ steps.version.outputs.rc_tag }}\`" + echo "- Stable target: \`v${{ steps.version.outputs.next }}\`" + echo "- Build workflow triggered by the tag push will publish the GitHub pre-release." + echo "- Announce in #rc-testing on Discord, then run \`Promote RC to stable\` when QA is green." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml new file mode 100644 index 0000000000..ac4cfc0796 --- /dev/null +++ b/.github/workflows/promote.yml @@ -0,0 +1,170 @@ +name: Promote RC to stable release + +on: + workflow_dispatch: + inputs: + rc_tag: + description: "RC tag to promote (e.g. v1.5.0-rc.2)" + required: true + type: string + release_notes_extra: + description: "Optional message to prepend to auto-generated release notes" + required: false + type: string + default: "" + +permissions: + contents: write + issues: write + pull-requests: write + +concurrency: + group: promote-${{ github.ref }} + cancel-in-progress: false + +jobs: + promote: + name: Promote RC to stable + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: ./.github/actions/setup + + - name: Validate RC tag and compute stable version + id: version + env: + RC_TAG: ${{ inputs.rc_tag }} + run: | + set -euo pipefail + TAG="${RC_TAG}" + if [[ ! "$TAG" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)-(rc|beta|alpha)\.[0-9]+$ ]]; then + echo "::error::Tag must look like v1.5.0-rc.1; got '${TAG}'" + exit 1 + fi + STABLE_VERSION="${BASH_REMATCH[1]}" + STABLE_TAG="v${STABLE_VERSION}" + echo "rc_tag=$TAG" >> "$GITHUB_OUTPUT" + echo "stable_version=$STABLE_VERSION" >> "$GITHUB_OUTPUT" + echo "stable_tag=$STABLE_TAG" >> "$GITHUB_OUTPUT" + echo "Promoting ${TAG} -> ${STABLE_TAG}" + + - name: Close versioned milestone + env: + TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }} + STABLE_VERSION: ${{ steps.version.outputs.stable_version }} + run: node .github/scripts/release-milestone-close.mjs + + - name: Bump package.json to stable version on the release branch + env: + TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }} + STABLE_VERSION: ${{ steps.version.outputs.stable_version }} + run: | + set -euo pipefail + # Promote checks out the FROZEN release branch (created by prerelease.yml) and + # rewrites package.json there. This guarantees the stable tag points at the + # same code that was tested as the RC plus any cherry-picked bugfixes. + BRANCH="release/v${STABLE_VERSION}" + git fetch origin "$BRANCH" + git checkout "$BRANCH" + git reset --hard "origin/${BRANCH}" + sed -i -E "s|(\"version\"[[:space:]]*:[[:space:]]*\")[^\"]*(\")|\1${STABLE_VERSION}\2|" package.json + echo "package.json version:" + grep '"version"' package.json + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add package.json + git commit --allow-empty -m "chore(release): bump to ${STABLE_VERSION} [skip ci]" || true + git push "https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$BRANCH" + + - name: Push stable tag on the release branch tip + env: + # Use GITHUB_TOKEN for the tag push: a tag is a ref, not a file change, so + # the workflows:write permission isn't needed. + # Note: GITHUB_TOKEN tag pushes do NOT trigger build.yml in this org's setup, + # so we explicitly trigger it via gh workflow run right after. + STABLE_TAG: ${{ steps.version.outputs.stable_tag }} + STABLE_VERSION: ${{ steps.version.outputs.stable_version }} + run: | + set -euo pipefail + BRANCH="release/v${STABLE_VERSION}" + git fetch origin "$BRANCH" + git checkout "$BRANCH" + git reset --hard "origin/${BRANCH}" + # Delete remote tag first (idempotent on rerun) and any local tag. + git push origin ":${STABLE_TAG}" 2>/dev/null || true + git tag -d "$STABLE_TAG" 2>/dev/null || true + git tag "$STABLE_TAG" + git push origin "$STABLE_TAG" + + - name: Merge release branch into main + env: + GH_TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }} + STABLE_VERSION: ${{ steps.version.outputs.stable_version }} + run: | + set -euo pipefail + # After the stable tag is published, sync main with the released snapshot via + # a rebase PR (PAT is a ruleset bypass actor so no approval is needed). + BRANCH="release/v${STABLE_VERSION}" + # If main already contains the release branch (clean fast-forward), there's + # nothing to merge — just make sure the branch is tracked locally. + git fetch origin main "$BRANCH" + if git merge-base --is-ancestor "origin/${BRANCH}" origin/main; then + echo "origin/${BRANCH} is already an ancestor of origin/main — nothing to merge." + exit 0 + fi + # Push the release branch's commits onto main as a fresh branch and PR it. + git checkout -b "${BRANCH}-sync" "origin/${BRANCH}" + git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "${BRANCH}-sync" + gh pr create \ + --base main \ + --head "${BRANCH}-sync" \ + --title "chore(release): release v${STABLE_VERSION} into main" \ + --body "Sync main with the released snapshot (RC + cherry-picked bugfixes + version bump). Rebase-merged via PAT; bypass applies because EtienneLescot is a ruleset bypass actor." \ + --repo "$GITHUB_REPOSITORY" || echo "(PR already exists — skipping)" + PR_NUMBER=$(gh pr list --head "${BRANCH}-sync" --state open --json number -q '.[0].number' --repo "$GITHUB_REPOSITORY") + if [[ -n "$PR_NUMBER" ]]; then + gh pr merge "$PR_NUMBER" --rebase --delete-branch --admin \ + --repo "$GITHUB_REPOSITORY" + fi + # The release branch itself was already used to publish and contains frozen + # history — leave it in place for forensics. A v1.6.0 release branch should + # never be deleted until the next major cuts over. + + - name: Trigger build workflow + env: + GH_TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }} + STABLE_TAG: ${{ steps.version.outputs.stable_tag }} + run: | + set -euo pipefail + # GITHUB_TOKEN tag pushes don't fire build.yml in this setup, dispatch it. + gh workflow run build.yml \ + -f release_tag="${STABLE_TAG}" \ + -f arch=both \ + --repo "$GITHUB_REPOSITORY" + + - name: Announce stable on Discord + if: success() + env: + DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }} + DISCORD_RELEASE_CHANNEL_ID: ${{ vars.DISCORD_RELEASE_CHANNEL_ID }} + GITHUB_TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }} + STABLE_TAG: ${{ steps.version.outputs.stable_tag }} + RC_TAG: ${{ steps.version.outputs.rc_tag }} + EXTRA: ${{ inputs.release_notes_extra }} + KIND: stable + run: node .github/scripts/discord-release-announce.mjs + + - name: Workflow summary + run: | + { + echo "## Release promoted" + echo "" + echo "- Stable tag: \`${{ steps.version.outputs.stable_tag }}\`" + echo "- Promoted from: \`${{ steps.version.outputs.rc_tag }}\`" + echo "- Tier 3 (homebrew/winget/nix/aur) will fire on the published release via OPENSCREEN_RELEASE_TOKEN." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/publish-winget.yml b/.github/workflows/publish-winget.yml index 62b4b7adf1..12fd0404a7 100644 --- a/.github/workflows/publish-winget.yml +++ b/.github/workflows/publish-winget.yml @@ -2,7 +2,7 @@ name: Publish release to WinGet on: release: - types: [released] + types: [published] workflow_dispatch: inputs: tag: @@ -13,14 +13,13 @@ on: jobs: publish: runs-on: windows-latest - if: github.event_name == 'workflow_dispatch' || !github.event.release.prerelease + if: (github.event_name == 'workflow_dispatch' || !github.event.release.prerelease) && vars.WINGET_IDENTIFIER != '' steps: - uses: vedantmgoyal9/winget-releaser@v2 with: - identifier: SiddharthVaddem.OpenScreen - # Match the Windows installer asset attached to each release. - # Today: "Openscreen.Setup.latest.exe". Adjust this regex if you - # ever rename the installer to include a version (e.g. "Setup\.\d+\.\d+\.\d+\.exe"). + identifier: ${{ vars.WINGET_IDENTIFIER }} + # Matches the Windows installer asset attached to each release, + # e.g. "Openscreen.Setup.1.5.0.exe". installers-regex: 'Setup\..*\.exe$' release-tag: ${{ inputs.tag || github.event.release.tag_name }} token: ${{ secrets.WINGET_ACC_TOKEN }} diff --git a/.github/workflows/update-homebrew-cask.yml b/.github/workflows/update-homebrew-cask.yml index 3d65cb0aaa..60661a3bb9 100644 --- a/.github/workflows/update-homebrew-cask.yml +++ b/.github/workflows/update-homebrew-cask.yml @@ -16,11 +16,11 @@ permissions: jobs: update-cask: runs-on: ubuntu-latest - if: github.event_name == 'workflow_dispatch' || !github.event.release.prerelease + if: (github.event_name == 'workflow_dispatch' || !github.event.release.prerelease) && vars.HOMEBREW_TAP_OWNER != '' && vars.HOMEBREW_TAP_REPO != '' env: - TAP_OWNER: siddharthvaddem - TAP_REPO: homebrew-openscreen - CASK_NAME: openscreen + TAP_OWNER: ${{ vars.HOMEBREW_TAP_OWNER }} + TAP_REPO: ${{ vars.HOMEBREW_TAP_REPO }} + CASK_NAME: ${{ vars.HOMEBREW_CASK_NAME || 'openscreen' }} steps: - name: Resolve tag and version id: meta @@ -38,6 +38,32 @@ jobs: echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT" + - name: Wait for release DMG assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.meta.outputs.tag }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + TIMEOUT_MINUTES=12 + POLL_INTERVAL=30 + MAX_ATTEMPTS=$(( (TIMEOUT_MINUTES * 60) / POLL_INTERVAL )) + VERSION="${TAG#v}" + ARM_DMG="Openscreen-Mac-arm64-${VERSION}.dmg" + X64_DMG="Openscreen-Mac-x64-${VERSION}.dmg" + + for i in $(seq 1 $MAX_ATTEMPTS); do + if gh release view "$TAG" --repo "$REPO" --json assets --jq \ + --arg arm "$ARM_DMG" --arg x64 "$X64_DMG" \ + '[.assets[] | select(.name == $arm or .name == $x64)] | length' 2>/dev/null | grep -q '^2$'; then + echo "Both DMG assets present: $ARM_DMG and $X64_DMG" + exit 0 + fi + echo "Waiting for DMG assets... (attempt $i/$MAX_ATTEMPTS)" + sleep $POLL_INTERVAL + done + echo "::warning::Timeout after ${TIMEOUT_MINUTES}min waiting for DMG assets. Proceeding anyway." + - name: Find macOS DMG assets id: assets env: @@ -143,10 +169,10 @@ jobs: zap trash: [ "~/Library/Application Support/Openscreen", - "~/Library/Caches/com.siddharthvaddem.openscreen", + "~/Library/Caches/com.etiennelescot.openscreen", "~/Library/Logs/Openscreen", - "~/Library/Preferences/com.siddharthvaddem.openscreen.plist", - "~/Library/Saved Application State/com.siddharthvaddem.openscreen.savedState", + "~/Library/Preferences/com.etiennelescot.openscreen.plist", + "~/Library/Saved Application State/com.etiennelescot.openscreen.savedState", ] end EOF diff --git a/.gitignore b/.gitignore index 82fc468b7c..61dc9d2fcf 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,6 @@ result-* #others **/*.import + +# Auto-caption model + ORT wasm — regenerated at build by scripts/fetch-caption-model.mjs +/caption-assets/ diff --git a/.harness/agent.md b/.harness/agent.md new file mode 100644 index 0000000000..1a0b40a688 --- /dev/null +++ b/.harness/agent.md @@ -0,0 +1,31 @@ +--- +name: openscreen-orchestrator +description: Orchestrator for the OpenScreen repo. Routes incoming work to the right specialist (dev / tester / reviewer), handles small tasks directly, and keeps the user informed of progress. +--- + +# OpenScreen Orchestrator + +You are the orchestrator for the OpenScreen project — a free, open-source screen recorder and video editor. You own the conversation with the user and route work to the right specialist. + +## Scope + +- **Own**: incoming work triage, delegation to the team, final user-facing summary, cross-cutting decisions. +- **Don't own**: feature implementation, test authorship, PR review — those are the reins' jobs. + +## How you work + +- Read `AGENTS.md` at the repo root for canonical commands and layout. +- The reins are configured in `.harness/reins/`. The daemon injects the roster at runtime — do not hardcode a list here. +- Routing rules: + - **Implementation / bug fix / refactor** → `openscreen-dev` + - **Test authorship / coverage audit / test strategy** → `openscreen-tester` + - **PR review / quality gate / security check** → `openscreen-reviewer` + - **Small reads, config inspection, single-file edits, clarifications** → handle directly, don't spawn a worker + - **Mixed work** (e.g. "implement feature X and review the resulting PR") → break into sequential tasks, dev first then reviewer; don't ask one rein to do another's job +- After a worker reports back, you verify the deliverable against the user's original ask before reporting to the user. Don't just relay raw worker output. +- Keep the user informed at meaningful checkpoints, not on every micro-step. + +## Stop when + +- The user's original ask is fully satisfied (or you've explicitly said what's blocked and why). +- You post a concise final summary to the user: what was done, what to look at, what's still open. diff --git a/.harness/docs/architecture-overview.md b/.harness/docs/architecture-overview.md new file mode 100644 index 0000000000..373f9618e6 --- /dev/null +++ b/.harness/docs/architecture-overview.md @@ -0,0 +1,41 @@ +# OpenScreen Architecture Notes + +Quick map of how the app fits together, for the Mavis reins. For deeper details, see `../docs/architecture/native-bridge.md` and `../docs/engineering/`. + +## Process layout + +OpenScreen is a three-process Electron app: + +1. **Main process** (`electron/main.ts` + siblings) — owns window lifecycle, IPC handlers, the recording orchestrator, and child-process management for the native helpers. +2. **Renderer** (`src/`) — React 18 + Vite app. The UI, the editor, the timeline, the Pixi.js composition surface, and the i18n layer. Runs with `contextIsolation: true`. +3. **Native capture helpers** — small, privileged child processes that own the platform-specific screen/audio/webcam capture APIs: + - macOS: Swift binary using ScreenCaptureKit (`electron/macos-helper/`) + - Windows: C++/Win32 binary using Windows Graphics Capture (`electron/windows-helper/`) + - Linux: falls back to a browser MediaStream pipeline (no native helper) + +## Data flow during a recording + +``` +[User clicks record] + | + v +Renderer (React) --IPC--> Main process --spawn--> Native helper + ^ | + | v + +--<-- frame chunks / audio chunks / metadata --<--+ +``` + +The native helper writes raw chunks; the main process multiplexes them with the timeline metadata; the renderer pulls the composed stream onto the Pixi.js canvas for live preview and final export. + +## Why the split + +- Native helpers are tiny, single-purpose, and have a narrow IPC surface. That keeps the privileged code reviewable. +- The renderer never talks to native APIs directly — it goes through typed IPC, which means the renderer stays portable (web) and the privilege boundary is auditable. +- The main process is the only thing that owns both the helper and the renderer's IPC channel, so it's the natural place for orchestration and the export pipeline. + +## What this means for changes + +- Touching recording behavior = main process + native helper + (usually) renderer UI. Three places to keep in sync. +- Touching the editor = renderer only. Cheap to iterate with `npm run build-vite`. +- Touching export = main process + renderer (preview matches export). Run a full recording → export loop to verify. +- Native code cannot be unit-tested in CI. Manual smoke test on a real macOS/Windows box is required for any change in `electron/*-helper/`. diff --git a/.harness/docs/git-workflow.md b/.harness/docs/git-workflow.md new file mode 100644 index 0000000000..cd0895fdc3 --- /dev/null +++ b/.harness/docs/git-workflow.md @@ -0,0 +1,147 @@ +# Git Workflow for OpenScreen + +Conventions for the Mavis reins when working in this repo. + +## Branches + +- Default branch: `main`. Never push to it directly. +- Feature branches: `feature/` or `fix/`. Match the style of recent merged PRs. +- One PR = one concern. Don't bundle a refactor with a feature. + +## Commits + +- Short imperative summary line (≤72 chars). Optional body explaining the why. +- Style in this repo is mixed (some conventional prefixes, some plain) — pick one and stay consistent within a PR. +- Husky pre-commit runs lint-staged (Biome on staged `*.{ts,tsx,js,jsx,mts,cts,json}`). Don't bypass with `--no-verify` unless something is genuinely broken; fix it instead. + +## Hooks (Mavis) + +- Pre-commit (`.harness/hooks/pre-commit.md`) — runs Biome + the affected unit test files. The dev is expected to have run `npm run lint:fix` already; this is a safety net. +- Post-commit (`.harness/hooks/post-commit.md`) — reminds the dev to push and consider running the reviewer on the resulting branch. + +## CI (`.github/workflows/ci.yml`) + +CI runs on every PR to `main` and every push to `main`: +- `npm run lint` (Biome) +- `npx tsc --noEmit` (TypeScript) +- `npm run test` (Vitest unit) +- `npm run test:browser` (Vitest + Playwright headless) +- `npx vite build` (renderer build smoke) + +All five must be green before merge. Native helper code is NOT covered by CI — manual smoke test is required for `electron/*-helper/` changes; note it in the PR description. + +## Pull request flow + +1. Branch from `main`. +2. Implement + add tests in the same package. +3. Run locally: `npm run lint && npx tsc --noEmit && npm run test`. For browser/e2e-touching changes, also run the relevant suite. +4. Push and open the PR via `gh pr create`. Use `.github/pull_request_template.md`. +5. Wait for the Mavis reviewer (`openscreen-reviewer`) PASS or address the requested changes. +6. Merge once CI is green and review is PASS. PR titles must follow Conventional Commits (enforced by the `semantic-pr` job in `ci.yml`) — this keeps the auto-generated release notes clean. + +## Release flow + +Two `workflow_dispatch` workflows cut a release. Trunk-based on `main`, but **release branches freeze the RC codebase between cut and promote** (see § Release branches below). Both require the `OPENSCREEN_RELEASE_TOKEN` secret — see `docs/secrets.md`. + +### Step 1: cut a release candidate + +`Actions` → `Cut a release candidate` → `Run workflow`. + +- `bump`: `patch | minor | major` (default `minor`) +- `rc_number`: integer, default `1` (use `.2`, `.3`, … for subsequent RCs) +- `target_version` (optional): override the auto-computed next version (e.g. `2.0.0` when bumping straight to a major) + +The workflow: + +1. Computes the next SemVer from `package.json` + `bump`, builds `vX.Y.Z-rc.N`. +2. Migrates every issue/PR in the rolling `Next Release` milestone into a fresh `vX.Y.Z` milestone. Each migrated item gets a hidden marker comment so re-running is idempotent. +3. Commits `package.json` → `X.Y.Z-rc.N` on a fresh branch `release/vX.Y.Z-rc.N`. **The branch is NOT merged into `main`** — it stays frozen so the RC build only contains what was on `main` at the moment of cut. +4. Pushes the tag `vX.Y.Z-rc.N` at the release branch tip. This triggers `build.yml`, which publishes a **GitHub pre-release** (badged as such, does not become "Latest"). macOS notarization is skipped on RC tags. +5. Posts in `#rc-testing` on Discord with the download link. + +Tier 3 (homebrew/winget/nix/aur) does **not** run on pre-releases — they're already gated on `!prerelease`. + +### Step 2: announce and QA + +Pin the pre-release link in `#rc-testing`. Get the maintainer team + a few early adopters to install and smoke-test. + +**Between RC cut and promote**, the only thing that may happen on `release/vX.Y.Z-rc.N` is **cherry-picks of bugfixes** that address problems discovered in the RC. Features, refactors, and CI/docs changes are **not** applied to the release branch — they live on `main` and ship in the next release cycle. + +If the RC has a regression, fix forward on `main`, then **cherry-pick the fix commit onto the release branch** with `git cherry-pick `, then re-cut as `vX.Y.Z-rc.(N+1)` (the rerun of `prerelease.yml` re-tags the release branch tip; no rebase required because the branch is frozen). The previous RC is auto-superseded by GitHub. + +### Step 3: promote to stable + +`Actions` → `Promote RC to stable release` → `Run workflow`. + +- `rc_tag`: e.g. `v1.5.0-rc.2` +- `release_notes_extra` (optional): a one-paragraph note that gets prepended to the auto-generated release notes + +The workflow: + +1. Validates the tag matches `^vX.Y.Z-(rc|beta|alpha)\.N$`. +2. Closes the `vX.Y.Z` milestone (snapshotting it for the release notes). +3. Checks out `release/vX.Y.Z-rc.N` (the frozen branch), strips `-rc.N` from `package.json`, and commits the bump there. The stable tag points at this tip — the released code is the exact RC + cherry-picks. +4. Pushes the tag `vX.Y.Z` and triggers `build.yml` (full notarization). The `release: published` event fires Tier 3 (homebrew/winget/nix/aur) thanks to `OPENSCREEN_RELEASE_TOKEN`. +5. Opens a **release-sync PR** (e.g. `release/v1.6.0-sync → main`) that brings `main` into line with the released snapshot. Rebase-merged via PAT (EtienneLescot is a ruleset bypass actor). +6. Posts in `#announcements` on Discord with the release notes + a "Closed issues in this release" list pulled from the milestone. + +The release branch itself **stays around** indefinitely — it is the frozen history of the release, useful for backports and forensics. Deletion happens only when a future major cuts over and supersedes it. + +### Release branches (the contract) + +Every released version has a corresponding **frozen branch**: + +``` +release/vX.Y.Z-rc.N exists from RC cut until promote finishes +release/vX.Y.Z-sync ephemeral, created by promote to merge into main +release/vX.Y.Z stable snapshot post-promote (kept for backports) +``` + +Key rules: + +1. **`prerelease.yml` creates the branch.** Nothing else pushes to it except the cherry-pick workflow during the RC window. +2. **`promote.yml` is the only writer** that turns `-rc.N` into the stable version on the branch. +3. **`main` is never frozen.** Develop as usual. The release branch is the freeze. +4. **Cherry-picks during the RC window** are committed manually by a maintainer (`git checkout release/vX.Y.Z-rc.N && git cherry-pick `), or rerun `prerelease.yml` to re-tag the branch tip with the same RC version (then bump rc_number). + +This exists because of the v1.6.0 incident (2026-07-05): the original `promote.yml` checked out `main`, so the stable tag captured the post-RC tip of `main` rather than the RC snapshot. Twenty-three commits (Tiptap, NotesWindow, an in-recorder lint button, AI handoff) ended up in v1.6.0 without ever being in v1.6.0-rc.1. The re-release of v1.6.0 on 2026-07-05 used `release/v1.6.0` and cherry-picked only the truly safe commits. + +### Manual fallback (emergency) + +If the dispatch UI is unavailable, the workflow still works from a shell: + +```bash +# Cut RC (skips milestone migration and Discord announce) +git checkout -b release/v1.5.0-rc.1 main +sed -i -E 's|("version"[[:space:]]*:[[:space:]]*")[^"]*(")|\11.5.0-rc.1\2|' package.json +git add package.json && git commit -m "chore(release): bump to 1.5.0-rc.1 [skip ci]" +git push origin release/v1.5.0-rc.1 +git push origin v1.5.0-rc.1 + +# Promote (skips milestone close and Discord announce) +git checkout release/v1.5.0-rc.1 +sed -i -E 's|("version"[[:space:]]*:[[:space:]]*")[^"]*(")|\11.5.0\2|' package.json +git commit -am "chore(release): bump to 1.5.0 [skip ci]" +git push origin release/v1.5.0 +git push origin v1.5.0 +``` + +The pipeline can't tell the difference between a manually-pushed tag and a workflow-pushed one — same `build.yml` runs either way. + +### Backports / patch on a previous line + +For a `v1.4.2` while `v1.5.0` is in flight: + +1. Branch `release/1.4.x` from the `v1.4.0` (or `v1.4.1`) tag. +2. Cherry-pick the fix commits. +3. Push the branch, then `git tag v1.4.2-rc.1` on the branch tip. +4. `git push origin release/1.4.x v1.4.2-rc.1` — `build.yml` works from any branch. + +No new workflow code is needed; the tag-pushed trigger is branch-agnostic. + +### Issue tracking during a release cycle + +- **Daily state**: issues/PRs accumulate in the rolling `Next Release` milestone. `merged-pr-bookkeeping.yml` adds them automatically on PR merge; maintainers can also drag issues in by hand. +- **At RC cut**: `prerelease.yml` snapshots `Next Release` into a versioned `vX.Y.Z` milestone. The rolling milestone is left open and empty for new work. +- **Between RC cut and promote**: any PR that merges during the RC window lands back in the empty `Next Release`. It is **not** retroactively added to `vX.Y.Z`. If a critical fix lands, cut `vX.Y.Z-rc.(N+1)` instead of promoting. +- **At promote**: `promote.yml` closes the `vX.Y.Z` milestone and uses its closed issues to populate the Discord release announcement. diff --git a/.harness/hooks/post-commit.md b/.harness/hooks/post-commit.md new file mode 100644 index 0000000000..be590c956b --- /dev/null +++ b/.harness/hooks/post-commit.md @@ -0,0 +1,28 @@ +--- +name: post-commit +event: post-commit +type: reminder +--- + +# Post-commit reminder for OpenScreen + +Runs after every successful `git commit`. Goal: nudge the dev toward the next step without blocking. + +## What it does + +Prints a single reminder line summarizing: + +- Number of commits ahead of `main` on the current branch. +- Whether the current branch has been pushed (`git status` reports `Your branch is up to date with 'origin/'` if pushed). +- A one-line suggestion: push the branch, or run `openscreen-reviewer` on the diff if you want a quality check before pushing. + +## What it does NOT do + +- It does NOT push automatically. The dev pushes explicitly. +- It does NOT spawn a reviewer automatically. Review is opt-in (it costs tokens and the dev may not want it for WIP commits). +- It does NOT block. If `git status` can't be read, the reminder is skipped silently. + +## Notes + +- This is intentionally lightweight — a single line of context, not a wall of text. The dev already knows what they just committed. +- If you want a deeper post-commit check (e.g. reviewer on every commit), change this hook to `type: gate` and have it spawn the reviewer. diff --git a/.harness/hooks/pre-commit.md b/.harness/hooks/pre-commit.md new file mode 100644 index 0000000000..d2950fc470 --- /dev/null +++ b/.harness/hooks/pre-commit.md @@ -0,0 +1,33 @@ +--- +name: pre-commit +event: pre-commit +type: gate +--- + +# Pre-commit gate for OpenScreen + +Runs on every `git commit` in this repo. Goal: catch the cheap stuff before the commit lands, without slowing the dev down. + +## What it does + +1. **Biome check (lint + format)** on staged `*.{ts,tsx,js,jsx,mts,cts,json}` files. Uses the same scope as `lint-staged` in `package.json`. +2. **TypeScript** — `npx tsc --noEmit` for the whole project. Cheap on this codebase, catches type errors that Biome misses. +3. **Vitest** — runs the affected unit test files only (Vitest's `--changed` against `main`). Skipped automatically if no tests are affected. + +## What it does NOT do + +- It does NOT run the full Vitest suite, the browser tests, the e2e tests, or any native helper test. Those are too slow for a pre-commit gate and belong to CI. +- It does NOT modify files. If Biome wants to reformat, the dev runs `npm run lint:fix` themselves. + +## Pass criteria + +All three steps exit 0. The commit proceeds. + +## Fail behavior + +The commit is blocked. The hook prints the failing step's output. The dev fixes and re-stages. + +## Notes + +- This hook is layered on top of the existing Husky `pre-commit` (lint-staged). They coexist: Husky handles staged-file Biome, this hook handles the project-wide tsc + test gate. +- Bypassing with `--no-verify` is allowed but discouraged; if you do, leave a one-line note in the commit body explaining why. diff --git a/.harness/memory/MEMORY.md b/.harness/memory/MEMORY.md new file mode 100644 index 0000000000..0f5205bc0c --- /dev/null +++ b/.harness/memory/MEMORY.md @@ -0,0 +1,26 @@ +# OpenScreen — Shared Team Memory + +This file is the shared memory across all Mavis reins in this repo. Add durable facts here that the team should remember across sessions: build quirks, gotchas, environment-specific notes. + +Format: +``` +## () + +``` + +--- + +## i18n: 13 locales must stay in sync (2026-06-22) +Any new user-facing string needs a key in all 13 locale folders under `src/i18n/locales/` (each locale is a subfolder, e.g. `src/i18n/locales/en/settings.json`). The `npm run i18n:check` script validates structural consistency. Don't ship translation gaps; either translate them or use a placeholder strategy that's consistent across locales. + +## Native helpers need manual smoke tests (2026-06-22) +CI runs on Linux only. The macOS (Swift/ScreenCaptureKit, in `electron/native/screencapturekit/`) and Windows (C++/WGC, in `electron/native/wgc-capture/`) native helpers cannot be auto-verified. Any change in those directories must include a manual smoke-test note in the PR description (recorded on a real host). + +## Biome owns lint AND format (2026-06-22) +There's no Prettier/ESLint — Biome 2.4 does both. Config in `biome.json`: tabs, double quotes, 100-col width, LF line endings. Don't add `eslint`/`prettier` configs on top; that would fight Biome. + +## `npm run build` is slow (2026-06-22) +`npm run build` runs tsc + vite build + electron-builder packaging. For renderer-only iteration use `npm run build-vite` (tsc + vite only, no packaging). Only run the full `build` when verifying a release artifact. + +## Release tag must point at the release branch, not main (2026-07-05) +On 2026-07-05 the original `promote.yml` did `git checkout main && git tag vX.Y.Z`, which captured the post-RC tip of `main` (23 commits after the RC cut) as the "stable" v1.6.0. The fix landed the same day: both `prerelease.yml` and `promote.yml` now use a frozen `release/vX.Y.Z-rc.N` branch and tag its tip — see `.github/workflows/prerelease.yml` § Push RC tag and `.github/workflows/promote.yml` § Push stable tag. When reviewing release-related changes, **always verify the tag is being applied to the release branch tip, not to main.** The build.yml `release_tag` input is the SHA, not a branch name; if you set it to a tag the GitHub Release check will look for the source ref — pass the release branch name when smoke-testing without a tag. diff --git a/.harness/reins/openscreen-dev/agent.md b/.harness/reins/openscreen-dev/agent.md new file mode 100644 index 0000000000..33c4a28a18 --- /dev/null +++ b/.harness/reins/openscreen-dev/agent.md @@ -0,0 +1,31 @@ +--- +name: openscreen-dev +description: Generalist developer for the OpenScreen Electron + React + TypeScript screen recorder. Implements features and bug fixes across the renderer, Electron main process, and native capture helpers (Swift on macOS, C++/Win32 on Windows). +--- + +# OpenScreen Developer + +You are the generalist implementer for the OpenScreen project — a free, open-source screen recorder and video editor (Electron + React 18 + TypeScript + Vite + Pixi.js v8 + Tailwind + Radix UI). + +## Scope + +- **Own**: implementation work across `src/` (React UI, editor, timeline, i18n, captioning/cursor/exporter libs), `electron/` (main process, IPC, recording orchestration), and the native helpers in `electron/native/screencapturekit/` (Swift / macOS ScreenCaptureKit) and `electron/native/wgc-capture/` (C++/Win32 WGC). +- **Don't own**: test authorship (hand off to `openscreen-tester`) and final PR review (hand off to `openscreen-reviewer`). You write tests for your own code as part of "done", but coverage audits and test strategy belong to the tester. + +## How you work + +- Read `AGENTS.md` at the repo root before touching anything — it has the canonical commands, layout, and conventions. +- When the change touches recording, IPC, or the native bridge, read `.harness/docs/architecture-overview.md` (start here), `docs/architecture/native-bridge.md` (deeper dive), and `docs/engineering/` (native helper roadmaps). +- TypeScript strict mode, Biome format (tabs, double quotes, 100-col). Run `npm run lint:fix` before committing. +- For renderer-only iteration use `npm run build-vite`. For full packaging use `npm run build` (electron-builder, slow). +- Native helpers require a real platform to test — don't claim "done" on macOS/Windows native code without a manual smoke test. +- Keep changes scoped. One PR = one concern. Don't refactor unrelated code in a feature PR. +- 13 locales in `src/i18n/locales/` (each locale is a subfolder, e.g. `src/i18n/locales/en/settings.json`). Touching user-facing strings = add a key to all 13 (or run `npm run i18n:check` and address what it flags). + +## Stop when + +- `npx tsc --noEmit` passes. +- `npm run lint` passes (or remaining warnings are pre-existing and unrelated). +- `npm run test` passes for any unit tests you added or affected. +- The change is documented in the PR description (what + why + how to test). +- You post a one-line summary back to the orchestrator with: files touched, commands run, manual test notes for native changes. diff --git a/.harness/reins/openscreen-reviewer/agent.md b/.harness/reins/openscreen-reviewer/agent.md new file mode 100644 index 0000000000..13a4b04a1b --- /dev/null +++ b/.harness/reins/openscreen-reviewer/agent.md @@ -0,0 +1,34 @@ +--- +name: openscreen-reviewer +description: PR reviewer for OpenScreen. Verifies code quality, security, type safety, and adherence to project conventions before merge. Runs on post-commit and on demand. +--- + +# OpenScreen Reviewer + +You are the PR review specialist for the OpenScreen project — a free, open-source screen recorder and video editor. + +## Scope + +- **Own**: final quality gate before merge. Code review for correctness, security, type safety, conventions, and project fit. +- **Don't own**: implementation (hand off to `openscreen-dev`), test authorship (hand off to `openscreen-tester`). You can request changes, not write the fix. + +## How you work + +- Read `AGENTS.md` at the repo root for the canonical commands and conventions. +- Read `.harness/docs/` for the project's architecture, engineering roadmaps, and testing notes when the change touches recording, IPC, or native code. +- Review criteria (in order): + 1. **Correctness**: does it do what the PR description claims? Any obvious bugs, race conditions, unhandled errors? + 2. **Security**: secrets logged, unsanitized inputs to native helpers, Electron IPC without `contextIsolation`, anything in `electron/*-helper/` that runs privileged. + 3. **Type safety**: no new `any` (Biome warns), no `as` casts that hide errors, strict-mode compliance. + 4. **Tests**: new behavior has tests, changes to existing behavior update the affected tests, CI command list (lint + typecheck + test) would pass. + 5. **Conventions**: Biome-clean (tabs, double quotes, 100-col), no new dependencies without justification, no paywall/premium language in UI, i18n keys added to all 13 locales when applicable. + 6. **Scope**: one concern per PR, no drive-by refactors, no unrelated formatting churn. +- For native changes (Swift / C++/Win32): require a manual smoke test note in the PR description. CI runs on Linux only — native code cannot be auto-verified. +- Be specific in feedback: file:line, what's wrong, what to do. Vague comments ("looks risky") waste rounds. + +## Stop when + +- You posted a PASS or a list of concrete requested changes. +- For PASS: include a one-line summary of what the PR does and why it's safe to merge. +- For CHANGES REQUESTED: include blocking items first, then nice-to-haves. Each item is file:line + concrete fix. +- You do not merge, push, or modify the PR — you only review. diff --git a/.harness/reins/openscreen-tester/agent.md b/.harness/reins/openscreen-tester/agent.md new file mode 100644 index 0000000000..cbf3afdc6c --- /dev/null +++ b/.harness/reins/openscreen-tester/agent.md @@ -0,0 +1,32 @@ +--- +name: openscreen-tester +description: Test specialist for OpenScreen. Owns Vitest unit/browser coverage, Playwright e2e specs, and verifying that new behavior has tests before it ships. Runs on demand and on git pre-commit hook. +--- + +# OpenScreen Tester + +You are the test specialist for the OpenScreen project — a free, open-source screen recorder and video editor. + +## Scope + +- **Own**: Vitest unit tests (`*.test.ts` / `*.test.tsx`, jsdom), Vitest browser tests (`vitest.browser.config.ts`, Playwright headless), Playwright e2e (`tests/e2e/`). +- **Don't own**: writing production code (hand off to `openscreen-dev`). You may add tests for existing code, but feature implementation is not your job. Final PR quality gate is `openscreen-reviewer`. + +## How you work + +- Read `AGENTS.md` at the repo root for commands and conventions. +- Read `docs/tests/writing-tests.md` for the project's test style guide. +- Match the style of neighboring `*.test.` files in the same package — don't invent new patterns. +- Unit tests: `npm run test` (Vitest, jsdom). Browser tests: `npm run test:browser` (needs `npm run test:browser:install` once). E2E: `npm run test:e2e` (Playwright). +- E2E specs in `tests/e2e/windows-native-checklist.spec.ts` are Windows-only — gate with `test.skip` for other platforms rather than deleting. +- i18n: `npm run i18n:check` validates the 13 locales under `src/i18n/locales/` — run it after translation changes. +- For Pixi/Canvas/GPU code, prefer browser tests (`test:browser`) over jsdom — jsdom can't render WebGL/Pixi meaningfully. +- Coverage gaps: report them concretely (file:line, what's missing, what to add). Don't write the test for someone else's feature unprompted — flag it. + +## Stop when + +- `npm run test` passes. +- For browser-tested changes: `npm run test:browser` passes. +- For e2e changes: `npm run test:e2e` passes (or you documented which specs were skipped and why). +- `npm run i18n:check` passes if any locale file was touched. +- You post back: test command run, pass/fail count, any specs skipped, any coverage gaps you found. diff --git a/.worktrees/wt-9ce78f24/HANDOFF.md b/.worktrees/wt-9ce78f24/HANDOFF.md new file mode 100644 index 0000000000..9ab72d3187 --- /dev/null +++ b/.worktrees/wt-9ce78f24/HANDOFF.md @@ -0,0 +1,299 @@ +# AI-Edition Implementation Handoff + +**Branch**: `docs/ai-edition-plan` (commit `cf25858`, pushed to `origin`) +**Worktree**: `G:\repos\openscreen\.worktrees\wt-9ce78f24` +**Dev server**: `http://localhost:5173/?windowType=editor` (browser mode with shim) + +--- + +## 1. Context + +The user (Etienne Lescot, repo owner) was working through the implementation of the **OpenScreen x Axcut AI-edition merge**. The original PR #35 (commit `1e9db17` on the same branch) introduced the planning docs only: + +- `docs/architecture/ai-edition-merge-plan.md` — the 10-phase merge plan +- `docs/architecture/axcut-inventory.md` — catalog of the axcut codebase +- `docs/architecture/openscreen-inventory.md` — catalog of the OpenScreen codebase +- `docs/architecture/ai-edition-collision-analysis.md` — collision analysis + +This implementation PR (`cf25858`) delivers the **code** for that plan — all phases 0, 1, 3, 4, 6-8, and partial 9, plus a developer-convenience browser shim and spec updates that changed the framing. + +The plan was re-framed mid-implementation. The user clarified: +- **New editing model** (multi-asset, clips, skips, transcript, virtual-time preview) = **default for all users**, not opt-in +- **AI features** (LLM provider config, chat) = **opt-in** behind `AI_FEATURES_ENABLED` +- **Local Whisper** = **privacy-safe, not gated** (runs in-browser, never calls out) + +This is the spec's `§0 Framing` section. See `docs/architecture/ai-edition-merge-plan.md` lines ~13-65. + +--- + +## 2. What was built (file by file) + +### 2.1 Schema & migration (`src/lib/ai-edition/`) + +| File | Purpose | +|------|---------| +| `schema/index.ts` | Vendored axcut v2 schema + v3 additions (`annotations[]`, `zoomRanges[]`, `legacyEditor` envelope, `transcripts[]`). `axcutSchemaVersion = 3`. `clip.sourceEndSec` made optional (duration unknown at migration time). | +| `schema/index.test.ts` | 15 schema tests (version enforcement, optional clip duration, envelope passthrough, etc.) | +| `document/timeline.ts` | Pure interval math: `normalizeIntervals`, `subtractInterval`, `invertIntervals`, `buildTimelineFromIntervals`, `replaceTimeline`, `restoreFullTimeline`. Ported from axcut `apps/server/src/lib/timeline.ts` (no event bridge, no agent — just the math). | +| `document/timeline.test.ts` | 14 tests covering all the above. | +| `document/migrate.ts` | Bidirectional `EditorProjectData` (v2) ↔ `AxcutDocument` (v3). Notes: `zoomRanges`/`annotations` use **ms** units to mirror the legacy types; timeline ops use **sec** units. The migration is lossless in both directions thanks to the `legacyEditor` passthrough. | +| `document/migrate.test.ts` | 14 tests including round-trip, v1 legacy, focus clamping. | +| `document/transcribe.ts` | `transcribeAsset(document, assetId)` wraps the existing `extractMono16kFromVideoUrl` + `transcribeMono16kToSegments` (from `src/lib/captioning/`). Returns an `AxcutTranscript`. `withTranscript` writes it back to the document. | +| `document/ids.ts` | `createId(prefix)` using `uuid.v4()`. | +| `timeline/virtual-preview.ts` | Pure time-mapping: `totalVirtualDuration`, `clampVirtualTime`, `locateVirtualPosition`, `locateSourcePosition`, `keptWordIdSet`, `formatSeconds`. | +| `timeline/virtual-preview.test.ts` | 8 tests. | +| `store/projectStore.ts` | Zustand store: `projectId`, `document`, `revision`, `status`, `error`, `sourceDurationSec`, `currentTimeSec`. Actions: `loadProject`, `createProject`, `addAsset`, `removeAsset`, `replaceTimeline`, `restoreFullTimeline`, `setTranscript`, `setSourceDuration`, `setCurrentTime`, `saveDocument`, `setDocument`, `clear`. | +| `store/projectStore.test.ts` | 5 tests with `nativeBridgeClient.aiEdition` mocked. | +| `exporter/documentExporter.ts` | Adapter: maps `AxcutDocument` → `VideoExporterConfig` / `GifExporterConfig`. Clips → `trimRegions` (inverse). Reads `legacyEditor` for wallpaper, cursor, webcam, etc. `sourceWidth`/`sourceHeight` come from caller. | + +### 2.2 Main-process services (`electron/ai-edition/`) + +| File | Purpose | +|------|---------| +| `document-service.ts` | `DocumentService(projectsRoot)`: `listProjects`, `getProject(projectId)`, `createProject(title)`, `saveProject(doc)`, `deleteProject(projectId)`, `addAsset(projectId, {path, label?})`, `removeAsset(projectId, assetId)`. One `.axcut` JSON file per project under `app.getPath('userData')/projects/`. Validates paths against an allowlist of video extensions. Cascades clips + skipRanges on asset removal. | +| `document-service.test.ts` | 16 tests (CRUD, path traversal, cascade, primary-asset reassignment). | +| `provider-registry.ts` | 8 provider definitions (anthropic, openai, google, mistral, openrouter, openai-compatible, openai-oauth, copilot-proxy) with `authKind`, `supportsReasoningEffort`, `envKeys`, `baseUrl`. Ported from axcut `provider-registry.ts`. | +| `llm-config-store.ts` | `LlmConfigStore(userDataPath)`: config in `llm-config.json` plain JSON, **credentials in `safeStorage`-encrypted bytes** at `llm-credentials.enc`. Env vars override stored keys (same precedence as axcut). | +| `chat-service.ts` | `runChat(projectId, message, llmConfig)`: validates config + API key, stores messages in a `Map`, **returns a stub assistant message** (LLM call needs `@langchain/*` deps). `getChatHistory(projectId)` returns the in-memory list. | +| `native-bridge/services/aiEditionService.ts` | Adapter to the existing `native-bridge` envelope: wraps `DocumentService`, `LlmConfigStore`, and the chat stubs into the `domain: "aiEdition"` IPC contract. | + +### 2.3 IPC bridge extensions + +- `electron/ipc/nativeBridge.ts` — added the `aiEdition` domain case. Each action calls into `AiEditionService` (`document.listProjects`, `document.get`, `document.create`, `document.save`, `document.delete`, `document.addAsset`, `document.removeAsset`, `llm.getSnapshot`, `llm.setConfig`, `llm.setApiKey`, `llm.removeApiKey`, `chat.run`, `chat.history`). +- `electron/ipc/handlers.ts` — wires `DocumentService` + `LlmConfigStore` + chat functions into the `NativeBridgeContext`. +- `src/native/contracts.ts` — adds `AiEditionLlmConfig`, `AiEditionLlmSnapshot`, `AiEditionChatMessage`, `AiEditionChatResult` types and the new `aiEdition` action cases to the `NativeBridgeRequest` union. +- `src/native/client.ts` — adds the `nativeBridgeClient.aiEdition` namespace with `listProjects`, `get`, `create`, `save`, `delete`, `addAsset`, `removeAsset`, `llmGetSnapshot`, `llmSetConfig`, `llmSetApiKey`, `llmRemoveApiKey`, `chatRun`, `chatHistory`. +- `src/native/browserShim.ts` — **new**. Browser-mode shim that: + - Stubs `window.electronAPI` (no-op `openVideoFilePicker`, `pickExportSavePath`, etc.) + - Overrides `nativeBridgeClient` methods to return mock data + - Persists projects/docs in `localStorage` (`browser-shim-projects`, `browser-shim-document`) + - Auto-installs when running in a plain browser at `http://localhost:5173/?windowType=editor` + - Detected via absence of `window.electronAPI` + +### 2.4 Renderer UI (`src/components/ai-edition/`) + +| File | Purpose | +|------|---------| +| `IconRail.tsx` | Vertical 36-44px icon rail with collapse/expand chevron. Used for both left and right rails. Tooltip on hover. | +| `NewEditorShell.tsx` | **The default editor** for all users (replaces legacy `VideoEditor`). Layout: top header (project title + 3 toggle buttons) + body with left rail | left content (Project/Chat) | center (video + timeline) | right content (Transcript/Background/Video effects/Camera/Cursor/Crop/Export) | right rail. Recording → asset on editor open (auto-creates project + adds asset). Legacy `.openscreen` loading via the "Open" header button (migrates v2 → v3). | +| `AiEditionShell.tsx` | Re-exports `AiEditionOrLegacy` which delegates to `NewEditorShell` (legacy VideoEditor is now unused but kept for rollback). | +| `ProjectPanel.tsx` | Left content: project list + create input + assets list. Uses raw Tailwind matching OpenScreen's dark surface. | +| `TimelinePane.tsx` + `.module.css` | Ported from axcut `apps/web/src/components/TimelinePane.tsx` (~837 lines). Ruler, kept/cut segments, playhead, zoom (Ctrl+wheel), pan (Alt+drag), add cut, delete cut, resize cut handles, fit button, navigator overview. | +| `VirtualPreview.tsx` + `.module.css` | Ported from axcut `apps/web/src/components/VirtualPreview.tsx`. Single-video element with virtual-time seeking; seeks across clip boundaries; reports metadata via `onLoadedMetadata`; exposes video element via `onVideoElement` callback. | +| `TranscriptEditor.tsx` + `.module.css` | Click word / shift-click word → range → "Cut" button → `dropWordRange` op. Kept words = default, skipped = red strikethrough. | +| `ChatPanel.tsx` | Right content when `leftTab === "chat"`. Messages list + input + send. In-memory history. | +| `EditorSettings.tsx` | Bridge that wraps the **original `SettingsPanel`** (from `src/components/video-editor/SettingsPanel.tsx`, unchanged). Reads from `AxcutDocument.legacyEditor` (wallpaper, cursor, webcam, shadow, etc.), `document.zoomRanges`, `document.annotations`. Writes back through `setDocument` / `saveDocument`. Maps `activeTab` to `SettingsPanelMode` (background/effects/layout/cursor/timeline/export). Calls `SettingsPanel` with `hideInternalRail` so the right rail is the only navigation. | + +### 2.5 App-level wiring + +- `src/App.tsx` — imports and calls `installBrowserShims()` before render. The `editor` windowType still lazy-loads the `AiEditionShell` (which now renders `NewEditorShell`). +- `src/components/video-editor/featureFlags.ts` — renamed `AI_EDITION_ENABLED` → `AI_FEATURES_ENABLED`, default `false`. The flag now **only** gates the LLM/agent UI (chat panel). The new editor is the default for everyone. +- `package.json` — added `zod: ^3.23.8` and `zustand: ^5.0.8`. + +### 2.6 Documentation + +- `docs/architecture/ai-edition-merge-plan.md` — **major rewrite**: + - **New §0 Framing** — two layers (new editor = default, AI features = opt-in) + - **§5.8 locked decision** updated: flag now gates only LLM/agent UI + - **§10 cut-over** — no editor cut-over (new editor is default); only AI features opt-in + - Locked decisions list re-ordered: framing change recorded + +--- + +## 3. What was tested + +- **`npx tsc --noEmit`**: clean (no errors) +- **`npm run lint`**: clean (1 warning, not error — `useExhaustiveDependencies` in TimelinePane, pre-existing pattern) +- **`npm run test`**: **313 / 313 tests pass** across 39 test files + - 16 `document-service.test.ts` + - 15 `schema/index.test.ts` + - 14 `timeline.test.ts` + - 14 `migrate.test.ts` + - 8 `virtual-preview.test.ts` + - 5 `projectStore.test.ts` + - + 239 pre-existing tests (all still passing) +- **Browser smoke test**: `http://localhost:5173/?windowType=editor` renders the editor with shim data, project create/select works, asset add works (mocked), transcript/chat panels render, settings panel shows correct view per right-rail tab. + +--- + +## 4. Key decisions and rationale + +### 4.1 The framing change (user-driven) + +The original plan treated "AI-edition" as a single opt-in feature. Mid-implementation the user said: *multi-asset/clips/etc. is valid outside of user opt-in. It is valid outside of user opt-in. The opt-in should be limited to llm/conversation.* This led to: +- `AI_EDITION_ENABLED` → `AI_FEATURES_ENABLED` (the rename makes the semantic explicit) +- New editor ships to all users by default (kill-switch removed) +- The right rail's chat / LLM config is the only gated surface +- Local Whisper stays ungated (privacy-safe by construction) + +### 4.2 Why the new editor ships as the default despite incomplete feature parity + +The spec calls for full feature parity (annotations, zoom, cursor, webcam, blur, crop, export, legacy `.openscreen` loading). The implementation delivers the **architecture** and the **export, legacy loading, transcript, transcription, settings panel** integrations, but the new editor's UI is intentionally simpler than the legacy `VideoEditor` for some affordances (no annotations/zoom UI for adding new ones, just editing existing ones from the `SettingsPanel`). This is acceptable for a first cut because: +- The legacy `VideoEditor` is still on disk and reachable via git (rollback path) +- Adding the remaining UI affordances is incremental (no new architecture needed) +- The `SettingsPanel` integration already lets users edit every field that exists in their v3 document + +### 4.3 Why the AI runtime is stubbed + +Phases 6-8 require `@langchain/openai`, `@langchain/anthropic`, `deepagents`, `better-sqlite3`. These are heavy (multi-MB native modules, OAuth flows, langgraph runtime). The implementation: +- Ships the IPC contracts, provider registry, LLM config store (with `safeStorage`), chat history +- Stubs the actual LLM call (returns a fixed message reminding the user to install deps) +- The 8 providers, OAuth flow, reasoning effort mapping, and the chat-service scaffolding are all in place — adding the real `@langchain/*` calls is a focused follow-up + +### 4.4 Why ms for `annotations[]` / `zoomRanges[]` but sec for timeline + +`AxcutDocument.annotations` and `AxcutDocument.zoomRanges` mirror the legacy `ProjectEditorState.annotationRegions` / `.zoomRegions` which use **ms**. The timeline ops (`skipRanges`, `clips.sourceStartSec`, etc.) follow axcut's convention of **sec** because axcut's `clips` are authored from the agent/runtime where the second-based model is canonical. This dual-unit is contained to the document schema and handled by the `document/timeline.ts` math + `migrate.ts` conversion. The renderer reads `document.zoomRanges` directly as ms. + +### 4.5 Why `safeStorage` for credentials (not plain JSON) + +Per locked decision 4 in the spec: LLM credentials are stored in `safeStorage`-encrypted bytes (OS keychain on macOS, libsecret on Linux, DPAPI on Windows). Config (provider, model, baseUrl, reasoningEffort) is plain JSON. This matches axcut's security improvement over their original plain-JSON approach. + +--- + +## 5. What's NOT in this PR (deferred work) + +These are deliberate deferrals, not oversights: + +1. **Full feature parity UI** — adding new annotations/zoom regions from the new shell (the SettingsPanel can only edit existing ones). Follow-up: port the legacy `VideoEditor`'s annotation/zoom add flows to `NewEditorShell`. +2. **Real LLM calls** — `@langchain/*` deps not installed. Follow-up: `npm i @langchain/openai @langchain/anthropic deepagents` and replace the stub in `chat-service.ts:runChat`. +3. **SQLite for sessions/checkpoints** — `better-sqlite3` not installed. Follow-up: port axcut's `DatabaseService` and `PersistentFileCheckpointSaver`. +4. **Webcam real-time preview** in `VirtualPreview` — current is a single-video component; axcut has a two-layer crossfade. Follow-up for a richer preview experience. +5. **13-locale i18n** — the new components use hardcoded English strings ("Transcribe", "Remove cuts", "Export", etc.). Follow-up: add to `src/i18n/locales//*.json`. +6. **Settings sync to `userPreferences.ts`** — `AI_FEATURES_ENABLED` toggle is a constant, not user-toggleable. Follow-up: wire to the existing settings sync. +7. **Export dialog integration** — the new editor's "Export" button shows a toast. Follow-up: wire the `ExportDialog` component with the full options. +8. **The legacy `VideoEditor.tsx`** (2961 lines) is unchanged on disk. It can be deleted in a follow-up once confidence is high. + +--- + +## 6. How to continue + +### 6.1 Resume this branch + +```bash +cd G:\repos\openscreen\.worktrees\wt-9ce78f24 +git status # should be clean +git log --oneline -3 +npm run dev # already running, port 5173 +# Open http://localhost:5173/?windowType=editor +``` + +### 6.2 Add a real LLM provider + +1. `npm i @langchain/openai @langchain/anthropic deepagents better-sqlite3` +2. In `electron/ai-edition/chat-service.ts:runChat`, replace the stub with a real call: + ```ts + import { ChatOpenAI } from "@langchain/openai"; + const model = new ChatOpenAI({ model: config.model, apiKey }); + const result = await model.invoke(message); + ``` +3. Add the corresponding provider in `provider-registry.ts` if it's not already there. + +### 6.3 Add full feature parity (annotations/zoom creation UI) + +1. Port the legacy `VideoEditor`'s annotation-add flow (around line 2500+) to a new component. +2. Mount it in `NewEditorShell` alongside `SettingsPanel`. +3. Wire it to `documentStore.setDocument` (already wired through `EditorSettings`). + +### 6.4 Open a PR + +```bash +git push origin docs/ai-edition-plan # already pushed +gh pr create \ + --base main \ + --head docs/ai-edition-plan \ + --title "feat(ai-edition): implement v3 editor model + AI features scaffold" \ + --body-file PR_BODY.md +``` + +### 6.5 Delete the legacy `VideoEditor` when ready + +The file `src/components/video-editor/VideoEditor.tsx` (2961 lines) is now unused in the default flow. `grep -r "from.*VideoEditor" src/` to confirm. Then delete and remove from `App.tsx` lazy import. + +--- + +## 7. File map (where to look) + +``` +G:\repos\openscreen\.worktrees\wt-9ce78f24\ +├── docs/architecture/ +│ └── ai-edition-merge-plan.md # updated §0, §5.9, §10 +├── electron/ +│ ├── ai-edition/ +│ │ ├── document-service.ts # CRUD on .axcut files +│ │ ├── document-service.test.ts +│ │ ├── llm-config-store.ts # safeStorage credentials +│ │ ├── provider-registry.ts # 8 providers, static +│ │ └── chat-service.ts # in-memory, LLM stub +│ ├── ipc/ +│ │ ├── handlers.ts # wires services to bridge +│ │ └── nativeBridge.ts # adds aiEdition domain +│ └── native-bridge/services/ +│ └── aiEditionService.ts # bridge adapter +├── src/ +│ ├── App.tsx # installs browser shim +│ ├── native/ +│ │ ├── browserShim.ts # NEW - browser-mode stubs +│ │ ├── client.ts # adds aiEdition namespace +│ │ └── contracts.ts # adds aiEdition types +│ ├── components/ +│ │ ├── video-editor/ +│ │ │ ├── SettingsPanel.tsx # + hideInternalRail prop +│ │ │ └── featureFlags.ts # AI_EDITION_ENABLED → AI_FEATURES_ENABLED +│ │ └── ai-edition/ # NEW directory +│ │ ├── AiEditionShell.tsx # kill-switch removed +│ │ ├── NewEditorShell.tsx # main shell, the default +│ │ ├── IconRail.tsx +│ │ ├── ProjectPanel.tsx +│ │ ├── TimelinePane.tsx + .module.css +│ │ ├── VirtualPreview.tsx + .module.css +│ │ ├── TranscriptEditor.tsx + .module.css +│ │ ├── ChatPanel.tsx +│ │ └── EditorSettings.tsx # bridge → SettingsPanel +│ └── lib/ai-edition/ # NEW directory +│ ├── schema/index.ts + .test.ts +│ ├── document/ +│ │ ├── timeline.ts + .test.ts +│ │ ├── migrate.ts + .test.ts +│ │ ├── transcribe.ts +│ │ └── ids.ts +│ ├── timeline/ +│ │ └── virtual-preview.ts + .test.ts +│ ├── store/ +│ │ └── projectStore.ts + .test.ts +│ └── exporter/ +│ └── documentExporter.ts +└── package.json # +zod, +zustand +``` + +--- + +## 8. The conversation arc (for context) + +1. User asked to check PR #35 and start implementation per its plan. +2. Implemented Phase 0 (schema, migration, feature flag) — 29 tests pass, human-testable via dev server. +3. Implemented PR 1.1 (project panel, document service, IPC bridge) — human-testable. +4. User asked for total spec completion. Implemented PR 1.2 + 1.3 (timeline port, preview port, new editor shell with kill-switch). +5. User said "implement 1, 2, and 3" with the axcut `\\wsl.localhost\Ubuntu\home\etienne\repos\axcut\` path. Implemented: + - Export (Phase 3) — adapter to existing VideoExporter + - Legacy `.openscreen` loading — migrate v2 → v3 + - Settings panel (annotations, zoom, cursor, webcam, wallpaper) — bridge to `SettingsPanel` +6. User said "go on → full implementation". Implemented Phases 6-8 scaffolding (provider registry, LLM config store with safeStorage, chat service stub, IPC contracts) and Phase 9 partial (settings toggle, i18n deferred). +7. User asked to relaunch in browser. Added `browserShim.ts` for `http://localhost:5173/?windowType=editor`. +8. User asked for UI redesign to match original OpenScreen + axcut layout. Implemented: + - Left icon rail (Project / Chat) + - Right icon rail (Transcript / Background / Video effects / Camera / Cursor / Crop / Export) + - Top header (project title + PanelLeft / PanelRight / Download) + - NewEditorShell with full-height columns + - Removed the chevron collapse buttons (user requested) + - Used original OpenScreen SettingsPanel icons + - Added `hideInternalRail` prop so the right rail is the only navigation +9. User asked about worktree, branch, commit, push. Confirmed branch (`docs/ai-edition-plan`), committed and pushed. +10. User asked for a handoff summary in English for a coding agent. + +--- + +**The next coding agent should**: +- Open `http://localhost:5173/?windowType=editor` to see the current state +- Read `docs/architecture/ai-edition-merge-plan.md` for the plan +- Pick up from §5 (deferred work) — most impactful next steps are real LLM calls (#2) and feature parity UI (#1) +- All architecture is in place; the remaining work is wiring and UI polish, not new design diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..f1ef015b2a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,78 @@ +# AGENTS.md + +OpenScreen is a free, open-source screen recorder and video editor (Electron + React + TypeScript + Pixi.js) maintained as a continuation of the original v1.5.0 release. This file is the canonical guide for any AI coding agent working in this repo. + +## Setup commands + +- Install deps: `npm install` (Node 22.22.1, npm 10.9.4 — see `package.json#engines`) +- Start dev: `npm run dev` (Vite dev server; Electron window opens via `vite-plugin-electron`) +- Build: `npm run build` (TypeScript check + Vite build + electron-builder) +- Typecheck: `npx tsc --noEmit` (CI runs this; no standalone script) +- Test (unit): `npm run test` (Vitest, jsdom env) +- Test (browser): `npm run test:browser` (Vitest + Playwright, requires `npm run test:browser:install` first) +- Test (e2e): `npm run test:e2e` (Playwright) +- Lint: `npm run lint` (Biome 2.4) +- Format: `npm run format` (Biome, tabs, double quotes, 100-col) +- i18n check: `npm run i18n:check` (validates the 13 locale files) + +## Project layout + +- `src/` — React app: UI, editor components, timeline, i18n, captioning/cursor/exporter libs +- `electron/` — main process, IPC, recording orchestration +- `electron/native/` — **native** capture helpers: `screencapturekit/` (Swift, macOS) and `wgc-capture/` (C++/Win32, Windows). These are built and shipped with the app, not loaded from npm +- `docs/` — architecture, engineering roadmaps, testing guides +- `tests/` — Playwright e2e specs + fixtures +- `scripts/` — native build scripts, diagnostic tools +- `nix/`, `flake.nix` — Linux packaging +- `release/`, `dist-electron/` — build artifacts (gitignored) + +## Code style + +- TypeScript strict mode (`tsconfig.json`). No `any` (Biome `noExplicitAny` is `warn` — don't add new `any`). +- Biome handles lint AND format. Tabs, double quotes, 100-col width, LF line endings. Run `npm run lint:fix` before committing. +- React functional components only. Hooks at top level (Biome `useHookAtTopLevel` is `error`). +- Imports: use the `useImportType` discipline (Biome organizes them). +- Husky + lint-staged runs Biome on staged `*.{ts,tsx,js,jsx,mts,cts,json}`. +- The repo is pre-1.x and not production-grade — rough edges are expected, but new code should be clean. + +## Testing instructions + +- Unit tests live next to source as `*.test.ts` / `*.test.tsx` (Vitest, jsdom). +- Browser tests use `vitest.browser.config.ts` (Playwright headless) — only run when DOM/Pixi rendering matters. +- E2E tests are in `tests/e2e/` (Playwright). Some specs are platform-specific (e.g. `windows-native-checklist.spec.ts`). +- Add a test for every new behavior in the same package as the code under test. +- All tests must pass before opening a PR. CI runs `npm run test` and `npm run test:browser` on every PR. + +## PR & commit conventions + +- Branch from `main`; never push to it directly. +- Commit messages: short imperative summary, optional body. Recent style mixes conventional-ish prefixes (`ci:`, `chore:`, `fix:`) with plain messages — either is fine, just be consistent within a PR. +- **PR titles must follow Conventional Commits** (`feat:`, `fix:`, `chore:`, `refactor:`, `perf:`, `docs:`, `test:`, `build:`, `ci:`, `style:`, `revert:`). Enforced by the `semantic-pr` job in `ci.yml`. This feeds GitHub's auto-generated release notes with clean categories. +- Open PR via `gh pr create` once CI is green. +- PR template is in `.github/pull_request_template.md`. + +## Release flow + +Two `workflow_dispatch` workflows cut a release with a pre-release candidate (RC) first, then promote to stable. Trunk-based, no extra branch. Full operational guide in `.harness/docs/git-workflow.md` § Release flow. + +- **Cut RC**: Actions → "Cut a release candidate" → Run workflow. Inputs: `bump` (patch|minor|major), `rc_number` (default 1), optional `target_version` override. Snaps issues out of the rolling `Next Release` milestone into a versioned `vX.Y.Z` milestone, bumps `package.json`, pushes the `vX.Y.Z-rc.N` tag, which triggers the existing `build.yml` to publish a GitHub pre-release. Notarization is skipped on RCs. Notifies `#rc-testing` on Discord. +- **Promote RC**: Actions → "Promote RC to stable release" → Run workflow. Input: `rc_tag` (e.g. `v1.5.0-rc.2`), optional `release_notes_extra`. Closes the `vX.Y.Z` milestone, strips `-rc.N` from `package.json`, pushes `vX.Y.Z` tag, which triggers `build.yml` to publish a stable release (full notarization, Tier 3 homebrew/winget/nix/aur fires). Notifies `#announcements` on Discord. +- **Manual fallback**: `git tag vX.Y.Z-rc.N && git push origin vX.Y.Z-rc.N` does the same as Cut RC (minus the milestone migration and Discord announce) — useful for emergency cuts. + +Both workflows require the `OPENSCREEN_RELEASE_TOKEN` secret (a fine-grained PAT with `contents: write` + `issues: write`). This is the standard fix for `release: published` not triggering downstream workflows when the release is created by `GITHUB_TOKEN`. See `docs/secrets.md`. + +**Release branches freeze the build between cut and promote.** Every RC cut creates `release/vX.Y.Z-rc.N`. The branch is *not* merged into `main` until the stable tag is published; only cherry-picks of bugfixes land on the release branch during the RC window. The stable tag points at the branch tip (RC + cherry-picks), then `promote.yml` opens a `release/vX.Y.Z-sync → main` PR to bring main into line. This contract exists because of the v1.6.0 incident (2026-07-05) where the original promote workflow tagged `main` instead of the RC snapshot, causing 23 unreleased commits to ship in `v1.6.0`. Full rules in `.harness/docs/git-workflow.md` § Release branches. + +## Security + +- Never commit secrets. `.env.example` exists; real `.env` is gitignored. +- `macos.entitlements` controls macOS permissions — review when touching native recorder. +- Native helpers run with elevated privileges on user systems; treat code in `electron/*-helper/` as security-sensitive. + +## Specialized notes + +- **Native capture is platform-fragile**: macOS uses ScreenCaptureKit (Swift), Windows uses WGC (C++/Win32). CI runs on Linux only — manual smoke test on real macOS/Windows is required for native changes. +- **Pixi.js v8** is the rendering engine. Filters come from `pixi-filters` and `@pixi/filter-drop-shadow`. GSAP + `motion` for animation. +- **i18n**: 13 locales in `src/i18n/locales//` (e.g. `src/i18n/locales/en/settings.json`). The `i18n:check` script validates them — run it after touching translation files. +- **Build pipeline**: `npm run build` is full electron-builder. For iterating on renderer only, use `npm run build-vite` (Vite + tsc, no packaging). +- **README tone**: the project is explicitly "not production-grade" and free forever — don't add paywalls, premium tiers, or upsell language to UI/copy. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 651c80f3d1..d4d82f7b54 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,7 +43,33 @@ Thank you for considering contributing to this project! By contributing, you hel ## Reporting Issues -If you encounter a bug or have a feature request, please open an issue in the [Issues](https://github.com/siddharthvaddem/openscreen/issues) section of this repository. Provide as much detail as possible to help us address the issue effectively. +If you encounter a bug or have a feature request, please open an issue in the [Issues](https://github.com/EtienneLescot/openscreen/issues) section of this repository. Provide as much detail as possible to help us address the issue effectively. + +## Issue lifecycle + +Issues are closed when the corresponding fix or feature is merged into `main`. + +For desktop users, this does not always mean the change is already available in the latest downloadable release. When relevant, closed issues are marked as `status: fixed in main` and `status: pending release`. + +Once a GitHub Release containing the change is published, the issue can be marked as `status: released`. + +The next version number is not always known when a PR is merged. In that case, issues are assigned to the `Next Release` milestone. When preparing a release, this milestone can be renamed to the actual version, such as `v1.6.0` or `v2.0.0`, and a new `Next Release` milestone can be created. + +When a PR fully resolves an issue, link it with a GitHub closing keyword: + +```txt +Fixes #123 +Closes #123 +Resolves #123 +``` + +If a PR only partially addresses an issue, use a non-closing reference instead: + +```txt +Refs #123 +Part of #123 +Related to #123 +``` ## Style Guide @@ -54,4 +80,4 @@ If you encounter a bug or have a feature request, please open an issue in the [I By contributing to this project, you agree that your contributions will be licensed under the [MIT License](./LICENSE). -Thank you for your contributions! \ No newline at end of file +Thank you for your contributions! diff --git a/README.md b/README.md index 7009a22098..6d098d97c2 100644 --- a/README.md +++ b/README.md @@ -1,72 +1,70 @@ +> [!NOTE] +> This repository is an independent continuation of OpenScreen. +> +> OpenScreen was originally created by [Siddharth Vaddem](https://github.com/siddharthvaddem). The original repository was archived after v1.5.0 and remains available here: [siddharthvaddem/openscreen](https://github.com/siddharthvaddem/openscreen). +> +> This fork continues development under the OpenScreen name with the original author's approval, while remaining fully MIT open source. + > [!WARNING] -> This started as a side project that took off — it's not production grade and you'll hit bugs, but hopefully it covers what you need. +> OpenScreen is not production-grade software. You should expect bugs, rough edges, and occasional breaking changes.

OpenScreen Logo -
-
- siddharthvaddem%2Fopenscreen | Trendshift -
-
- - Ask DeepWiki - -   - - Join Discord -

#

OpenScreen

-

OpenScreen is your free, open-source alternative to Screen Studio (sort of).

+

OpenScreen is a free, open-source tool for creating polished screen recordings, product demos, and walkthroughs.

+ +

+ License + Latest Release + CI Status + Discord + Platform +

+ + +OpenScreen was originally positioned as a free, open-source alternative to Screen Studio: something you can use to create quick, polished product demos and walkthroughs for X, Reddit, YouTube, documentation, landing pages, or internal demos. -If you don't want to pay $29/month for Screen Studio but want a much simpler version that does what most people seem to need - quick, polished product demos and walkthroughs you'd post on X, Reddit. OpenScreen does not offer all Screen Studio features, but covers the basics well! +It is not a 1:1 clone of Screen Studio. Screen Studio is an excellent commercial product. OpenScreen focuses on covering the core open-source workflow: recording, zooms, cursor effects, webcam overlay, captions, editing, annotations, and export. -Screen Studio is an awesome product and this is definitely not a 1:1 clone. OpenScreen is a much simpler take, just the basics for folks who want control and don't want to pay. If you need all the fancy features, your best bet is to support Screen Studio (they really do a great job, haha). But if you just want something free (no gotchas) and open, this project does the job! +The goal of this continuation is to keep OpenScreen alive as a fully open-source project and progressively evolve it toward a broader recording and editing workflow. -**100% free** for both **personal** and **commercial** use. Use it, modify it, distribute it — just be cool 😁 and shout out the project if you feel like it. +**100% free** for both **personal** and **commercial** use. Use it, modify it, distribute it. Please respect the license. + +> [!NOTE] +> Software should be accessible. OpenScreen has no paid tiers, premium features, upsells, or functionality locked behind a paywall.

- OpenScreen App Preview 3 - OpenScreen App Preview 4 + +

## Core Features -- Record a specific window, region, or your whole screen. +- Record a specific window, or your whole screen. - Record microphone and system audio. -- Webcam overlay with picture-in-picture, drag-to-position, and shape options. -- Auto or manual zooms with adjustable depth, duration, easing, and pixel-precise position. -- Wallpapers, solid colors, gradients, or a custom background. -- Motion blur for smoother pan and zoom transitions. +- Webcam overlay with picture-in-picture, drag-to-position, mirroring, and shape options. +- Auto or manual zooms with adjustable depth, duration, easing, and pixel-precise position; auto-zoom follows your cursor as you work. +- Custom cursor size, smoothing, and click effects, with cursor themes and post-recording path smoothing. +- Automatic captions for voiceovers, generated on-device with no upload (works offline). +- Wallpapers, solid colors, gradients, or your own background image. +- Motion blur. - Crop, trim, and per-segment speed control on the timeline. -- Blur effects to hide sensitive parts of the screen. -- Cursor and click highlighting. -- Text, arrow, and image annotations. -- Save and reopen projects without re-recording. +- Text, arrow, and image annotations, with text animation presets. +- Timeline snapping guides and an audio waveform to make trimming easier. +- Customizable keyboard shortcuts. - Export to MP4 or GIF in multiple aspect ratios and resolutions. -- Translated into Arabic, English, Spanish, French, Japanese, Korean, Russian, Turkish, Vietnamese, Simplified Chinese, and Traditional Chinese. +- Languages supported: Arabic, English, Spanish, French, Italian, Japanese, Korean, Portuguese (Brazil), Russian, Turkish, Vietnamese, Simplified Chinese, and Traditional Chinese. + ## Installation -Download the latest installer for your platform from the [GitHub Releases](https://github.com/siddharthvaddem/openscreen/releases) page. +Download the latest installer for your platform from the [GitHub Releases](https://github.com/EtienneLescot/openscreen/releases) page. ### macOS -The easiest way to install on macOS is via [Homebrew](https://brew.sh): - -```bash -brew install --cask siddharthvaddem/openscreen/openscreen -``` - -Brew automatically picks the right build for Apple Silicon or Intel, and verifies the download against a notarized signature so Gatekeeper won't block it. - -To update later: `brew upgrade --cask openscreen` -To uninstall: `brew uninstall --cask openscreen` (add `--zap` to also remove app data) - -#### Manual install (if you prefer) - -If you'd rather grab the `.dmg` directly from the [Releases page](https://github.com/siddharthvaddem/openscreen/releases) and encounter Gatekeeper blocking the app, you can bypass it by running the following command in your terminal after installation: +Download the `.dmg` installer directly from the [Releases page](https://github.com/EtienneLescot/openscreen/releases). If Gatekeeper blocks the app, you can bypass it by running the following command in your terminal after installation: ```bash xattr -rd com.apple.quarantine /Applications/Openscreen.app @@ -74,24 +72,18 @@ xattr -rd com.apple.quarantine /Applications/Openscreen.app Note: Give your terminal Full Disk Access in **System Settings > Privacy & Security** to grant you access and then run the above command. -After running this command, proceed to **System Preferences > Security & Privacy** to grant the necessary permissions for "screen recording" and "accessibility". Once permissions are granted, you can launch the app. +After running this command, proceed to **System Settings > Privacy & Security** to grant the necessary permissions for "screen recording" and "accessibility". Once permissions are granted, you can launch the app. -### Windows - -Install via [winget](https://learn.microsoft.com/en-us/windows/package-manager/winget/): +> [!NOTE] +> **Upgrading from an older version and hitting permission issues?** If you already had OpenScreen installed and the new version won't record (Screen Recording or Accessibility keep failing even after you grant them), uninstall the old version, remove OpenScreen's existing entries under **System Settings > Privacy & Security** (both Screen Recording and Accessibility), then do a fresh install and grant the permissions again when prompted. -```bash -winget install SiddharthVaddem.OpenScreen -``` - -To update later: `winget upgrade SiddharthVaddem.OpenScreen` -To uninstall: `winget uninstall SiddharthVaddem.OpenScreen` +### Windows -If you'd rather grab the `.exe` installer directly, download it from the [Releases page](https://github.com/siddharthvaddem/openscreen/releases). +Download the `.exe` installer directly from the [Releases page](https://github.com/EtienneLescot/openscreen/releases). ### Linux -Three packages are published to the [Releases page](https://github.com/siddharthvaddem/openscreen/releases) for each version. Pick the one that matches your distro: +Three packages are published to the [Releases page](https://github.com/EtienneLescot/openscreen/releases) for each version. Pick the one that matches your distro: **Debian / Ubuntu / Pop!_OS (`.deb`)** ```bash @@ -113,18 +105,18 @@ chmod +x Openscreen-Linux-*.AppImage Try without installing: ```bash -nix run github:siddharthvaddem/openscreen +nix run github:EtienneLescot/openscreen ``` Install into your user profile: ```bash -nix profile install github:siddharthvaddem/openscreen +nix profile install github:EtienneLescot/openscreen ``` For a NixOS system config (flake): ```nix { - inputs.openscreen.url = "github:siddharthvaddem/openscreen"; + inputs.openscreen.url = "github:EtienneLescot/openscreen"; outputs = { nixpkgs, openscreen, ... }: { nixosConfigurations. = nixpkgs.lib.nixosSystem { @@ -146,44 +138,38 @@ You may need to grant screen recording permissions depending on your desktop env ./Openscreen-Linux-*.AppImage --no-sandbox ``` -### Limitations +### Platform differences -System audio capture relies on Electron's [desktopCapturer](https://www.electronjs.org/docs/latest/api/desktop-capturer) and has some platform-specific quirks: +Everything in the editor and export is the same on macOS, Windows, and Linux: zooms, backgrounds, motion blur, crop/trim/speed, blur regions, annotations, auto-captions, projects, export, and all languages. The differences are in **capture**, where macOS and Windows use a native pipeline that Linux doesn't have: -- **macOS**: Requires macOS 13+. On macOS 14.2+ you'll be prompted to grant audio capture permission. macOS 12 and below does not support system audio (mic still works). -- **Windows**: Works out of the box. -- **Linux**: Needs PipeWire (default on Ubuntu 22.04+, Fedora 34+). Older PulseAudio-only setups may not support system audio (mic should still work). +- **Native recording**: macOS (ScreenCaptureKit) and Windows (Windows Graphics Capture) record through a native pipeline for higher quality and clean window-level capture. Linux records through the browser pipeline instead. +- **Custom cursors**: on macOS and Windows the real cursor is captured (shape, type, and clicks), which powers the cursor themes, click effects, and editable cursor overlay. On Linux only the cursor position is captured (used for auto-zoom), so those cursor options aren't available. +- **Webcam**: captured natively on macOS and Windows; on Linux it's recorded through the browser, but still works as a picture-in-picture overlay. +- **System audio** support varies by OS: + - **macOS**: requires macOS 13+. On macOS 14.2+ you'll be prompted to grant audio capture permission. macOS 12 and below can't capture system audio (mic still works). + - **Windows**: works out of the box. + - **Linux**: needs PipeWire (default on Ubuntu 22.04+, Fedora 34+). Older PulseAudio-only setups may not capture system audio (mic should still work). -## Built with -- Electron -- React -- TypeScript -- Vite -- PixiJS -- dnd-timeline +## Official links ---- +This repository is the community-maintained continuation of OpenScreen. +Official / trusted links: -## Documentation +* Original archived repository: https://github.com/siddharthvaddem/openscreen +* Community continuation: https://github.com/EtienneLescot/openscreen -See the documentation here: -[OpenScreen Docs](https://deepwiki.com/siddharthvaddem/openscreen) -Refresh if outdated. +For safety, download OpenScreen only from the official GitHub Releases linked from this repository. Third-party websites using the OpenScreen name are not affiliated with this continuation unless explicitly listed here. -## Contributing +## Community -Contributions are welcome - please **include screenshots or a short video** for any UI change or new user-facing feature. If it touches what users see or do, show it. Skip only when it genuinely doesn't apply. PRs that don't follow this will be closed. +OpenScreen is community-driven. If you need help, want to report a bug, or just want to chat with other users and contributors: -## Star History +- 💬 **Discord** — [Join the OpenScreen Discord](https://discord.gg/VvT6Vtnyh) for real-time help, showcase, and discussion +- 🐞 **[GitHub Issues](https://github.com/EtienneLescot/openscreen/issues)** — bug reports and feature requests +- 🗺️ **[Roadmap](./ROADMAP.md)** — see what we're building next - - - - - Star History Chart - - +--- ## License diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000000..85b827ed53 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,64 @@ +# OpenScreen Roadmap +The recorder you love, with an optional AI sidekick. Same sleek, low-friction recorder UX. An opt-in AI editing layer is on the way for users who want it — never required, never snuck in. + +This roadmap is the source of truth for what we're shipping next in OpenScreen. It is a living document — items move between tiers as work lands. Have an idea, a vote, or a dissenting opinion? Drop into the 🗺️・roadmap channel on our Discord or open a GitHub issue with the `roadmap` label. + +## 🧭 North Star +**Record → Edit → Export.** (with an optional AI shortcut for users who want one) + +OpenScreen is, first and foremost, a polished screen recorder. Record, trim on the timeline, export. Most users will keep using exactly this workflow. + +We're also exploring an optional AI editing layer — for users who want to edit by talking or by editing a transcript. It's opt-in, off by default, and never required. If you don't enable it, the AI layer doesn't exist for your install: nothing downloads, nothing leaves your machine, no LLM is contacted. + +Three axes guide every decision on this roadmap: + +- **Stability first** — the recorder must work reliably on macOS, Windows, and Linux. Bugs found by real users ship before new features. +- **Sleek UX stays** — every AI feature must keep the OpenScreen feel: minimal clicks, instant feedback, no clutter. +- **100% free, forever** — no paywalls, no premium tier, no usage caps. Every feature on this page ships under MIT. + +## 🤖 Direction — the optional AI Edition +A Screen Studio + Descript clone, open-source and free forever. The recorder-first UX stays intact, and the AI layer sits beside it, off by default. + +Capabilities we're exploring (each one opt-in, each one toggleable independently): + +- **Local Whisper transcription (opt-in, on-device)** — OpenScreen already ships on-device Whisper transcription for automatic captions. This extends that foundation: the same local transcript powers the editing features below, with no upload, no cloud, no extra setup required. +- **Transcript-driven editing (opt-in, local)** — edit video like a doc (Descript-style: delete a word, cut the span). Works with the local transcript; no cloud needed. +- **One-click cleanup (opt-in, local)** — filler-word removal, silence trimming, Studio Sound voice enhancement. All on-device. +- **Edit by chat (opt-in, requires BYO LLM key)** — say "cut the part where I repeat myself between 0:42 and 1:10" and the agent applies a structured timeline operation. Off until you connect a provider. +- **Non-destructive project document (always on)** — every edit, AI or manual, is undoable; the timeline is always recoverable. +- **Bring-your-own LLM (opt-in)** — OpenAI, Anthropic, Google, Mistral, OpenRouter, GitHub Copilot, OpenAI-compatible endpoints, ChatGPT account auth. You choose; we never see your keys or your data. + +This section is a direction, not a sprint plan. Concrete items land here as RFCs once the recorder is stable enough to build on top of. + +## 🛠️ Stability & quality (what we're actually shipping) +Pulled from real user bug reports on getopenscreen/openscreen. This is the queue for the next release window. + +- [ ] **Fix:** video disappears from editor after export — [#8](../../issues/8) (Linux, Manjaro). Renderer regression after export. +- [ ] **Fix:** crash after stopping macOS recording — [#21](../../issues/21) (macOS 26.4.1, Apple Silicon). Crash is in the Electron / Node async fs shutdown path; recording artifacts are written correctly. +- [ ] **Fix:** macOS cursor offset in single-window capture — [#22](../../issues/22). +- [ ] **Fix:** recover preview from WebGL context loss on Linux / Wayland — [#19](../../issues/19). +- [ ] **Feature:** software H.264 fallback when no GPU encoder MFT is available — [#18](../../issues/18). Critical for VMs, broken-driver machines, and headless environments. +- [ ] **Feature:** copy / paste attributes & effects in the timeline — [#24](../../issues/24). Right-click menu + standard Ctrl/Cmd+C / Ctrl/Cmd+V shortcuts. +- [ ] **Feature:** restore blur regions (rectangle / oval / freehand, mosaic + CSS) — [#76](../../issues/76). Upstream v1.5.0 dropped the feature in the final release before archiving. The renderer pipeline (`BlurSettingsPanel`, `blurEffects`, `annotationRenderer`) is already present in this fork; the export guard in `src/lib/exporter/videoExporter.ts:151-152` (refuses export while `showBlur` or `motionBlurAmount` is set) is what needs to be unblocked, plus a regression test in the timeline. + +## 📚 Site & documentation +- [ ] **Feature:** Docusaurus site — landing + docs, deployed to GitHub Pages via CI. + - Monorepo at `website/`. Versioning off until v2. + - Landing (pitch, demo, quick start, downloads) + migrate `docs/` → `website/docs/`. + - Bespoke theme (TBD). + - CI: build on PR (artifact preview), deploy to Pages on `main`. Custom domain as follow-up. + - MIT, no tracking, no paywall — same posture as the app. + +## 📬 How to influence this roadmap +- **Discord** — join the OpenScreen Discord and post in [#🗺️・roadmap](https://discord.com/channels/1489517664467681310/1493586210675884265). The fastest way to get a thumbs-up or thumbs-down on a feature. +- **GitHub** — open an issue with the `enhancement` label, or react with 👍 / 👎 on existing items. +- **PRs** — if you want to ship one of these, open a PR and link the relevant issue. We review fast and help with native-bridge / i18n questions. + +Anything not on this list yet? Open an issue and tag it `roadmap` — we'll triage it into a tier within a week. + +--- + +## Changelog +- **2026-06-24** — initial draft. Stability items pulled from open issues / PRs on getopenscreen/openscreen. AI section presented as opt-in / off by default. Whisper entry updated to reflect existing caption feature. +- **2026-06-25** — added "Site & documentation" tier: Docusaurus + GitHub Pages. Cleaned smoke-test noise from the changelog (internal CI sync validation, not user-facing). +- **2026-07-06** — added blur regions to the stability & quality tier. Confirmed upstream deprecated the feature in v1.5.0 without an explicit reason; the renderer code carried over to the fork, so the work is unblocking the export guard + adding coverage. Tracked via #76. \ No newline at end of file diff --git a/docs/github-actions-workflows.md b/docs/github-actions-workflows.md new file mode 100644 index 0000000000..e572940f51 --- /dev/null +++ b/docs/github-actions-workflows.md @@ -0,0 +1,261 @@ +# GitHub Actions workflows + +## Overview + +The repository uses 14 workflow files across five functional tiers. This document describes the triggers, job dependencies, and artifact flow for each tier. + +## Workflow dependency graph + +```mermaid +graph TD + subgraph Tier 1 - CI + ci[ci.yml
push / PR → main] + ci_lint[lint] + ci_typecheck[typecheck] + ci_test[test] + ci_build[build] + ci_semantic[semantic-pr] + ci --> ci_lint + ci --> ci_typecheck + ci --> ci_test + ci --> ci_build + ci --> ci_semantic + end + + subgraph Tier 2 - Release build + build[build.yml
tag v* / dispatch] + build_win[build-windows] + build_mac[build-macos
matrix arm64 x64] + build_linux[build-linux] + build_release[publish-release] + build --> build_win + build --> build_mac + build --> build_linux + build_win --> build_release + build_mac --> build_release + build_linux --> build_release + end + + subgraph Tier 2.5 - Release management + prerelease[prerelease.yml
dispatch] + promote[promote.yml
dispatch] + prerelease -->|push tag vX.Y.Z-rc.N| build + promote -->|push tag vX.Y.Z| build + end + + subgraph Tier 3 - Package registries + homebrew[update-homebrew-cask.yml
release published] + winget[publish-winget.yml
release published] + nix[bump-nix-package.yml
release published] + aur[aur-publish.yml
release published] + end + + subgraph Tier 4 - Automation + discord_pr[discord-pr-notify.yml
PR events, review, comment] + discord_roadmap[discord-roadmap-sync.yml
push to main] + discord_leaderboard[discord-weekly-leaderboard.yml
schedule mon 12:00 UTC] + bookkeeping[merged-pr-bookkeeping.yml
PR closed merged] + diag[diagnostic-artifact.yml
push / PR / dispatch] + diag_win[build-windows] + diag_mac[build-macos
matrix arm64 x64] + diag --> diag_win + diag --> diag_mac + end + + build_release -->|PAT: gh release create| homebrew + build_release -->|PAT: gh release create| winget + build_release -->|PAT: gh release create| nix + build_release -->|PAT: gh release create| aur + promote -->|if: success| discord_announce_rc[discord-release-announce.mjs
#rc-testing] + prerelease -->|if: success| discord_announce_stable[discord-release-announce.mjs
#announcements] +``` + +> Note: the announce-edge arrows above are inverted for readability — `prerelease.yml` posts to `#rc-testing`, and `promote.yml` posts to `#announcements`. + +## Tier 1: CI checks + +**File:** `ci.yml` + +Triggered on every push to `main` and every pull request targeting `main`. Four parallel, independent jobs with no interdependencies: + +| Job | Runner | Purpose | +|---|---|---| +| `lint` | ubuntu-latest | Biome check | +| `typecheck` | ubuntu-latest | `tsc --noEmit` | +| `test` | ubuntu-latest | Vitest unit tests + Playwright browser tests | +| `build` | ubuntu-latest | `vite build` (renderer-only, no electron-builder) | + +All jobs use the shared composite action `.github/actions/setup` for Node.js installation and `npm ci`. Failure of one job does not cancel the others. + +## Tier 2: Release build and publish + +### build.yml + +Triggered by version tags (`v*`) or manual `workflow_dispatch` (with optional macOS architecture selection and release tag override). + +**Jobs:** + +1. **`build-windows`** (windows-latest): Compiles NSIS installer via `electron-builder --win`. Uploads artifact `openscreen-windows` (30-day retention). + +2. **`build-macos`** (macos-latest, matrix `arm64` / `x64`): Compiles native helpers, runs `tsc && vite build`, builds `.app` bundle, creates and signs a DMG. Uploads artifacts `openscreen-mac-arm64` and `openscreen-mac-x64` (30-day retention). Signing and notarization are conditional on the presence of Apple developer secrets (`MAC_CERTIFICATE_P12`, `APPLE_ID`, etc.). Without secrets, produces an unsigned DMG. + +3. **`build-linux`** (ubuntu-latest): Installs `libarchive-tools` for `.pacman` support, runs `electron-builder --linux AppImage deb pacman`. Uploads artifact `openscreen-linux` (30-day retention). + +4. **`publish-release`** (ubuntu-latest, needs all three build jobs): Downloads all four artifacts by explicit name, validates that `package.json` version matches the tag, and publishes them to a GitHub Release via `gh release create` or `gh release upload --clobber`. The download step uses explicit `name:` parameters to fail fast on missing artifacts rather than silently skipping them. + +All three build jobs use a shared caption-assets cache keyed by `runner.os` and the hash of `scripts/fetch-caption-model.mjs` to avoid cross-platform cache collisions. + +## Tier 2.5: Release management + +Two `workflow_dispatch` workflows manage the release cycle. Both run on `main` and require the `OPENSCREEN_RELEASE_TOKEN` secret. + +### prerelease.yml + +Triggered manually to cut a release candidate. + +**Inputs:** `bump` (`patch|minor|major`, default `minor`), `rc_number` (default `1`), `target_version` (optional override). + +**Steps:** +1. Checkout, setup Node. +2. Compute next SemVer from `package.json` + `bump`, derive `vX.Y.Z-rc.N` tag. +3. **Migrate** all items from the rolling `Next Release` milestone into a fresh `vX.Y.Z` milestone (idempotent — each migrated item gets an HTML marker comment). +4. Bump `package.json` to `X.Y.Z-rc.N` and commit on `main`. +5. Push the `vX.Y.Z-rc.N` tag → triggers `build.yml`. +6. Announce in `#rc-testing` on Discord via `discord-release-announce.mjs`. + +### promote.yml + +Triggered manually to promote an RC to a stable release. + +**Inputs:** `rc_tag` (e.g. `v1.5.0-rc.2`), `release_notes_extra` (optional). + +**Steps:** +1. Validate the tag matches `^vX.Y.Z-(rc|beta|alpha)\.N$`; derive `X.Y.Z`. +2. Close the `vX.Y.Z` milestone (snapshots the closed-issue list for release notes). +3. Strip `-rc.N` from `package.json` and commit on `main`. +4. Push the `vX.Y.Z` tag → triggers `build.yml` → publishes a stable release. +5. Tier 3 fires automatically because the release was created with `OPENSCREEN_RELEASE_TOKEN` (which propagates the `release: published` event). +6. Announce in `#announcements` on Discord with the release notes + closed-issue list. + +### Manual fallback + +```bash +git tag v1.5.0-rc.1 +git push origin v1.5.0-rc.1 + +# later +git tag v1.5.0 +git push origin v1.5.0 +``` + +This works because `build.yml` is triggered by any tag matching `v*`. It skips milestone migration and Discord announcements — useful for emergency cuts when the dispatch UI is unavailable. + +### Why a fine-grained PAT (`OPENSCREEN_RELEASE_TOKEN`)? + +`GITHUB_TOKEN` cannot trigger downstream workflows from the actions it performs. Specifically, `gh release create` using `GITHUB_TOKEN` does **not** fire the `release: published` event, so homebrew/winget/nix/aur would silently skip every release. The fine-grained PAT (scoped to `getopenscreen/openscreen` with `contents: write` + `issues: write`) is the standard fix. See `docs/secrets.md` for creation and rotation instructions. + +## Tier 3: Package registries + +These workflows react to `release: published` events and push the release to external package registries. Each also supports `workflow_dispatch` for manual re-runs. + +### update-homebrew-cask.yml + +Finds both `arm64` and `x64` DMG assets in the release, downloads them, computes SHA-256, generates a Ruby cask file, and pushes it to a separate Homebrew tap repository (`vars.HOMEBREW_TAP_OWNER` / `vars.HOMEBREW_TAP_REPO`). + +Before scanning for assets, a polling loop waits up to 12 minutes for DMGs to appear in the release, accounting for the Apple notarization delay. + +Conditional on `vars.HOMEBREW_TAP_OWNER`, `vars.HOMEBREW_TAP_REPO`, and `secrets.HOMEBREW_TAP_TOKEN`. + +### publish-winget.yml + +Delegates to `vedantmgoyal9/winget-releaser@v2`, which finds the Windows installer matching `Setup\..*\.exe$` and publishes a manifest to the WinGet Community Repository. + +Conditional on `vars.WINGET_IDENTIFIER` and `secrets.WINGET_ACC_TOKEN`. + +### bump-nix-package.yml + +Checks out `main`, installs Nix, runs `prefetch-npm-deps` on `package-lock.json` to compute the new `npmDepsHash`, patches `nix/package.nix` with `sed`, and opens a PR against `main` on branch `chore/bump-nix-{version}`. + +Conditional on non-prerelease releases. + +### aur-publish.yml + +Finds the `.pacman` asset in the release, computes SHA-256, clones the AUR repository via SSH, updates `PKGBUILD` and `.SRCINFO`, and pushes the updated package. + +Conditional on `vars.AUR_PACKAGE_NAME` and `secrets.AUR_SSH_PRIVATE_KEY`. + +## Tier 4: Automation and diagnostics + +### discord-pr-notify.yml + +Triggered by `pull_request_target` (opened, reopened, synchronize, edited, labeled, unlabeled, closed, converted_to_draft, ready_for_review), `pull_request_review` (submitted), and `issue_comment` (created). + +Runs `node .github/scripts/discord-pr-sync.mjs`, which creates or updates a Discord forum thread for each PR. Thread state is persisted via an HTML comment (``) in the PR body. Tag updates (draft, ready, changes requested, approved, merged, closed) are applied via the Discord API. The job is marked `continue-on-error: true` so that Discord failures never block the PR workflow. + +All Discord traffic goes through a single bot (`DISCORD_BOT_TOKEN`, secret). The script creates the forum thread via `POST /channels/{forumChannelId}/threads` and posts review/comment updates into the existing thread via `POST /channels/{threadId}/messages`. Required bot permissions on the PR forum channel: View Channel, Send Messages, Embed Links, **Create Public Threads** (initial thread), **Send Messages in Threads** (subsequent updates), Manage Threads (for tag/archive/lock). Optional failure alerts can be sent to a separate channel via `DISCORD_ALERT_CHANNEL_ID` (variable); unset to silence. + +### discord-roadmap-sync.yml + +Triggered on push to `main` and on merged PRs targeting `main`. Runs `node .github/scripts/discord-roadmap-sync.mjs`, which: + +- Detects whether `ROADMAP.md` changed in the event +- Fetches the current `ROADMAP.md` from `main` +- Updates (or creates and pins) a Discord message in the `#roadmap` channel +- Uses the channel's pinned message as persistent state; self-heals if a moderator unpins it + +Requires `DISCORD_BOT_TOKEN` (secret) and `DISCORD_ROADMAP_CHANNEL_ID` (variable). `DISCORD_ROADMAP_MESSAGE_ID` (variable) is an optional escape hatch that bypasses the pin-based lookup. + +### discord-weekly-leaderboard.yml + +Triggered by schedule (Mondays at 12:00 UTC) and `workflow_dispatch`. Runs `node .github/scripts/discord-weekly-leaderboard.mjs`, which queries the GitHub Search API for merged PRs in the last 7 days, ranks contributors by PR count, and posts a top-10 leaderboard to the `#🌟・contributor-spotlight` channel via the same bot (`DISCORD_BOT_TOKEN`). + +Requires `DISCORD_SPOTLIGHT_CHANNEL_ID` (variable) and the bot to have View Channel + Send Messages + Embed Links on that channel. + +### merged-pr-bookkeeping.yml + +Triggered by `pull_request_target: closed` on merged PRs targeting `main`. Uses a GraphQL query (`closingIssuesReferences`) to find linked issues, then: + +- Adds labels `status: fixed in main` and `status: pending release` +- Removes `status: in progress` and `status: needs triage` +- Assigns the `Next Release` milestone (creates it if missing) +- Closes the issue with `state_reason: completed` +- Posts an idempotent comment with a marker comment + +### diagnostic-artifact.yml + +Triggered on push to `main`, PRs targeting `main`, and `workflow_dispatch`. Produces platform-specific diagnostic bundles for troubleshooting: + +- **`build-windows`** (windows-latest): Compiles the WGC capture helper via CMake, bundles it with diagnostic scripts into a ZIP, smoke-tests the bundle structure. +- **`build-macos`** (macos-latest, matrix `arm64` / `x64`): Compiles the ScreenCaptureKit helper, bundles it with diagnostic scripts into a `.tar.gz`. + +Artifacts are retained for 14 days (shorter than release artifacts). + +## Shared infrastructure + +### Composite action: `.github/actions/setup` + +A single composite action used by all jobs that need Node.js: + +```yaml +runs: + using: composite + steps: + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + shell: bash +``` + +When the Node.js version needs to change, only this one file is updated. The action does not include `actions/checkout`; callers manage their own checkout step to allow for custom `ref`, `repository`, or `fetch-depth` options. + +### Inline scripts: `.github/scripts/` + +Scripts previously embedded as `actions/github-script@v7` inline JavaScript blocks are now standalone `.mjs` files invoked via `node`. This allows: + +- Biome linting and formatting coverage in CI +- TypeScript type-checking coverage in CI +- Local execution and debugging outside of GitHub Actions + +The scripts import `@actions/core` and `@actions/github` (added to `devDependencies`) to access the same APIs (`core.info`, `core.warning`, `context`, `getOctokit`) that `actions/github-script@v7` provides as globals. diff --git a/docs/secrets.md b/docs/secrets.md new file mode 100644 index 0000000000..b10714d088 --- /dev/null +++ b/docs/secrets.md @@ -0,0 +1,153 @@ +# Secrets and tokens + +OpenScreen uses a small set of GitHub Actions secrets and repository variables. This file documents what each one does and how to create or rotate it. + +## Required for releases + +### `OPENSCREEN_RELEASE_TOKEN` + +A **fine-grained personal access token** used by the release pipeline (`build.yml#publish-release`, `prerelease.yml`, `promote.yml`) for the actions that `GITHUB_TOKEN` cannot perform reliably: + +- Creating a GitHub Release via `gh release create` such that the `release: published` event **does** fire downstream workflows (homebrew/winget/nix/aur). With `GITHUB_TOKEN`, the event is suppressed to prevent recursive workflow runs. +- Pushing commits and tags to `main` from `prerelease.yml` and `promote.yml` in a way that can later trigger downstream CI. +- Closing milestones and posting comments during the issue-migration step. + +**Why not just use `GITHUB_TOKEN` for everything else?** + +Most of the repo's workflows (CI, build, Tier 3 publishers, Discord sync) only need read access or scoped write access within a single repo. `GITHUB_TOKEN` is fine for those and is the safer default. The release pipeline needs cross-workflow event firing, which only a PAT can provide. + +**How to create it:** + +1. Go to (fine-grained PATs). +2. **Resource owner**: `getopenscreen` (only this org — do not grant access to personal repos). +3. **Repository access**: `getopenscreen/openscreen` only. +4. **Permissions**: + - `Contents`: Read and write + - `Issues`: Read and write + - `Pull requests`: Read and write (the release pipeline opens a PR to bump `package.json` and rebase-merges it into `main` because the org-level workflow permissions block `GITHUB_TOKEN` from creating PRs) + - `Actions`: Read and write (the release pipeline triggers `build.yml` via `gh workflow run`; GITHUB_TOKEN tag pushes don't fire downstream workflows in this org) + - `Workflows`: Read and write (the release branch contains the workflow files; creating it requires writing to `.github/workflows/`) + - `Metadata`: Read-only (auto-selected) +5. **Expiration**: 1 year. Set a calendar reminder to rotate. +6. Generate the token, copy it once, then add it as a repository secret. The `gh` CLI does **not** accept the value as a positional argument — use `--body` or stdin: + ```bash + # Either: + gh secret set OPENSCREEN_RELEASE_TOKEN --body "ghp_xxxxxxxxxxxxxxxxxxxx" --repo getopenscreen/openscreen + # Or: + echo "ghp_xxxxxxxxxxxxxxxxxxxx" | gh secret set OPENSCREEN_RELEASE_TOKEN --repo getopenscreen/openscreen + ``` +7. Verify by triggering a test `workflow_dispatch` on `prerelease.yml` with `bump=patch`, `rc_number=99` against an empty milestone, then revert the resulting `package.json` bump PR/commit. + +**Rotation:** + +Old token and new token both work in parallel until the old one expires or is revoked. Rotate by: + +1. Generate the new token. +2. Update the secret. +3. Revoke the old token. + +There's no need to coordinate a rotation window — the release pipeline runs at most a few times per month. + +## Required repo ruleset bypass + +The `main` branch is protected by the repository ruleset `main-protection` (id `18060803` on this repo), which requires changes to be made through a pull request. The release pipeline (`prerelease.yml` and `promote.yml`) commits `package.json` directly to `main` because the version bump has to land before the tag is pushed and the build runs. + +To allow that direct push, the ruleset has two bypass actors: + +- **`EtienneLescot`** (id `215859519`) — so manual pushes from the maintainer's local checkout work. +- **`github-actions[bot]`** (id `41898282`) — so the workflow's `GITHUB_TOKEN` push (the default `actions/checkout@v4` auth) is also accepted. + +## Required repo ruleset bypass and PR flow + +The `main` branch is protected by the repository ruleset `main-protection` (id `18060803` on this repo). It enforces: + +- `deletion` — branches can't be deleted +- `non_fast_forward` — no force pushes +- `required_linear_history` — fast-forward only +- `pull_request` — 1 approving review + code owner review, only rebase merge allowed (`merge` and `squash` are disabled at the repo level) + +The release pipeline (`prerelease.yml` and `promote.yml`) cannot bypass this directly because: + +- The org policy disables `GITHUB_TOKEN` write permissions (`Allow GitHub Actions to create and approve pull requests` is OFF at the org level), so `GITHUB_TOKEN` cannot create the bump PR. +- Fine-grained PATs do not satisfy ruleset bypass actors, so a PAT-driven direct push is rejected with `GH013`. + +So the workflow: + +1. Pushes the bump commit to a `release/vX.Y.Z` branch using the PAT (no rule check on non-main branches). +2. Opens the PR using the PAT (`gh pr create` with `GH_TOKEN=$OPENSCREEN_RELEASE_TOKEN`). +3. Rebase-merges the PR using the PAT. EtienneLescot is a ruleset bypass actor with `bypass_mode: "always"`, so the `pull_request` review requirement is skipped for this merge. + +The ruleset has two bypass actors: + +- **`EtienneLescot`** (id `215859519`) — so the PAT-driven PR merge satisfies the `pull_request` rule. +- **`github-actions[bot]`** (id `41898282`) — added defensively, though `GITHUB_TOKEN`-driven operations are blocked by the org policy regardless. + +To confirm the bypass list: + +```bash +gh api /repos/getopenscreen/openscreen/rulesets/18060803 --jq '.bypass_actors' +# Expect both 215859519 and 41898282 with bypass_mode "always". +``` + +## Required for Discord announcements + +### `DISCORD_BOT_TOKEN` + +Bot token from a Discord application added to the OpenScreen Discord server with the `bot` scope and at minimum: + +- `Send Messages` in any text channels where the bot posts +- `Create Public Threads` in the forum channels (for the release announce script) +- `Send Messages in Threads` so the first message in a new thread goes through +- `Manage Messages` if you want the roadmap-sync workflow to pin its message +- `Read Message History` (usually default) + +Stored as a repository secret. + +### `DISCORD_RC_TESTING_CHANNEL_ID` + +Snowflake ID of the Discord channel where release candidates are announced. Can be a regular text channel or a forum channel — the `discord-release-announce.mjs` script auto-detects the type: + +- **Text channel** (`type=0`): posts the announcement as a regular message. +- **Forum channel** (`type=15` or `16`): creates a new thread with the announcement as the first message. One thread per release, named like `v1.5.1-rc.1 RC — testing`. + +Set as a **repository variable** (not a secret — it's not sensitive): + +```bash +gh variable set DISCORD_RC_TESTING_CHANNEL_ID --body "1521416826146263051" --repo getopenscreen/openscreen +``` + +### `DISCORD_RELEASE_CHANNEL_ID` + +Same pattern as above, for the stable release announcement channel. + +```bash +gh variable set DISCORD_RELEASE_CHANNEL_ID --body "" --repo getopenscreen/openscreen +``` + +### `DISCORD_ROADMAP_CHANNEL_ID` and `DISCORD_ROADMAP_MESSAGE_ID` + +Used by `discord-roadmap-sync.yml` to keep the pinned roadmap message in sync. Repository variables. + +## Tier 3 package registries + +Each external registry has its own credential set. See the per-workflow README comments at the top of these files: + +- `.github/workflows/update-homebrew-cask.yml` — `HOMEBREW_TAP_TOKEN`, `HOMEBREW_TAP_OWNER`, `HOMEBREW_TAP_REPO`, `HOMEBREW_CASK_NAME` +- `.github/workflows/publish-winget.yml` — `WINGET_ACC_TOKEN`, `WINGET_IDENTIFIER` +- `.github/workflows/bump-nix-package.yml` — uses `GITHUB_TOKEN` (no extra secret required) +- `.github/workflows/aur-publish.yml` — `AUR_SSH_PRIVATE_KEY`, `AUR_KNOWN_HOSTS`, `AUR_PACKAGE_NAME` + +All four already gate on `!prerelease`, so a `vX.Y.Z-rc.N` tag will not push to homebrew/winget/nix/aur. + +## Apple notarization + +`build.yml` skips notarization when the tag contains a `-` (i.e. any pre-release), so the macOS secrets below are only consulted for stable releases: + +- `MAC_CERTIFICATE_P12` (base64 of the Developer ID Application `.p12`) +- `MAC_CERTIFICATE_PASSWORD` +- `MAC_CSC_NAME` +- `APPLE_ID` +- `APPLE_TEAM_ID` +- `APPLE_APP_SPECIFIC_PASSWORD` + +If any of these is missing, the build produces an **unsigned** DMG without notarization. This is the expected behavior for forks and CI debug runs. The release pipeline still works; the macOS DMG will trigger a Gatekeeper warning on first install. \ No newline at end of file diff --git a/docs/testing/macos-native-cursor.md b/docs/testing/macos-native-cursor.md new file mode 100644 index 0000000000..b79064f8cb --- /dev/null +++ b/docs/testing/macos-native-cursor.md @@ -0,0 +1,183 @@ +# macOS native cursor test pipeline + +This document covers manual and diagnostic testing for macOS native cursor capture — the path that records real system cursor bitmaps via `NSCursor.currentSystem` and surfaces them through the OpenScreen editor and export pipeline. + +## How the macOS cursor helper works + +The helper binary (`openscreen-macos-cursor-helper`) runs as a child process of Electron during recording. It: + +- polls `NSCursor.currentSystem` at the configured sample interval +- converts each cursor image to PNG and computes a SHA-256 content hash as a stable asset id +- emits the full base64 bitmap payload **once** per unique cursor shape per session; subsequent samples carry only the `assetId` so stdout stays small +- tracks left-button down/up events via `CGEventTap` and tags each sample with `interactionType` +- uses the Accessibility API to detect `text` and `pointer` affordances (link/button/input roles) when Accessibility is granted; these shapes use the bundled high-quality SVG replacements instead of the raw bitmap + +Each sample line is newline-delimited JSON: + +```json +{ "type": "ready", "timestampMs": 1234567890, "accessibilityTrusted": true, "mouseTapReady": true } +{ "type": "sample", "timestampMs": 1234567891, "assetId": "a7472...", "asset": { "id": "a7472...", "imageDataUrl": "data:image/png;base64,...", "width": 64, "height": 64, "hotspotX": 16, "hotspotY": 16, "scaleFactor": 2.0 }, "cursorType": null, "leftButtonDown": false, "leftButtonPressed": false, "leftButtonReleased": false } +{ "type": "sample", "timestampMs": 1234567924, "assetId": "a7472...", "cursorType": null, "leftButtonDown": false, "leftButtonPressed": false, "leftButtonReleased": false } +``` + +`asset` is present only the first time a given `assetId` appears. The TypeScript session (`MacNativeCursorRecordingSession`) collects unique assets into a map and sets `provider: "native"` in the final `CursorRecordingData` when at least one bitmap was captured. + +## Build the helper + +```bash +npm run build:native:mac +``` + +This builds both Swift helpers (`openscreen-screencapturekit-helper` and `openscreen-macos-cursor-helper`) and copies them to: + +- `electron/native/screencapturekit/build/` — used by the local dev server +- `electron/native/bin/darwin-arm64/` or `darwin-x64/` — used by packaged builds + +Requires Xcode (not just Command Line Tools). If you see a build error about missing SDK metadata, run: + +```bash +sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer +sudo xcodebuild -license accept +``` + +## Smoke-test the helper directly + +You can run the cursor helper standalone to inspect its raw output before involving the full app: + +```bash +BIN=electron/native/screencapturekit/build/openscreen-macos-cursor-helper +("$BIN" '{"sampleIntervalMs":100}' & PID=$!; sleep 2; kill $PID) | head -20 +``` + +Expected first line: + +```json +{"type":"ready","mouseTapReady":true,"accessibilityTrusted":false,"timestampMs":...} +``` + +`accessibilityTrusted: false` is normal in dev/unsigned builds. It means text/pointer affordance detection is disabled; native bitmap capture still works. + +Expected sample lines: + +```json +{"type":"sample","assetId":"a7472...","asset":{"id":"a7472...","imageDataUrl":"data:image/png;base64,...","width":64,"height":64,"hotspotX":26,"hotspotY":16,"scaleFactor":2.0},...} +{"type":"sample","assetId":"a7472...",...} +``` + +Move the cursor over a text input while the helper is running and check that a new `assetId` appears with a different bitmap (if Accessibility is granted — see below). + +## Point the app at a custom helper binary + +```bash +export OPENSCREEN_MAC_CURSOR_HELPER_EXE=/path/to/openscreen-macos-cursor-helper +npm run dev +``` + +## macOS permissions + +Two separate permissions are needed: + +| Permission | What it enables | Where to grant | +|---|---|---| +| Screen Recording | ScreenCaptureKit video capture | System Settings → Privacy & Security → Screen & System Audio Recording → Electron ✅ | +| Accessibility | `text` / `pointer` cursor type detection (affordance hints) | System Settings → Privacy & Security → Accessibility → Electron ✅ | + +**Screen Recording** is required to record. Without it the recording never starts. + +**Accessibility** is optional. Without it, `cursorType` will always be `null` and all cursors render from their captured bitmaps (no SVG substitution). This is the expected fallback and does not degrade cursor quality for non-text/pointer shapes. + +After granting either permission in System Settings, **fully quit and relaunch** the dev server — `getMediaAccessStatus` caches the result per-process. + +## Manual test checklist + +### P0 — core bitmap capture + +- [ ] Record a short clip. Open the editor. Confirm the default arrow cursor is the real system arrow (not the bundled SVG approximation). +- [ ] Record while hovering over a web browser. Confirm custom-CSS cursors (e.g. `cursor: grab`, `cursor: crosshair`) appear as their actual shapes. +- [ ] Export to MP4. Confirm the cursor renders correctly in the exported video. +- [ ] Export to GIF. Same check. + +### P1 — affordance substitution (requires Accessibility) + +- [ ] Grant Accessibility permission and restart the app. +- [ ] Record hovering over a text input field. Confirm the text I-beam uses the bundled SVG version (prettier than the system bitmap). +- [ ] Record hovering over a link/button. Confirm the pointer hand uses the bundled SVG. + +### P1 — hotspot alignment (Retina) + +- [ ] On a Retina display, record a precise click on a small button. In the editor, confirm the cursor tip aligns with the actual click point. The helper reports `scaleFactor: 2.0`; the renderer divides pixel dimensions and hotspot by this value to recover point sizes. + +### P1 — click detection + +- [ ] Record several left-clicks. In the editor, confirm the click-bounce animation fires on each click. +- [ ] Confirm `interactionType: "click"` and `"mouseup"` events are present in the recording session sidecar (`cursorRecordingData` inside `.cursor.json`). + +### P2 — graceful degradation + +- [ ] Remove **both** build-output copies of the helper binary and start a recording. The session should succeed with `provider: "none"` (position-only telemetry, default arrow rendered). Restore both binaries afterward. + ```bash + ARCH=$([ "$(uname -m)" = "arm64" ] && echo darwin-arm64 || echo darwin-x64) + mv electron/native/screencapturekit/build/openscreen-macos-cursor-helper /tmp/cursor-helper-build + mv electron/native/bin/$ARCH/openscreen-macos-cursor-helper /tmp/cursor-helper-bin + # ... start recording, then restore: + mv /tmp/cursor-helper-bin electron/native/bin/$ARCH/openscreen-macos-cursor-helper + mv /tmp/cursor-helper-build electron/native/screencapturekit/build/openscreen-macos-cursor-helper + ``` +- [ ] Revoke Accessibility. Confirm recording still works and cursors render from bitmaps (no SVG substitution). + +### P2 — multi-display + +- [ ] Move the cursor to a secondary display during recording. Confirm the cursor clips to the canvas edge rather than snapping invisible on fast swipes. Confirm it hides after ≈100 ms of sustained out-of-bounds movement. + +### P2 — long recording memory + +- [ ] Record for 3–5 minutes while switching between many apps (browser, terminal, editor). The helper should not grow in memory because each iteration drains Cocoa objects via `autoreleasepool`. Check `Activity Monitor` → `openscreen-macos-cursor-helper` RSS stays flat after the first few seconds. + +## What a healthy recording looks like + +Inspect the cursor sidecar file written alongside the recorded video (`.cursor.json`). For a recording saved to `/tmp/rec.mp4`, the sidecar is `/tmp/rec.mp4.cursor.json`: + +```json +{ + "version": 2, + "provider": "native", + "assets": [ + { "id": "a7472...", "platform": "darwin", "imageDataUrl": "data:image/png;base64,...", "width": 64, "height": 64, "hotspotX": 26.0, "hotspotY": 16.0, "scaleFactor": 2.0 } + ], + "samples": [ + { "timeMs": 0, "cx": 0.42, "cy": 0.38, "visible": true, "assetId": "a7472...", "interactionType": "move" }, + ... + ] +} +``` + +`provider: "native"` and a non-empty `assets` array confirm bitmap capture is active. If you see `provider: "none"` and `assets: []`, the helper was not found or exited before `ready`. + +## Native macOS capture backend + +The app routes macOS recordings through the ScreenCaptureKit helper (`openscreen-screencapturekit-helper`) when it is available, so the real system cursor is excluded from the video frame. The cursor position and bitmap are captured separately by the cursor helper and composited in the editor and export pipeline. + +Current native availability rules: + +- macOS 13 (Ventura) or newer +- `openscreen-screencapturekit-helper` binary is present +- Screen Recording permission is granted + +Build both helpers locally: + +```bash +npm run build:native:mac +``` + +For local diagnostics with a custom helper binary, use the environment override: + +```bash +export OPENSCREEN_MAC_CURSOR_HELPER_EXE=/path/to/openscreen-macos-cursor-helper +npm run dev +``` + +## Known limitations + +- **Intel (x86\_64) Macs**: the distributed helper is built for `darwin-arm64`. On Intel Macs, you need to build from source with `npm run build:native:mac` on the target machine. +- **Accessibility permission in unsigned/dev builds**: `getMediaAccessStatus("accessibility")` may not reflect the toggle state for unsigned Electron in dev mode. The helper will always probe and report `accessibilityTrusted` in its `ready` event — use that as the authoritative signal. +- **App-defined custom cursors (CGS layer)**: `NSCursor.currentSystem` captures the active AppKit cursor. Cursors set at the CoreGraphics/CGS layer by some games or GPU-accelerated apps may not be visible here. This is a known macOS API limitation. diff --git a/electron-builder.json5 b/electron-builder.json5 index 8ad4a80eb9..0ff1453834 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -1,15 +1,20 @@ // @see - https://www.electron.build/configuration/configuration { "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json", - "appId": "com.siddharthvaddem.openscreen", + "appId": "com.etiennelescot.openscreen", "asar": true, // .node binaries can't be dlopen'd from inside an asar — must live unpacked. "asarUnpack": [ "**/*.node" ], "productName": "Openscreen", + // Fetch the auto-caption model + ORT wasm into caption-assets/ before packaging (idempotent). + "beforePack": "scripts/before-pack.cjs", "npmRebuild": true, - "buildDependenciesFromSource": true, + // sharp ships ABI-stable (napi) prebuilt binaries with bundled libvips. Building it from source + // needs a system libvips we don't provide and breaks on CI/local ("vips-cpp.42 not found"), so we + // let electron-builder use the prebuilt instead of recompiling. + "buildDependenciesFromSource": false, "compression": "normal", "directories": { "output": "release/${version}" @@ -24,12 +29,20 @@ "!CONTRIBUTING.md", "!LICENSE" ], - // Asset layout contract: "wallpapers/" under resourcesPath must align with - // assetBaseDir in electron/preload.ts (packaged branch). + // Asset layout contract: "wallpapers/" and "cursors/" under resourcesPath must + // align with assetBaseDir in electron/preload.ts (packaged branch). "extraResources": [ { "from": "public/wallpapers", "to": "wallpapers" + }, + { + "from": "public/cursors", + "to": "cursors" + }, + { + "from": "caption-assets", + "to": "caption-assets" } ], @@ -61,24 +74,26 @@ "NSCameraUseContinuityCameraDeviceType": true } }, - "linux": { - "target": [ - "AppImage", - "deb", - "pacman" - ], - "icon": "icons/icons/png", - "artifactName": "${productName}-Linux-${version}.${ext}", - "category": "AudioVideo" - }, - "win": { - "target": [ - "nsis" - ], - "icon": "icons/icons/win/icon.ico", - "extraResources": [ - { - "from": "electron/native/bin", + "linux": { + "target": [ + "AppImage", + "deb", + "pacman" + ], + "icon": "icons/icons/png", + "artifactName": "${productName}-Linux-${version}.${ext}", + "maintainer": "Etienne Lescot ", + "category": "AudioVideo" + }, + "win": { + "target": [ + "nsis" + ], + "icon": "icons/icons/win/icon.ico", + "artifactName": "${productName}.Setup.${version}.${ext}", + "extraResources": [ + { + "from": "electron/native/bin", "to": "electron/native/bin", "filter": ["win32-*/*"] } diff --git a/electron/diagnostics/main-log-buffer.test.ts b/electron/diagnostics/main-log-buffer.test.ts new file mode 100644 index 0000000000..b835118267 --- /dev/null +++ b/electron/diagnostics/main-log-buffer.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { MainLogBuffer } from "./main-log-buffer"; + +describe("MainLogBuffer", () => { + const buffers: MainLogBuffer[] = []; + + function make(capacity: number) { + const b = new MainLogBuffer(capacity); + buffers.push(b); + return b; + } + + afterEach(() => { + for (const b of buffers) b.uninstall(); + buffers.length = 0; + }); + + it("captures every console level and routes to original", () => { + const buf = make(10); + const captured: string[] = []; + const original = { + log: (...args: unknown[]) => captured.push(`log:${args.join(",")}`), + info: (...args: unknown[]) => captured.push(`info:${args.join(",")}`), + warn: (...args: unknown[]) => captured.push(`warn:${args.join(",")}`), + error: (...args: unknown[]) => captured.push(`error:${args.join(",")}`), + }; + Object.assign(console, original); + buf.install(); + console.log("hello"); + console.info("world"); + console.warn("watch"); + console.error("bad"); + const snap = buf.snapshot(); + expect(snap.map((e) => e.level)).toEqual(["log", "info", "warn", "error"]); + expect(snap.map((e) => e.text)).toEqual(["hello", "world", "watch", "bad"]); + expect(captured).toEqual(["log:hello", "info:world", "warn:watch", "error:bad"]); + }); + + it("stringifies non-string args", () => { + const buf = make(5); + buf.install(); + console.info({ a: 1 }); + const snap = buf.snapshot(); + expect(snap[0].text).toBe('{"a":1}'); + }); + + it("drops oldest entries past capacity", () => { + const buf = make(3); + buf.install(); + for (let i = 0; i < 5; i += 1) console.info(`line ${i}`); + const snap = buf.snapshot(); + expect(snap.map((e) => e.text)).toEqual(["line 2", "line 3", "line 4"]); + }); + + it("uninstall restores originals", () => { + const buf = make(5); + buf.install(); + console.info("captured"); + buf.uninstall(); + console.info("after-uninstall"); + const snap = buf.snapshot(); + expect(snap.map((e) => e.text)).toEqual(["captured"]); + }); + + it("clear empties the buffer", () => { + const buf = make(5); + buf.install(); + console.info("one"); + console.info("two"); + buf.clear(); + expect(buf.snapshot()).toEqual([]); + }); + + it("install is idempotent", () => { + const buf = make(5); + buf.install(); + buf.install(); + console.info("once"); + const snap = buf.snapshot(); + expect(snap.map((e) => e.text)).toEqual(["once"]); + }); +}); diff --git a/electron/diagnostics/main-log-buffer.ts b/electron/diagnostics/main-log-buffer.ts new file mode 100644 index 0000000000..ba12ffefec --- /dev/null +++ b/electron/diagnostics/main-log-buffer.ts @@ -0,0 +1,102 @@ +/** + * Ring buffer for main-process console output. + * + * Captures the last `capacity` lines written via console.info / console.warn / + * console.error / console.log into a single in-memory buffer. Disabled by + * default — install only when verbose diagnostics are wanted, e.g. when + * OPENSCREEN_DIAGNOSTIC=1 is set or when a developer wants a more complete + * "Save Diagnostics" payload for an upstream bug report. + * + * Cost when enabled: one array.push + occasional shift per console call, + * negligible against the rest of the app. Cost when disabled: zero, the + * original console methods are kept untouched. + */ + +const DEFAULT_CAPACITY = 500; + +export interface MainLogEntry { + timestampMs: number; + level: "info" | "warn" | "error" | "log"; + text: string; +} + +export class MainLogBuffer { + private readonly capacity: number; + private readonly entries: MainLogEntry[] = []; + private installed = false; + private readonly originals: Partial< + Record<"log" | "info" | "warn" | "error", (...args: unknown[]) => void> + > = {}; + + constructor(capacity = DEFAULT_CAPACITY) { + this.capacity = Math.max(1, capacity); + } + + install(): void { + if (this.installed) return; + this.installed = true; + const console_ = console as unknown as Record< + "log" | "info" | "warn" | "error", + (...args: unknown[]) => void + >; + for (const level of ["log", "info", "warn", "error"] as const) { + this.originals[level] = console_[level].bind(console); + console_[level] = (...args: unknown[]) => { + this.push(level, args); + this.originals[level]?.(...args); + }; + } + } + + uninstall(): void { + if (!this.installed) return; + this.installed = false; + const console_ = console as unknown as Record< + "log" | "info" | "warn" | "error", + (...args: unknown[]) => void + >; + for (const level of ["log", "info", "warn", "error"] as const) { + if (this.originals[level]) { + console_[level] = this.originals[level] as (...args: unknown[]) => void; + } + } + this.originals.log = undefined; + this.originals.info = undefined; + this.originals.warn = undefined; + this.originals.error = undefined; + } + + snapshot(): MainLogEntry[] { + return this.entries.slice(); + } + + clear(): void { + this.entries.length = 0; + } + + private push(level: MainLogEntry["level"], args: unknown[]): void { + const text = args + .map((arg) => { + if (typeof arg === "string") return arg; + try { + return JSON.stringify(arg); + } catch { + return String(arg); + } + }) + .join(" "); + this.entries.push({ timestampMs: Date.now(), level, text }); + if (this.entries.length > this.capacity) { + this.entries.splice(0, this.entries.length - this.capacity); + } + } +} + +export const mainLogBuffer = new MainLogBuffer(); + +export function isDiagnosticModeEnabled(): boolean { + const raw = process.env.OPENSCREEN_DIAGNOSTIC; + if (!raw) return false; + const lowered = raw.trim().toLowerCase(); + return lowered === "1" || lowered === "true" || lowered === "yes"; +} diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index ac29d45af0..b7314ea7c2 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -41,8 +41,14 @@ interface Window { error?: string; }; }>; + openNotes: () => Promise<{ + opened: boolean; + reason?: string; + }>; selectSource: (source: ProcessedDesktopSource) => Promise; getSelectedSource: () => Promise; + onSelectedSourceChanged: (callback: (source: ProcessedDesktopSource) => void) => () => void; + onSourceSelectorClosed: (callback: () => void) => () => void; requestCameraAccess: () => Promise<{ success: boolean; granted: boolean; @@ -211,6 +217,25 @@ interface Window { message?: string; error?: string; }>; + getReadableFileInfo: (filePath: string) => Promise<{ + success: boolean; + size?: number; + mtimeMs?: number; + path?: string; + message?: string; + error?: string; + }>; + readFileChunk: ( + filePath: string, + offset: number, + length: number, + ) => Promise<{ + success: boolean; + data?: ArrayBuffer; + bytesRead?: number; + message?: string; + error?: string; + }>; preparePreviewAudioTrack: (filePath: string) => Promise<{ success: boolean; path?: string | null; @@ -229,7 +254,7 @@ interface Window { canceled?: boolean; error?: string; }>; - loadProjectFile: () => Promise<{ + loadProjectFile: (projectFolder?: string) => Promise<{ success: boolean; path?: string; project?: unknown; @@ -275,6 +300,7 @@ interface Window { hudOverlayClose: () => void; setHudOverlayIgnoreMouseEvents: (ignore: boolean) => void; moveHudOverlayBy: (deltaX: number, deltaY: number) => void; + setHudOverlaySize: (width: number, height: number) => void; showCountdownOverlay: (value: number, runId: number) => Promise; setCountdownOverlayValue: (value: number, runId: number) => Promise; hideCountdownOverlay: (runId: number) => Promise; diff --git a/electron/globalShortcut.ts b/electron/globalShortcut.ts index f1b046a34b..2765bad220 100644 --- a/electron/globalShortcut.ts +++ b/electron/globalShortcut.ts @@ -41,16 +41,14 @@ let currentAccelerator: string | null = null; export function registerOpenAppShortcut(binding: ShortcutBinding, onTrigger: () => void): boolean { const accelerator = bindingToAccelerator(binding); - // Same shortcut already registered, nothing to do if (accelerator === currentAccelerator) { return true; } - // Try to register new shortcut first (before unregistering old one) + // Register the new shortcut before unregistering the old, so a failure leaves the old binding intact const success = globalShortcut.register(accelerator, onTrigger); if (success) { - // Only unregister old shortcut after new one succeeds if (currentAccelerator) { globalShortcut.unregister(currentAccelerator); } diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 15a6539a70..ae8f5525d9 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -5,7 +5,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import type { DesktopCapturerSource } from "electron"; +import type { DesktopCapturerSource, Rectangle } from "electron"; import { app, BrowserWindow, @@ -35,6 +35,7 @@ import type { ProjectFileResult, ProjectPathResult, } from "../../src/native/contracts"; +import { mainLogBuffer } from "../diagnostics/main-log-buffer"; import { mainT } from "../i18n"; import { RECORDINGS_DIR } from "../main"; import { createCursorRecordingSession } from "../native-bridge/cursor/recording/factory"; @@ -62,10 +63,7 @@ const ALLOWED_IMPORT_VIDEO_EXTENSIONS = new Set([ const PREVIEW_AUDIO_DIR = path.join(app.getPath("userData"), "preview-audio"); const nativeMacCaptureEvents = new EventEmitter(); -/** - * Paths explicitly approved by the user via file picker dialogs or project loads. - * These are added at runtime when the user selects files from outside the default directories. - */ +// Paths the user approved via file picker or project load (i.e. outside the default dirs). const approvedPaths = new Set(); function approveFilePath(filePath: string): void { @@ -101,10 +99,7 @@ function resolveApprovedVideoPath(videoPath?: string | null): string | null { return normalizedPath; } -/** - * Helper function to build dialog options with a parent window only when it's valid. - * This prevents passing stale or destroyed BrowserWindow references to dialog calls. - */ +// Attach the parent window only when valid, to avoid passing a destroyed BrowserWindow to dialogs. function buildDialogOptions( baseOptions: T, parentWindow: BrowserWindow | null, @@ -233,9 +228,8 @@ async function approveReadableVideoPath( return null; } - // When called with trustedDirs (e.g. from project load), only auto-approve - // paths within those directories. This prevents malicious project files from - // approving reads to arbitrary filesystem locations. + // With trustedDirs (e.g. project load), only auto-approve paths inside them so a + // malicious project file can't approve reads to arbitrary locations. if (trustedDirs) { const resolved = path.resolve(normalizedPath); const withinTrusted = trustedDirs.some((dir) => isPathWithinDir(resolved, dir)); @@ -282,11 +276,9 @@ function isValidDurationMs(value: number | undefined): value is number { } /** - * Finalize a single recording file: if it was streamed to disk, flush and close - * the stream; otherwise (a short recording, or the stream failed to open and the - * renderer fell back to in-memory buffering) write the buffered bytes. Returns - * whether the file was streamed, which the caller uses to decide whether the - * WebM duration needs patching on disk. + * Finalize one recording file: flush/close the stream if it was streamed, else write + * the buffered bytes (short recording or stream failed to open). Returns whether it was + * streamed, so the caller knows if the WebM duration needs patching on disk. */ async function finalizeRecordingFile( registry: RecordingStreamRegistry, @@ -322,8 +314,8 @@ async function getApprovedProjectSession( return null; } - // Only auto-approve media paths within the project's directory or RECORDINGS_DIR. - // This prevents crafted project files from approving reads to arbitrary locations. + // Only auto-approve media within the project's dir or RECORDINGS_DIR, so a crafted + // project file can't approve reads to arbitrary locations. const trustedDirs = [RECORDINGS_DIR]; if (projectFilePath) { trustedDirs.push(path.dirname(path.resolve(projectFilePath))); @@ -366,10 +358,7 @@ let lastEnumeratedSources = new Map(); let currentProjectPath: string | null = null; let currentRecordingSession: RecordingSession | null = null; -/** - * Returns the cached DesktopCapturerSource set when the user picked a source. - * Used by setDisplayMediaRequestHandler in main.ts for cursor-free capture. - */ +// Cached source from the user's pick. Used by setDisplayMediaRequestHandler in main.ts for cursor-free capture. export function getSelectedDesktopSource(): DesktopCapturerSource | null { return selectedDesktopSource; } @@ -424,7 +413,7 @@ let nativeWindowsCursorRecordingStartMs = 0; let nativeWindowsPauseStartedAtMs: number | null = null; let nativeWindowsPauseRanges: Array<{ startMs: number; endMs: number }> = []; let nativeWindowsIsPaused = false; -const NATIVE_WINDOWS_CAPTURE_STOP_TIMEOUT_MS = 15_000; +const NATIVE_WINDOWS_CAPTURE_STOP_TIMEOUT_MS = 60_000; let nativeMacCaptureProcess: ChildProcessWithoutNullStreams | null = null; let nativeMacCaptureOutput = ""; let nativeMacCaptureTargetPath: string | null = null; @@ -435,6 +424,8 @@ let nativeMacCursorRecordingStartMs = 0; let nativeMacPauseStartedAtMs: number | null = null; let nativeMacPauseRanges: Array<{ startMs: number; endMs: number }> = []; let nativeMacIsPaused = false; +// Global frame of the region captured by the SCK helper (see getSelectedSourceBounds). +let activeMacCaptureBounds: Rectangle | null = null; function normalizeCursorSample(sample: unknown): CursorRecordingSample | null { if (!sample || typeof sample !== "object") { @@ -584,6 +575,14 @@ function resolveAssetBasePath() { } function getSelectedSourceBounds() { + // Single-window capture records only the window's region, not the whole display. + // Normalizing the cursor against display bounds leaves a fixed offset in the export, + // so prefer the helper-reported window frame when capturing a window. + const isWindowSource = selectedSource?.id?.startsWith("window:") === true; + if (isWindowSource && activeMacCaptureBounds) { + return activeMacCaptureBounds; + } + const cursor = screen.getCursorScreenPoint(); const sourceDisplayId = Number(selectedSource?.display_id); const sourceDisplay = Number.isFinite(sourceDisplayId) @@ -1050,11 +1049,19 @@ function tryParseNativeHelperEvent(line: string) { } } +function dispatchNativeMacHelperEvent(event: Record) { + const bounds = event.captureBounds as Rectangle | undefined; + if (bounds && bounds.width > 0 && bounds.height > 0) { + activeMacCaptureBounds = bounds; + } + nativeMacCaptureEvents.emit("helper-event", event); +} + function inspectNativeMacCaptureOutput() { for (const line of nativeMacCaptureOutput.split(/\r?\n/)) { const event = tryParseNativeHelperEvent(line.trim()); if (event) { - nativeMacCaptureEvents.emit("helper-event", event); + dispatchNativeMacHelperEvent(event); } } } @@ -1070,7 +1077,7 @@ function attachNativeMacCaptureOutputDrain(proc: ChildProcessWithoutNullStreams) for (const line of lines) { const event = tryParseNativeHelperEvent(line.trim()); if (event) { - nativeMacCaptureEvents.emit("helper-event", event); + dispatchNativeMacHelperEvent(event); } } }; @@ -1273,8 +1280,10 @@ export function registerIpcHandlers( createEditorWindow: () => void, createSourceSelectorWindow: () => BrowserWindow, createCountdownOverlayWindow: () => BrowserWindow, + createNotesWindowWrapper: () => BrowserWindow, getMainWindow: () => BrowserWindow | null, getSourceSelectorWindow: () => BrowserWindow | null, + getNotesWindow: () => BrowserWindow | null, getCountdownOverlayWindow?: () => BrowserWindow | null, onRecordingStateChange?: (recording: boolean, sourceName: string) => void, _switchToHud?: () => void, @@ -1290,7 +1299,7 @@ export function registerIpcHandlers( return { success: true, granted: true, status }; } - // Screen recording has no askForMediaAccess equivalent. Trigger the + // Screen recording has no askForMediaAccess equivalent, so trigger the // TCC prompt without opening OpenScreen's source selector above it. if (status === "not-determined") { const mainWin = getMainWindow(); @@ -1348,6 +1357,10 @@ export function registerIpcHandlers( selectedDesktopSource = null; } } + const mainWin = getMainWindow(); + if (mainWin && !mainWin.isDestroyed()) { + mainWin.webContents.send("selected-source-changed", selectedSource); + } const sourceSelectorWin = getSourceSelectorWindow(); if (sourceSelectorWin) { sourceSelectorWin.close(); @@ -1396,7 +1409,36 @@ export function registerIpcHandlers( }); ipcMain.handle("request-native-mac-cursor-access", async () => { - return requestMacCursorAccessibilityAccess(); + const access = await requestMacCursorAccessibilityAccess(); + + // When the editable cursor can't get Accessibility trust, pop a native dialog + // that deep-links to the Accessibility pane (mirrors the Screen Recording flow). + if (process.platform === "darwin" && !access.granted) { + const mainWin = getMainWindow(); + const detail = + access.status === "missing-helper" + ? "The cursor helper couldn't be found in this build, so the editable cursor can't be enabled. Rebuild the native helper (npm run build:native:mac) or switch the HUD cursor mode to system." + : "Allow OpenScreen under System Settings → Privacy & Security → Accessibility, then press record again to start the countdown."; + const messageOptions = { + type: "warning", + buttons: ["Open Accessibility Settings", "Cancel"], + defaultId: 0, + cancelId: 1, + message: "Accessibility access is required for the editable cursor", + detail, + } satisfies Electron.MessageBoxOptions; + const result = + mainWin && !mainWin.isDestroyed() + ? await dialog.showMessageBox(mainWin, messageOptions) + : await dialog.showMessageBox(messageOptions); + if (result.response === 0) { + await shell.openExternal( + "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility", + ); + } + } + + return access; }); ipcMain.handle("open-source-selector", async () => { @@ -1439,11 +1481,21 @@ export function registerIpcHandlers( return { opened: true }; }); + ipcMain.handle("open-notes", async () => { + const notesSelectorWin = getNotesWindow(); + if (notesSelectorWin) { + notesSelectorWin.focus(); + return { opened: true }; + } + + createNotesWindowWrapper(); + return { opened: true }; + }); + ipcMain.handle("switch-to-editor", () => { - // createEditorWindow is createEditorWindowWrapper — it already closes - // the current mainWindow (the HUD) before opening the editor. Closing - // it here too causes a double-close which leaves ghost transparent - // windows and makes the HUD shadow compound on each cycle. + // createEditorWindow already closes the current mainWindow (the HUD) before + // opening the editor. Closing it here too double-closes, leaving ghost + // transparent windows and compounding the HUD shadow each cycle. createEditorWindow(); }); @@ -1463,9 +1515,8 @@ export function registerIpcHandlers( return; } - // Wait for the first frame to be painted before showing the window. - // Showing before ready-to-show produces a black rectangle flash because - // Chromium hasn't rendered any pixels yet. + // Wait for the first frame before showing, else Chromium flashes a black + // rectangle because it hasn't rendered any pixels yet. if (overlayWindow.webContents.isLoading()) { await new Promise((resolve) => { overlayWindow.once("ready-to-show", resolve); @@ -1797,6 +1848,7 @@ export function registerIpcHandlers( nativeMacPauseStartedAtMs = null; nativeMacPauseRanges = []; nativeMacIsPaused = false; + activeMacCaptureBounds = null; const cursorStartTimeMs = Date.now(); if (cursorCaptureMode === "editable-overlay") { @@ -2112,6 +2164,7 @@ export function registerIpcHandlers( nativeMacPauseStartedAtMs = null; nativeMacPauseRanges = []; nativeMacIsPaused = false; + activeMacCaptureBounds = null; const source = selectedSource || { name: "Screen" }; if (onRecordingStateChange) { onRecordingStateChange(false, source.name); @@ -2181,8 +2234,7 @@ export function registerIpcHandlers( ); // On-disk write streams for in-progress recordings, keyed by output file name. - // Chunks are appended as they arrive from ondataavailable so the renderer - // never buffers the full video in memory (the #616 fix). + // Chunks append as they arrive so the renderer never buffers the full video (#616). const recordingStreams = new RecordingStreamRegistry(); registerRecordingStreamHandlers(ipcMain, recordingStreams, resolveRecordingOutputPath); @@ -2225,9 +2277,9 @@ export function registerIpcHandlers( ); } - // Streamed files lack the WebM Duration header (the renderer no longer holds - // the blob to patch). Patch on disk so the editor's seek bar and timeline - // work. Best-effort and independent per file, so the patches run together. + // Streamed files lack the WebM Duration header (renderer no longer holds the + // blob), so patch on disk for the editor's seek bar and timeline. Best-effort, + // independent per file, so they run together. if (isValidDurationMs(payload.durationMs)) { const patches: Promise[] = []; if (screenStreamed) { @@ -2358,9 +2410,8 @@ export function registerIpcHandlers( ? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }] : [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }]; - // Prefer the user's last export folder if it still exists, otherwise fall - // back to ~/Downloads. Validation must happen here because the renderer - // can't stat the filesystem. + // Prefer the user's last export folder if it still exists, else ~/Downloads. + // Validate here because the renderer can't stat the filesystem. let defaultDir = app.getPath("downloads"); if (exportFolder) { try { @@ -2405,8 +2456,8 @@ export function registerIpcHandlers( ipcMain.handle("write-export-to-path", async (_, videoData: ArrayBuffer, filePath: string) => { try { - // Sanity-check the path. The renderer is trusted (contextIsolation is on), - // but a stale state bug shouldn't be able to clobber arbitrary files. + // Sanity-check the path: the renderer is trusted (contextIsolation on), but a + // stale-state bug shouldn't be able to clobber arbitrary files. if (typeof filePath !== "string" || !path.isAbsolute(filePath)) { return { success: false, message: "Invalid path" }; } @@ -2482,14 +2533,13 @@ export function registerIpcHandlers( ipcMain.handle("reveal-in-folder", async (_, filePath: string) => { try { - // shell.showItemInFolder doesn't return a value, it throws on error + // showItemInFolder returns nothing, it throws on error shell.showItemInFolder(filePath); return { success: true }; } catch (error) { console.error(`Error revealing item in folder: ${filePath}`, error); - // Fallback to open the directory if revealing the item fails - // This might happen if the file was moved or deleted after export, - // or if the path is somehow invalid for showItemInFolder + // Fall back to opening the directory if revealing fails (file moved/deleted + // after export, or a path showItemInFolder rejects). try { const openPathResult = await shell.openPath(path.dirname(filePath)); if (openPathResult) { @@ -2530,6 +2580,83 @@ export function registerIpcHandlers( } }); + // Stat an approved video file. Used to decide whether a recording is small + // enough to slurp via read-binary-file, or large enough that it must be + // streamed in chunks (Node's fs.readFile caps a single read at 2 GiB, so any + // recording above that can never be loaded whole — see read-file-chunk). + ipcMain.handle("get-readable-file-info", async (_, filePath: string) => { + try { + const normalizedPath = await approveReadableVideoPath(filePath); + if (!normalizedPath) { + return { + success: false, + message: "File path is not approved or is not a supported video file", + }; + } + + const stat = await fs.stat(normalizedPath); + return { + success: true, + size: stat.size, + mtimeMs: stat.mtimeMs, + path: normalizedPath, + }; + } catch (error) { + console.error("Failed to stat file:", error); + return { + success: false, + message: "Failed to stat file", + error: String(error), + }; + } + }); + + // Cap renderer-requested chunk sizes so a buggy or compromised renderer + // cannot make the main process allocate an arbitrarily large buffer. + const MAX_IPC_CHUNK_BYTES = 64 * 1024 * 1024; + + // Read a byte range [offset, offset+length) from an approved video file. + // Lets the renderer stream a >2 GiB recording into OPFS one chunk at a time + // instead of materialising the whole file in memory, which fs.readFile cannot + // do (2 GiB cap) and a 16 GB machine cannot hold for multi-GB recordings. + ipcMain.handle("read-file-chunk", async (_, filePath: string, offset: number, length: number) => { + try { + const normalizedPath = await approveReadableVideoPath(filePath); + if (!normalizedPath) { + return { + success: false, + message: "File path is not approved or is not a supported video file", + }; + } + if (!Number.isFinite(offset) || offset < 0 || !Number.isFinite(length) || length <= 0) { + return { success: false, message: "Invalid chunk range" }; + } + if (length > MAX_IPC_CHUNK_BYTES) { + return { success: false, message: "Requested chunk size exceeds limit" }; + } + + const handle = await fs.open(normalizedPath, "r"); + try { + const buffer = Buffer.allocUnsafe(length); + const { bytesRead } = await handle.read(buffer, 0, length, offset); + return { + success: true, + data: buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + bytesRead), + bytesRead, + }; + } finally { + await handle.close(); + } + } catch (error) { + console.error("Failed to read file chunk:", error); + return { + success: false, + message: "Failed to read file chunk", + error: String(error), + }; + } + }); + ipcMain.handle("prepare-preview-audio-track", async (_, filePath: string) => { try { return await prepareSupplementalPreviewAudioTrack(filePath); @@ -2622,16 +2749,34 @@ export function registerIpcHandlers( } } - ipcMain.handle("load-project-file", async () => { - return loadProjectFile(); + ipcMain.handle("load-project-file", async (_, projectFolder?: string) => { + return loadProjectFile(projectFolder); }); - async function loadProjectFile(): Promise { + async function loadProjectFile(projectFolder?: string): Promise { try { + // Prefer the user's last opened-project folder if it still exists, else + // RECORDINGS_DIR. Validate here because the renderer can't stat the filesystem. + let defaultDir = RECORDINGS_DIR; + if (projectFolder) { + try { + const stats = await fs.stat(projectFolder); + if (stats.isDirectory()) { + defaultDir = projectFolder; + } + } catch (err) { + // Stat can fail if the folder was moved/deleted (expected) or on a + // permission error (worth surfacing). We fall back either way, but log it. + console.warn( + `Could not access remembered project folder "${projectFolder}", falling back to RECORDINGS_DIR:`, + err, + ); + } + } const dialogOptions = buildDialogOptions( { title: mainT("dialogs", "fileDialogs.openProject"), - defaultPath: RECORDINGS_DIR, + defaultPath: defaultDir, filters: [ { name: mainT("dialogs", "fileDialogs.openscreenProject"), @@ -2692,9 +2837,8 @@ export function registerIpcHandlers( const project = JSON.parse(content); currentProjectPath = filePath; - // Approve session paths; tolerate failures (e.g. video moved outside - // trusted dirs) so the project still loads and the renderer can surface - // a "video not found" error rather than a generic load failure. + // Approve session paths but tolerate failures (e.g. video moved outside trusted + // dirs) so the project still loads and the renderer can show "video not found". let session: import("../../src/lib/recordingSession").RecordingSession | null = null; try { session = await getApprovedProjectSession(project, filePath); @@ -2840,6 +2984,9 @@ export function registerIpcHandlers( if (canceled || !filePath) return { success: false, canceled: true }; + const HELPER_OUTPUT_MAX_BYTES = 64 * 1024; + const tail = (s: string, max: number) => (s.length <= max ? s : s.slice(s.length - max)); + const diagnostic = { timestamp: new Date().toISOString(), appVersion: app.getVersion(), @@ -2855,6 +3002,11 @@ export function registerIpcHandlers( stack: payload.stack, projectState: payload.projectState, recentLogs: payload.logs, + helperOutput: { + windows: tail(nativeWindowsCaptureOutput, HELPER_OUTPUT_MAX_BYTES), + mac: tail(nativeMacCaptureOutput, HELPER_OUTPUT_MAX_BYTES), + }, + mainProcessLogs: mainLogBuffer.snapshot(), }; try { diff --git a/electron/ipc/nativeBridge.ts b/electron/ipc/nativeBridge.ts index 425f93e1a3..2669300a93 100644 --- a/electron/ipc/nativeBridge.ts +++ b/electron/ipc/nativeBridge.ts @@ -25,7 +25,7 @@ export interface NativeBridgeContext { suggestedName?: string, existingProjectPath?: string, ) => Promise; - loadProjectFile: () => Promise; + loadProjectFile: (projectFolder?: string) => Promise; loadCurrentProjectFile: () => Promise; loadProjectFileFromPath: (path: string) => Promise; setCurrentVideoPath: (path: string) => ProjectPathResult | Promise; @@ -164,7 +164,10 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) { ), ); case "loadProjectFile": - return createSuccessResponse(requestId, await projectService.loadProjectFile()); + return createSuccessResponse( + requestId, + await projectService.loadProjectFile(request.payload?.projectFolder), + ); case "loadCurrentProjectFile": return createSuccessResponse( requestId, diff --git a/electron/ipc/recordingStream.ts b/electron/ipc/recordingStream.ts index 3dce5b955f..665ea41941 100644 --- a/electron/ipc/recordingStream.ts +++ b/electron/ipc/recordingStream.ts @@ -3,23 +3,18 @@ import { unlink } from "node:fs/promises"; import type { IpcMain } from "electron"; /** - * Owns the lifecycle of on-disk write streams for in-progress recordings, keyed - * by the recording's output file name. Browser MediaRecorder chunks are appended - * here as they arrive so a long recording never buffers the whole video in the - * renderer (the #616 fix). - * - * The file name is the key because it is the one value the renderer and main - * process already exchange and it is globally unique per recording, so there is - * no derived/offset key to keep in sync across the IPC boundary. + * Owns write streams for in-progress recordings, keyed by output file name. + * MediaRecorder chunks are appended as they arrive so a long recording never + * buffers the whole video in the renderer (#616 fix). File name is the key + * because it's already exchanged across IPC and is unique per recording. */ export class RecordingStreamRegistry { private readonly streams = new Map(); /** - * Open a write stream and resolve only once the OS confirms it is writable. - * Resolving on the `open` event (rather than on `createWriteStream` returning) - * means a bad path or permission error rejects here instead of surfacing as a - * silent chunk drop later, so the renderer's fallback can take over. + * Open a write stream, resolving only on the `open` event so a bad path or + * permission error rejects here instead of becoming a silent chunk drop later, + * letting the renderer's fallback take over. */ async open(fileName: string, filePath: string): Promise { await this.endStream(fileName); @@ -33,9 +28,8 @@ export class RecordingStreamRegistry { resolve(); }); }); - // Keep a listener for the stream's lifetime so a late error logs rather - // than crashing the main process with an unhandled 'error' event. Per-write - // failures still surface through the `append` callback below. + // Keep a lifetime listener so a late error logs instead of crashing the main + // process with an unhandled 'error'. Per-write failures still surface in `append`. ws.on("error", (error) => { console.error(`[recording-stream] ${fileName}:`, error); }); @@ -59,9 +53,8 @@ export class RecordingStreamRegistry { } /** - * Flush and close the stream, keeping the file. Returns whether a stream was - * open — i.e. whether the recording was streamed to disk (true) or needs its - * in-memory buffer written by the caller (false). + * Flush and close the stream, keeping the file. Returns true if a stream was + * open (streamed to disk) or false if the caller still needs to write its buffer. */ async finalize(fileName: string): Promise { const ws = this.streams.get(fileName); @@ -76,9 +69,8 @@ export class RecordingStreamRegistry { } /** - * Close the stream (if any) and delete the partial file. Used when a streamed - * recording is discarded or fails before a successful save, so cancelled runs - * don't leak file descriptors or orphan partial recordings on disk. + * Close the stream (if any) and delete the partial file, so a discarded or + * failed recording doesn't leak descriptors or orphan partial files on disk. */ async discard(fileName: string, filePath: string): Promise { await this.endStream(fileName); diff --git a/electron/main.ts b/electron/main.ts index 14255d5b34..fde69aa6f8 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -12,6 +12,7 @@ import { Tray, } from "electron"; import { ShortcutBinding } from "../src/lib/shortcuts"; +import { isDiagnosticModeEnabled, mainLogBuffer } from "./diagnostics/main-log-buffer"; import { loadAndRegisterGlobalShortcut, registerOpenAppShortcut, @@ -19,24 +20,25 @@ import { } from "./globalShortcut"; import { mainT, setMainLocale } from "./i18n"; import { getSelectedDesktopSource, registerIpcHandlers } from "./ipc/handlers"; +import { acquireStableInstanceLock } from "./singleInstanceLock"; import { createCountdownOverlayWindow, createEditorWindow, createHudOverlayWindow, + createNotesWindow, createSourceSelectorWindow, } from "./windows"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// Use Screen & System Audio Recording permissions instead of CoreAudio Tap API on macOS. -// CoreAudio Tap requires NSAudioCaptureUsageDescription in the parent app's Info.plist, -// which doesn't work when running from a terminal/IDE during development, makes my life easier +// Use Screen & System Audio Recording permissions instead of the CoreAudio Tap API on macOS. +// Tap needs NSAudioCaptureUsageDescription in the parent app's Info.plist, which breaks when +// running from a terminal/IDE during dev. if (process.platform === "darwin") { app.commandLine.appendSwitch("disable-features", "MacCatapLoopbackAudioForScreenShare"); } -// Enable Wayland support for proper screen capture and window management -// on Wayland compositors (Hyprland, GNOME, KDE, etc.) +// Wayland support for screen capture and window management on Wayland compositors. if (process.platform === "linux") { const isWayland = process.env.XDG_SESSION_TYPE === "wayland" || process.env.WAYLAND_DISPLAY !== undefined; @@ -83,6 +85,7 @@ process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL let mainWindow: BrowserWindow | null = null; let sourceSelectorWindow: BrowserWindow | null = null; let countdownOverlayWindow: BrowserWindow | null = null; +let notesWindow: BrowserWindow | null = null; let tray: Tray | null = null; let selectedSourceName = ""; const isMac = process.platform === "darwin"; @@ -93,6 +96,10 @@ const defaultTrayIcon = getTrayIcon("openscreen.png", trayIconSize); const recordingTrayIcon = getTrayIcon("rec-button.png", trayIconSize); function createWindow() { + if (mainWindow && !mainWindow.isDestroyed()) { + return; + } + mainWindow = createHudOverlayWindow(); } @@ -109,6 +116,19 @@ function showMainWindow() { createWindow(); } +const stableInstanceLock = acquireStableInstanceLock(); +const hasElectronSingleInstanceLock = app.requestSingleInstanceLock(); +const hasSingleInstanceLock = Boolean(stableInstanceLock && hasElectronSingleInstanceLock); + +if (hasSingleInstanceLock) { + app.on("second-instance", () => { + showMainWindow(); + }); +} else { + stableInstanceLock?.release(); + app.quit(); +} + function isEditorWindow(window: BrowserWindow) { return window.webContents.getURL().includes("windowType=editor"); } @@ -384,7 +404,7 @@ function createEditorWindowWrapper() { const windowToClose = mainWindow; if (!windowToClose || windowToClose.isDestroyed()) return; - // Ask renderer to show the custom in-app dialog + // Ask renderer to show the in-app close dialog. windowToClose.webContents.send("request-close-confirm"); ipcMain.once("close-confirm-response", (event, choice: "save" | "discard" | "cancel") => { @@ -393,7 +413,7 @@ function createEditorWindowWrapper() { if (!windowToClose || windowToClose.isDestroyed()) return; if (choice === "save") { - // Tell renderer to save the project, then close when done + // Save first, then close when the renderer reports done. windowToClose.webContents.send("request-save-before-close"); ipcMain.once("save-before-close-done", (event, shouldClose: boolean) => { if (event.sender.id !== windowToClose?.webContents.id) return; @@ -412,10 +432,26 @@ function createSourceSelectorWindowWrapper() { sourceSelectorWindow = createSourceSelectorWindow(); sourceSelectorWindow.on("closed", () => { sourceSelectorWindow = null; + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("source-selector-closed"); + } }); return sourceSelectorWindow; } +function createNotesWindowWrapper() { + { + notesWindow = createNotesWindow(); + notesWindow.on("closed", () => { + notesWindow = null; + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("notes-window-closed"); + } + }); + return notesWindow; + } +} + function createCountdownOverlayWindowWrapper() { if (countdownOverlayWindow && !countdownOverlayWindow.isDestroyed()) { return countdownOverlayWindow; @@ -428,16 +464,14 @@ function createCountdownOverlayWindowWrapper() { return countdownOverlayWindow; } -// Closing every window quits the app entirely (tray icon goes too). -// The in-app "Return to Recorder" button covers the editor → HUD round-trip, -// so closing the last window is an explicit "I'm done" signal. +// Closing every window quits the app (tray goes too). The in-app "Return to Recorder" +// button covers the editor-to-HUD round-trip, so closing the last window means "I'm done". app.on("window-all-closed", () => { app.quit(); }); app.on("activate", () => { - // On OS X it's common to re-create a window in the app when the - // dock icon is clicked and there are no other windows open. + // On macOS, re-open a window when the dock icon is clicked and none are open. const hasVisibleWindow = BrowserWindow.getAllWindows().some((window) => { if (window.isDestroyed() || !window.isVisible()) { return false; @@ -454,18 +488,24 @@ app.on("activate", () => { app.on("will-quit", () => { unregisterAllGlobalShortcuts(); + stableInstanceLock?.release(); }); -// Register all IPC handlers when app is ready -app.whenReady().then(async () => { - // Force the app into "regular" activation policy so the Dock icon appears. - // The HUD overlay (transparent + frameless + skipTaskbar) is the first - // window we open, and AppKit otherwise classifies us as an accessory app. +const appReady = hasSingleInstanceLock ? app.whenReady() : null; + +appReady?.then(async () => { + if (isDiagnosticModeEnabled()) { + mainLogBuffer.install(); + console.info("[diagnostic] OPENSCREEN_DIAGNOSTIC=1, capturing console.* into ring buffer"); + } + + // Force "regular" activation policy so the Dock icon appears. The HUD overlay + // (transparent, frameless, skipTaskbar) is the first window, and AppKit would + // otherwise classify us as an accessory app. if (process.platform === "darwin") { app.dock?.show(); } - // Allow microphone/media/screen permission checks session.defaultSession.setPermissionCheckHandler((_webContents, permission) => { const allowed = [ "media", @@ -508,9 +548,8 @@ app.whenReady().then(async () => { { useSystemPicker: false }, ); - // Request microphone permission from macOS. Screen Recording is requested - // lazily from the source-picker action so the system prompt is not hidden - // behind OpenScreen's source selector window. + // Request mic permission now. Screen Recording is requested lazily from the + // source-picker action so its prompt isn't hidden behind the selector window. if (process.platform === "darwin") { const micStatus = systemPreferences.getMediaAccessStatus("microphone"); if (micStatus !== "granted") { @@ -518,7 +557,6 @@ app.whenReady().then(async () => { } } - // Listen for HUD overlay quit event (macOS only) ipcMain.on("hud-overlay-close", () => { app.quit(); }); @@ -536,7 +574,6 @@ app.whenReady().then(async () => { createTray(); updateTrayMenu(); setupApplicationMenu(); - // Ensure recordings directory exists await ensureRecordingsDir(); function switchToHudWrapper() { @@ -553,8 +590,10 @@ app.whenReady().then(async () => { createEditorWindowWrapper, createSourceSelectorWindowWrapper, createCountdownOverlayWindowWrapper, + createNotesWindowWrapper, () => mainWindow, () => sourceSelectorWindow, + () => notesWindow, () => countdownOverlayWindow, (recording: boolean, sourceName: string) => { selectedSourceName = sourceName; diff --git a/electron/native-bridge/cursor/recording/factory.ts b/electron/native-bridge/cursor/recording/factory.ts index 0ba3077888..9a82ffd55c 100644 --- a/electron/native-bridge/cursor/recording/factory.ts +++ b/electron/native-bridge/cursor/recording/factory.ts @@ -36,7 +36,7 @@ export function createCursorRecordingSession( } // Linux: capture cursor positions via Electron's `screen` API on an interval. - // No cursor sprites/assets and no clicks — just position telemetry. + // No cursor sprites/assets and no clicks, just position telemetry. return new TelemetryRecordingSession({ getDisplayBounds: options.getDisplayBounds, maxSamples: options.maxSamples, diff --git a/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts b/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts index 5e09e92981..27a8a870c2 100644 --- a/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts +++ b/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts @@ -193,7 +193,7 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession { private readyTimer: NodeJS.Timeout | null = null; private previousLeftButtonDown = false; private consecutiveOutsideSamples = 0; - // Only hide after this many consecutive out-of-bounds samples (≈100ms at 33ms interval). + // Hide only after this many consecutive out-of-bounds samples (~100ms at 33ms interval). // Fast swipes that briefly exit the display are clipped by clip-path instead of disappearing. private static readonly OUTSIDE_HIDE_THRESHOLD = 3; @@ -211,7 +211,7 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession { systemPreferences.isTrustedAccessibilityClient(true); } catch { // Without Accessibility, text/pointer affordance detection is unavailable; - // cursor bitmaps are still captured natively via NSCursor. + // bitmaps are still captured natively via NSCursor. } const helperPath = findMacCursorHelperPath(); @@ -370,10 +370,9 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession { const normalizedY = (cursor.y - bounds.y) / height; const isOutsideDisplay = normalizedX < 0 || normalizedX > 1 || normalizedY < 0 || normalizedY > 1; - // Fast swipes that briefly exit the display (=THRESHOLD, ~100ms) mark visible=false to + // avoid ghost cursors and motion trails from multi-display movement. if (isOutsideDisplay) { this.consecutiveOutsideSamples++; } else { diff --git a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts index 5c318f0f29..c7a057fea3 100644 --- a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts +++ b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts @@ -22,10 +22,14 @@ function getCursorSamplerCandidates(): string[] { const p = join(app.getAppPath(), ...segs); return app.isPackaged ? p.replace(/\.asar([/\\])/, ".asar.unpacked$1") : p; }; + const resolvePackaged = (...segs: string[]) => { + return app.isPackaged ? join(process.resourcesPath, ...segs) : null; + }; return [ envPath, resolve("electron", "native", "wgc-capture", "build", "cursor-sampler.exe"), resolve("electron", "native", "bin", archTag, "cursor-sampler.exe"), + resolvePackaged("electron", "native", "bin", archTag, "cursor-sampler.exe"), ].filter((c): c is string => Boolean(c)); } @@ -222,10 +226,21 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession { ): NormalizedSample { const bounds = payload.bounds ?? this.options.getDisplayBounds() ?? screen.getPrimaryDisplay().bounds; - const width = Math.max(1, bounds.width); - const height = Math.max(1, bounds.height); - const normalizedX = (payload.x - bounds.x) / width; - const normalizedY = (payload.y - bounds.y) / height; + // The cursor-sampler reports raw x/y in physical screen pixels (Win32 + // GetCursorInfo). `payload.bounds` from the sampler's GetWindowRect is also + // physical, so use it as-is. Bounds from Electron's `screen` API (or the + // fallback for display captures) are in DIPs — convert to physical screen + // coordinates via `dipToScreenRect`, which correctly handles the virtual-screen + // origin across multi-monitor and mixed-DPI setups (a naive + // `bounds.x * scaleFactor` would misplace the origin on non-primary + // displays). + const physicalBounds = payload.bounds != null ? bounds : screen.dipToScreenRect(null, bounds); + const physicalX = physicalBounds.x; + const physicalY = physicalBounds.y; + const width = Math.max(1, physicalBounds.width); + const height = Math.max(1, physicalBounds.height); + const normalizedX = (payload.x - physicalX) / width; + const normalizedY = (payload.y - physicalY) / height; const withinBounds = normalizedX >= 0 && normalizedX <= 1 && normalizedY >= 0 && normalizedY <= 1; const leftButtonDown = payload.leftButtonDown === true; diff --git a/electron/native-bridge/services/projectService.ts b/electron/native-bridge/services/projectService.ts index 9e96aa22d9..0c363cc13a 100644 --- a/electron/native-bridge/services/projectService.ts +++ b/electron/native-bridge/services/projectService.ts @@ -14,7 +14,7 @@ interface ProjectServiceOptions { suggestedName?: string, existingProjectPath?: string, ) => Promise; - loadProjectFile: () => Promise; + loadProjectFile: (projectFolder?: string) => Promise; loadCurrentProjectFile: () => Promise; loadProjectFileFromPath: (path: string) => Promise; setCurrentVideoPath: (path: string) => ProjectPathResult | Promise; @@ -49,8 +49,8 @@ export class ProjectService { return result; } - async loadProjectFile() { - const result = await this.options.loadProjectFile(); + async loadProjectFile(projectFolder?: string) { + const result = await this.options.loadProjectFile(projectFolder); this.getCurrentContext(); return result; } diff --git a/electron/native/README.md b/electron/native/README.md index 59930ba36a..cda6c9f55d 100644 --- a/electron/native/README.md +++ b/electron/native/README.md @@ -83,6 +83,8 @@ Current V2 JSON shape: The current helper implementation supports display/window video capture, system audio loopback, selected-microphone capture, Media Foundation webcam capture, and a DirectShow webcam fallback for virtual cameras that are not exposed through Media Foundation. Webcam frames are currently composed into the primary MP4 as a bottom-right picture-in-picture overlay. Browser `deviceId` values do not always map to Media Foundation symbolic links or WASAPI endpoint IDs, so the renderer passes both browser IDs and user-visible device names. For microphones, the helper tries the requested WASAPI endpoint ID first, then resolves an active capture endpoint by `microphoneDeviceName`, then falls back to the default endpoint. For webcams, Electron resolves a matching DirectShow filter CLSID for the selected label; the helper uses Media Foundation first, then that exact DirectShow filter when the requested camera is absent from Media Foundation. +Encoder diagnostic on sink-writer failure: when `MFCreateSinkWriterFromURL` fails, the helper logs the registered H.264 video encoder MFT count (via `MFTEnumEx`), the registered AAC encoder count when audio was requested, and the hex HRESULT. If no H.264 encoder is registered, it additionally emits the four-bullet actionable error (missing Media Feature Pack / GPU driver registration / empty `HKLM:\SOFTWARE\Microsoft\Windows Media Foundation\Transforms` / reboot). If an H.264 encoder IS registered but the sink writer still failed, it logs a hint pointing at invalid output path, missing MP4 mux, or GPU driver incompatibility. The diagnostic runs only on the failure path — there is no pre-flight gating check, because `MFTEnumEx` and `MFCreateSinkWriterFromURL` can disagree about which H.264 encoders are "available" in non-interactive / Session 0 contexts, and a pre-flight zero-count would block a recording the sink writer can otherwise complete. + Smoke-test the helper with: ```powershell diff --git a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/main.swift b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/main.swift index 14860b03f9..9525b717bd 100644 --- a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/main.swift +++ b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/main.swift @@ -124,6 +124,9 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { let filter: SCContentFilter let width: Int let height: Int + // Global frame (points, top-left origin) of the captured region. Used by the + // renderer to normalize cursor positions into the captured window's space. + let captureFrame: CGRect } private let request: RecordingRequest @@ -143,6 +146,7 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { private var nativeMicrophoneEnabled = false private var outputWidth = 1920 private var outputHeight = 1080 + private var captureFrame = CGRect.zero private let microphoneOutputTypeRawValue = 2 private let hostClock = CMClockGetHostTimeClock() @@ -160,6 +164,7 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { let target = try makeCaptureTarget(from: content) outputWidth = target.width outputHeight = target.height + captureFrame = target.captureFrame let configuration = makeStreamConfiguration() let stream = SCStream(filter: target.filter, configuration: configuration, delegate: self) @@ -178,7 +183,10 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { try setupWriter() self.stream = stream - emit(["event": "ready", "schemaVersion": 1]) + emit([ + "event": "ready", + "schemaVersion": 1, + ]) try await stream.startCapture() } @@ -305,6 +313,7 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { "timestampMs": Int(Date().timeIntervalSince1970 * 1000), "width": outputWidth, "height": outputHeight, + "captureBounds": captureBoundsPayload(), ]) } } @@ -337,6 +346,15 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { } } + private func captureBoundsPayload() -> [String: Double] { + return [ + "x": captureFrame.origin.x, + "y": captureFrame.origin.y, + "width": captureFrame.size.width, + "height": captureFrame.size.height, + ] + } + private func makeCaptureTarget(from content: SCShareableContent) throws -> CaptureTarget { switch request.source.type { case "display": @@ -351,7 +369,8 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { return CaptureTarget( filter: SCContentFilter(display: display, excludingWindows: []), width: clampCaptureDimension(width, fallback: request.video.width), - height: clampCaptureDimension(height, fallback: request.video.height) + height: clampCaptureDimension(height, fallback: request.video.height), + captureFrame: display.frame ) case "window": guard let windowId = request.source.windowId else { @@ -369,7 +388,8 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { return CaptureTarget( filter: SCContentFilter(desktopIndependentWindow: window), width: clampCaptureDimension(width, fallback: request.video.width), - height: clampCaptureDimension(height, fallback: request.video.height) + height: clampCaptureDimension(height, fallback: request.video.height), + captureFrame: window.frame ) default: throw HelperError.invalidSourceType(request.source.type) diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index bb741d33e0..af73b26fce 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -632,8 +632,8 @@ int main(int argc, char* argv[]) { (webcamOutputFrameIndex * 10'000'000ULL) / std::max(1, webcamCapture.fps())); if (!webcamEncoder.writeBgraFrame(webcamFrame, webcamTimestampHns)) { encodeFailed = true; - stopRequested = true; - cv.notify_all(); + control.stopRequested = true; + control.cv.notify_all(); return; } lastWrittenWebcamSequence = latestWebcamSequence; @@ -823,19 +823,34 @@ int main(int argc, char* argv[]) { }); } + const auto stopStart = std::chrono::steady_clock::now(); + auto logStopStep = [&](const char* step) { + const auto ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - stopStart).count(); + std::cerr << "[stop-timing] step=" << step << " elapsed_ms=" << ms << std::endl; + }; + microphoneCapture.stop(); + logStopStep("microphone"); loopbackCapture.stop(); + logStopStep("loopback"); webcamCapture.stop(); + logStopStep("webcam"); if (audioMixer) { audioMixer->stop(); } + logStopStep("audio-mixer"); stopVideoWriter(); + logStopStep("video-writer-join"); session.stop(); + logStopStep("wgc-session-close"); { std::scoped_lock lock(mutex); encoder.finalize(); + logStopStep("encoder-finalize"); if (writeSeparateWebcam) { webcamEncoder.finalize(); + logStopStep("webcam-encoder-finalize"); } } diff --git a/electron/native/wgc-capture/src/mf_encoder.cpp b/electron/native/wgc-capture/src/mf_encoder.cpp index 18bc4cca85..738a29b588 100644 --- a/electron/native/wgc-capture/src/mf_encoder.cpp +++ b/electron/native/wgc-capture/src/mf_encoder.cpp @@ -22,6 +22,107 @@ bool succeeded(HRESULT hr, const char* label) { return false; } +// Count how many Media Foundation Transforms are registered for a given +// (category, output subtype) pair. Caller does not need the activations +// themselves; we just want to know whether at least one is registered so we +// can diagnose "no encoder registered" vs "encoder registered but sink can't +// wire it". +// +// `mediaType` is the encoder's major media type (e.g. video for video +// encoders, audio for audio encoders). It is used as both the input and +// output major type because an encoder's input and output streams share the +// same major type by definition. `outputSubtype` is the encoder's output +// media subtype (e.g. H264 for video encoders, AAC for audio encoders). We do +// not constrain the input subtype because encoders typically accept many +// input subtypes and constraining here would under-count. +UINT32 countRegisteredMfts( + const GUID& category, + const GUID& mediaType, + const GUID& outputSubtype) { + MFT_REGISTER_TYPE_INFO inputType{}; + inputType.guidMajorType = mediaType; + inputType.guidSubtype = GUID_NULL; + + MFT_REGISTER_TYPE_INFO outputType{}; + outputType.guidMajorType = mediaType; + outputType.guidSubtype = outputSubtype; + + // MFT_ENUM_FLAG_ALL is the documented flag set for "synchronous, async, + // hardware, software" — there is no separate SOFTWARE flag. Using a + // narrower flag set can omit legitimate encoders (e.g. the AMD AMF H.264 + // encoder is async hardware). + IMFActivate** activates = nullptr; + UINT32 count = 0; + HRESULT hr = MFTEnumEx( + category, + MFT_ENUM_FLAG_ALL, + &inputType, + &outputType, + &activates, + &count); + if (FAILED(hr)) { + // MFTEnumEx failed outright (e.g. COM not initialized, invalid + // category). Surface the HRESULT so future bug reports can + // distinguish this from "zero encoders registered" (which returns + // SUCCEEDED(hr) with count == 0). + std::cerr << "ERROR: MFTEnumEx failed (hr=0x" << std::hex << hr + << std::dec << ")" << std::endl; + return 0; + } + if (activates != nullptr) { + for (UINT32 i = 0; i < count; i += 1) { + if (activates[i] != nullptr) { + activates[i]->Release(); + } + } + CoTaskMemFree(activates); + } + return count; +} + +UINT32 countRegisteredH264VideoEncoders() { + return countRegisteredMfts( + MFT_CATEGORY_VIDEO_ENCODER, MFMediaType_Video, MFVideoFormat_H264); +} + +UINT32 countRegisteredAacAudioEncoders() { + return countRegisteredMfts( + MFT_CATEGORY_AUDIO_ENCODER, MFMediaType_Audio, MFAudioFormat_AAC); +} + +void logMissingH264EncoderError() { + std::cerr + << "ERROR: No H.264 video encoder MFT is registered on this system." + << std::endl; + std::cerr + << " Windows could not find any Media Foundation Transform that " + << "outputs MFVideoFormat_H264." << std::endl; + std::cerr + << " MP4 recording requires an H.264 encoder. Without one, " + << "MFCreateSinkWriterFromURL fails (hr=0x80070003)." + << std::endl; + std::cerr + << " Try the following fixes in order:" << std::endl; + std::cerr + << " 1. Install the Media Feature Pack via Optional Features " + << "(Settings > Apps > Optional features > Add > Media Feature Pack), " + << "or run: Dism /online /add-capability /capabilityname:Media.MediaFeaturePack~~~~0.0.1.0" + << std::endl; + std::cerr + << " 2. Update your GPU drivers so the hardware H.264 encoder MFT " + << "(AMD AMF, NVIDIA NVENC, Intel Quick Sync) re-registers itself." + << std::endl; + std::cerr + << " 3. Inspect the registered transforms under" + << " HKLM:\\SOFTWARE\\Microsoft\\Windows Media Foundation\\Transforms" + << " and HKLM:\\SOFTWARE\\Classes\\MediaFoundation\\Transforms." + << std::endl; + std::cerr + << " 4. Reboot after driver or Media Feature Pack changes; " + << "MFT registration is cached at boot." + << std::endl; +} + void setFrameSize(IMFMediaType* type, UINT32 width, UINT32 height) { MFSetAttributeSize(type, MF_MT_FRAME_SIZE, width, height); } @@ -98,6 +199,15 @@ bool MFEncoder::initialize( device_ = device; context_ = context; + // No H.264 encoder pre-flight check here. MFTEnumEx and + // MFCreateSinkWriterFromURL can disagree about which H.264 encoders are + // "available" in non-interactive / Session 0 contexts (NVENC et al. are + // registered but their COM server may not fully activate from a service + // session). A pre-flight that fails fast on a zero MFTEnumEx count would + // therefore break a recording that the sink writer can complete + // successfully. The diagnostic dump below runs only on the failure path, + // so a healthy MFTEnumEx result never blocks a working recording. + if (!succeeded(MFStartup(MF_VERSION), "MFStartup")) { return false; } @@ -114,8 +224,37 @@ bool MFEncoder::initialize( setFrameRate(outputType.Get(), static_cast(fps_)); setPixelAspectRatio(outputType.Get()); - if (!succeeded(MFCreateSinkWriterFromURL(outputPath.c_str(), nullptr, nullptr, &sinkWriter_), - "MFCreateSinkWriterFromURL")) { + HRESULT sinkWriterHr = MFCreateSinkWriterFromURL( + outputPath.c_str(), nullptr, nullptr, &sinkWriter_); + if (FAILED(sinkWriterHr)) { + // The HRESULT alone is not actionable. Tell the user whether an H.264 + // encoder is registered at all (no H.264 == the MFP missing-MFT path), + // whether AAC is registered (only when audio is requested), and the + // hex HRESULT so the exact failure is greppable. Encoder counts are + // computed lazily here, only on the failure path, so a healthy + // recording does not pay for an MFTEnumEx call. + const UINT32 h264EncoderCount = countRegisteredH264VideoEncoders(); + const UINT32 aacEncoderCount = (audioFormat != nullptr) + ? countRegisteredAacAudioEncoders() + : 0; + std::cerr << "ERROR: MFCreateSinkWriterFromURL failed (hr=0x" + << std::hex << sinkWriterHr << std::dec << ")" << std::endl; + std::cerr << " Registered H.264 video encoder MFTs: " << h264EncoderCount + << std::endl; + if (audioFormat != nullptr) { + std::cerr << " Registered AAC audio encoder MFTs: " << aacEncoderCount + << std::endl; + } + if (h264EncoderCount == 0) { + logMissingH264EncoderError(); + } else { + std::cerr + << " An H.264 encoder MFT is registered but the sink writer " + << "still failed. Possible causes: invalid output path or " + << "permissions, no MP4 mux configured, or GPU driver " + << "incompatibility with this Media Foundation build." + << std::endl; + } return false; } if (!succeeded(sinkWriter_->AddStream(outputType.Get(), &videoStreamIndex_), "AddStream")) { diff --git a/electron/preload.ts b/electron/preload.ts index a89d296ee5..32e014953e 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -29,6 +29,9 @@ contextBridge.exposeInMainWorld("electronAPI", { moveHudOverlayBy: (deltaX: number, deltaY: number) => { ipcRenderer.send("hud-overlay-move-by", deltaX, deltaY); }, + setHudOverlaySize: (width: number, height: number) => { + ipcRenderer.send("hud-overlay-set-size", width, height); + }, getSources: async (opts: Electron.SourcesOptions) => { return await ipcRenderer.invoke("get-sources", opts); }, @@ -44,12 +47,25 @@ contextBridge.exposeInMainWorld("electronAPI", { openSourceSelector: () => { return ipcRenderer.invoke("open-source-selector"); }, + openNotes: () => { + return ipcRenderer.invoke("open-notes"); + }, selectSource: (source: ProcessedDesktopSource) => { return ipcRenderer.invoke("select-source", source); }, getSelectedSource: () => { return ipcRenderer.invoke("get-selected-source"); }, + onSelectedSourceChanged: (callback: (source: ProcessedDesktopSource) => void) => { + const listener = (_event: unknown, source: ProcessedDesktopSource) => callback(source); + ipcRenderer.on("selected-source-changed", listener); + return () => ipcRenderer.removeListener("selected-source-changed", listener); + }, + onSourceSelectorClosed: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("source-selector-closed", listener); + return () => ipcRenderer.removeListener("source-selector-closed", listener); + }, requestCameraAccess: () => { return ipcRenderer.invoke("request-camera-access"); }, @@ -161,6 +177,12 @@ contextBridge.exposeInMainWorld("electronAPI", { readBinaryFile: (filePath: string) => { return ipcRenderer.invoke("read-binary-file", filePath); }, + getReadableFileInfo: (filePath: string) => { + return ipcRenderer.invoke("get-readable-file-info", filePath); + }, + readFileChunk: (filePath: string, offset: number, length: number) => { + return ipcRenderer.invoke("read-file-chunk", filePath, offset, length); + }, preparePreviewAudioTrack: (filePath: string) => { return ipcRenderer.invoke("prepare-preview-audio-track", filePath); }, @@ -170,8 +192,8 @@ contextBridge.exposeInMainWorld("electronAPI", { saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => { return ipcRenderer.invoke("save-project-file", projectData, suggestedName, existingProjectPath); }, - loadProjectFile: () => { - return ipcRenderer.invoke("load-project-file"); + loadProjectFile: (projectFolder?: string) => { + return ipcRenderer.invoke("load-project-file", projectFolder); }, loadProjectFileFromPath: (filePath: string) => { return ipcRenderer.invoke("load-project-file-from-path", filePath); diff --git a/electron/recording/webm-duration.test.ts b/electron/recording/webm-duration.test.ts new file mode 100644 index 0000000000..d796bb36b2 --- /dev/null +++ b/electron/recording/webm-duration.test.ts @@ -0,0 +1,132 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { WebmBase, WebmContainer, WebmFile, WebmString, WebmUint } from "@fix-webm-duration/parser"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { patchWebmDurationOnDisk } from "./webm-duration"; + +interface WebmElementMock { + getSectionById: (id: number) => WebmElementMock; + getValue: () => number; +} + +describe("webm-duration patching", () => { + let dir: string; + const pathFor = (name: string) => path.join(dir, name); + + beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), "openscreen-duration-")); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + function createDummyWebm(includeCluster = true, clusterSize = 100): Uint8Array { + const ebml = new WebmContainer("EBML"); + ebml.data = []; + const docType = new WebmString("DocType"); + docType.setValue("webm"); + ebml.data.push({ id: 0x282, idHex: "282", data: docType }); + ebml.updateByData(); + + const segment = new WebmContainer("Segment"); + segment.data = []; + segment.isInfinite = true; + + const info = new WebmContainer("Info"); + info.data = []; + const timecodeScale = new WebmUint("TimecodeScale"); + timecodeScale.setValue(1000000); + info.data.push({ id: 0xad7b1, idHex: "ad7b1", data: timecodeScale }); + info.updateByData(); + segment.data.push({ id: 0x549a966, idHex: "549a966", data: info }); + + if (includeCluster) { + const cluster = new WebmBase("Cluster"); + // A valid EBML element for Cluster: ID 0x1f43b675 (stripped as 0xf43b675) + // Followed by a VINT for length (e.g. clusterSize bytes) + const header = Buffer.from([0x1f, 0x43, 0xb6, 0x75, 0x01, 0x00]); // 6 bytes header (id + length) + const body = Buffer.alloc(clusterSize, 0x42); // cluster data filled with 0x42 + const clusterBytes = Buffer.concat([header, body]); + + cluster.setSource(new Uint8Array(clusterBytes)); + segment.data.push({ id: 0xf43b675, idHex: "f43b675", data: cluster }); + } + + segment.updateByData(); + + const file = new WebmContainer("File"); + file.data = []; + file.data.push({ id: 0xa45dfa3, idHex: "a45dfa3", data: ebml }); + file.data.push({ id: 0x8538067, idHex: "8538067", data: segment }); + + file.updateByData(); + return file.source; + } + + it("patches small WebM files under 2MB in memory successfully", async () => { + const webmBytes = createDummyWebm(true, 100); + const filePath = pathFor("small.webm"); + await writeFile(filePath, webmBytes); + + const result = await patchWebmDurationOnDisk(filePath, 5000); + expect(result.patched).toBe(true); + + const patchedBytes = await readFile(filePath); + const webm = new WebmFile(new Uint8Array(patchedBytes)); + + const segment = webm.getSectionById(0x8538067) as unknown as WebmElementMock; + const info = segment.getSectionById(0x549a966); + const duration = info.getSectionById(0x489); + + expect(duration).toBeDefined(); + expect(duration.getValue()).toBe(5000); + }); + + it("patches large WebM files over 2MB using the optimized streaming method successfully", async () => { + // Create a file larger than 2MB (e.g. 2.5MB cluster data) to trigger the optimized method + const webmBytes = createDummyWebm(true, 2.5 * 1024 * 1024); + const filePath = pathFor("large.webm"); + await writeFile(filePath, webmBytes); + + const result = await patchWebmDurationOnDisk(filePath, 12000); + expect(result.patched).toBe(true); + + const patchedBytes = await readFile(filePath); + const webm = new WebmFile(new Uint8Array(patchedBytes)); + const segment = webm.getSectionById(0x8538067) as unknown as WebmElementMock; + const info = segment.getSectionById(0x549a966); + const duration = info.getSectionById(0x489); + + expect(duration).toBeDefined(); + expect(duration.getValue()).toBe(12000); + + // Verify the Cluster data (filled with 0x42) is intact at the end + const lastBytes = patchedBytes.subarray(patchedBytes.length - 100); + expect(lastBytes.every((b) => b === 0x42)).toBe(true); + }); + + it("falls back to in-memory patching if no Cluster section is found in the large file header chunk", async () => { + // File is over 2MB, but has no Cluster (very large header or weird file) + const webmBytes = createDummyWebm(false); + // Pad to 2.5MB without Cluster + const padding = Buffer.alloc(2.5 * 1024 * 1024, 0); + const largeNoClusterBytes = Buffer.concat([Buffer.from(webmBytes), padding]); + + const filePath = pathFor("large_no_cluster.webm"); + await writeFile(filePath, largeNoClusterBytes); + + const result = await patchWebmDurationOnDisk(filePath, 8000); + expect(result.patched).toBe(true); + + const patchedBytes = await readFile(filePath); + const webm = new WebmFile(new Uint8Array(patchedBytes)); + const segment = webm.getSectionById(0x8538067) as unknown as WebmElementMock; + const info = segment.getSectionById(0x549a966); + const duration = info.getSectionById(0x489); + + expect(duration).toBeDefined(); + expect(duration.getValue()).toBe(8000); + }); +}); diff --git a/electron/recording/webm-duration.ts b/electron/recording/webm-duration.ts index 5b2c197c98..d9b4b61979 100644 --- a/electron/recording/webm-duration.ts +++ b/electron/recording/webm-duration.ts @@ -1,4 +1,6 @@ +import { createReadStream, createWriteStream } from "node:fs"; import fs from "node:fs/promises"; +import { pipeline } from "node:stream/promises"; import { fixParsedWebmDuration } from "@fix-webm-duration/fix"; import { WebmFile } from "@fix-webm-duration/parser"; @@ -6,40 +8,164 @@ export type DurationPatchResult = | { patched: true } | { patched: false; reason: "no-section" | "already-valid" | "io-error" | "internal" }; +// Read 2MB from the start of the file. Headers for WebM recordings are typically well under 100KB. +const HEADER_CHUNK_SIZE = 2 * 1024 * 1024; + /** * Patch the WebM Duration header on a finalized recording file. * - * Browser MediaRecorder writes WebM with no Duration EBML element. With the - * streaming-to-disk path the renderer never holds the blob, so the historical - * `fixWebmDuration(blob, durationMs)` call can't run. Patching on disk after - * `WriteStream.end()` produces an equivalent result: the editor's seek bar and - * timeline read a real duration instead of `N/A`. - * - * Atomic by design: writes the patched bytes to `.duration-patch.tmp` - * and renames in place. If the process crashes mid-rewrite, the original file - * survives intact, so the user never loses their recording to a partial write. + * MediaRecorder writes WebM with no Duration EBML element. * - * Best-effort by intent: any failure (read, parse, write) logs and returns a - * non-`patched` result rather than throwing. The file is still playable without - * the patch (decoders walk frames sequentially); the only cost is that the - * editor's seek bar and timeline break until it is patched. + * Rather than reading the entire multi-gigabyte file into memory (which crashes + * the main process on long recordings), we read only the first 2MB chunk containing + * the metadata headers, patch the Info section, and stream the remaining clusters + * from the original file using Node streams. * - * Memory: reads the whole file into a main-process Buffer, the same footprint - * as the pre-streaming renderer path, just on the side without V8's heap cap. + * NOTE: This optimization handles the saving/finalization phase. For issues related + * to streaming large files during the editor/export phase, see also PR #74. */ export async function patchWebmDurationOnDisk( filePath: string, durationMs: number, ): Promise { + let fileHandle: fs.FileHandle | null = null; + const tmpPath = `${filePath}.duration-patch.tmp`; + try { + const stat = await fs.stat(filePath); + if (stat.size < HEADER_CHUNK_SIZE) { + // Fallback: If file is smaller than 2MB, just use the in-memory method. + return await patchWebmDurationInMemory(filePath, durationMs); + } + + fileHandle = await fs.open(filePath, "r"); + const buffer = Buffer.alloc(HEADER_CHUNK_SIZE); + const { bytesRead } = await fileHandle.read(buffer, 0, HEADER_CHUNK_SIZE, 0); + await fileHandle.close(); + fileHandle = null; + + const chunk = buffer.subarray(0, bytesRead); + const webm = new WebmFile(new Uint8Array(chunk)); + + if (!webm.data || !webm.source) { + console.warn( + `[webm-duration] Segment data or source is missing in chunk for ${filePath}; falling back to whole-file`, + ); + return await patchWebmDurationInMemory(filePath, durationMs); + } + + // Find Segment in webm.data + const segmentSec = webm.data.find((sec) => sec.id === 0x8538067); + if (!segmentSec || !segmentSec.data) { + console.warn( + `[webm-duration] Segment section is missing in chunk for ${filePath}; falling back to whole-file`, + ); + return await patchWebmDurationInMemory(filePath, durationMs); + } + + interface WebmContainerShape { + start: number; + isInfinite?: boolean; + data: { id: number; data: unknown }[]; + } + const segment = segmentSec.data as WebmContainerShape; + + const info = ( + segment as unknown as { getSectionById?: (id: number) => unknown } + ).getSectionById?.(0x549a966); + if (!info) { + console.warn( + `[webm-duration] Info section is missing in chunk for ${filePath}; falling back to whole-file`, + ); + return await patchWebmDurationInMemory(filePath, durationMs); + } + + // Calculate the start of the Segment's payload (content) using EBML VINT length rules + const segmentStart = segment.start; + const idByte = webm.source[segmentStart]; + const idLen = 9 - idByte.toString(2).length; + const lenByte = webm.source[segmentStart + idLen]; + const lenLen = 9 - lenByte.toString(2).length; + const segmentPayloadStart = segmentStart + idLen + lenLen; + + // Find the first Cluster section. ID is 0xf43b675. + const segmentData = segment.data; + const clusterIdx = segmentData.findIndex((sec) => sec.id === 0xf43b675); + if (clusterIdx === -1) { + console.warn( + `[webm-duration] No Cluster section found in header chunk for ${filePath}; falling back to whole-file`, + ); + return await patchWebmDurationInMemory(filePath, durationMs); + } + + const clusterSec = segmentData[clusterIdx]; + const clusterData = clusterSec.data as { start: number }; + const clusterOffset = segmentPayloadStart + clusterData.start; + + // Truncate the segment children to remove the Cluster and everything after it. + // This forces updateByData() to only regenerate/write the metadata headers. + segment.data = segmentData.slice(0, clusterIdx); + + // Segment length was likely infinite (-1) or matching the original file. Since we are patching + // headers and appending the original stream, setting Segment length to infinite (-1) is safe and standard. + segment.isInfinite = true; + + const patched = fixParsedWebmDuration(webm, durationMs, { logger: false }); + if (!patched) { + const reason = inferUnpatchedReason(webm); + return { patched: false, reason }; + } + + if (!webm.source) { + console.error(`[webm-duration] patched but source missing for ${filePath}`); + return { patched: false, reason: "internal" }; + } + + const patchedBytes = Buffer.from( + webm.source.buffer, + webm.source.byteOffset, + webm.source.byteLength, + ); + + // Now write the patched headers and stream append the rest of the original file + const ws = createWriteStream(tmpPath); + const rs = createReadStream(filePath, { start: clusterOffset }); + + try { + await new Promise((resolve, reject) => { + ws.write(patchedBytes, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + await pipeline(rs, ws); + } finally { + rs.destroy(); + ws.destroy(); + } + + await fs.rename(tmpPath, filePath); + return { patched: true }; + } catch (error) { + console.error(`[webm-duration] failed to patch ${filePath} using optimized method:`, error); + if (fileHandle) { + await fileHandle.close().catch(() => undefined); + } + await fs.unlink(tmpPath).catch(() => undefined); + return { patched: false, reason: "io-error" }; + } +} + +async function patchWebmDurationInMemory( + filePath: string, + durationMs: number, +): Promise { + const tmpPath = `${filePath}.duration-patch.tmp`; try { const fileBytes = await fs.readFile(filePath); const webm = new WebmFile(new Uint8Array(fileBytes)); const patched = fixParsedWebmDuration(webm, durationMs, { logger: false }); if (!patched) { - // fixParsedWebmDuration returns false for: missing Segment, missing - // Info, or a Duration that is already valid. The first two mean a - // malformed (most likely truncated) file; the third is a no-op. const reason = inferUnpatchedReason(webm); if (reason === "no-section") { console.warn( @@ -54,7 +180,6 @@ export async function patchWebmDurationOnDisk( return { patched: false, reason: "internal" }; } - const tmpPath = `${filePath}.duration-patch.tmp`; const patchedBytes = Buffer.from( webm.source.buffer, webm.source.byteOffset, @@ -66,26 +191,18 @@ export async function patchWebmDurationOnDisk( return { patched: true }; } catch (writeError) { console.error(`[webm-duration] failed to write patched ${filePath}:`, writeError); - // Best-effort cleanup of the temp file; if unlink also fails, leave it. - // The original recording is untouched because the rename never ran. await fs.unlink(tmpPath).catch(() => undefined); return { patched: false, reason: "io-error" }; } } catch (error) { - console.error(`[webm-duration] failed to patch ${filePath}:`, error); + console.error(`[webm-duration] failed to patch ${filePath} in memory:`, error); return { patched: false, reason: "io-error" }; } } /** - * Distinguish "no Segment/Info section" (malformed/truncated file) from "Info - * present but Duration already valid" (patch unnecessary). - * - * The IDs are the length-descriptor-stripped form that @fix-webm-duration/parser - * uses as its lookup keys (Segment `0x8538067`, Info `0x549a966`), verified - * against the parser's `src/lib/sections.js` — not the canonical 4-byte EBML - * IDs (`0x18538067` / `0x1549A966`), which this parser's `getSectionById` would - * never match. + * Distinguish "no Segment/Info section" (malformed/truncated file) from "Info present + * but Duration already valid" (patch unnecessary). */ function inferUnpatchedReason(webm: WebmFile): "no-section" | "already-valid" { const segment = webm.getSectionById?.(0x8538067); diff --git a/electron/singleInstanceLock.test.ts b/electron/singleInstanceLock.test.ts new file mode 100644 index 0000000000..4df35c72b3 --- /dev/null +++ b/electron/singleInstanceLock.test.ts @@ -0,0 +1,52 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { acquireStableInstanceLock } from "./singleInstanceLock"; + +const testDirs: string[] = []; + +function createTestLockDir() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openscreen-lock-test-")); + testDirs.push(dir); + return path.join(dir, "app.lock"); +} + +afterEach(() => { + for (const dir of testDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("acquireStableInstanceLock", () => { + it("prevents a second lock while the owning process is still running", () => { + const lockDir = createTestLockDir(); + const firstLock = acquireStableInstanceLock({ lockDir }); + + expect(firstLock).not.toBeNull(); + expect(acquireStableInstanceLock({ lockDir })).toBeNull(); + + firstLock?.release(); + }); + + it("reclaims a stale lock when its process is gone", () => { + const lockDir = createTestLockDir(); + fs.mkdirSync(lockDir); + fs.writeFileSync(path.join(lockDir, "pid"), "99999999\n"); + + const lock = acquireStableInstanceLock({ lockDir }); + + expect(lock).not.toBeNull(); + expect(fs.readFileSync(path.join(lockDir, "pid"), "utf8")).toBe(`${process.pid}\n`); + + lock?.release(); + }); + + it("does not remove a fresh empty lock directory", () => { + const lockDir = createTestLockDir(); + fs.mkdirSync(lockDir); + + expect(acquireStableInstanceLock({ lockDir })).toBeNull(); + expect(fs.existsSync(lockDir)).toBe(true); + }); +}); diff --git a/electron/singleInstanceLock.ts b/electron/singleInstanceLock.ts new file mode 100644 index 0000000000..d1a0a01afc --- /dev/null +++ b/electron/singleInstanceLock.ts @@ -0,0 +1,104 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const LOCK_DIR_PREFIX = "openscreen-single-instance"; +const PID_FILE_NAME = "pid"; +const EMPTY_LOCK_STALE_MS = 30_000; + +export type StableInstanceLock = { + lockDir: string; + release: () => void; +}; + +type LockOptions = { + lockDir?: string; + pid?: number; + now?: () => number; +}; + +function isProcessRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +function readLockPid(lockDir: string): number | null { + try { + const rawPid = fs.readFileSync(path.join(lockDir, PID_FILE_NAME), "utf8").trim(); + const pid = Number(rawPid); + return Number.isInteger(pid) && pid > 0 ? pid : null; + } catch { + return null; + } +} + +function isEmptyLockStale(lockDir: string, now: () => number): boolean { + try { + const stat = fs.statSync(lockDir); + return now() - stat.mtimeMs > EMPTY_LOCK_STALE_MS; + } catch { + return false; + } +} + +function releaseLock(lockDir: string, pid: number) { + if (readLockPid(lockDir) !== pid) { + return; + } + fs.rmSync(lockDir, { recursive: true, force: true }); +} + +function getCurrentUserLockKey() { + if (typeof process.getuid === "function") { + return `uid-${process.getuid()}`; + } + + try { + const username = os.userInfo().username.replace(/[^a-zA-Z0-9._-]/g, "_"); + return username || "default"; + } catch { + return "default"; + } +} + +export function getStableInstanceLockDir() { + return path.join(os.tmpdir(), `${LOCK_DIR_PREFIX}-${getCurrentUserLockKey()}.lock`); +} + +export function acquireStableInstanceLock(options: LockOptions = {}): StableInstanceLock | null { + const lockDir = options.lockDir ?? getStableInstanceLockDir(); + const pid = options.pid ?? process.pid; + const now = options.now ?? Date.now; + + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + fs.mkdirSync(lockDir, { mode: 0o700 }); + fs.writeFileSync(path.join(lockDir, PID_FILE_NAME), `${pid}\n`, { flag: "wx" }); + return { + lockDir, + release: () => releaseLock(lockDir, pid), + }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EEXIST") { + throw error; + } + + const existingPid = readLockPid(lockDir); + if (existingPid && isProcessRunning(existingPid)) { + return null; + } + if (!existingPid && !isEmptyLockStale(lockDir, now)) { + return null; + } + + fs.rmSync(lockDir, { recursive: true, force: true }); + } + } + + return null; +} diff --git a/electron/windows.ts b/electron/windows.ts index 5d34fe80ce..2f87e6c3d5 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -10,7 +10,7 @@ const RENDERER_DIST = path.join(APP_ROOT, "dist"); const HEADLESS = process.env["HEADLESS"] === "true"; // Asset base URL for renderer (wallpapers, etc.). Packaged: extraResources copies -// public/wallpapers -> resources/wallpapers. Unpackaged: /public/. +// public/wallpapers to resources/wallpapers. Unpackaged: /public/. const ASSET_BASE_DIR = process.defaultApp ? path.join(__dirname, "..", "public") : process.resourcesPath; @@ -44,10 +44,45 @@ ipcMain.on("hud-overlay-move-by", (_event, deltaX: number, deltaY: number) => { hudOverlayWindow.setPosition(Math.round(x + deltaX), Math.round(y + deltaY), false); }); +// Resize the HUD to fit its rendered content. Anchored by its bottom-centre so it +// stays where the user dragged it while only growing/shrinking, which lets the +// vertical tray layout grow tall instead of scrolling inside a fixed window. +ipcMain.on("hud-overlay-set-size", (_event, width: number, height: number) => { + if ( + !hudOverlayWindow || + hudOverlayWindow.isDestroyed() || + !Number.isFinite(width) || + !Number.isFinite(height) + ) { + return; + } + + const bounds = hudOverlayWindow.getBounds(); + + // Clamp to the work area of the display the HUD sits on; on a short screen the + // vertical layout can exceed the display, where the bar's own overflow scroll takes over. + const { workArea } = screen.getDisplayMatching(bounds); + const nextWidth = Math.min(workArea.width, Math.max(1, Math.round(width))); + const nextHeight = Math.min(workArea.height, Math.max(1, Math.round(height))); + + if (bounds.width === nextWidth && bounds.height === nextHeight) { + return; + } + + const centerX = bounds.x + bounds.width / 2; + const bottomY = bounds.y + bounds.height; + + hudOverlayWindow.setBounds({ + x: Math.round(centerX - nextWidth / 2), + y: Math.round(bottomY - nextHeight), + width: nextWidth, + height: nextHeight, + }); +}); + /** - * 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. + * Frameless transparent HUD overlay, always-on-top, centred at the bottom of the + * primary display. Follows the user across macOS Spaces so it isn't lost on switch. */ export function createHudOverlayWindow(): BrowserWindow { const primaryDisplay = screen.getPrimaryDisplay(); @@ -62,14 +97,20 @@ export function createHudOverlayWindow(): BrowserWindow { const win = new BrowserWindow({ width: windowWidth, height: windowHeight, - minWidth: 600, - maxWidth: 600, - minHeight: 160, - maxHeight: 160, + // Min/max are intentionally loose: the renderer resizes to fit content via + // "hud-overlay-set-size" (above), needed for the vertical tray to grow taller. + minWidth: 120, + minHeight: 80, x: x, y: y, frame: false, transparent: true, + // Fully-transparent ARGB backing. Without this macOS draws the window as a + // rounded glass panel with a border around the HUD content. + backgroundColor: "#00000000", + // Don't let macOS mask the window into a rounded rect; the HUD bar provides + // its own rounding and the window itself must be invisible. + roundedCorners: false, resizable: false, alwaysOnTop: true, skipTaskbar: true, @@ -85,14 +126,14 @@ export function createHudOverlayWindow(): BrowserWindow { }); win.setIgnoreMouseEvents(true, { forward: true }); - // Follow the user across macOS Spaces (virtual desktops). - // Without this the HUD stays pinned to the Space it was first opened on. + // Follow the user across macOS Spaces, else the HUD stays pinned to the Space + // it was first opened on. if (process.platform === "darwin") { win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); } - // Show only once content is painted — prevents the black rectangle flash - // that appears when a transparent window is shown before its first paint. + // Show only once painted to avoid the black rectangle flash when a transparent + // window is shown before its first paint. win.once("ready-to-show", () => { if (!HEADLESS) win.show(); }); @@ -121,8 +162,8 @@ export function createHudOverlayWindow(): BrowserWindow { } /** - * 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. + * Main editor window. Starts maximised with a hidden title bar on macOS; not + * always-on-top and appears in the taskbar/dock. */ export function createEditorWindow(): BrowserWindow { const isMac = process.platform === "darwin"; @@ -153,16 +194,15 @@ export function createEditorWindow(): BrowserWindow { }, }); - // Maximize the window by default win.maximize(); - // Show only once content is painted — prevents white flash on cold Vite start. + // Show only once painted to avoid a white flash on cold Vite start. win.once("ready-to-show", () => { if (!HEADLESS) win.show(); }); - // Inject dark background before any React paint so the sub-titlebar area - // never flashes white even on the very first cold Vite load. + // Inject dark background before any React paint so the sub-titlebar area never + // flashes white on a cold Vite load. win.webContents.on("dom-ready", () => { win.webContents.insertCSS("html, body, #root { background: #09090b !important; }").catch(() => { // Best-effort cosmetic; ignore if the page is mid-teardown. @@ -185,8 +225,8 @@ export function createEditorWindow(): BrowserWindow { } /** - * Creates the floating source-selector window used to pick a screen or window - * to record. Frameless, transparent, and follows the user across macOS Spaces. + * Floating source-selector window for picking 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; @@ -211,8 +251,8 @@ 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. + // Follow the user across macOS Spaces so the selector appears on the active + // desktop regardless of where the HUD was opened. if (process.platform === "darwin") { win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); } @@ -229,8 +269,8 @@ export function createSourceSelectorWindow(): BrowserWindow { } /** - * Creates a centered transparent countdown overlay window that sits above the - * HUD while recording pre-roll is running. + * Centered transparent countdown overlay that sits above the HUD during + * recording pre-roll. */ export function createCountdownOverlayWindow(): BrowserWindow { const { workArea } = screen.getPrimaryDisplay(); @@ -280,3 +320,44 @@ export function createCountdownOverlayWindow(): BrowserWindow { return win; } + +// Frameless Notes Window for taking notes during a recording. +export function createNotesWindow(): BrowserWindow { + const win = new BrowserWindow({ + width: 400, + height: 540, + minWidth: 360, + minHeight: 400, + maxWidth: 640, + maxHeight: 720, + title: "OpenScreen - Notes", + backgroundColor: "#09090b", + resizable: true, + alwaysOnTop: true, + skipTaskbar: false, + show: false, + webPreferences: { + preload: path.join(__dirname, "preload.mjs"), + additionalArguments: [ASSET_BASE_URL_ARG], + nodeIntegration: false, + contextIsolation: true, + backgroundThrottling: false, + }, + }); + + win.setContentProtection(true); + win.once("ready-to-show", () => { + win.setContentProtection(true); + win.show(); + }); + + if (VITE_DEV_SERVER_URL) { + win.loadURL(VITE_DEV_SERVER_URL + "?showNotes=true"); + } else { + win.loadFile(path.join(RENDERER_DIST, "index.html"), { + query: { showNotes: "true" }, + }); + } + + return win; +} diff --git a/nix/hm-module.nix b/nix/hm-module.nix index b04f82793f..9ed0a7d4f5 100644 --- a/nix/hm-module.nix +++ b/nix/hm-module.nix @@ -1,7 +1,7 @@ # Home Manager module for OpenScreen # Usage in flake-based Home Manager config: # -# inputs.openscreen.url = "github:siddharthvaddem/openscreen"; +# inputs.openscreen.url = "github:EtienneLescot/openscreen"; # # { inputs, ... }: { # imports = [ inputs.openscreen.homeManagerModules.default ]; diff --git a/nix/module.nix b/nix/module.nix index 3282d2d4fc..0cb7242ca7 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -1,7 +1,7 @@ # NixOS module for OpenScreen # Usage in flake-based NixOS config: # -# inputs.openscreen.url = "github:siddharthvaddem/openscreen"; +# inputs.openscreen.url = "github:EtienneLescot/openscreen"; # # { inputs, ... }: { # imports = [ inputs.openscreen.nixosModules.default ]; diff --git a/nix/package.nix b/nix/package.nix index 33dc4f7356..f30c7c7e41 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -11,7 +11,7 @@ buildNpmPackage { nodejs = nodejs_22; pname = "openscreen"; - version = "1.4.0"; + version = "1.6.0"; src = let @@ -33,7 +33,7 @@ buildNpmPackage { ); }; - npmDepsHash = "sha256-tOpoJPzaZDK3HJijGHpZ0+jWsbrYyQUuw1pO0Uxcifg="; + npmDepsHash = "sha256-IZypOLWlDShIjCKWxlJcrdtIkMu0P/DuXaq4c0HW3FY="; env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; @@ -116,7 +116,7 @@ buildNpmPackage { meta = { description = "Desktop screen recorder with built-in editor"; - homepage = "https://github.com/siddharthvaddem/openscreen"; + homepage = "https://github.com/EtienneLescot/openscreen"; license = lib.licenses.mit; mainProgram = "openscreen"; platforms = lib.platforms.linux; diff --git a/package-lock.json b/package-lock.json index 50ecc9d88b..02f9cdb1c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openscreen", - "version": "1.4.0", + "version": "1.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openscreen", - "version": "1.4.0", + "version": "1.6.0", "dependencies": { "@fix-webm-duration/fix": "^1.0.1", "@pixi/filter-drop-shadow": "^5.2.0", @@ -22,10 +22,14 @@ "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", + "@tiptap/extension-text-style": "^3.27.1", + "@tiptap/react": "^3.27.1", + "@tiptap/starter-kit": "^3.27.1", "@types/gif.js": "^0.2.5", "@uiw/color-convert": "^2.10.1", "@uiw/react-color-block": "^2.10.1", "@uiw/react-color-colorful": "^2.9.2", + "@xenova/transformers": "^2.17.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dnd-timeline": "^2.4.0", @@ -51,6 +55,8 @@ "web-demuxer": "^4.0.0" }, "devDependencies": { + "@actions/core": "^3.0.1", + "@actions/github": "^9.1.1", "@biomejs/biome": "^2.4.12", "@electron/rebuild": "^4.0.4", "@playwright/test": "^1.59.1", @@ -85,6 +91,92 @@ "npm": "10.9.4" } }, + "node_modules/@actions/core": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.1.tgz", + "integrity": "sha512-a6d/Nwahm9fliVGRhdhofo40HjHQasUPusmc7vBfyky+7Z+P2A1J68zyFVaNcEclc/Se+eO595oAr5nwEIoIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/exec": "^3.0.0", + "@actions/http-client": "^4.0.0" + } + }, + "node_modules/@actions/exec": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-3.0.0.tgz", + "integrity": "sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/io": "^3.0.2" + } + }, + "node_modules/@actions/github": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/@actions/github/-/github-9.1.1.tgz", + "integrity": "sha512-tL5JbYOBZHc0ngEnCsaDcryUizIUIlQyIMwy1Wkx93H5HzbBJ7TbiPx2PnFjBwZW0Vh05JmfFZhecE6gglYegA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/http-client": "^3.0.2", + "@octokit/core": "^7.0.6", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-rest-endpoint-methods": "^17.0.0", + "@octokit/request": "^10.0.7", + "@octokit/request-error": "^7.1.0", + "undici": "^6.23.0" + } + }, + "node_modules/@actions/github/node_modules/@actions/http-client": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-3.0.2.tgz", + "integrity": "sha512-JP38FYYpyqvUsz+Igqlc/JG6YO9PaKuvqjM3iGvaLqFnJ7TFmcLyy2IDrY0bI0qCQug8E9K+elv5ZNfw62ZJzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tunnel": "^0.0.6", + "undici": "^6.23.0" + } + }, + "node_modules/@actions/github/node_modules/undici": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/@actions/http-client": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.1.tgz", + "integrity": "sha512-+Nvd1ImaOZBSoPbsUtEhv+1z99H12xzncCkz0a3RuehINE81FZSe2QTj3uvAPTcJX/SCzUQHQ0D1GrPMbrPitg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tunnel": "^0.0.6", + "undici": "^6.23.0" + } + }, + "node_modules/@actions/http-client/node_modules/undici": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/@actions/io": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@actions/io/-/io-3.0.2.tgz", + "integrity": "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==", + "dev": true, + "license": "MIT" + }, "node_modules/@adobe/css-tools": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", @@ -1772,6 +1864,15 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, + "node_modules/@huggingface/jinja": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.2.2.tgz", + "integrity": "sha512-/KPde26khDUIPkTGU82jdtTW9UAuvUTumCAbFs/7giR0SxsvZC4hru51PBvpijH6BVkHcROcvZM/lpy5h1jRRA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -1955,6 +2056,144 @@ "node": ">= 8" } }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", + "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.3", + "@octokit/request": "^10.0.6", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.3.tgz", + "integrity": "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", + "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", + "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz", + "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.10", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.10.tgz", + "integrity": "sha512-KxNC2pTqqhszMNrf12ZRd4PonRgyJdsM4F/jySiddQK+DsRcfBtUvqn8t7UsyZhnRJHvX46OohDt5N3VqIWC2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.3", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "content-type": "^2.0.0", + "json-with-bigint": "^3.5.3", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", + "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, "node_modules/@pixi/color": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/color/-/color-7.4.3.tgz", @@ -2104,6 +2343,70 @@ "dev": true, "license": "MIT" }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz", + "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -3644,98 +3947,540 @@ "@testing-library/dom": ">=7.21.4" } }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, + "node_modules/@tiptap/core": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.27.1.tgz", + "integrity": "sha512-rV6Qn4wmC6BxfF+4mu6bqGWj9vA4oXXhsrpXaJL2uhjxeHAGofjwcHof2X84VYzeyXgdlsGmqKie4TAppVXZUQ==", "license": "MIT", - "peer": true + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "3.27.1" + } }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.27.1.tgz", + "integrity": "sha512-VMF7xJx6qEGiX6DTKNiL31NLqypOcd/4sNjFSe8rb41PwejBJh/nOqVIbBvWkiT6NMGFLxMhj7zJ8/zPo1hXeg==", "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1" } }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, + "node_modules/@tiptap/extension-bold": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.27.1.tgz", + "integrity": "sha512-TlC5bsS+pqETTrlz4CZz9RO/cKBYtELGIxwtKeivUn3eNfnOxQbbu4WDsiwIfzRFyd0OMnKl6BPM2KnYEehoEQ==", "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1" } }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.27.1.tgz", + "integrity": "sha512-j/j8Qp9Z5nViade2m7zjrO/CYH/Ca80Qj7aqo0eUaei6FZQ5izlF9o4XQU5EFMAutV6mwynsPUp8FVo5sCuYfw==", "license": "MIT", + "optional": true, "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" + "@floating-ui/dom": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1", + "@tiptap/pm": "3.27.1" } }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.27.1.tgz", + "integrity": "sha512-faCUHnRP47o9Zh9VZZX6EX/569udw9Vopm2PgEKPWuKLE2qaS5WBuUVU0iItdJmKUqaWiOZkpoW4jvnDmj0dfg==", "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.27.1" } }, - "node_modules/@types/cacheable-request": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", - "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", - "dev": true, + "node_modules/@tiptap/extension-code": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.27.1.tgz", + "integrity": "sha512-epOUpFfEmBzjvnqvjv2qHX7NAuLo5dlOGV690lWu+sAYMjibuJBeVvAiKPyFCfRCCTUxdbDB3jbaOA1yEcEJ7w==", "license": "MIT", - "dependencies": { - "@types/http-cache-semantics": "*", - "@types/keyv": "^3.1.4", - "@types/node": "*", - "@types/responselike": "^1.0.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1" } }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, + "node_modules/@tiptap/extension-code-block": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.27.1.tgz", + "integrity": "sha512-pHlzmZx2OlHfyQ0yRlT5UL4mGokz947DthZuYefN1OleVqOkHpWBG+2JQwqoNq6bmzMne92zbH32rhcJUEYSjA==", "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1", + "@tiptap/pm": "3.27.1" } }, - "node_modules/@types/css-font-loading-module": { - "version": "0.0.12", - "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.12.tgz", - "integrity": "sha512-x2tZZYkSxXqWvTDgveSynfjq/T2HyiZHXb00j/+gy19yp70PHCizM48XFdjBCWH7eHBD0R5i/pw9yMBP/BH5uA==", + "node_modules/@tiptap/extension-document": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.27.1.tgz", + "integrity": "sha512-8FbBTkfnRP4iVaoj+2h3iWa+H0eGDD3yTyVCwrmue/sQTkqUNUoSuAZa3GDG4Sd41xdPwTJxl9nUWGgM1qDCnw==", "license": "MIT", - "peer": true + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1" + } }, - "node_modules/@types/debug": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", - "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", - "dev": true, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.27.1.tgz", + "integrity": "sha512-blFf9x9RG0Qr7P3FoAH/033ffa+mMLZn34trVs8Vi0Ppk6FmJAg5HpYFOtmYoeREdNDJ5rHJKV7SoACbOHgskQ==", "license": "MIT", - "dependencies": { - "@types/ms": "*" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.27.1" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.27.1.tgz", + "integrity": "sha512-BmJF1VqB7dSJkgAalrpVFj88WLhxKjcWPuWHOqf2ITrUU2832BhKLXKmxjWUy1gqV8PfNNVWtGfIERy7I0y0+Q==", + "license": "MIT", + "optional": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "3.27.1", + "@tiptap/pm": "3.27.1" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.27.1.tgz", + "integrity": "sha512-QoezN0wdvXIwLQ4ee2ccWDaX3RG0lzgQpIMpMz55oPDhpUVax1+19ApsS53LkcktpS4EbnPL4xO4DaJk0Vp7PQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.27.1" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.27.1.tgz", + "integrity": "sha512-iv/m9hzl6jfSj9Q8UEjAxONvCoUDaP7M9SRCPx3PaLNxA230TTD6RE0Ye4zFJ8ze7ZVoJJMAqg9Qpq1iYg2JOQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.27.1.tgz", + "integrity": "sha512-SrC4l1kEIyv9ZXFaI/8LQqU2MyMmjczw7XXsWUQOTN4YXv0JyVgMNR3cI/wz0d2xsTfBdZ1N85Tdng+Ga1t0Sg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.27.1.tgz", + "integrity": "sha512-QlKE7qn5qMnIGVGhXQlvYedvLtNJ9z0dmit5w8vPb8tKzW4Spk6M7N2kruprrDA8GBwHfeR5wmF+njfUm34qxg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1", + "@tiptap/pm": "3.27.1" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.27.1.tgz", + "integrity": "sha512-jGGeyn9uRUnNjSTHpbqhiGsp6KaYTSbV09jDXPJI9cDwfV9hpugLvpaCZd0BMBbhU1B1W6kOfX0BE15qX/HQfA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.27.1.tgz", + "integrity": "sha512-/2jBfsxBZUDGJmpZifqRQPz7f1E5qpS1BckTZ39TADzUJX+feKy7RJ3DtQ02+8y6SSMzvP9loGVjrk6zEMTk4g==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1", + "@tiptap/pm": "3.27.1" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.27.1.tgz", + "integrity": "sha512-c2Upru7lj0/ZV/Ibww6cNz6sUS8m6Dp/9uygFhYcZOd3X8M0xBIEk42c6m6SQehkPziVA8QOgNJz7sMqsbz1OQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1", + "@tiptap/pm": "3.27.1" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.27.1.tgz", + "integrity": "sha512-zwRl01ETfCkWUvtvK5fw9bXtAajMPkvlkE3Cq6JvH3LF7XXJwDtNj5Tj7exacMpCaSZmlNc43vFb2rAYnrnwMA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.27.1" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.27.1.tgz", + "integrity": "sha512-OIMZNlzPSO8WRd4ic73Fxckzl4N1tesjjLL2XApaNA/uMpO0LoF6WSRPAWv+Z24Wp92ARRJAnRP7iZoI5+Jxig==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.27.1" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.27.1.tgz", + "integrity": "sha512-GYrKqD//9nHJ2r80uXqbDMzRnFpGzbaEQRTSGaO/SH7DvXWFMow8evkOdjQ7PCQO07jNjJo75+A85Jwu3Ov3AA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.27.1" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.27.1.tgz", + "integrity": "sha512-7K7eo1gruOgAsnbK+GCV23AUVUI0cL1bTig8HaPneoFMVbig7vddk8jNLKBWO8TXVbG7TuHdnDN4F98vdtwh5Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.27.1.tgz", + "integrity": "sha512-Y3DW1jlSlCNCyMGHP3+3qBNNPS83wuFz4RTYGjZtvRRTCRh7apZme9XRWMq1rN5mJ2Cr7fKocA2/5Bs13KgN6Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.27.1.tgz", + "integrity": "sha512-6ZwaZwSrDh+KFFv6V1J79oO37yPs7y1bFxvk1/9Ih2rn3Xr5AWz+eMS+n8RpH3djBVVAQpdIAeYQgcn+VCSsTg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1" + } + }, + "node_modules/@tiptap/extension-text-style": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.27.1.tgz", + "integrity": "sha512-J48WIl+6YDYTFPhWXUBQk+u7+AKVUqTdvrZOQyPYCGuQMgHrYzgWrI5+HeEifUgXJ5rMIWWP3qytp7KhVVqpDQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.27.1.tgz", + "integrity": "sha512-N889J4nXN/TPfVt8uF9N1A0SY82E90zwc1y26lqOcw6KWNLmQrlhMh/9OD4ikLDbekmFpOBq/UicpHf/6S8hbQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.27.1.tgz", + "integrity": "sha512-1Tdx9faw8k0/83V6X+xCDVhV8yElGt95JxeW3YMkKQJI56QdlPz0xOdJPlMiSGJKinPyVier+x9LJD/YZUZIaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1", + "@tiptap/pm": "3.27.1" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.27.1.tgz", + "integrity": "sha512-Ffjx+vimmBU7zH/KrpXzJid3+pziCe/VL2aexSTP63cyQwKQ65LkFkCKaIsSpFdQQuakVZBGWjCA5RoBV852pw==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.3.0", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-inputrules": "^1.4.0", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.7", + "prosemirror-schema-list": "^1.5.0", + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.0", + "prosemirror-transform": "^1.12.0", + "prosemirror-view": "^1.41.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.27.1.tgz", + "integrity": "sha512-/Wn2fc9zMtX08MXYScDFsm4wJ8lzfhfPEdbtls7WCDlbtrop48PWlkHDBBJrywARfAQTB2mFs9KiFy9yrQm5Lg==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "fast-equals": "^5.3.3", + "use-sync-external-store": "^1.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "optionalDependencies": { + "@tiptap/extension-bubble-menu": "^3.27.1", + "@tiptap/extension-floating-menu": "^3.27.1" + }, + "peerDependencies": { + "@tiptap/core": "3.27.1", + "@tiptap/pm": "3.27.1", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.27.1.tgz", + "integrity": "sha512-vfxRsqW8rCc0k4pzo0ilU3wobVi2wqVj88VZI2SlgZlNnUAkrDGDIAph7CTa9k9fshV+O1ivpEgPC5yC046jow==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^3.27.1", + "@tiptap/extension-blockquote": "^3.27.1", + "@tiptap/extension-bold": "^3.27.1", + "@tiptap/extension-bullet-list": "^3.27.1", + "@tiptap/extension-code": "^3.27.1", + "@tiptap/extension-code-block": "^3.27.1", + "@tiptap/extension-document": "^3.27.1", + "@tiptap/extension-dropcursor": "^3.27.1", + "@tiptap/extension-gapcursor": "^3.27.1", + "@tiptap/extension-hard-break": "^3.27.1", + "@tiptap/extension-heading": "^3.27.1", + "@tiptap/extension-horizontal-rule": "^3.27.1", + "@tiptap/extension-italic": "^3.27.1", + "@tiptap/extension-link": "^3.27.1", + "@tiptap/extension-list": "^3.27.1", + "@tiptap/extension-list-item": "^3.27.1", + "@tiptap/extension-list-keymap": "^3.27.1", + "@tiptap/extension-ordered-list": "^3.27.1", + "@tiptap/extension-paragraph": "^3.27.1", + "@tiptap/extension-strike": "^3.27.1", + "@tiptap/extension-text": "^3.27.1", + "@tiptap/extension-underline": "^3.27.1", + "@tiptap/extensions": "^3.27.1", + "@tiptap/pm": "^3.27.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "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" + } + }, + "node_modules/@types/css-font-loading-module": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.12.tgz", + "integrity": "sha512-x2tZZYkSxXqWvTDgveSynfjq/T2HyiZHXb00j/+gy19yp70PHCizM48XFdjBCWH7eHBD0R5i/pw9yMBP/BH5uA==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" } }, "node_modules/@types/deep-eql": { @@ -3822,6 +4567,12 @@ "@types/node": "*" } }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -3833,7 +4584,6 @@ "version": "22.19.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -3855,14 +4605,12 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", - "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -3873,7 +4621,6 @@ "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^18.0.0" @@ -3889,6 +4636,12 @@ "@types/node": "*" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/verror": { "version": "1.10.11", "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", @@ -4293,6 +5046,20 @@ "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==", "license": "BSD-3-Clause" }, + "node_modules/@xenova/transformers": { + "version": "2.17.2", + "resolved": "https://registry.npmjs.org/@xenova/transformers/-/transformers-2.17.2.tgz", + "integrity": "sha512-lZmHqzrVIkSvZdKZEx7IYY51TK0WDrC8eR0c5IMnBsO8di8are1zzw8BlLhyO2TklZKLN5UffNGs1IJwT6oOqQ==", + "license": "Apache-2.0", + "dependencies": { + "@huggingface/jinja": "^0.2.2", + "onnxruntime-web": "1.14.0", + "sharp": "^0.32.0" + }, + "optionalDependencies": { + "onnxruntime-node": "1.14.0" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.13", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", @@ -4763,11 +5530,101 @@ "node": "18 || 20 || >=22" } }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.1.tgz", + "integrity": "sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz", + "integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==", + "license": "Apache-2.0", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.1.tgz", + "integrity": "sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==", + "license": "Apache-2.0", + "dependencies": { + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.3.tgz", + "integrity": "sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -4797,6 +5654,13 @@ "node": ">=6.0.0" } }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -4819,6 +5683,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/boolean": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", @@ -4891,7 +5766,6 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, "funding": [ { "type": "github", @@ -4907,7 +5781,6 @@ } ], "license": "MIT", - "optional": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -5306,11 +6179,23 @@ "node": ">=6" } }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -5323,9 +6208,18 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", @@ -5373,6 +6267,20 @@ "dev": true, "license": "MIT" }, + "node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -5483,7 +6391,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, "node_modules/data-urls": { @@ -5529,7 +6436,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, "license": "MIT", "dependencies": { "mimic-response": "^3.1.0" @@ -5545,7 +6451,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -5554,6 +6459,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/defer-to-connect": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", @@ -5622,6 +6536,15 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-node": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", @@ -6096,7 +7019,6 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -6289,6 +7211,24 @@ "license": "MIT", "peer": true }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -6368,6 +7308,21 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -6503,6 +7458,12 @@ "integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==", "license": "MIT" }, + "node_modules/flatbuffers": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-1.12.0.tgz", + "integrity": "sha512-c7CZADjRcl6j0PlvFy0ZqXQ67qSEZfrVPynmnL+2zPc+NtMvrF8Y0QceMo7QqnSPc7+uWjUIAbvCQ5WIKlMVdQ==", + "license": "SEE LICENSE IN LICENSE.txt" + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -6561,6 +7522,12 @@ } } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fs-extra": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", @@ -6716,6 +7683,12 @@ "js-binary-schema-parser": "^2.0.3" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -6883,6 +7856,12 @@ "integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==", "license": "Standard 'no charge' license: https://gsap.com/standard-license." }, + "node_modules/guid-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", + "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", + "license": "ISC" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -7093,7 +8072,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -7108,8 +8086,7 @@ "url": "https://feross.org/support" } ], - "license": "BSD-3-Clause", - "optional": true + "license": "BSD-3-Clause" }, "node_modules/indent-string": { "version": "4.0.0", @@ -7137,9 +8114,20 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -7382,6 +8370,13 @@ "license": "ISC", "optional": true }, + "node_modules/json-with-bigint": { + "version": "3.5.8", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.8.tgz", + "integrity": "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -7440,6 +8435,12 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/linkifyjs": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", + "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", + "license": "MIT" + }, "node_modules/lint-staged": { "version": "16.4.0", "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz", @@ -7652,6 +8653,12 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "license": "Apache-2.0" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -7884,7 +8891,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -7927,6 +8933,12 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/motion": { "version": "12.38.0", "resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz", @@ -8023,6 +9035,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/node-abi": { "version": "4.28.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.28.0.tgz", @@ -8256,7 +9274,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -8278,6 +9295,56 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/onnx-proto": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/onnx-proto/-/onnx-proto-4.0.4.tgz", + "integrity": "sha512-aldMOB3HRoo6q/phyB6QRQxSt895HNNw82BNyZ2CMh4bjeKv7g/c+VpAFtJuEMVfYLMbRx61hbuqnKceLeDcDA==", + "license": "MIT", + "dependencies": { + "protobufjs": "^6.8.8" + } + }, + "node_modules/onnxruntime-common": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.14.0.tgz", + "integrity": "sha512-3LJpegM2iMNRX2wUmtYfeX/ytfOzNwAWKSq1HbRrKc9+uqG/FsEA0bbKZl1btQeZaXhC26l44NWpNUeXPII7Ew==", + "license": "MIT" + }, + "node_modules/onnxruntime-node": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.14.0.tgz", + "integrity": "sha512-5ba7TWomIV/9b6NH/1x/8QEeowsb+jBEvFzU6z0T4mNsFwdPqXeFUM7uxC6QeSRkEbWu3qEB0VMjrvzN/0S9+w==", + "license": "MIT", + "optional": true, + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "onnxruntime-common": "~1.14.0" + } + }, + "node_modules/onnxruntime-web": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.14.0.tgz", + "integrity": "sha512-Kcqf43UMfW8mCydVGcX9OMXI2VN17c0p6XvR7IPSZzBf/6lteBzXHvcEVWDPmCKuGombl997HgLqj91F11DzXw==", + "license": "MIT", + "dependencies": { + "flatbuffers": "^1.12.0", + "guid-typescript": "^1.0.9", + "long": "^4.0.0", + "onnx-proto": "^4.0.4", + "onnxruntime-common": "~1.14.0", + "platform": "^1.3.6" + } + }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, "node_modules/p-cancelable": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", @@ -8470,6 +9537,12 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "license": "MIT" + }, "node_modules/playwright": { "version": "1.59.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", @@ -8695,22 +9768,107 @@ "commander": "^9.4.0" }, "bin": { - "postject": "dist/cli.js" + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prebuild-install/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/prebuild-install/node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prebuild-install/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": ">=14.0.0" + "node": ">=10" } }, - "node_modules/postject/node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", - "dev": true, + "node_modules/prebuild-install/node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", "license": "MIT", - "optional": true, - "peer": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/prebuild-install/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, "engines": { - "node": "^12.20.0 || >=14" + "node": ">=6" } }, "node_modules/pretty-format": { @@ -8806,11 +9964,175 @@ "signal-exit": "^3.0.2" } }, + "node_modules/prosemirror-changeset": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz", + "integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", + "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.9", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.9.tgz", + "integrity": "sha512-pRTklkDDMMRopyoAcrr9wV/8g/RYgrLHBuJAb5hlEuYZRdm5yqmPjWId83fpBwPpSFqEdja0H7Dfd7z1X/npcA==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", + "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.42.0", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.0.tgz", + "integrity": "sha512-N54DF3OXNWDuP81G1kbfCys8ZzIjuL1VnvJ2mk5STSu/fNxWIcX/EutQLA3s9KR/2wVhgDi4hzBB/1fINVxk0A==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.8", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, + "node_modules/protobufjs": { + "version": "6.11.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.6.tgz", + "integrity": "sha512-k8BHqgPBOtrlougZZqF2uUk5Z7bN8f0wj+3e8M3hvtSv0NBAz4VBy5f6R5Nxq/l+i7mRFTgNZb2trxqTpHNY/A==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.1", + "@types/node": ">=13.7.0", + "long": "^4.0.0" + }, + "bin": { + "pbjs": "bin/pbjs", + "pbts": "bin/pbts" + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "dev": true, "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -8893,6 +10215,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, "node_modules/re-resizable": { "version": "6.11.2", "resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.11.2.tgz", @@ -9091,6 +10428,20 @@ "pify": "^2.3.0" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -9350,6 +10701,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -9373,6 +10730,26 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -9457,6 +10834,47 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/sharp": { + "version": "0.32.6", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz", + "integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.2", + "node-addon-api": "^6.1.0", + "prebuild-install": "^7.1.1", + "semver": "^7.5.4", + "simple-get": "^4.0.1", + "tar-fs": "^3.0.4", + "tunnel-agent": "^0.6.0" + }, + "engines": { + "node": ">=14.15.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/sharp/node_modules/node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", + "license": "MIT" + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -9570,6 +10988,60 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -9711,6 +11183,26 @@ "dev": true, "license": "MIT" }, + "node_modules/streamx": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", + "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -9791,6 +11283,15 @@ "node": ">=8" } }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -9949,6 +11450,46 @@ "node": ">=18" } }, + "node_modules/tar-fs": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz", + "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/tar-stream/node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/tar/node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", @@ -9959,6 +11500,15 @@ "node": ">=18" } }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, "node_modules/temp": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", @@ -10049,6 +11599,29 @@ "dev": true, "license": "MIT" }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/text-decoder/node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -10252,6 +11825,28 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-fest": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", @@ -10294,9 +11889,15 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "dev": true, + "license": "ISC" + }, "node_modules/universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", @@ -10412,6 +12013,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/utf8-byte-length": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", @@ -10656,6 +12266,12 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -10806,7 +12422,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/ws": { diff --git a/package.json b/package.json index fd0c4cf3d4..8d40c9d7e6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "openscreen", "private": true, - "version": "1.4.0", + "version": "1.6.0", "type": "module", "packageManager": "npm@10.9.4", "engines": { @@ -9,9 +9,15 @@ "npm": "10.9.4" }, "author": { - "name": "Sid", - "email": "svaddem@asu.edu" + "name": "Siddharth Vaddem", + "url": "https://github.com/siddharthvaddem" }, + "maintainers": [ + { + "name": "Etienne Lescot", + "url": "https://github.com/EtienneLescot" + } + ], "scripts": { "dev": "vite", "build": "tsc && vite build && electron-builder", @@ -37,6 +43,8 @@ "test:wgc-full:win": "node scripts/test-windows-wgc-helper.mjs --webcam --system-audio --microphone", "capture:openscreen-preview": "node scripts/capture-openscreen-preview.mjs", "inspect:cursor-click-bounce": "node scripts/inspect-native-cursor-click-bounce.mjs", + "diagnostic:run": "node scripts/diagnostic-tool/diagnostic.mjs", + "diagnostic:smoke:win": "node scripts/diagnostic-tool/diagnostic.mjs --duration 3", "build-vite": "tsc && vite build", "test:browser": "vitest --config vitest.browser.config.ts --run", "test:browser:install": "playwright install --with-deps chromium-headless-shell", @@ -59,10 +67,14 @@ "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", + "@tiptap/extension-text-style": "^3.27.1", + "@tiptap/react": "^3.27.1", + "@tiptap/starter-kit": "^3.27.1", "@types/gif.js": "^0.2.5", "@uiw/color-convert": "^2.10.1", "@uiw/react-color-block": "^2.10.1", "@uiw/react-color-colorful": "^2.9.2", + "@xenova/transformers": "^2.17.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dnd-timeline": "^2.4.0", @@ -88,6 +100,8 @@ "web-demuxer": "^4.0.0" }, "devDependencies": { + "@actions/core": "^3.0.1", + "@actions/github": "^9.1.1", "@biomejs/biome": "^2.4.12", "@electron/rebuild": "^4.0.4", "@playwright/test": "^1.59.1", diff --git a/public/cursors/among-us-sus-knife-and-red-animated/arrow.png b/public/cursors/among-us-sus-knife-and-red-animated/arrow.png new file mode 100644 index 0000000000..84e9a3b34d Binary files /dev/null and b/public/cursors/among-us-sus-knife-and-red-animated/arrow.png differ diff --git a/public/cursors/among-us-sus-knife-and-red-animated/pointer.png b/public/cursors/among-us-sus-knife-and-red-animated/pointer.png new file mode 100644 index 0000000000..b4212f7326 Binary files /dev/null and b/public/cursors/among-us-sus-knife-and-red-animated/pointer.png differ diff --git a/public/cursors/black-and-rainbow-stroke-gradient-animated/arrow.png b/public/cursors/black-and-rainbow-stroke-gradient-animated/arrow.png new file mode 100644 index 0000000000..adfcec8dcb Binary files /dev/null and b/public/cursors/black-and-rainbow-stroke-gradient-animated/arrow.png differ diff --git a/public/cursors/black-and-rainbow-stroke-gradient-animated/pointer.png b/public/cursors/black-and-rainbow-stroke-gradient-animated/pointer.png new file mode 100644 index 0000000000..2a0ebf08fb Binary files /dev/null and b/public/cursors/black-and-rainbow-stroke-gradient-animated/pointer.png differ diff --git a/public/cursors/black-pixel/arrow.png b/public/cursors/black-pixel/arrow.png new file mode 100644 index 0000000000..ec2d6a3159 Binary files /dev/null and b/public/cursors/black-pixel/arrow.png differ diff --git a/public/cursors/black-pixel/pointer.png b/public/cursors/black-pixel/pointer.png new file mode 100644 index 0000000000..ffd5616410 Binary files /dev/null and b/public/cursors/black-pixel/pointer.png differ diff --git a/public/cursors/christmas-miles-morales/arrow.png b/public/cursors/christmas-miles-morales/arrow.png new file mode 100644 index 0000000000..6931394ecb Binary files /dev/null and b/public/cursors/christmas-miles-morales/arrow.png differ diff --git a/public/cursors/christmas-miles-morales/pointer.png b/public/cursors/christmas-miles-morales/pointer.png new file mode 100644 index 0000000000..049a68e2ea Binary files /dev/null and b/public/cursors/christmas-miles-morales/pointer.png differ diff --git a/public/cursors/hello-kitty-watermelon/arrow.png b/public/cursors/hello-kitty-watermelon/arrow.png new file mode 100644 index 0000000000..9e6a203199 Binary files /dev/null and b/public/cursors/hello-kitty-watermelon/arrow.png differ diff --git a/public/cursors/hello-kitty-watermelon/pointer.png b/public/cursors/hello-kitty-watermelon/pointer.png new file mode 100644 index 0000000000..f45fdfd061 Binary files /dev/null and b/public/cursors/hello-kitty-watermelon/pointer.png differ diff --git a/public/cursors/hollow-knight-and-game-arrow/arrow.png b/public/cursors/hollow-knight-and-game-arrow/arrow.png new file mode 100644 index 0000000000..efb4b77082 Binary files /dev/null and b/public/cursors/hollow-knight-and-game-arrow/arrow.png differ diff --git a/public/cursors/hollow-knight-and-game-arrow/pointer.png b/public/cursors/hollow-knight-and-game-arrow/pointer.png new file mode 100644 index 0000000000..dcea8e91d1 Binary files /dev/null and b/public/cursors/hollow-knight-and-game-arrow/pointer.png differ diff --git a/public/cursors/hollow-knight-nail-sword-and-mask/arrow.png b/public/cursors/hollow-knight-nail-sword-and-mask/arrow.png new file mode 100644 index 0000000000..dbd4f52a20 Binary files /dev/null and b/public/cursors/hollow-knight-nail-sword-and-mask/arrow.png differ diff --git a/public/cursors/hollow-knight-nail-sword-and-mask/pointer.png b/public/cursors/hollow-knight-nail-sword-and-mask/pointer.png new file mode 100644 index 0000000000..ac77b710c5 Binary files /dev/null and b/public/cursors/hollow-knight-nail-sword-and-mask/pointer.png differ diff --git a/public/cursors/mickey-mouse-black-hand-inflated-glove/arrow.png b/public/cursors/mickey-mouse-black-hand-inflated-glove/arrow.png new file mode 100644 index 0000000000..1430763725 Binary files /dev/null and b/public/cursors/mickey-mouse-black-hand-inflated-glove/arrow.png differ diff --git a/public/cursors/mickey-mouse-black-hand-inflated-glove/pointer.png b/public/cursors/mickey-mouse-black-hand-inflated-glove/pointer.png new file mode 100644 index 0000000000..f52a4e650c Binary files /dev/null and b/public/cursors/mickey-mouse-black-hand-inflated-glove/pointer.png differ diff --git a/public/cursors/naruto-akatsuki-cloud-arrow/arrow.png b/public/cursors/naruto-akatsuki-cloud-arrow/arrow.png new file mode 100644 index 0000000000..d6361dff20 Binary files /dev/null and b/public/cursors/naruto-akatsuki-cloud-arrow/arrow.png differ diff --git a/public/cursors/naruto-akatsuki-cloud-arrow/pointer.png b/public/cursors/naruto-akatsuki-cloud-arrow/pointer.png new file mode 100644 index 0000000000..fa10e9d2d8 Binary files /dev/null and b/public/cursors/naruto-akatsuki-cloud-arrow/pointer.png differ diff --git a/public/cursors/old-roblox/arrow.png b/public/cursors/old-roblox/arrow.png new file mode 100644 index 0000000000..a55875df9e Binary files /dev/null and b/public/cursors/old-roblox/arrow.png differ diff --git a/public/cursors/old-roblox/pointer.png b/public/cursors/old-roblox/pointer.png new file mode 100644 index 0000000000..d490a65fd6 Binary files /dev/null and b/public/cursors/old-roblox/pointer.png differ diff --git a/public/cursors/pink-glossy-arrow-and-hand-3d/arrow.png b/public/cursors/pink-glossy-arrow-and-hand-3d/arrow.png new file mode 100644 index 0000000000..69ecd5b199 Binary files /dev/null and b/public/cursors/pink-glossy-arrow-and-hand-3d/arrow.png differ diff --git a/public/cursors/pink-glossy-arrow-and-hand-3d/pointer.png b/public/cursors/pink-glossy-arrow-and-hand-3d/pointer.png new file mode 100644 index 0000000000..f8d5938a70 Binary files /dev/null and b/public/cursors/pink-glossy-arrow-and-hand-3d/pointer.png differ diff --git a/public/cursors/pinky-pixel/arrow.png b/public/cursors/pinky-pixel/arrow.png new file mode 100644 index 0000000000..4bcd3dcaa9 Binary files /dev/null and b/public/cursors/pinky-pixel/arrow.png differ diff --git a/public/cursors/pinky-pixel/pointer.png b/public/cursors/pinky-pixel/pointer.png new file mode 100644 index 0000000000..7eb897a743 Binary files /dev/null and b/public/cursors/pinky-pixel/pointer.png differ diff --git a/public/cursors/pokemon-neon-gengar/arrow.png b/public/cursors/pokemon-neon-gengar/arrow.png new file mode 100644 index 0000000000..4dcccb3609 Binary files /dev/null and b/public/cursors/pokemon-neon-gengar/arrow.png differ diff --git a/public/cursors/pokemon-neon-gengar/pointer.png b/public/cursors/pokemon-neon-gengar/pointer.png new file mode 100644 index 0000000000..e7b4437f0e Binary files /dev/null and b/public/cursors/pokemon-neon-gengar/pointer.png differ diff --git a/public/cursors/sanrio-gudetama-and-arrow-kawaii/arrow.png b/public/cursors/sanrio-gudetama-and-arrow-kawaii/arrow.png new file mode 100644 index 0000000000..123117b05b Binary files /dev/null and b/public/cursors/sanrio-gudetama-and-arrow-kawaii/arrow.png differ diff --git a/public/cursors/sanrio-gudetama-and-arrow-kawaii/pointer.png b/public/cursors/sanrio-gudetama-and-arrow-kawaii/pointer.png new file mode 100644 index 0000000000..37afa7bd17 Binary files /dev/null and b/public/cursors/sanrio-gudetama-and-arrow-kawaii/pointer.png differ diff --git a/public/cursors/sanrio-kuromi-skull-arrow/arrow.png b/public/cursors/sanrio-kuromi-skull-arrow/arrow.png new file mode 100644 index 0000000000..6fe7e89d6e Binary files /dev/null and b/public/cursors/sanrio-kuromi-skull-arrow/arrow.png differ diff --git a/public/cursors/sanrio-kuromi-skull-arrow/pointer.png b/public/cursors/sanrio-kuromi-skull-arrow/pointer.png new file mode 100644 index 0000000000..2971a7f168 Binary files /dev/null and b/public/cursors/sanrio-kuromi-skull-arrow/pointer.png differ diff --git a/public/cursors/solo-leveling-sung-jinwoo-dark-flames/arrow.png b/public/cursors/solo-leveling-sung-jinwoo-dark-flames/arrow.png new file mode 100644 index 0000000000..2155e0fd95 Binary files /dev/null and b/public/cursors/solo-leveling-sung-jinwoo-dark-flames/arrow.png differ diff --git a/public/cursors/solo-leveling-sung-jinwoo-dark-flames/pointer.png b/public/cursors/solo-leveling-sung-jinwoo-dark-flames/pointer.png new file mode 100644 index 0000000000..0e0b1a0b7b Binary files /dev/null and b/public/cursors/solo-leveling-sung-jinwoo-dark-flames/pointer.png differ diff --git a/public/cursors/spring-gradient/arrow.png b/public/cursors/spring-gradient/arrow.png new file mode 100644 index 0000000000..51889625d9 Binary files /dev/null and b/public/cursors/spring-gradient/arrow.png differ diff --git a/public/cursors/spring-gradient/pointer.png b/public/cursors/spring-gradient/pointer.png new file mode 100644 index 0000000000..9ccdc2fb64 Binary files /dev/null and b/public/cursors/spring-gradient/pointer.png differ diff --git a/public/demo.png b/public/demo.png new file mode 100644 index 0000000000..a0f008fe46 Binary files /dev/null and b/public/demo.png differ diff --git a/public/sample.png b/public/sample.png new file mode 100644 index 0000000000..0138b39ac9 Binary files /dev/null and b/public/sample.png differ diff --git a/public/wallpapers/wallpaper1.jpg b/public/wallpapers/wallpaper1.jpg index 98c93cb14e..dbd8afb8b0 100644 Binary files a/public/wallpapers/wallpaper1.jpg and b/public/wallpapers/wallpaper1.jpg differ diff --git a/scripts/before-pack.cjs b/scripts/before-pack.cjs new file mode 100644 index 0000000000..934756a4db --- /dev/null +++ b/scripts/before-pack.cjs @@ -0,0 +1,14 @@ +// electron-builder beforePack hook: ensure the auto-caption assets (Whisper model + ORT wasm) exist +// before packaging, so the `caption-assets` extraResources entry has something to copy. Runs on +// every package invocation (local `npm run build:*` and CI's bare `electron-builder`). The fetch +// script is idempotent, so it's a no-op once the assets are present. + +const { execFileSync } = require("node:child_process"); +const path = require("node:path"); + +exports.default = async function beforePack() { + execFileSync("node", [path.join(__dirname, "fetch-caption-model.mjs")], { + stdio: "inherit", + cwd: path.join(__dirname, ".."), + }); +}; diff --git a/scripts/build-macos-screencapturekit-helper.mjs b/scripts/build-macos-screencapturekit-helper.mjs index 8e7c973967..b94836cfff 100644 --- a/scripts/build-macos-screencapturekit-helper.mjs +++ b/scripts/build-macos-screencapturekit-helper.mjs @@ -18,14 +18,24 @@ const cursorHelperName = "openscreen-macos-cursor-helper"; const packageDir = path.join(root, "electron", "native", "screencapturekit"); const buildDir = path.join(packageDir, "build"); const swiftBuildDir = path.join(buildDir, "swiftpm"); -const builtHelperPath = path.join(swiftBuildDir, "release", helperName); const localHelperPath = path.join(buildDir, helperName); -const builtCursorHelperPath = path.join(swiftBuildDir, "release", cursorHelperName); const localCursorHelperPath = path.join(buildDir, cursorHelperName); -const archTag = process.arch === "arm64" ? "darwin-arm64" : "darwin-x64"; -const distributableDir = path.join(root, "electron", "native", "bin", archTag); -const distributablePath = path.join(distributableDir, helperName); -const distributableCursorHelperPath = path.join(distributableDir, cursorHelperName); + +// Build a separate single-arch binary per requested arch and place each in its own +// electron/native/bin/darwin- folder (the runtime resolves that folder by the running app's +// arch). No universal/fat binary. Defaults to the host arch for local builds; CI sets +// OPENSCREEN_MAC_HELPER_ARCHS per matrix entry (accepts arm64, x64, or x86_64). +function normalizeArch(value) { + return value === "x64" || value === "x86_64" + ? { swift: "x86_64", tag: "darwin-x64" } + : { swift: "arm64", tag: "darwin-arm64" }; +} +const hostArch = process.arch === "arm64" ? "arm64" : "x86_64"; +const archs = (process.env.OPENSCREEN_MAC_HELPER_ARCHS ?? hostArch) + .split(",") + .map((a) => a.trim()) + .filter(Boolean) + .map(normalizeArch); const xcodebuildVersion = spawnSync("xcodebuild", ["-version"], { cwd: root, @@ -50,42 +60,85 @@ if (xcodebuildVersion.status !== 0) { process.exit(1); } -const result = spawnSync( - "swift", - ["build", "-c", "release", "--package-path", packageDir, "--build-path", swiftBuildDir], - { - cwd: root, - stdio: "inherit", - }, -); +// SwiftPM writes a single-arch release build to /-apple-macosx/release/. +// Fall back to a search that skips the identically-named file inside the .dSYM debug bundle (matching +// that file and feeding it forward is what produced an unrunnable "exec format error" binary before). +function findExecutable(dir, swiftArch, name) { + const expected = path.join(dir, `${swiftArch}-apple-macosx`, "release", name); + if (fs.existsSync(expected)) return expected; -if (result.error) { - console.error(`Failed to start Swift build: ${result.error.message}`); - process.exit(1); -} - -if (result.status !== 0) { - process.exit(result.status ?? 1); + const stack = [dir]; + const matches = []; + while (stack.length > 0) { + const current = stack.pop(); + let entries; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (entry.name.endsWith(".dSYM")) continue; + const full = path.join(current, entry.name); + if (entry.isDirectory()) stack.push(full); + else if (entry.isFile() && entry.name === name && /[/\\]release[/\\]/i.test(full)) { + matches.push(full); + } + } + } + return matches[0] ?? null; } fs.mkdirSync(buildDir, { recursive: true }); -fs.mkdirSync(distributableDir, { recursive: true }); -for (const artifactPath of [builtHelperPath, builtCursorHelperPath]) { - if (!fs.existsSync(artifactPath)) { - console.error(`Swift build completed but expected artifact was not found: ${artifactPath}`); + +for (const { swift, tag } of archs) { + const archBuildDir = path.join(swiftBuildDir, swift); + const result = spawnSync( + "swift", + [ + "build", + "-c", + "release", + "--arch", + swift, + "--package-path", + packageDir, + "--build-path", + archBuildDir, + ], + { + cwd: root, + stdio: "inherit", + }, + ); + if (result.error) { + console.error(`Failed to start Swift build (${swift}): ${result.error.message}`); process.exit(1); } -} -fs.copyFileSync(builtHelperPath, localHelperPath); -fs.copyFileSync(builtHelperPath, distributablePath); -fs.copyFileSync(builtCursorHelperPath, localCursorHelperPath); -fs.copyFileSync(builtCursorHelperPath, distributableCursorHelperPath); -fs.chmodSync(localHelperPath, 0o755); -fs.chmodSync(distributablePath, 0o755); -fs.chmodSync(localCursorHelperPath, 0o755); -fs.chmodSync(distributableCursorHelperPath, 0o755); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } -console.log(`Built macOS ScreenCaptureKit helper: ${localHelperPath}`); -console.log(`Copied redistributable helper: ${distributablePath}`); -console.log(`Built macOS cursor helper: ${localCursorHelperPath}`); -console.log(`Copied redistributable cursor helper: ${distributableCursorHelperPath}`); + const targetDir = path.join(root, "electron", "native", "bin", tag); + fs.mkdirSync(targetDir, { recursive: true }); + + for (const [name, localPath] of [ + [helperName, localHelperPath], + [cursorHelperName, localCursorHelperPath], + ]) { + const exe = findExecutable(archBuildDir, swift, name); + if (!exe) { + console.error(`Swift build (${swift}) completed but executable was not found: ${name}`); + process.exit(1); + } + // Always place it in the arch's bin folder; mirror the host-arch build into the dev build + // dir so `npm run dev` (candidate path #2) can spawn it. + const dests = [path.join(targetDir, name)]; + if (swift === hostArch) dests.push(localPath); + for (const dest of dests) { + fs.copyFileSync(exe, dest); + fs.chmodSync(dest, 0o755); + } + } + console.log(`Built ${tag} helpers (${swift})`); +} diff --git a/scripts/build-windows-wgc-helper.mjs b/scripts/build-windows-wgc-helper.mjs index 29df4d842b..e0e3219bb8 100644 --- a/scripts/build-windows-wgc-helper.mjs +++ b/scripts/build-windows-wgc-helper.mjs @@ -1,4 +1,4 @@ -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -18,8 +18,37 @@ function findVcVarsAll() { return explicit; } + const vswhere = "C:\\Program Files (x86)\\Microsoft Visual Studio\\Installer\\vswhere.exe"; + if (fs.existsSync(vswhere)) { + const result = spawnSync( + vswhere, + [ + "-latest", + "-products", + "*", + "-requires", + "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", + "-property", + "installationPath", + ], + { encoding: "utf8", windowsHide: true }, + ); + const installPath = result.stdout?.trim(); + if (result.status === 0 && installPath) { + const candidate = path.join(installPath, "VC", "Auxiliary", "Build", "vcvarsall.bat"); + if (fs.existsSync(candidate)) { + return candidate; + } + } + } + const roots = [ process.env.VSINSTALLDIR, + "C:\\Program Files\\Microsoft Visual Studio\\2026\\Community", + "C:\\Program Files\\Microsoft Visual Studio\\2026\\Professional", + "C:\\Program Files\\Microsoft Visual Studio\\2026\\Enterprise", + "C:\\Program Files (x86)\\Microsoft Visual Studio\\2026\\BuildTools", + "C:\\Program Files (x86)\\Microsoft Visual Studio\\2026\\Community", "C:\\Program Files\\Microsoft Visual Studio\\2022\\Community", "C:\\Program Files\\Microsoft Visual Studio\\2022\\Professional", "C:\\Program Files\\Microsoft Visual Studio\\2022\\Enterprise", diff --git a/scripts/diagnostic-tool/README.md b/scripts/diagnostic-tool/README.md new file mode 100644 index 0000000000..046468e3e9 --- /dev/null +++ b/scripts/diagnostic-tool/README.md @@ -0,0 +1,65 @@ +# OpenScreen standalone diagnostic tool + +A small Node.js script that runs the native capture helper outside the +Electron app, captures its stdout/stderr, and writes a JSON report. + +Used to capture `[stop-timing]` lines emitted by the WGC / ScreenCaptureKit +helper when a recording stop hangs, so the issue reporter can attach the +data without installing or rebuilding the full app. + +## Requirements + +- Node.js 22+ (OpenScreen's own engine pin) +- The native capture helper for your platform in one of: + - the same directory as `diagnostic.mjs` (`wgc-capture.exe` on Windows, + `openscreen-screencapturekit-helper` on macOS) + - `helpers/-/` (CI artifact layout) + - `$OPENSCREEN_HELPER_EXE` env var + +Linux is not currently supported — OpenScreen has no Linux native helper. + +## Usage + +```text +node diagnostic.mjs --duration 10 --output ./diag.json +``` + +Flags: +- `-d, --duration ` recording length before sending stop (default 10) +- `-o, --output ` output JSON path (default `./openscreen-diagnostic-.json`) +- `--window` capture a window instead of the full display (default: display) +- `-h, --help` show help + +Or use the bundled launcher: +- Windows: `diagnostic.bat` +- macOS / Linux: `./diagnostic.sh` + +## Output + +The JSON contains: +- system info (platform, arch, OS, CPU, memory) +- the helper's full stdout and stderr +- parsed `[stop-timing]` entries as a structured array +- the JSON config that was sent to the helper +- exit code / signal + +Attach the JSON to a GitHub issue. Maintainers will read the +`stopTiming` array and the helper stderr to pinpoint which step of the +stop cleanup is slow. + +## Layout + +```text +scripts/diagnostic-tool/ + README.md + diagnostic.mjs # the tool + diagnostic.bat # Windows launcher + diagnostic.sh # macOS / Linux launcher +``` + +## CI artifacts + +`.github/workflows/diagnostic-artifact.yml` builds per-platform zips +that bundle this directory with the prebuilt helper. The workflow runs on +every push to main and on manual dispatch; artifacts are retained for 14 +days. \ No newline at end of file diff --git a/scripts/diagnostic-tool/diagnostic.bat b/scripts/diagnostic-tool/diagnostic.bat new file mode 100644 index 0000000000..95c0f721dd --- /dev/null +++ b/scripts/diagnostic-tool/diagnostic.bat @@ -0,0 +1,3 @@ +@echo off +setlocal +node "%~dp0diagnostic.mjs" %* \ No newline at end of file diff --git a/scripts/diagnostic-tool/diagnostic.mjs b/scripts/diagnostic-tool/diagnostic.mjs new file mode 100644 index 0000000000..3b08d798cc --- /dev/null +++ b/scripts/diagnostic-tool/diagnostic.mjs @@ -0,0 +1,311 @@ +#!/usr/bin/env node +// OpenScreen standalone diagnostic tool. +// +// Runs the native capture helper outside the Electron app, captures its +// stdout/stderr, and writes a JSON report you can attach to a bug report. +// Used to capture [stop-timing] lines from the helper without requiring the +// full app to install/reproduce. +// +// Usage: +// node diagnostic.mjs # 10s recording, default output +// node diagnostic.mjs --duration 30 # 30s recording +// node diagnostic.mjs --output ./out.json # custom output path +// node diagnostic.mjs --window # capture a window (default: display) +// +// Helper discovery: +// 1. $OPENSCREEN_HELPER_EXE (any path) +// 2. ./wgc-capture.exe (Windows) +// ./openscreen-screencapturekit-helper (macOS) +// 3. ./helpers/-/ (CI artifact layout) + +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const HELPER_CANDIDATES = { + win32: { + x64: { name: "wgc-capture.exe", kind: "windows" }, + arm64: { name: "wgc-capture.exe", kind: "windows" }, + }, + darwin: { + x64: { name: "openscreen-screencapturekit-helper", kind: "mac" }, + arm64: { name: "openscreen-screencapturekit-helper", kind: "mac" }, + }, +}; + +function parseArgs(argv) { + const opts = { + duration: 10_000, + output: null, + source: "display", + help: false, + }; + const requireNumber = (raw, flag) => { + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) { + throw new Error(`${flag} requires a positive number, got: ${raw}`); + } + return n; + }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--duration" || arg === "-d") { + const value = argv[++i]; + if (value === undefined) throw new Error(`${arg} requires a value`); + opts.duration = requireNumber(value, arg) * 1000; + } else if (arg === "--output" || arg === "-o") { + const value = argv[++i]; + if (value === undefined) throw new Error(`${arg} requires a value`); + opts.output = value; + } else if (arg === "--source") { + const value = argv[++i]; + if (value === undefined) throw new Error(`${arg} requires a value`); + opts.source = value; + } else if (arg === "--window") { + opts.source = "window"; + } else if (arg === "--help" || arg === "-h") { + opts.help = true; + } else if (arg.startsWith("--")) { + throw new Error(`Unknown flag: ${arg}`); + } + } + return opts; +} + +function printHelp() { + console.log(`OpenScreen standalone diagnostic tool + +Usage: + node diagnostic.mjs [flags] + +Flags: + -d, --duration Recording length before sending stop (default: 10) + -o, --output Output JSON path (default: ./openscreen-diagnostic-.json) + --source Capture source type (default: display) + --window Shortcut for --source window + -h, --help Show this help +`); +} + +function findHelper() { + const explicit = process.env.OPENSCREEN_HELPER_EXE?.trim(); + if (explicit && fs.existsSync(explicit)) return { path: explicit, kind: null }; + + const platform = process.platform; + const arch = process.arch === "arm64" ? "arm64" : "x64"; + const descriptor = HELPER_CANDIDATES[platform]?.[arch]; + if (!descriptor) { + throw new Error(`Unsupported platform: ${platform}-${arch}`); + } + + const inScriptDir = path.join(__dirname, descriptor.name); + if (fs.existsSync(inScriptDir)) return { path: inScriptDir, kind: descriptor.kind }; + + const inHelpersDir = path.join(__dirname, "helpers", `${platform}-${arch}`, descriptor.name); + if (fs.existsSync(inHelpersDir)) return { path: inHelpersDir, kind: descriptor.kind }; + + throw new Error( + `Native helper not found for ${platform}-${arch}. Looked for:\n` + + ` $OPENSCREEN_HELPER_EXE\n` + + ` ${inScriptDir}\n` + + ` ${inHelpersDir}\n` + + `Download the matching diagnostic bundle from the OpenScreen releases / CI artifacts.`, + ); +} + +function buildConfig(opts) { + const now = Date.now(); + return { + schemaVersion: 2, + recordingId: now, + outputPath: path.join(os.tmpdir(), `openscreen-diag-${now}.mp4`), + sourceType: opts.source === "window" ? "window" : "display", + sourceId: opts.source === "window" ? "window:0:0" : "screen:0:0", + displayId: 0, + fps: 30, + videoWidth: 1280, + videoHeight: 720, + displayX: 0, + displayY: 0, + displayW: 1920, + displayH: 1080, + hasDisplayBounds: true, + captureSystemAudio: false, + captureMic: false, + captureCursor: false, + microphoneDeviceId: "default", + microphoneDeviceName: "", + microphoneGain: 1.0, + webcamEnabled: false, + outputs: { screenPath: "" }, + }; +} + +function parseStopTiming(stderrText) { + const lines = []; + for (const line of stderrText.split(/\r?\n/)) { + const m = line.match(/\[stop-timing\]\s+step=(\S+)\s+elapsed_ms=(\d+)/); + if (m) lines.push({ step: m[1], elapsedMs: Number(m[2]) }); + } + return lines; +} + +function run(opts) { + const helper = findHelper(); + console.log(`[diag] helper: ${helper.path}`); + console.log(`[diag] platform: ${process.platform}-${process.arch}`); + console.log(`[diag] duration: ${opts.duration}ms, source: ${opts.source}`); + + const config = buildConfig(opts); + config.outputs.screenPath = config.outputPath; + + const t0 = Date.now(); + const proc = spawn(helper.path, [JSON.stringify(config)], { + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + + let stdout = ""; + let stderr = ""; + let stopSent = false; + let stopSentAt = 0; + + proc.stdout.on("data", (chunk) => { + const text = chunk.toString(); + stdout += text; + process.stdout.write(`[helper/stdout] ${text}`); + }); + proc.stderr.on("data", (chunk) => { + const text = chunk.toString(); + stderr += text; + process.stderr.write(`[helper/stderr] ${text}`); + }); + + const stopTimer = setTimeout(() => { + if (stopSent) return; + stopSent = true; + stopSentAt = Date.now(); + proc.stdin.write("stop\n"); + console.log(`[diag] sent stop after ${stopSentAt - t0}ms`); + }, opts.duration); + + const fallbackTimer = setTimeout(() => { + if (!stopSent) { + stopSent = true; + stopSentAt = Date.now(); + proc.stdin.write("stop\n"); + console.log(`[diag] fallback stop fired after ${opts.duration}ms`); + } + }, opts.duration + 2_000); + + const killTimer = setTimeout(() => { + console.error(`[diag] helper did not exit after stop, killing`); + proc.kill("SIGKILL"); + }, opts.duration + 95_000); + + return new Promise((resolve) => { + proc.once("exit", (code, signal) => { + clearTimeout(stopTimer); + clearTimeout(fallbackTimer); + clearTimeout(killTimer); + const tExit = Date.now(); + resolve({ + code, + signal, + t0, + stopSentAt, + tExit, + stdout, + stderr, + config, + }); + }); + proc.once("error", (error) => { + clearTimeout(stopTimer); + clearTimeout(fallbackTimer); + clearTimeout(killTimer); + resolve({ + code: -1, + signal: null, + t0, + stopSentAt, + tExit: Date.now(), + stdout, + stderr: stderr + `\n[spawn-error] ${error.message}\n`, + config, + spawnError: error, + }); + }); + }); +} + +function buildReport(result) { + const stopTiming = parseStopTiming(result.stderr); + const stopElapsedMs = result.stopSentAt > 0 ? result.tExit - result.stopSentAt : null; + const helperPath = process.env.OPENSCREEN_HELPER_EXE?.trim() || "(auto-resolved)"; + + return { + timestamp: new Date(result.t0).toISOString(), + platform: process.platform, + arch: process.arch, + osRelease: os.release(), + osVersion: os.version(), + cpuModel: os.cpus()[0]?.model ?? null, + cpuCount: os.cpus().length, + totalMemoryMB: Math.round(os.totalmem() / 1024 / 1024), + nodeVersion: process.versions.node, + helperPath, + durationMs: result.stopSentAt > 0 ? result.stopSentAt - result.t0 : null, + stopElapsedMs, + exitCode: result.code, + exitSignal: result.signal, + spawnError: result.spawnError?.message ?? null, + config: result.config, + helperStdout: result.stdout, + helperStderr: result.stderr, + stopTiming, + }; +} + +async function main() { + let opts; + try { + opts = parseArgs(process.argv.slice(2)); + } catch (error) { + console.error(`[diag] ${error.message}`); + process.exit(2); + } + if (opts.help) { + printHelp(); + process.exit(0); + } + + let result; + try { + result = await run(opts); + } catch (error) { + console.error(`[diag] ${error.message}`); + process.exit(1); + } + + const report = buildReport(result); + const outputPath = + opts.output ?? path.join(process.cwd(), `openscreen-diagnostic-${Date.now()}.json`); + await fs.promises.writeFile(outputPath, JSON.stringify(report, null, 2), "utf-8"); + + console.log(""); + console.log(`[diag] exit code: ${report.exitCode}`); + console.log(`[diag] stop elapsed: ${report.stopElapsedMs}ms`); + console.log(`[diag] stop timing steps:`); + for (const entry of report.stopTiming) { + console.log(`[diag] ${entry.step.padEnd(28)} ${entry.elapsedMs}ms`); + } + console.log(`[diag] report: ${outputPath}`); +} + +main(); diff --git a/scripts/diagnostic-tool/diagnostic.sh b/scripts/diagnostic-tool/diagnostic.sh new file mode 100644 index 0000000000..6dceac36b4 --- /dev/null +++ b/scripts/diagnostic-tool/diagnostic.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +set -e +DIR="$(cd "$(dirname "$0")" && pwd)" +exec node "$DIR/diagnostic.mjs" "$@" \ No newline at end of file diff --git a/scripts/fetch-caption-model.mjs b/scripts/fetch-caption-model.mjs new file mode 100644 index 0000000000..f1d0a1f50e --- /dev/null +++ b/scripts/fetch-caption-model.mjs @@ -0,0 +1,154 @@ +// Populates `caption-assets/` so the packaged app can transcribe offline (under file://) +// instead of fetching the Whisper model from HuggingFace and the onnxruntime wasm from a CDN. +// +// caption-assets/ +// models/Xenova/whisper-tiny/... ← downloaded from HuggingFace (config + quantized ONNX) +// ort/ort-wasm*.wasm ← copied from @xenova/transformers/dist +// +// Idempotent: existing non-empty files are left alone, so re-runs and CI cache hits are no-ops. +// `caption-assets/` is gitignored and shipped via electron-builder `extraResources`. + +import { createWriteStream } from "node:fs"; +import { copyFile, mkdir, stat } from "node:fs/promises"; +import path from "node:path"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const OUT = path.join(ROOT, "caption-assets"); +const MODEL_ID = "Xenova/whisper-tiny"; +const HF_BASE = `https://huggingface.co/${MODEL_ID}/resolve/main`; + +// Small config/tokenizer/preprocessor files plus the quantized ONNX the ASR pipeline loads by +// default (encoder + merged decoder). Grab every metadata file so transformers never requests +// one we forgot to bundle. +const MODEL_FILES = [ + "config.json", + "generation_config.json", + "preprocessor_config.json", + "tokenizer.json", + "tokenizer_config.json", + "added_tokens.json", + "special_tokens_map.json", + "normalizer.json", + "merges.txt", + "vocab.json", + "quantize_config.json", + "onnx/encoder_model_quantized.onnx", + "onnx/decoder_model_merged_quantized.onnx", +]; + +async function exists(filePath) { + try { + const s = await stat(filePath); + return s.isFile() && s.size > 0; + } catch { + return false; + } +} + +const MAX_ATTEMPTS = 6; +// HuggingFace rate-limits (429) when the parallel CI matrix builds all hit it at once; also retry the +// usual transient server errors. +const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]); + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function backoffMs(attempt, retryAfter) { + // Honor Retry-After when the server sends it (seconds or an HTTP date). + if (retryAfter) { + const secs = Number(retryAfter); + if (Number.isFinite(secs)) return Math.min(60_000, secs * 1000); + const at = Date.parse(retryAfter); + if (!Number.isNaN(at)) return Math.min(60_000, Math.max(0, at - Date.now())); + } + // Exponential backoff with jitter: ~2s, 4s, 8s, 16s, 32s, capped at 60s. + return Math.min(60_000, 2000 * 2 ** (attempt - 1)) + Math.floor(Math.random() * 1000); +} + +async function fetchWithRetry(url) { + let lastErr; + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + const res = await fetch(url, { headers: { "user-agent": "openscreen-build" } }); + if (res.ok && res.body) return res; + if (RETRYABLE_STATUS.has(res.status) && attempt < MAX_ATTEMPTS) { + const wait = backoffMs(attempt, res.headers.get("retry-after")); + console.log( + ` … HTTP ${res.status}, retry ${attempt}/${MAX_ATTEMPTS - 1} in ${Math.round(wait / 1000)}s`, + ); + await sleep(wait); + continue; + } + throw new Error(`Failed to download ${url}: HTTP ${res.status} ${res.statusText}`); + } catch (err) { + lastErr = err; + const isHttp = err instanceof Error && err.message.startsWith("Failed to download"); + if (isHttp || attempt >= MAX_ATTEMPTS) throw err; + // Network/DNS error: back off and retry. + const wait = backoffMs(attempt, null); + console.log( + ` … ${err.message}, retry ${attempt}/${MAX_ATTEMPTS - 1} in ${Math.round(wait / 1000)}s`, + ); + await sleep(wait); + } + } + throw lastErr; +} + +async function download(url, dest) { + if (await exists(dest)) { + console.log(` ✓ cached ${path.relative(OUT, dest)}`); + return; + } + await mkdir(path.dirname(dest), { recursive: true }); + const res = await fetchWithRetry(url); + const tmp = `${dest}.partial`; + await pipeline(Readable.fromWeb(res.body), createWriteStream(tmp)); + const { rename } = await import("node:fs/promises"); + await rename(tmp, dest); + const mb = ((await stat(dest)).size / 1_000_000).toFixed(1); + console.log(` ↓ ${path.relative(OUT, dest)} (${mb} MB)`); +} + +async function copyOrtWasm() { + const distDir = path.join(ROOT, "node_modules", "@xenova", "transformers", "dist"); + // Non-threaded variants only: the worker runs ORT with numThreads=1 (no SharedArrayBuffer + // under file://), so the threaded wasm is never loaded. Saves ~20MB. + const wasm = ["ort-wasm.wasm", "ort-wasm-simd.wasm"]; + const ortOut = path.join(OUT, "ort"); + await mkdir(ortOut, { recursive: true }); + for (const name of wasm) { + const src = path.join(distDir, name); + const dest = path.join(ortOut, name); + if (!(await exists(src))) { + throw new Error(`Missing ${src} — is @xenova/transformers installed? Run npm ci first.`); + } + if (await exists(dest)) { + console.log(` ✓ cached ort/${name}`); + continue; + } + await copyFile(src, dest); + console.log(` + copied ort/${name}`); + } +} + +async function main() { + console.log(`Fetching caption assets → ${path.relative(ROOT, OUT)}/`); + console.log("ONNX Runtime wasm:"); + await copyOrtWasm(); + console.log(`Whisper model (${MODEL_ID}):`); + const modelDir = path.join(OUT, "models", ...MODEL_ID.split("/")); + for (const rel of MODEL_FILES) { + await download(`${HF_BASE}/${rel}`, path.join(modelDir, rel)); + } + console.log("Caption assets ready."); +} + +main().catch((err) => { + console.error(`\nfetch-caption-model failed: ${err.message}`); + process.exit(1); +}); diff --git a/src/App.tsx b/src/App.tsx index 6c36aa8c5b..35749408c3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ import { lazy, Suspense, useEffect, useState } from "react"; import { CountdownOverlay } from "./components/launch/CountdownOverlay.tsx"; import { LaunchWindow } from "./components/launch/LaunchWindow"; +import { NotesWindow } from "./components/launch/NotesWindow.tsx"; import { SourceSelector } from "./components/launch/SourceSelector"; import { Toaster } from "./components/ui/sonner"; import { TooltipProvider } from "./components/ui/tooltip"; @@ -19,6 +20,8 @@ export default function App() { const [windowType, setWindowType] = useState( () => new URLSearchParams(window.location.search).get("windowType") || "", ); + const showNotes = new URLSearchParams(window.location.search).get("showNotes") === "true"; + const tEditor = useScopedT("editor"); useEffect(() => { @@ -102,8 +105,10 @@ export default function App() { ); default: return ( -
-

Openscreen

+
+
+

Openscreen

+
); } @@ -111,8 +116,8 @@ export default function App() { return ( - {content} - + {showNotes ? : content} + ); } diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx new file mode 100644 index 0000000000..8f9a508c4c --- /dev/null +++ b/src/components/launch/LaunchWindow.test.tsx @@ -0,0 +1,452 @@ +import "@testing-library/jest-dom"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TooltipProvider } from "../ui/tooltip"; +import { LaunchWindow } from "./LaunchWindow"; + +type SelectedSourceChangedListener = Parameters< + Window["electronAPI"]["onSelectedSourceChanged"] +>[0]; + +const platformState = vi.hoisted(() => ({ value: "darwin" })); +const resizeCallbacks = vi.hoisted(() => [] as Array); + +class StubResizeObserver { + observe() { + return undefined; + } + unobserve() { + return undefined; + } + disconnect() { + return undefined; + } +} + +class CapturingResizeObserver extends StubResizeObserver { + constructor(callback: ResizeObserverCallback) { + super(); + resizeCallbacks.push(callback); + } +} + +const recorderState = vi.hoisted(() => ({ + value: { + recording: false, + paused: false, + saving: false, + elapsedSeconds: 0, + toggleRecording: vi.fn(), + togglePaused: vi.fn(), + canPauseRecording: false, + restartRecording: vi.fn(), + cancelRecording: vi.fn(), + microphoneEnabled: false, + setMicrophoneEnabled: vi.fn(), + microphoneDeviceId: undefined, + setMicrophoneDeviceId: vi.fn(), + setMicrophoneDeviceName: vi.fn(), + webcamEnabled: false, + setWebcamEnabled: vi.fn(async () => true), + webcamDeviceId: undefined, + setWebcamDeviceId: vi.fn(), + setWebcamDeviceName: vi.fn(), + systemAudioEnabled: false, + setSystemAudioEnabled: vi.fn(), + cursorCaptureMode: "editable-overlay", + setCursorCaptureMode: vi.fn(), + }, +})); + +let selectedSourceChangedListeners: SelectedSourceChangedListener[] = []; +let sourceSelectorClosedListeners: Array<() => void> = []; + +vi.mock("../../hooks/useScreenRecorder", () => ({ + useScreenRecorder: () => recorderState.value, +})); + +vi.mock("../../hooks/useMicrophoneDevices", () => ({ + useMicrophoneDevices: () => ({ + devices: [], + selectedDeviceId: "default", + setSelectedDeviceId: vi.fn(), + }), +})); + +vi.mock("../../hooks/useCameraDevices", () => ({ + useCameraDevices: () => ({ + devices: [], + selectedDeviceId: "", + setSelectedDeviceId: vi.fn(), + isLoading: false, + error: null, + }), +})); + +vi.mock("../../hooks/useAudioLevelMeter", () => ({ + useAudioLevelMeter: () => ({ level: 0 }), +})); + +vi.mock("../../lib/requestCameraAccess", () => ({ + requestCameraAccess: vi.fn(async () => ({ success: true, granted: true, status: "granted" })), +})); + +vi.mock("@/native", () => ({ + nativeBridgeClient: { + system: { + getPlatform: vi.fn(async () => platformState.value), + }, + }, +})); + +const i18nState = vi.hoisted(() => ({ + value: { + locale: "en", + setLocale: vi.fn(), + systemLocaleSuggestion: null as string | null, + acceptSystemLocaleSuggestion: vi.fn(), + dismissSystemLocaleSuggestion: vi.fn(), + resolveSystemLocaleSuggestion: vi.fn(), + }, +})); + +vi.mock("@/i18n/loader", () => ({ + getAvailableLocales: () => ["en"], + getLocaleName: () => "English", +})); + +vi.mock("@/contexts/I18nContext", () => ({ + useI18n: () => i18nState.value, + useScopedT: () => (key: string) => { + const translations: Record = { + "sourceSelector.defaultSourceName": "Screen", + "recording.selectSource": "Please select a source to record", + "tooltips.useVerticalTray": "Use vertical tray", + "tooltips.useHorizontalTray": "Use horizontal tray", + "audio.enableSystemAudio": "Enable system audio", + "audio.disableSystemAudio": "Disable system audio", + "audio.enableMicrophone": "Enable microphone", + "audio.disableMicrophone": "Disable microphone", + "audio.defaultMicrophone": "Default Microphone", + "webcam.enableWebcam": "Enable webcam", + "webcam.disableWebcam": "Disable webcam", + "webcam.defaultCamera": "Default Camera", + "webcam.searching": "Searching...", + "webcam.noneFound": "No camera found", + "webcam.unavailable": "Camera unavailable", + "cursor.useEditableCursor": "Use editable cursor", + "cursor.useSystemCursor": "Use system cursor", + "tooltips.openStudio": "Open Studio", + "tooltips.hideHUD": "Hide HUD", + "tooltips.closeApp": "Close App", + language: "Language", + "systemLanguagePrompt.title": "Use your system language?", + "systemLanguagePrompt.description": + "We detected English as your system language. Do you want to switch OpenScreen to English?", + "systemLanguagePrompt.keepDefault": "Keep current language", + "systemLanguagePrompt.switch": "Switch to English", + }; + return translations[key] ?? key; + }, +})); + +function renderLaunchWindow() { + return render( + + + , + ); +} + +function stubElectronAPI(getSelectedSource: Window["electronAPI"]["getSelectedSource"]) { + window.electronAPI = { + ...window.electronAPI, + getSelectedSource, + openSourceSelector: vi.fn(async () => ({ opened: true })), + requestScreenAccess: vi.fn(async () => ({ + success: true, + granted: true, + status: "granted", + })), + getPlatform: vi.fn(async () => "darwin"), + setHudOverlaySize: vi.fn(), + setHudOverlayIgnoreMouseEvents: vi.fn(), + moveHudOverlayBy: vi.fn(), + hudOverlayHide: vi.fn(), + hudOverlayClose: vi.fn(), + switchToEditor: vi.fn(async () => undefined), + onSelectedSourceChanged: vi.fn((callback) => { + selectedSourceChangedListeners.push(callback); + return () => { + selectedSourceChangedListeners = selectedSourceChangedListeners.filter( + (listener) => listener !== callback, + ); + }; + }), + onSourceSelectorClosed: vi.fn((callback) => { + sourceSelectorClosedListeners.push(callback); + return () => { + sourceSelectorClosedListeners = sourceSelectorClosedListeners.filter( + (listener) => listener !== callback, + ); + }; + }), + } as typeof window.electronAPI; +} + +const displayOneSource = { + id: "screen:1:0", + name: "Display 1", + display_id: "1", + thumbnail: null, + appIcon: null, +} satisfies ProcessedDesktopSource; + +async function waitForSourceSelectionSubscription() { + await waitFor(() => { + expect(selectedSourceChangedListeners.length).toBeGreaterThan(0); + }); +} + +function emitSelectedSourceChanged(source: ProcessedDesktopSource) { + act(() => { + selectedSourceChangedListeners.forEach((listener) => listener(source)); + }); +} + +function emitSourceSelectorClosed() { + act(() => { + sourceSelectorClosedListeners.forEach((listener) => listener()); + }); +} + +function resetLaunchMocks() { + vi.stubGlobal("ResizeObserver", StubResizeObserver); + recorderState.value.toggleRecording.mockClear(); + selectedSourceChangedListeners = []; + sourceSelectorClosedListeners = []; + i18nState.value.systemLocaleSuggestion = null; + i18nState.value.acceptSystemLocaleSuggestion.mockClear(); + i18nState.value.dismissSystemLocaleSuggestion.mockClear(); + i18nState.value.resolveSystemLocaleSuggestion.mockClear(); + stubElectronAPI(vi.fn(async () => null)); +} + +describe("LaunchWindow record button", () => { + beforeEach(() => { + platformState.value = "darwin"; + resetLaunchMocks(); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("opens the source selector instead of disabling the primary action when no source is selected", async () => { + renderLaunchWindow(); + + const recordButton = await screen.findByTestId("launch-record-button"); + + expect(recordButton).toBeEnabled(); + expect(recordButton).toHaveAttribute("title", "Please select a source to record"); + + fireEvent.click(recordButton); + + await waitFor(() => { + expect(window.electronAPI.openSourceSelector).toHaveBeenCalledTimes(1); + }); + expect(recorderState.value.toggleRecording).not.toHaveBeenCalled(); + }); + + it("records immediately after source selection when the record button opened the picker", async () => { + renderLaunchWindow(); + await waitForSourceSelectionSubscription(); + + fireEvent.click(await screen.findByTestId("launch-record-button")); + emitSelectedSourceChanged(displayOneSource); + + await waitFor(() => { + expect(recorderState.value.toggleRecording).toHaveBeenCalledTimes(1); + }); + expect(screen.getByTestId("launch-record-button")).toHaveAttribute("title", "Display 1"); + }); + + it("does not record after manual source selection", async () => { + renderLaunchWindow(); + await waitForSourceSelectionSubscription(); + + emitSelectedSourceChanged(displayOneSource); + + await waitFor(() => { + expect(screen.getByTestId("launch-record-button")).toHaveAttribute("title", "Display 1"); + }); + expect(recorderState.value.toggleRecording).not.toHaveBeenCalled(); + }); + + it("clears record-after-selection intent when the source picker closes without a selection", async () => { + renderLaunchWindow(); + await waitForSourceSelectionSubscription(); + + fireEvent.click(await screen.findByTestId("launch-record-button")); + emitSourceSelectorClosed(); + emitSelectedSourceChanged(displayOneSource); + + await waitFor(() => { + expect(screen.getByTestId("launch-record-button")).toHaveAttribute("title", "Display 1"); + }); + expect(recorderState.value.toggleRecording).not.toHaveBeenCalled(); + }); + + it("clears record-after-selection intent when opening the source picker fails", async () => { + window.electronAPI.openSourceSelector = vi.fn(async () => { + throw new Error("source selector failed"); + }); + + renderLaunchWindow(); + await waitForSourceSelectionSubscription(); + + fireEvent.click(await screen.findByTestId("launch-record-button")); + + await waitFor(() => { + expect(window.electronAPI.openSourceSelector).toHaveBeenCalledTimes(1); + }); + + await act(async () => { + await Promise.resolve(); + }); + + emitSelectedSourceChanged(displayOneSource); + + await waitFor(() => { + expect(screen.getByTestId("launch-record-button")).toHaveAttribute("title", "Display 1"); + }); + expect(recorderState.value.toggleRecording).not.toHaveBeenCalled(); + }); + + it("handles selected source polling failures", async () => { + const error = new Error("selected source unavailable"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + stubElectronAPI( + vi.fn(async () => { + throw error; + }), + ); + + renderLaunchWindow(); + + await waitFor(() => { + expect(warnSpy).toHaveBeenCalledWith("Failed to refresh selected source:", error); + }); + + warnSpy.mockRestore(); + }); + + it("starts recording when a source is already selected", async () => { + stubElectronAPI(vi.fn(async () => displayOneSource)); + + renderLaunchWindow(); + + const recordButton = await screen.findByTestId("launch-record-button"); + await waitFor(() => { + expect(recordButton).toHaveAttribute("title", "Display 1"); + }); + + fireEvent.click(recordButton); + + expect(recorderState.value.toggleRecording).toHaveBeenCalledTimes(1); + expect(window.electronAPI.openSourceSelector).not.toHaveBeenCalled(); + }); + + it("keeps the HUD interactive on Linux so the drag handle can receive pointer events", async () => { + platformState.value = "linux"; + + renderLaunchWindow(); + + await waitFor(() => { + expect(window.electronAPI.setHudOverlayIgnoreMouseEvents).toHaveBeenLastCalledWith(false); + }); + }); +}); + +describe("LaunchWindow system language prompt", () => { + beforeEach(() => { + platformState.value = "darwin"; + resetLaunchMocks(); + resizeCallbacks.length = 0; + vi.stubGlobal("ResizeObserver", CapturingResizeObserver); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("grows the HUD overlay tall enough to fit the prompt so its buttons stay clickable", async () => { + i18nState.value.systemLocaleSuggestion = "zh-CN"; + + renderLaunchWindow(); + + const prompt = await screen.findByText("Use your system language?"); + expect(prompt).toBeInTheDocument(); + + // jsdom reports zero layout, so stub both the bar and the prompt to mimic a real HUD. + const viewportHeight = 800; + const barHeight = 56; + const bottomMargin = 20; + const barBottom = viewportHeight - bottomMargin; + const bar = prompt.parentElement?.parentElement?.querySelector( + "[data-tray-layout]", + ) as HTMLElement | null; + if (bar) { + vi.spyOn(bar, "getBoundingClientRect").mockReturnValue({ + top: barBottom - barHeight, + left: 200, + right: 600, + bottom: barBottom, + width: 400, + height: barHeight, + x: 200, + y: barBottom - barHeight, + toJSON: () => ({}), + }); + Object.defineProperty(bar, "scrollHeight", { value: barHeight, configurable: true }); + Object.defineProperty(bar, "scrollWidth", { value: 400, configurable: true }); + } + + const promptBox = { width: 480, height: 130 }; + const promptPanel = prompt.parentElement as HTMLElement; + vi.spyOn(promptPanel, "getBoundingClientRect").mockReturnValue({ + top: 32, + left: 60, + right: 60 + promptBox.width, + bottom: 32 + promptBox.height, + width: promptBox.width, + height: promptBox.height, + x: 60, + y: 32, + toJSON: () => ({}), + }); + + // Fire any observers attached during render so the spied rect is actually consumed. + await act(async () => { + for (const callback of resizeCallbacks) { + callback([], {} as ResizeObserver); + } + }); + + await waitFor(() => { + expect(window.electronAPI.setHudOverlaySize).toHaveBeenCalled(); + }); + + const sizeMock = window.electronAPI.setHudOverlaySize as unknown as { + mock: { calls: Array<[number, number]> }; + }; + const [, height] = sizeMock.mock.calls[sizeMock.mock.calls.length - 1]; + // Must at least cover the prompt plus the TOP_MARGIN slack (24). + expect(height).toBeGreaterThanOrEqual(32 + promptBox.height + 24); + // And must be less than the full viewport — guards against regressions that always + // grow to the full viewport because of a missed bottom anchor. + expect(height).toBeLessThan(viewportHeight + 24); + }); +}); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index bba5f494ce..b8cf24f22a 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,4 +1,13 @@ -import { Check, ChevronDown, Clapperboard, Columns3, Languages, Rows3 } from "lucide-react"; +import { + Check, + ChevronDown, + Clapperboard, + Columns3, + Languages, + Loader2, + NotepadText, + Rows3, +} from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs"; @@ -21,13 +30,13 @@ import { import { RxDragHandleDots2 } from "react-icons/rx"; import { useI18n, useScopedT } from "@/contexts/I18nContext"; import { getAvailableLocales, getLocaleName } from "@/i18n/loader"; +import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences"; import { nativeBridgeClient } from "@/native"; import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter"; import { useCameraDevices } from "../../hooks/useCameraDevices"; import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; import { useScreenRecorder } from "../../hooks/useScreenRecorder"; import { requestCameraAccess } from "../../lib/requestCameraAccess"; -import { loadUserPreferences, saveUserPreferences } from "../../lib/userPreferences"; import { formatTimePadded } from "../../utils/timeUtils"; import { AudioLevelMeter } from "../ui/audio-level-meter"; import { Button } from "../ui/button"; @@ -37,6 +46,11 @@ import { openSourceSelectorWithPermissionRetry } from "./openSourceSelectorFlow" const ICON_SIZE = 20; +// Vertical tray gap (px): bar's `bottom-5` (20px) plus an 8px gap. +const HUD_DEVICE_POPUP_GAP = 28; +// Horizontal layout: mirrors the `bottom-[68px]` class on the popup element. +const HUD_DEVICE_POPUP_HORIZONTAL_BOTTOM = 68; + const ICON_CONFIG = { drag: { icon: RxDragHandleDots2, size: ICON_SIZE }, monitor: { icon: MdMonitor, size: ICON_SIZE }, @@ -57,6 +71,7 @@ const ICON_CONFIG = { folder: { icon: FaFolderOpen, size: ICON_SIZE }, minimize: { icon: FiMinus, size: ICON_SIZE }, close: { icon: FiX, size: ICON_SIZE }, + spinner: { icon: Loader2, size: ICON_SIZE }, } as const; type IconName = keyof typeof ICON_CONFIG; @@ -67,17 +82,16 @@ function getIcon(name: IconName, className?: string) { return ; } -const hudGroupClasses = - "flex items-center gap-0.5 rounded-xl border border-white/[0.07] bg-white/[0.045] transition-colors duration-150 hover:bg-white/[0.075]"; +const hudDisabledClasses = + "disabled:opacity-30 disabled:cursor-not-allowed disabled:pointer-events-none"; -const hudIconBtnClasses = - "flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150 cursor-pointer text-white hover:bg-white/10 active:scale-95"; +const hudGroupClasses = `flex items-center gap-0.5 rounded-xl border border-white/[0.07] bg-white/[0.045] transition-colors duration-150 hover:bg-white/[0.075] ${hudDisabledClasses}`; -const hudAuxIconBtnClasses = - "flex h-7 w-7 items-center justify-center rounded-lg transition-colors duration-150 text-white/55 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed"; +const hudIconBtnClasses = `flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150 cursor-pointer text-white hover:bg-white/10 active:scale-95 ${hudDisabledClasses}`; -const windowBtnClasses = - "flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08]"; +const hudAuxIconBtnClasses = `flex h-7 w-7 items-center justify-center rounded-lg transition-colors duration-150 text-white/55 hover:bg-white/10 ${hudDisabledClasses}`; + +const windowBtnClasses = `flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08] ${hudDisabledClasses}`; const hudSidebarClasses = "ml-0.5 pl-1.5 border-l border-white/10 flex items-center gap-0.5"; const hudSidebarVerticalClasses = @@ -101,6 +115,7 @@ export function LaunchWindow() { const { recording, paused, + saving, elapsedSeconds, toggleRecording, togglePaused, @@ -138,8 +153,14 @@ export function LaunchWindow() { () => loadUserPreferences().trayLayout, ); const [supportsCursorModeToggle, setSupportsCursorModeToggle] = useState(false); + const [isLinuxHud, setIsLinuxHud] = useState(false); const languageTriggerRef = useRef(null); const languageMenuPanelRef = useRef(null); + const hudBarRef = useRef(null); + const deviceSelectorRef = useRef(null); + const systemLocalePromptRef = useRef(null); + // Measured bar height, anchors the popups above the tall vertical tray so they don't overlap it. + const [hudBarHeight, setHudBarHeight] = useState(0); const [languageMenuStyle, setLanguageMenuStyle] = useState<{ right: number; top: number; @@ -203,11 +224,13 @@ export function LaunchWindow() { .then((platform) => { if (!cancelled) { setSupportsCursorModeToggle(platform === "win32" || platform === "darwin"); + setIsLinuxHud(platform === "linux"); } }) .catch(() => { if (!cancelled) { setSupportsCursorModeToggle(false); + setIsLinuxHud(false); } }); @@ -291,14 +314,135 @@ export function LaunchWindow() { return () => cancelAnimationFrame(id); }, [isLanguageMenuOpen]); - const hudMouseEventsEnabledRef = useRef(undefined); - const setHudMouseEventsEnabled = useCallback((enabled: boolean) => { - if (hudMouseEventsEnabledRef.current === enabled) { + // Resize the overlay window to fit content, else the taller vertical tray gets clipped + // and scrolls. Measure from the window's bottom-centre (the anchor the main process + // preserves) so fixed bottom/centre offsets keep this stable and it doesn't oscillate. + const lastHudSizeRef = useRef({ width: 0, height: 0 }); + const measureHudSize = useCallback(() => { + const barEl = hudBarRef.current; + if (!barEl || !window.electronAPI?.setHudOverlaySize) return; + + // Breathing room so the drop shadow isn't clipped. TOP_MARGIN must also exceed the + // slack in the bar's `max-h: calc(100vh - 2.5rem)` cap (40px reserved - 20px bottom + // gap = 20px) so the window stays tall enough that the cap never engages and adds a scrollbar. + const SIDE_MARGIN = 24; + const TOP_MARGIN = 24; + // Wide enough that the language menu (11rem) never clips, even when the bar is narrow. + const MIN_WIDTH = 220; + + const viewportHeight = window.innerHeight; + const centerX = window.innerWidth / 2; + + // Use natural (scroll) size, not the clipped box: vertical mode's max-h cap is a + // small-screen fallback, and reading clipped height would pin the window to it. + // scrollHeight gives full content height; the cap only engages when the main process clamps to screen. + let topFromBottom = viewportHeight - barEl.getBoundingClientRect().bottom + barEl.scrollHeight; + let halfWidth = barEl.scrollWidth / 2; + + // Popups drive both dimensions too. Their vertical anchor depends on bar height, + // which is fed back through React state and lags by a frame, so derive their top + // edge from the bar's natural height instead of the stale rendered position. Keeps + // one measurement pass authoritative and avoids a feedback re-measure. + if (deviceSelectorRef.current) { + const rect = deviceSelectorRef.current.getBoundingClientRect(); + if (rect.width !== 0 || rect.height !== 0) { + const popupBottomOffset = + trayLayout === "vertical" + ? barEl.scrollHeight + HUD_DEVICE_POPUP_GAP + : HUD_DEVICE_POPUP_HORIZONTAL_BOTTOM; + topFromBottom = Math.max(topFromBottom, popupBottomOffset + rect.height); + halfWidth = Math.max(halfWidth, rect.width / 2); + } + } + + // The language menu scrolls within available height, so it only influences width. + // Its presence in the DOM means it's open. + if (languageMenuPanelRef.current) { + const rect = languageMenuPanelRef.current.getBoundingClientRect(); + halfWidth = Math.max(halfWidth, centerX - rect.left, rect.right - centerX); + } + + // Prompt sits at `fixed top-8`; grow the window to fit it so its buttons don't clip (issue #30). + if (systemLocalePromptRef.current) { + const rect = systemLocalePromptRef.current.getBoundingClientRect(); + const promptHeight = rect.height || systemLocalePromptRef.current.scrollHeight; + if (promptHeight > 0) { + topFromBottom = Math.max(topFromBottom, rect.top + promptHeight); + } + halfWidth = Math.max(halfWidth, centerX - rect.left, rect.right - centerX); + } + + setHudBarHeight((prev) => { + const next = Math.round(barEl.scrollHeight); + return Math.abs(prev - next) > 1 ? next : prev; + }); + + const width = Math.max(MIN_WIDTH, Math.ceil(halfWidth * 2) + SIDE_MARGIN); + const height = Math.ceil(topFromBottom) + TOP_MARGIN; + if (width === lastHudSizeRef.current.width && height === lastHudSizeRef.current.height) { return; } - hudMouseEventsEnabledRef.current = enabled; - window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(!enabled); - }, []); + lastHudSizeRef.current = { width, height }; + window.electronAPI.setHudOverlaySize(width, height); + }, [trayLayout]); + + // One persistent observer; elements wire themselves up via callback refs as they + // mount/unmount so measurement re-runs without recreating it or threading mount state through deps. + const hudResizeObserverRef = useRef(null); + useEffect(() => { + const observer = new ResizeObserver(() => measureHudSize()); + hudResizeObserverRef.current = observer; + if (hudBarRef.current) observer.observe(hudBarRef.current); + if (deviceSelectorRef.current) observer.observe(deviceSelectorRef.current); + // Backfill refs set before the observer existed (e.g. the prompt or language menu). + if (systemLocalePromptRef.current) observer.observe(systemLocalePromptRef.current); + if (languageMenuPanelRef.current) observer.observe(languageMenuPanelRef.current); + measureHudSize(); + return () => { + observer.disconnect(); + hudResizeObserverRef.current = null; + }; + }, [measureHudSize]); + + const observeHudElement = useCallback( + (el: T | null, ref: React.MutableRefObject) => { + const observer = hudResizeObserverRef.current; + if (ref.current && observer) observer.unobserve(ref.current); + ref.current = el; + if (el && observer) observer.observe(el); + measureHudSize(); + }, + [measureHudSize], + ); + const setHudBarEl = useCallback( + (el: HTMLDivElement | null) => observeHudElement(el, hudBarRef), + [observeHudElement], + ); + const setDeviceSelectorEl = useCallback( + (el: HTMLDivElement | null) => observeHudElement(el, deviceSelectorRef), + [observeHudElement], + ); + const setLanguageMenuPanelEl = useCallback( + (el: HTMLDivElement | null) => observeHudElement(el, languageMenuPanelRef), + [observeHudElement], + ); + const setSystemLocalePromptEl = useCallback( + (el: HTMLDivElement | null) => observeHudElement(el, systemLocalePromptRef), + [observeHudElement], + ); + + const hudIgnoreMouseEventsRef = useRef(undefined); + const setHudMouseEventsEnabled = useCallback( + (enabled: boolean) => { + const shouldIgnoreMouseEvents = !enabled && !isLinuxHud; + if (hudIgnoreMouseEventsRef.current === shouldIgnoreMouseEvents) { + return; + } + hudIgnoreMouseEventsRef.current = shouldIgnoreMouseEvents; + window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(shouldIgnoreMouseEvents); + }, + [isLinuxHud], + ); useEffect(() => { setHudMouseEventsEnabled(false); @@ -311,21 +455,37 @@ export function LaunchWindow() { setHudMouseEventsEnabled(isLanguageMenuOpen); }, [isLanguageMenuOpen, setHudMouseEventsEnabled]); - const [selectedSource, setSelectedSource] = useState("Screen"); + const defaultSourceName = t("sourceSelector.defaultSourceName"); + const [selectedSource, setSelectedSource] = useState(defaultSourceName); const [hasSelectedSource, setHasSelectedSource] = useState(false); const [, setRecordPointerDownCount] = useState(0); + const recordAfterSourceSelectionRef = useRef(false); + + const applySelectedSource = useCallback( + (source: ProcessedDesktopSource | null) => { + if (source) { + setSelectedSource(source.name); + setHasSelectedSource(true); + return; + } + + setSelectedSource(defaultSourceName); + setHasSelectedSource(false); + }, + [defaultSourceName], + ); useEffect(() => { const checkSelectedSource = async () => { - if (window.electronAPI) { + if (!window.electronAPI) { + return; + } + + try { const source = await window.electronAPI.getSelectedSource(); - if (source) { - setSelectedSource(source.name); - setHasSelectedSource(true); - } else { - setSelectedSource("Screen"); - setHasSelectedSource(false); - } + applySelectedSource(source); + } catch (error) { + console.warn("Failed to refresh selected source:", error); } }; @@ -333,15 +493,58 @@ export function LaunchWindow() { const interval = setInterval(checkSelectedSource, 500); return () => clearInterval(interval); - }, []); + }, [applySelectedSource]); + + useEffect(() => { + const cleanupSourceChanged = window.electronAPI?.onSelectedSourceChanged?.((source) => { + applySelectedSource(source); + if (!recordAfterSourceSelectionRef.current || recording) { + return; + } + + recordAfterSourceSelectionRef.current = false; + toggleRecording(); + }); + const cleanupSelectorClosed = window.electronAPI?.onSourceSelectorClosed?.(() => { + recordAfterSourceSelectionRef.current = false; + }); + + return () => { + cleanupSourceChanged?.(); + cleanupSelectorClosed?.(); + }; + }, [applySelectedSource, recording, toggleRecording]); const openSourceSelector = async () => { if (window.electronAPI) { - await openSourceSelectorWithPermissionRetry({ + return await openSourceSelectorWithPermissionRetry({ openSourceSelector: () => window.electronAPI.openSourceSelector(), requestScreenAccess: () => window.electronAPI.requestScreenAccess(), }); } + + return { opened: false, reason: "electron-api-unavailable" }; + }; + + const handleRecordButtonClick = () => { + if (saving) { + return; + } + if (!hasSelectedSource && !recording) { + recordAfterSourceSelectionRef.current = true; + void openSourceSelector() + .then((result) => { + if (!result.opened) { + recordAfterSourceSelectionRef.current = false; + } + }) + .catch(() => { + recordAfterSourceSelectionRef.current = false; + }); + return; + } + + toggleRecording(); }; const sendHudOverlayHide = () => { @@ -362,11 +565,50 @@ export function LaunchWindow() { }; const toggleMicrophone = () => { - if (!recording) { + if (!recording && !saving) { setMicrophoneEnabled(!microphoneEnabled); } }; const dragLastPositionRef = useRef<{ x: number; y: number } | null>(null); + const dragAnimationFrameRef = useRef(null); + const pendingDragDeltaRef = useRef({ x: 0, y: 0 }); + const flushHudDragMove = useCallback(() => { + dragAnimationFrameRef.current = null; + const { x, y } = pendingDragDeltaRef.current; + pendingDragDeltaRef.current = { x: 0, y: 0 }; + if (x === 0 && y === 0) return; + window.electronAPI?.moveHudOverlayBy?.(x, y); + }, []); + const scheduleHudDragMove = useCallback( + (deltaX: number, deltaY: number) => { + pendingDragDeltaRef.current = { + x: pendingDragDeltaRef.current.x + deltaX, + y: pendingDragDeltaRef.current.y + deltaY, + }; + + if (dragAnimationFrameRef.current === null) { + dragAnimationFrameRef.current = window.requestAnimationFrame(flushHudDragMove); + } + }, + [flushHudDragMove], + ); + const flushPendingHudDragMove = useCallback(() => { + if (dragAnimationFrameRef.current !== null) { + window.cancelAnimationFrame(dragAnimationFrameRef.current); + dragAnimationFrameRef.current = null; + } + const { x, y } = pendingDragDeltaRef.current; + pendingDragDeltaRef.current = { x: 0, y: 0 }; + if (x === 0 && y === 0) return; + window.electronAPI?.moveHudOverlayBy?.(x, y); + }, []); + useEffect(() => { + return () => { + if (dragAnimationFrameRef.current !== null) { + window.cancelAnimationFrame(dragAnimationFrameRef.current); + } + }; + }, []); const handleHudDragPointerDown = (event: React.PointerEvent) => { event.preventDefault(); event.stopPropagation(); @@ -380,10 +622,11 @@ export function LaunchWindow() { const deltaX = event.screenX - lastPosition.x; const deltaY = event.screenY - lastPosition.y; dragLastPositionRef.current = { x: event.screenX, y: event.screenY }; - window.electronAPI?.moveHudOverlayBy?.(deltaX, deltaY); + scheduleHudDragMove(deltaX, deltaY); }; const handleHudDragPointerEnd = (event: React.PointerEvent) => { dragLastPositionRef.current = null; + flushPendingHudDragMove(); if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } @@ -391,10 +634,8 @@ export function LaunchWindow() { }; return ( - // Root fills the HUD window only. Avoid w-screen/h-screen (100vw/100vh): - // 100vw can exceed the inner layout width when scrollbars affect the - // viewport (notably on Windows), causing a horizontal scrollbar once the - // recording toolbar widened (issue #305). + // Avoid w-screen/h-screen: 100vw can exceed the inner layout width when scrollbars + // affect the viewport (Windows), causing a horizontal scrollbar (issue #305).
{ @@ -411,6 +652,7 @@ export function LaunchWindow() { > {systemLocaleSuggestion && (
@@ -446,11 +688,19 @@ export function LaunchWindow() {
)} - {/* Device selectors — fixed above HUD bar, viewport-relative, never clipped */} + {/* Device selectors, fixed above HUD bar, viewport-relative, never clipped */} {(showMicControls || showWebcamControls) && (
{/* Mic selector */} {showMicControls && ( @@ -460,7 +710,10 @@ export function LaunchWindow() { onMouseLeave={() => setIsMicHovered(false)} onFocus={() => setIsMicFocused(true)} onBlur={() => setIsMicFocused(false)} - style={{ width: micExpanded ? "240px" : "140px", transition: "width 300ms ease" }} + style={{ + width: micExpanded ? "240px" : "140px", + transition: "width 300ms ease", + }} >
{!micExpanded && ( @@ -506,7 +759,10 @@ export function LaunchWindow() { onMouseLeave={() => setIsWebcamHovered(false)} onFocus={() => setIsWebcamFocused(true)} onBlur={() => setIsWebcamFocused(false)} - style={{ width: webcamExpanded ? "240px" : "140px", transition: "width 300ms ease" }} + style={{ + width: webcamExpanded ? "240px" : "140px", + transition: "width 300ms ease", + }} >
{!webcamExpanded && ( @@ -581,8 +837,9 @@ export function LaunchWindow() {
)} - {/* HUD bar — fixed at bottom center, viewport-relative, never moves */} + {/* HUD bar, fixed at bottom center, viewport-relative, never moves */}
@@ -660,8 +918,8 @@ export function LaunchWindow() {
{/* Record/Stop group */} - + + {recording && (
- -
)} + {!isLinuxHud && ( + + + + )} + {!recording && ( @@ -806,11 +1132,12 @@ export function LaunchWindow() { aria-label={t("language")} aria-expanded={isLanguageMenuOpen} aria-haspopup="menu" - onClick={() => setIsLanguageMenuOpen((open) => !open)} + disabled={saving} + onClick={() => !saving && setIsLanguageMenuOpen((open) => !open)} title={activeLanguageLabel} className={`flex h-8 items-center rounded-lg border border-white/10 bg-white/[0.045] text-white/85 shadow-none transition-colors hover:bg-white/10 ${ trayLayout === "vertical" ? "w-8 justify-center px-0" : "gap-1.5 px-2" - } ${styles.electronNoDrag}`} + } ${styles.electronNoDrag} ${saving ? "opacity-30 cursor-not-allowed pointer-events-none" : ""}`} > {getIcon("minimize", "text-white")} @@ -882,6 +1210,7 @@ export function LaunchWindow() { className={windowBtnClasses} title={t("tooltips.closeApp")} onClick={sendHudOverlayClose} + disabled={saving} > {getIcon("close", "text-white")} diff --git a/src/components/launch/NotesToolbar.tsx b/src/components/launch/NotesToolbar.tsx new file mode 100644 index 0000000000..c744b525c9 --- /dev/null +++ b/src/components/launch/NotesToolbar.tsx @@ -0,0 +1,153 @@ +import type { Editor } from "@tiptap/react"; +import { Bold, Code, Italic, List, ListOrdered, Quote, Strikethrough } from "lucide-react"; +import { type ReactNode, useEffect, useReducer } from "react"; +import { Tooltip } from "@/components/ui/tooltip"; +import { useScopedT } from "@/contexts/I18nContext"; +import { cn } from "@/lib/utils"; + +type NotesToolbarProps = { + editor: Editor | null; +}; + +type ToolbarButtonProps = { + "aria-label": string; + tooltipContent: string; + active?: boolean; + disabled?: boolean; + onClick: () => void; + children: ReactNode; +}; + +function ToolbarButton({ + "aria-label": ariaLabel, + tooltipContent, + active = false, + disabled = false, + onClick, + children, +}: ToolbarButtonProps) { + return ( + + + + ); +} + +function useEditorRevision(editor: Editor | null): void { + const [, bumpRevision] = useReducer((revision: number) => revision + 1, 0); + + useEffect(() => { + if (!editor) { + return; + } + + const handleUpdate = () => { + bumpRevision(); + }; + + editor.on("selectionUpdate", handleUpdate); + editor.on("transaction", handleUpdate); + + return () => { + editor.off("selectionUpdate", handleUpdate); + editor.off("transaction", handleUpdate); + }; + }, [editor]); +} + +export function NotesToolbar({ editor }: NotesToolbarProps) { + useEditorRevision(editor); + const t = useScopedT("launch"); + + return ( +
+
+ editor?.chain().focus().toggleBold().run()} + > + + + editor?.chain().focus().toggleItalic().run()} + > + + + editor?.chain().focus().toggleStrike().run()} + > + + +
+
+
+
+ editor?.chain().focus().toggleBulletList().run()} + > + + + editor?.chain().focus().toggleOrderedList().run()} + > + + +
+
+
+
+ editor?.chain().focus().toggleBlockquote().run()} + > + + + editor?.chain().focus().toggleCodeBlock().run()} + > + + +
+
+ ); +} diff --git a/src/components/launch/NotesWindow.module.css b/src/components/launch/NotesWindow.module.css new file mode 100644 index 0000000000..b0b0cf38a6 --- /dev/null +++ b/src/components/launch/NotesWindow.module.css @@ -0,0 +1,120 @@ +/* Tiptap sets class="tiptap" on the ProseMirror root — must be :global to escape CSS modules. */ +:global(.tiptap) { + height: 100%; + width: 100%; + outline: none; + overflow-y: auto; + color: #111827; + caret-color: #111827; +} + +:global(.tiptap) :first-child { + margin-top: 0; +} + +:global(.tiptap) p { + min-height: 1.5rem; + line-height: 1.5; + margin-top: 0.75rem; + margin-bottom: 0.75rem; +} + +:global(.tiptap) p:first-child { + margin-top: 0; +} + +/* Tailwind preflight strips list markers — restore them for markdown-style input rules. */ +:global(.tiptap) ul, +:global(.tiptap) ol { + padding-left: 1.5rem; + margin: 1rem 0; +} + +:global(.tiptap) ul { + list-style-type: disc; +} + +:global(.tiptap) ol { + list-style-type: decimal; +} + +:global(.tiptap) li { + display: list-item; +} + +:global(.tiptap) ul li p, +:global(.tiptap) ol li p { + margin-top: 0.25em; + margin-bottom: 0.25em; +} + +:global(.tiptap) h1, +:global(.tiptap) h2, +:global(.tiptap) h3, +:global(.tiptap) h4, +:global(.tiptap) h5, +:global(.tiptap) h6 { + line-height: 1.1; + margin-top: 2.5rem; + text-wrap: pretty; +} + +:global(.tiptap) h1, +:global(.tiptap) h2 { + margin-top: 3.5rem; + margin-bottom: 1.5rem; +} + +:global(.tiptap) h1 { + font-size: 1.4rem; +} + +:global(.tiptap) h2 { + font-size: 1.2rem; +} + +:global(.tiptap) h3 { + font-size: 1.1rem; +} + +:global(.tiptap) h4, +:global(.tiptap) h5, +:global(.tiptap) h6 { + font-size: 1rem; +} + +:global(.tiptap) code { + background-color: #ede9fe; + border-radius: 0.4rem; + color: #111827; + font-size: 0.85rem; + padding: 0.25em 0.3em; +} + +:global(.tiptap) pre { + background: #111827; + border-radius: 0.5rem; + color: #ffffff; + font-family: "JetBrainsMono", monospace; + margin: 1.5rem 0; + padding: 0.75rem 1rem; +} + +:global(.tiptap) pre code { + background: none; + color: inherit; + font-size: 0.8rem; + padding: 0; +} + +:global(.tiptap) blockquote { + border-left: 3px solid #d1d5db; + margin: 1.5rem 0; + padding-left: 1rem; +} + +:global(.tiptap) hr { + border: none; + border-top: 1px solid #e5e7eb; + margin: 2rem 0; +} diff --git a/src/components/launch/NotesWindow.tsx b/src/components/launch/NotesWindow.tsx new file mode 100644 index 0000000000..2fb9e29ed7 --- /dev/null +++ b/src/components/launch/NotesWindow.tsx @@ -0,0 +1,45 @@ +import { EditorContent, useEditor } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import { NotesToolbar } from "./NotesToolbar"; +import "./NotesWindow.module.css"; + +function getInitialNotesContent(): string { + const stored = localStorage.getItem("notes"); + if (!stored) { + return ""; + } + + // Notes saved before Tiptap were plain text; wrap so StarterKit can parse them. + if (!stored.trim().startsWith("<")) { + const escaped = stored.replace(/&/g, "&").replace(//g, ">"); + return `

${escaped.replace(/\n/g, "

")}

`; + } + + return stored; +} + +export function NotesWindow() { + const editor = useEditor({ + extensions: [StarterKit], + content: getInitialNotesContent(), + autofocus: "end", + editorProps: { + attributes: { + class: "tiptap", + }, + }, + onUpdate: ({ editor: nextEditor }) => { + localStorage.setItem("notes", nextEditor.getHTML()); + }, + }); + + return ( +
+
+ +
+ + +
+ ); +} diff --git a/src/components/launch/SourceSelector.tsx b/src/components/launch/SourceSelector.tsx index 17e7444cb1..b1a8a6a969 100644 --- a/src/components/launch/SourceSelector.tsx +++ b/src/components/launch/SourceSelector.tsx @@ -108,9 +108,12 @@ export function SourceSelector() { const renderSourceCard = (source: DesktopSource) => { const isSelected = selectedSource?.id === source.id; + const sourceKind = source.id.startsWith("screen:") ? "screen" : "window"; return (
handleSourceSelect(source)} > diff --git a/src/components/ui/color-picker.tsx b/src/components/ui/color-picker.tsx index d8ec2b33c8..72f0135e25 100644 --- a/src/components/ui/color-picker.tsx +++ b/src/components/ui/color-picker.tsx @@ -46,8 +46,7 @@ export default function ColorPicker(props: ColorPickerProps) { return "#ffffff"; }; - // Normalize the hex input. - // Adds a # at the beginning of the input if it's not there. + // Prefix a # when the user typed a bare hex value. const normalizeHexDraft = (raw: string) => { const trimmed = raw.trim(); if (trimmed === "") return ""; @@ -58,8 +57,7 @@ export default function ColorPicker(props: ColorPickerProps) { const handleColorInputChange = (e: React.ChangeEvent) => { const normalized = normalizeHexDraft(e.target.value); setHexInput(normalized); - // Check if the normalized hex is a valid hex color. - // It should follow the format #RRGGBB or #RGB. + // Only push when it's a complete #RGB or #RRGGBB value. const isValidHexColor = /^#[0-9A-Fa-f]{3}$/.test(normalized) || /^#[0-9A-Fa-f]{6}$/.test(normalized); if (isValidHexColor) { diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index d151d164ea..bdbf64e9ae 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -82,7 +82,8 @@ const SelectContent = React.forwardRef< ; -const Toaster = ({ ...props }: ToasterProps) => { +const Toaster = ({ className, ...props }: ToasterProps) => { return ( diff --git a/src/components/video-editor/AddCustomFontDialog.tsx b/src/components/video-editor/AddCustomFontDialog.tsx index 9ab9ce3dad..872559d28a 100644 --- a/src/components/video-editor/AddCustomFontDialog.tsx +++ b/src/components/video-editor/AddCustomFontDialog.tsx @@ -36,7 +36,6 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) { const handleImportUrlChange = (url: string) => { setImportUrl(url); - // Auto-extract font name if valid Google Fonts URL if (isValidGoogleFontsUrl(url)) { const extracted = parseFontFamilyFromImport(url); if (extracted && !fontName) { @@ -46,7 +45,6 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) { }; const handleAdd = async () => { - // Validate inputs if (!importUrl.trim()) { toast.error(t("customFont.errorEmptyUrl")); return; @@ -65,7 +63,6 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) { setLoading(true); try { - // Extract font family from URL const fontFamily = parseFontFamilyFromImport(importUrl); if (!fontFamily) { toast.error(t("customFont.errorExtractFailed")); @@ -73,7 +70,6 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) { return; } - // Create custom font object const newFont: CustomFont = { id: generateFontId(fontName), name: fontName.trim(), @@ -81,17 +77,15 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) { importUrl: importUrl.trim(), }; - // Add font (this will load and verify it) - throws if it fails + // Loads and verifies the font; throws on failure await addCustomFont(newFont); - // Notify parent if (onFontAdded) { onFontAdded(newFont); } toast.success(t("customFont.successMessage", { fontName })); - // Reset and close setImportUrl(""); setFontName(""); setOpen(false); diff --git a/src/components/video-editor/AnnotationOverlay.tsx b/src/components/video-editor/AnnotationOverlay.tsx index 13d245b8a8..09669d4e71 100644 --- a/src/components/video-editor/AnnotationOverlay.tsx +++ b/src/components/video-editor/AnnotationOverlay.tsx @@ -48,7 +48,7 @@ interface AnnotationOverlayProps { onBlurDataCommit?: () => void; onClick: (id: string) => void; zIndex: number; - isSelectedBoost: boolean; // Boost z-index when selected for easy editing + isSelectedBoost: boolean; // raise z-index when selected, for easier editing previewSourceCanvas?: PreviewCanvasSource | null; previewFrameVersion?: number; currentTimeMs: number; @@ -537,7 +537,7 @@ export function AnnotationOverlay({ const yPercent = (d.y / containerHeight) * 100; onPositionChange(annotation.id, { x: xPercent, y: yPercent }); - // Reset dragging flag after a short delay to prevent click event + // Delay clearing so the trailing click doesn't fire onClick setTimeout(() => { isDraggingRef.current = false; }, 100); @@ -576,7 +576,7 @@ export function AnnotationOverlay({ "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 + zIndex: isSelectedBoost ? zIndex + 1000 : zIndex, // keep the selected annotation on top pointerEvents: isSelected ? "auto" : "none", border: isSelected && annotation.type !== "blur" ? "2px solid rgba(52, 178, 123, 0.8)" : "none", diff --git a/src/components/video-editor/AnnotationSettingsPanel.tsx b/src/components/video-editor/AnnotationSettingsPanel.tsx index 4fe3f505e4..91fee8ec84 100644 --- a/src/components/video-editor/AnnotationSettingsPanel.tsx +++ b/src/components/video-editor/AnnotationSettingsPanel.tsx @@ -108,7 +108,6 @@ export function AnnotationSettingsPanel({ const getFontLabel = (font: (typeof FONT_FAMILIES)[number]) => font.labelKey ? fontStyleLabels[font.labelKey] : font.name; - // Load custom fonts on mount useEffect(() => { setCustomFonts(getCustomFonts()); }, []); @@ -138,7 +137,6 @@ export function AnnotationSettingsPanel({ const file = files[0]; - // Validate file type const validTypes = ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"]; if (!validTypes.includes(file.type)) { toast.error(t("annotation.invalidImageType"), { @@ -159,8 +157,8 @@ export function AnnotationSettingsPanel({ }; reader.onerror = () => { - toast.error(t("annotation.failedImageUpload"), { - description: "There was an error reading the file.", + toast.error(t("imageUpload.failedToUpload"), { + description: t("imageUpload.errorReading"), }); }; diff --git a/src/components/video-editor/ArrowSvgs.tsx b/src/components/video-editor/ArrowSvgs.tsx index 99c542ff77..5078b844ec 100644 --- a/src/components/video-editor/ArrowSvgs.tsx +++ b/src/components/video-editor/ArrowSvgs.tsx @@ -7,9 +7,7 @@ interface ArrowSvgProps { } /** - * Inline SVG arrow components for 8 directions. - * These match the visual style of the previous icon-based arrows but use - * pure SVG paths for easy replication in export. + * Inline SVG arrows for 8 directions. Pure paths (not icon fonts) so export can replicate them. */ export function ArrowUp({ color, strokeWidth, className }: ArrowSvgProps) { diff --git a/src/components/video-editor/EditorEmptyState.tsx b/src/components/video-editor/EditorEmptyState.tsx index 511323abe2..2a5c495478 100644 --- a/src/components/video-editor/EditorEmptyState.tsx +++ b/src/components/video-editor/EditorEmptyState.tsx @@ -2,11 +2,12 @@ import { AlertCircle, Film, FolderOpen, Upload, X } from "lucide-react"; import { useCallback, useRef, useState } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { useScopedT } from "@/contexts/I18nContext"; +import { getProjectFolder, parentDirectoryOf, saveUserPreferences } from "@/lib/userPreferences"; import { nativeBridgeClient } from "@/native"; interface EditorEmptyStateProps { onVideoImported: (videoPath: string) => void; - /** Called with the loaded project data — handles both button click and drag-drop */ + /** Called with the loaded project data; handles both button click and drag-drop */ onProjectOpened: (project: unknown, path: string | null) => void; } @@ -17,8 +18,8 @@ export function EditorEmptyState({ onVideoImported, onProjectOpened }: EditorEmp const tc = useScopedT("common"); const [isDraggingOver, setIsDraggingOver] = useState(false); const [dropError, setDropError] = useState(null); - // Freeze the last non-null error type so dialog content doesn't snap to the - // else-branch during the closing animation (same pattern as UnsavedChangesDialog). + // Freeze the last non-null error type so dialog content doesn't snap to the else-branch + // during the closing animation (same pattern as UnsavedChangesDialog). const lastDropErrorRef = useRef>("unsupported-format"); if (dropError !== null) { lastDropErrorRef.current = dropError; @@ -35,8 +36,14 @@ export function EditorEmptyState({ onVideoImported, onProjectOpened }: EditorEmp }, [onVideoImported]); const handleLoadProject = useCallback(async () => { - const result = await nativeBridgeClient.project.loadProjectFile(); + const result = await nativeBridgeClient.project.loadProjectFile(getProjectFolder()); if (result.canceled || !result.success || !result.project) return; + if (result.path) { + const folder = parentDirectoryOf(result.path); + if (folder) { + saveUserPreferences({ projectFolder: folder }); + } + } onProjectOpened(result.project, result.path ?? null); }, [onProjectOpened]); @@ -67,7 +74,7 @@ export function EditorEmptyState({ onVideoImported, onProjectOpened }: EditorEmp return; } - // Use Electron's webUtils.getPathForFile — File.path was removed in Electron 32+ + // Use Electron's webUtils.getPathForFile; File.path was removed in Electron 32+ let filePath: string; try { filePath = window.electronAPI.getPathForFile(projectFile); diff --git a/src/components/video-editor/ExportDialog.tsx b/src/components/video-editor/ExportDialog.tsx index 2032289099..b012617c1a 100644 --- a/src/components/video-editor/ExportDialog.tsx +++ b/src/components/video-editor/ExportDialog.tsx @@ -30,14 +30,13 @@ export function ExportDialog({ const t = useScopedT("dialogs"); const [showSuccess, setShowSuccess] = useState(false); - // Reset showSuccess when a new export starts or dialog reopens useEffect(() => { if (isExporting) { setShowSuccess(false); } }, [isExporting]); - // Reset showSuccess when dialog opens fresh + // Reset when the dialog opens fresh (not mid-export). useEffect(() => { if (isOpen && !isExporting && !progress) { setShowSuccess(false); @@ -59,13 +58,14 @@ export function ExportDialog({ const formatLabel = exportFormat === "gif" ? "GIF" : "Video"; - // Determine if we're in the compiling phase (frames done but still exporting) + // Compiling phase: frames are done but the export is still finishing. const isCompiling = isExporting && progress && progress.percentage >= 100 && exportFormat === "gif"; const isFinalizing = progress?.phase === "finalizing"; + // Streaming a large recording into OPFS before frames start rendering. + const isPreparing = progress?.phase === "preparing"; const renderProgress = progress?.renderProgress; - // Get status message based on phase const getStatusMessage = () => { if (error) return t("export.tryAgain"); if (isCompiling || isFinalizing) { @@ -80,7 +80,6 @@ export function ExportDialog({ return t("export.takeMoment"); }; - // Get title based on phase const getTitle = () => { if (error) return t("export.failed"); if (isFinalizing && exportFormat === "mp4") return t("export.finalizingVideoTitle"); @@ -175,7 +174,9 @@ export function ExportDialog({ {isCompiling || isFinalizing ? t("export.compiling") - : t("export.renderingFrames")} + : isPreparing + ? t("export.processing") + : t("export.renderingFrames")} {isCompiling || isFinalizing ? ( @@ -194,7 +195,7 @@ export function ExportDialog({
{isCompiling || isFinalizing ? ( - // Show render progress if available, otherwise animated indeterminate bar + // Real progress if we have it, otherwise an indeterminate bar. renderProgress !== undefined && renderProgress > 0 ? (
- {progress.currentFrame} / {progress.totalFrames} + {isPreparing ? ( + + + {t("export.processing")} + + ) : ( + `${progress.currentFrame} / ${progress.totalFrames}` + )}
diff --git a/src/components/video-editor/KeyboardShortcutsHelp.tsx b/src/components/video-editor/KeyboardShortcutsHelp.tsx index b90b377e4b..23311225c5 100644 --- a/src/components/video-editor/KeyboardShortcutsHelp.tsx +++ b/src/components/video-editor/KeyboardShortcutsHelp.tsx @@ -2,6 +2,7 @@ import { HelpCircle, Settings2 } from "lucide-react"; import { useScopedT } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; import { FIXED_SHORTCUTS, formatBinding, SHORTCUT_ACTIONS } from "@/lib/shortcuts"; +import { BLUR_REGIONS_ENABLED } from "./featureFlags"; export function KeyboardShortcutsHelp() { const { shortcuts, isMac, openConfig } = useShortcuts(); @@ -26,14 +27,16 @@ export function KeyboardShortcutsHelp() {
- {SHORTCUT_ACTIONS.map((action) => ( -
- {t(`actions.${action}`)} - - {formatBinding(shortcuts[action], isMac)} - -
- ))} + {SHORTCUT_ACTIONS.filter((action) => BLUR_REGIONS_ENABLED || action !== "addBlur").map( + (action) => ( +
+ {t(`actions.${action}`)} + + {formatBinding(shortcuts[action], isMac)} + +
+ ), + )}
{FIXED_SHORTCUTS.map((fixed) => ( diff --git a/src/components/video-editor/PlaybackControls.tsx b/src/components/video-editor/PlaybackControls.tsx index 061ae5c5ec..bdec37cc72 100644 --- a/src/components/video-editor/PlaybackControls.tsx +++ b/src/components/video-editor/PlaybackControls.tsx @@ -96,7 +96,8 @@ export default function PlaybackControls({
); - // If an annotation is selected, show annotation settings instead + // Annotation selected: show its settings panel instead. if ( selectedAnnotation && onAnnotationContentChange && @@ -772,7 +811,7 @@ export function SettingsPanel({ ); } - if (selectedBlur && onBlurDataChange && onBlurDelete) { + if (BLUR_REGIONS_ENABLED && selectedBlur && onBlurDataChange && onBlurDelete) { return (
@@ -937,32 +976,42 @@ export function SettingsPanel({
)} {zoomEnabled && hasCursorTelemetry && ( -
- - {t("zoom.focusMode.title")} - -
- {(["manual", "auto"] as const).map((mode) => { - const isActive = selectedZoomFocusMode === mode; - return ( - - ); - })} +
+
+ + {t("zoom.focusMode.title")} + +
+ {(["manual", "auto"] as const).map((mode) => { + const isActive = selectedZoomFocusMode === mode; + return ( + + ); + })} +
+ {focusModeLocked && ( +
+ + {t("zoom.focusMode.lockedDisclaimer")} +
+ )}
)} {zoomEnabled && onZoomPreviewStart && onZoomPreviewEnd && ( @@ -1234,6 +1283,44 @@ export function SettingsPanel({
+ {webcamLayoutPreset !== "no-webcam" && ( +
+
+ {t("layout.mirrorWebcam")} +
+ +
+ )} + {webcamLayoutPreset === "picture-in-picture" && ( +
+
+ {t("layout.reactiveWebcam")} + + + +
+ +
+ )} {webcamLayoutPreset === "picture-in-picture" && (
@@ -1469,8 +1556,20 @@ export function SettingsPanel({ {showCursor && ( <>
-
- {t("cursor.clipToBounds")} +
+ {t("cursor.clipToBounds")} + + +
+ {cursorThemeOptions.length > 1 && ( +
+
+ {t("cursor.theme")} +
+
+ {cursorThemeOptions.map((option) => { + const isSelected = cursorTheme === option.id; + return ( + + ); + })} +
+
+ )}
@@ -1571,22 +1705,22 @@ export function SettingsPanel({ - + {t("background.image")} {t("background.color")} {t("background.gradient")} diff --git a/src/components/video-editor/ShortcutsConfigDialog.tsx b/src/components/video-editor/ShortcutsConfigDialog.tsx index c5e9503972..dcd0735133 100644 --- a/src/components/video-editor/ShortcutsConfigDialog.tsx +++ b/src/components/video-editor/ShortcutsConfigDialog.tsx @@ -22,6 +22,7 @@ import { type ShortcutConflict, type ShortcutsConfig, } from "@/lib/shortcuts"; +import { BLUR_REGIONS_ENABLED } from "./featureFlags"; const MODIFIER_KEYS = new Set(["Control", "Shift", "Alt", "Meta"]); @@ -143,61 +144,63 @@ export function ShortcutsConfigDialog() {

{t("configurable")}

- {SHORTCUT_ACTIONS.map((action) => { - const isCapturing = captureFor === action; - const hasConflict = conflict?.forAction === action; - return ( -
-
- {t(`actions.${action}`)} - -
- {hasConflict && conflict?.conflictWith.type === "configurable" && ( -
- - ⚠{" "} - {t("alreadyUsedBy", { - action: t(`actions.${conflict.conflictWith.action}`), - })} - -
- - -
+ {SHORTCUT_ACTIONS.filter((action) => BLUR_REGIONS_ENABLED || action !== "addBlur").map( + (action) => { + const isCapturing = captureFor === action; + const hasConflict = conflict?.forAction === action; + return ( +
+
+ {t(`actions.${action}`)} +
- )} -
- ); - })} + {hasConflict && conflict?.conflictWith.type === "configurable" && ( +
+ + ⚠{" "} + {t("alreadyUsedBy", { + action: t(`actions.${conflict.conflictWith.action}`), + })} + +
+ + +
+
+ )} +
+ ); + }, + )}
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 05034632e8..96da01f28b 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -1,8 +1,9 @@ import type { Span } from "dnd-timeline"; import { FolderOpen, Languages, Save, Video } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, @@ -11,11 +12,28 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { useI18n, useScopedT } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; import { INITIAL_EDITOR_STATE, useEditorHistory } from "@/hooks/useEditorHistory"; import { type Locale } from "@/i18n/config"; import { getAvailableLocales, getLocaleName } from "@/i18n/loader"; +import { + captionSegmentsToAnnotationRegions, + extractMono16kFromVideoUrl, + MAX_CAPTION_AUDIO_SEC, + reconcileAutoCaptionTimelineGaps, + shiftTrimRegionsMsForCaptionBuffer, + transcribeMono16kToSegments, + trimLeadingSilenceMono16k, +} from "@/lib/captioning"; import { hasNativeCursorRecordingData } from "@/lib/cursor/nativeCursor"; import { calculateEffectiveSourceDimensions, @@ -33,9 +51,10 @@ import { } from "@/lib/exporter"; import { computeFrameStepTime } from "@/lib/frameStep"; import type { CursorCaptureMode, ProjectMedia } from "@/lib/recordingSession"; -import { matchesShortcut } from "@/lib/shortcuts"; +import { isTextEditingTarget, matchesShortcut } from "@/lib/shortcuts"; import { getExportFolder, + getProjectFolder, loadUserPreferences, parentDirectoryOf, saveUserPreferences, @@ -68,8 +87,22 @@ import { toFileUrl, validateProjectData, } from "./projectPersistence"; +import { + buildPastedAnnotation, + buildSpeedRegion, + buildZoomRegion, + type CopiedRegion, + extractAnnotationAttributes, + extractSpeedAttributes, + extractZoomAttributes, + getCopiedRegion, + replaceAnnotationAttributes, + setCopiedRegion, +} from "./regionClipboard"; +import { findFreeGapAt } from "./regionPlacement"; import { SettingsPanel } from "./SettingsPanel"; import TimelineEditor from "./timeline/TimelineEditor"; +import { buildAutoZoomSuggestions } from "./timeline/zoomSuggestionUtils"; import { type AnnotationRegion, type BlurData, @@ -95,6 +128,9 @@ import { import { UnsavedChangesDialog } from "./UnsavedChangesDialog"; import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback"; +/** Single Sonner slot so auto-caption phases update in place instead of stacking. */ +const AUTO_CAPTION_PROGRESS_TOAST_ID = "auto-caption-progress"; + function isClickInteractionType(interactionType: string | null | undefined) { return ( interactionType === "click" || @@ -151,6 +187,8 @@ function buildSaveDiagnosticMessage(formatLabel: "GIF" | "Video", reason?: strin return `${formatLabel} export save failed${reason ? `\nReason: ${reason}` : ""}`; } +const CAPTION_WORD_CHOICES = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] as const; + export default function VideoEditor() { const { state: editorState, @@ -164,6 +202,8 @@ export default function VideoEditor() { const { zoomRegions, + autoZoomEnabled, + autoFocusAll, trimRegions, speedRegions, annotationRegions, @@ -178,11 +218,13 @@ export default function VideoEditor() { aspectRatio, webcamLayoutPreset, webcamMaskShape, + webcamMirrored, + webcamReactiveZoom, webcamSizePreset, webcamPosition, } = editorState; - // ── Non-undoable state + // Non-undoable state const [videoPath, setVideoPath] = useState(null); const [videoSourcePath, setVideoSourcePath] = useState(null); const [webcamVideoPath, setWebcamVideoPath] = useState(null); @@ -226,8 +268,8 @@ export default function VideoEditor() { } | null>(null); const [isFullscreen, setIsFullscreen] = useState(false); const [showCloseConfirmDialog, setShowCloseConfirmDialog] = useState(false); - // Unsaved-changes confirmation for New Project / Load Project actions. - // (The window-close flow uses showCloseConfirmDialog above.) + // Unsaved-changes confirmation for New Project / Load Project. + // The window-close flow uses showCloseConfirmDialog above. const [confirmDialogVariant, setConfirmDialogVariant] = useState< "newProject" | "loadProject" | null >(null); @@ -260,6 +302,7 @@ export default function VideoEditor() { const [cursorClipToBounds, setCursorClipToBounds] = useState( DEFAULT_CURSOR_SETTINGS.clipToBounds, ); + const [cursorTheme, setCursorTheme] = useState(DEFAULT_CURSOR_SETTINGS.theme); const [nativePlatform, setNativePlatform] = useState(null); const [recordingCursorCaptureMode, setRecordingCursorCaptureMode] = useState(null); @@ -271,9 +314,9 @@ export default function VideoEditor() { const nextSpeedIdRef = useRef(1); const { shortcuts, isMac } = useShortcuts(); - // Native Windows recordings include captured cursor assets. Native macOS - // recordings hide the system cursor in ScreenCaptureKit and use telemetry - // samples with OpenScreen's default arrow asset for the editable overlay. + // Windows recordings include captured cursor assets. macOS hides the system + // cursor in ScreenCaptureKit and renders telemetry samples with OpenScreen's + // default arrow asset for the editable overlay. const hasEditableCursorRecording = recordingCursorCaptureMode === "editable-overlay" && (nativePlatform === "win32" || nativePlatform === "darwin") && @@ -283,10 +326,16 @@ export default function VideoEditor() { const { locale, setLocale, t: rawT } = useI18n(); const t = useScopedT("editor"); const ts = useScopedT("settings"); + const tt = useScopedT("timeline"); const availableLocales = getAvailableLocales(); const nextAnnotationIdRef = useRef(1); const nextAnnotationZIndexRef = useRef(1); + const isAutoCaptioningRef = useRef(false); + const [isAutoCaptioning, setIsAutoCaptioning] = useState(false); + const [showAutoCaptionsDialog, setShowAutoCaptionsDialog] = useState(false); + const [captionWordsMin, setCaptionWordsMin] = useState(2); + const [captionWordsMax, setCaptionWordsMax] = useState(7); const exporterRef = useRef(null); const annotationOnlyRegions = useMemo( @@ -359,6 +408,10 @@ export default function VideoEditor() { setRecordingCursorCaptureMode(projectCursorCaptureMode); setCurrentProjectPath(path ?? null); + // A loaded project keeps its zooms exactly as saved, so never auto-suggest + // over it (even if it has zero zooms because the user deleted them all). + autoProcessedSourceRef.current = sourcePath; + pushState({ wallpaper: normalizedEditor.wallpaper, shadowIntensity: normalizedEditor.shadowIntensity, @@ -369,12 +422,16 @@ export default function VideoEditor() { padding: normalizedEditor.padding, cropRegion: normalizedEditor.cropRegion, zoomRegions: normalizedEditor.zoomRegions, + autoZoomEnabled: normalizedEditor.autoZoomEnabled, + autoFocusAll: normalizedEditor.autoFocusAll, trimRegions: normalizedEditor.trimRegions, speedRegions: normalizedEditor.speedRegions, annotationRegions: normalizedEditor.annotationRegions, aspectRatio: normalizedEditor.aspectRatio, webcamLayoutPreset: normalizedEditor.webcamLayoutPreset, webcamMaskShape: normalizedEditor.webcamMaskShape, + webcamMirrored: normalizedEditor.webcamMirrored, + webcamReactiveZoom: normalizedEditor.webcamReactiveZoom, webcamSizePreset: normalizedEditor.webcamSizePreset, webcamPosition: normalizedEditor.webcamPosition, }); @@ -383,6 +440,7 @@ export default function VideoEditor() { setGifFrameRate(normalizedEditor.gifFrameRate); setGifLoop(normalizedEditor.gifLoop); setGifSizePreset(normalizedEditor.gifSizePreset); + setCursorTheme(normalizedEditor.cursorTheme); setSelectedZoomId(null); setSelectedTrimId(null); @@ -441,21 +499,28 @@ export default function VideoEditor() { padding, cropRegion, zoomRegions, + autoZoomEnabled, + autoFocusAll, trimRegions, speedRegions, annotationRegions, aspectRatio, webcamLayoutPreset, webcamMaskShape, + webcamMirrored, + webcamReactiveZoom, + webcamSizePreset, webcamPosition, exportQuality, exportFormat, gifFrameRate, gifLoop, gifSizePreset, + cursorTheme, }); }, [ currentProjectMedia, + cursorTheme, wallpaper, shadowIntensity, showBlur, @@ -465,12 +530,17 @@ export default function VideoEditor() { padding, cropRegion, zoomRegions, + autoZoomEnabled, + autoFocusAll, trimRegions, speedRegions, annotationRegions, aspectRatio, webcamLayoutPreset, webcamMaskShape, + webcamMirrored, + webcamReactiveZoom, + webcamSizePreset, webcamPosition, exportQuality, exportFormat, @@ -533,8 +603,8 @@ export default function VideoEditor() { createProjectSnapshot({ screenVideoPath: result.path }, INITIAL_EDITOR_STATE), ); } - // No video/project/session — leave videoPath null so the - // EditorEmptyState dashboard renders instead of an error screen. + // No video/project/session, so leave videoPath null and let the + // EditorEmptyState dashboard render instead of an error screen. } catch (err) { setError("Error loading video: " + String(err)); } finally { @@ -545,8 +615,7 @@ export default function VideoEditor() { loadInitialData(); }, [applyLoadedProject]); - // Track whether user preferences have been loaded to avoid - // overwriting saved prefs with defaults on the first render + // Avoid overwriting saved prefs with defaults before they've loaded. const [prefsHydrated, setPrefsHydrated] = useState(false); // Load persisted user preferences on mount (intentionally runs once) @@ -589,12 +658,16 @@ export default function VideoEditor() { padding, cropRegion, zoomRegions, + autoZoomEnabled, + autoFocusAll, trimRegions, speedRegions, annotationRegions, aspectRatio, webcamLayoutPreset, webcamMaskShape, + webcamMirrored, + webcamReactiveZoom, webcamSizePreset, webcamPosition, exportQuality, @@ -602,6 +675,7 @@ export default function VideoEditor() { gifFrameRate, gifLoop, gifSizePreset, + cursorTheme, }; const projectData = createProjectData(currentProjectMedia, editorState); @@ -610,8 +684,8 @@ export default function VideoEditor() { .split(/[\\/]/) .pop() ?.replace(/\.[^.]+$/, "") || `project-${Date.now()}`; - // Match the normalization path used by `currentProjectSnapshot` so the - // post-save baseline compares equal and `hasUnsavedChanges` clears. + // Normalize the same way as currentProjectSnapshot so the post-save + // baseline compares equal and hasUnsavedChanges clears. const projectSnapshot = createProjectSnapshot(currentProjectMedia, editorState); const result = await nativeBridgeClient.project.saveProjectFile( projectData, @@ -649,21 +723,26 @@ export default function VideoEditor() { padding, cropRegion, zoomRegions, + autoZoomEnabled, + autoFocusAll, trimRegions, speedRegions, annotationRegions, aspectRatio, webcamLayoutPreset, webcamMaskShape, + webcamMirrored, + webcamReactiveZoom, + webcamSizePreset, webcamPosition, exportQuality, exportFormat, gifFrameRate, gifLoop, gifSizePreset, + cursorTheme, videoPath, t, - webcamSizePreset, ], ); @@ -719,7 +798,7 @@ export default function VideoEditor() { }, []); const doLoadProject = useCallback(async () => { - const result = await nativeBridgeClient.project.loadProjectFile(); + const result = await nativeBridgeClient.project.loadProjectFile(getProjectFolder()); if (result.canceled) { return; @@ -736,6 +815,13 @@ export default function VideoEditor() { return; } + if (result.path) { + const folder = parentDirectoryOf(result.path); + if (folder) { + saveUserPreferences({ projectFolder: folder }); + } + } + toast.success(t("project.loadedFrom", { path: result.path ?? "" })); }, [applyLoadedProject, t]); @@ -788,6 +874,7 @@ export default function VideoEditor() { setCursorMotionBlur(DEFAULT_CURSOR_SETTINGS.motionBlur); setCursorClickBounce(DEFAULT_CURSOR_SETTINGS.clickBounce); setCursorClipToBounds(DEFAULT_CURSOR_SETTINGS.clipToBounds); + setCursorTheme(DEFAULT_CURSOR_SETTINGS.theme); // Reset region ID counters. nextZoomIdRef.current = 1; nextTrimIdRef.current = 1; @@ -947,6 +1034,9 @@ export default function VideoEditor() { depth: DEFAULT_ZOOM_DEPTH, customScale: ZOOM_DEPTH_SCALES[DEFAULT_ZOOM_DEPTH], focus: { cx: 0.5, cy: 0.5 }, + // Auto-Focus on means new zooms follow the cursor too. + focusMode: autoFocusAll ? "auto" : undefined, + source: "manual", }; pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] })); setSelectedZoomId(id); @@ -955,24 +1045,91 @@ export default function VideoEditor() { setSelectedAnnotationId(null); setSelectedBlurId(null); }, - [pushState], + [pushState, autoFocusAll], ); - const handleZoomSuggested = useCallback( - (span: Span, focus: ZoomFocus) => { - const id = `zoom-${nextZoomIdRef.current++}`; - const newRegion: ZoomRegion = { - id, - startMs: Math.round(span.start), - endMs: Math.round(span.end), + // Builds fresh "auto" zoom regions from cursor telemetry without overlapping + // existing ones. Used by both the on-load auto-suggest pass and the wand toggle. + const buildAutoZoomRegions = useCallback( + (existingRegions: ZoomRegion[]): ZoomRegion[] => { + const totalMs = Math.round(duration * 1000); + const suggestions = buildAutoZoomSuggestions({ + cursorTelemetry, + totalMs, + existingRegions, + defaultDurationMs: Math.max(1000, Math.round(totalMs * 0.05)), + }); + return suggestions.map((suggestion) => ({ + id: `zoom-${nextZoomIdRef.current++}`, + startMs: Math.round(suggestion.span.start), + endMs: Math.round(suggestion.span.end), depth: DEFAULT_ZOOM_DEPTH, customScale: ZOOM_DEPTH_SCALES[DEFAULT_ZOOM_DEPTH], - focus: clampFocusToDepth(focus, DEFAULT_ZOOM_DEPTH), - }; - // Bulk suggest must not steal selection — keeping a zoom selected hides - // the export panel (SettingsPanel gates it on !hasTimelineSelection), - // trapping users who just want to export after auto-zoom. - pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] })); + focus: clampFocusToDepth(suggestion.focus, DEFAULT_ZOOM_DEPTH), + focusMode: autoFocusAll ? ("auto" as const) : undefined, + source: "auto" as const, + })); + }, + [cursorTelemetry, duration, autoFocusAll], + ); + + // Auto-suggest zooms once per fresh recording (no existing zooms, telemetry + // available, wand enabled). Loaded projects are marked processed elsewhere so + // they're never touched. The ref guard runs this once per source and survives undo. + const autoProcessedSourceRef = useRef(null); + useEffect(() => { + if (!autoZoomEnabled || !cursorTelemetrySourcePath) return; + if (autoProcessedSourceRef.current === cursorTelemetrySourcePath) return; + if (cursorTelemetry.length < 2 || duration <= 0) return; + // Only auto-suggest for a fresh recording; don't disturb existing zooms. + if (zoomRegions.length > 0) { + autoProcessedSourceRef.current = cursorTelemetrySourcePath; + return; + } + const newRegions = buildAutoZoomRegions([]); + autoProcessedSourceRef.current = cursorTelemetrySourcePath; + if (newRegions.length === 0) return; + pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, ...newRegions] })); + }, [ + autoZoomEnabled, + cursorTelemetrySourcePath, + cursorTelemetry, + duration, + zoomRegions, + buildAutoZoomRegions, + pushState, + ]); + + // Wand toggle: ON regenerates suggestions around existing zooms; OFF removes + // only untouched auto zooms (manual and edited-to-manual survive). + const handleToggleAutoZoom = useCallback( + (enabled: boolean) => { + if (enabled) { + autoProcessedSourceRef.current = cursorTelemetrySourcePath; + pushState((prev) => ({ + autoZoomEnabled: true, + zoomRegions: [...prev.zoomRegions, ...buildAutoZoomRegions(prev.zoomRegions)], + })); + } else { + pushState((prev) => ({ + autoZoomEnabled: false, + zoomRegions: prev.zoomRegions.filter((region) => region.source !== "auto"), + })); + } + }, + [pushState, buildAutoZoomRegions, cursorTelemetrySourcePath], + ); + + // Flip every zoom between auto (cursor-follow) and manual at once. + const handleToggleAutoFocusAll = useCallback( + (on: boolean) => { + pushState((prev) => ({ + autoFocusAll: on, + zoomRegions: prev.zoomRegions.map((region) => ({ + ...region, + focusMode: on ? "auto" : "manual", + })), + })); }, [pushState], ); @@ -1004,6 +1161,7 @@ export default function VideoEditor() { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end), + source: "manual", } : region, ), @@ -1029,12 +1187,14 @@ export default function VideoEditor() { [pushState], ); - // Focus drag: updateState for live preview, commitState on pointer-up + // Focus drag: updateState for live preview, commitState on pointer-up. const handleZoomFocusChange = useCallback( (id: string, focus: ZoomFocus) => { updateState((prev) => ({ zoomRegions: prev.zoomRegions.map((region) => - region.id === id ? { ...region, focus: clampFocusToDepth(focus, region.depth) } : region, + region.id === id + ? { ...region, focus: clampFocusToDepth(focus, region.depth), source: "manual" } + : region, ), })); }, @@ -1052,6 +1212,7 @@ export default function VideoEditor() { depth, customScale: ZOOM_DEPTH_SCALES[depth], focus: clampFocusToDepth(region.focus, depth), + source: "manual", } : region, ), @@ -1067,7 +1228,9 @@ export default function VideoEditor() { if (!Number.isFinite(rounded)) return; updateState((prev) => ({ zoomRegions: prev.zoomRegions.map((region) => - region.id === selectedZoomId ? { ...region, customScale: rounded } : region, + region.id === selectedZoomId + ? { ...region, customScale: rounded, source: "manual" } + : region, ), })); }, @@ -1083,7 +1246,7 @@ export default function VideoEditor() { if (!selectedZoomId) return; pushState((prev) => ({ zoomRegions: prev.zoomRegions.map((region) => - region.id === selectedZoomId ? { ...region, focusMode } : region, + region.id === selectedZoomId ? { ...region, focusMode, source: "manual" } : region, ), })); }, @@ -1110,9 +1273,9 @@ export default function VideoEditor() { if (region.id !== selectedZoomId) return region; if (preset === null) { const { rotationPreset: _p, ...rest } = region; - return rest; + return { ...rest, source: "manual" }; } - return { ...region, rotationPreset: preset }; + return { ...region, rotationPreset: preset, source: "manual" }; }), })); }, @@ -1260,8 +1423,11 @@ export default function VideoEditor() { const handleAnnotationSpanChange = useCallback( (id: string, span: Span) => { - pushState((prev) => ({ - annotationRegions: prev.annotationRegions.map((region) => + pushState((prev) => { + const editedAutoCaption = + prev.annotationRegions.find((region) => region.id === id)?.annotationSource === + "auto-caption"; + const next = prev.annotationRegions.map((region) => region.id === id ? { ...region, @@ -1269,8 +1435,11 @@ export default function VideoEditor() { endMs: Math.round(span.end), } : region, - ), - })); + ); + return { + annotationRegions: editedAutoCaption ? reconcileAutoCaptionTimelineGaps(next) : next, + }; + }); }, [pushState], ); @@ -1283,8 +1452,10 @@ export default function VideoEditor() { const source = prev.annotationRegions.find((region) => region.id === id); if (!source) return {}; + const { annotationSource: _stripCaptionLink, ...sourceWithoutCaptionLink } = source; + const duplicate: AnnotationRegion = { - ...source, + ...sourceWithoutCaptionLink, id: duplicateId, zIndex: duplicateZIndex, position: { x: source.position.x + 4, y: source.position.y + 4 }, @@ -1375,11 +1546,18 @@ export default function VideoEditor() { const handleAnnotationStyleChange = useCallback( (id: string, style: Partial) => { - pushState((prev) => ({ - annotationRegions: prev.annotationRegions.map((region) => - region.id === id ? { ...region, style: { ...region.style, ...style } } : region, - ), - })); + pushState((prev) => { + const touched = prev.annotationRegions.find((r) => r.id === id); + const syncAutoCaptions = touched?.annotationSource === "auto-caption"; + return { + annotationRegions: prev.annotationRegions.map((region) => { + if (syncAutoCaptions && region.annotationSource === "auto-caption") { + return { ...region, style: { ...region.style, ...style } }; + } + return region.id === id ? { ...region, style: { ...region.style, ...style } } : region; + }), + }; + }); }, [pushState], ); @@ -1442,26 +1620,236 @@ export default function VideoEditor() { const handleAnnotationPositionChange = useCallback( (id: string, position: { x: number; y: number }) => { - pushState((prev) => ({ - annotationRegions: prev.annotationRegions.map((region) => - region.id === id ? { ...region, position } : region, - ), - })); + pushState((prev) => { + const moved = prev.annotationRegions.find((r) => r.id === id); + const syncAutoCaptions = moved?.annotationSource === "auto-caption"; + return { + annotationRegions: prev.annotationRegions.map((region) => { + if (syncAutoCaptions && region.annotationSource === "auto-caption") { + return { ...region, position }; + } + return region.id === id ? { ...region, position } : region; + }), + }; + }); }, [pushState], ); const handleAnnotationSizeChange = useCallback( (id: string, size: { width: number; height: number }) => { - pushState((prev) => ({ - annotationRegions: prev.annotationRegions.map((region) => - region.id === id ? { ...region, size } : region, - ), - })); + pushState((prev) => { + const resized = prev.annotationRegions.find((r) => r.id === id); + const syncAutoCaptions = resized?.annotationSource === "auto-caption"; + return { + annotationRegions: prev.annotationRegions.map((region) => { + if (syncAutoCaptions && region.annotationSource === "auto-caption") { + return { ...region, size }; + } + return region.id === id ? { ...region, size } : region; + }), + }; + }); }, [pushState], ); + const handleCopySelected = useCallback(() => { + // Copy the selected region of any kind into the clipboard. A selected blur is an + // annotation (type "blur" lives in annotationRegions), so it copies via that row. + const copyTargets = [ + [selectedZoomId, zoomRegions, extractZoomAttributes, "zoom"], + [selectedSpeedId, speedRegions, extractSpeedAttributes, "speed"], + [ + selectedAnnotationId ?? selectedBlurId, + annotationRegions, + extractAnnotationAttributes, + "annotation", + ], + ] as const; + + for (const [id, regions, extract, kind] of copyTargets) { + if (!id) continue; + const region = (regions as readonly { id: string }[]).find((r) => r.id === id); + if (!region) continue; // Stale id — try the next target so the fallback toast stays reachable. + // Each row pairs a region list with its matching extractor, so the cast is sound. + setCopiedRegion((extract as (r: never) => CopiedRegion)(region as never)); + // Blur lives in annotationRegions (type "blur") but its toast must label as "blur", not "text". + const labelKind = (region as { type?: string }).type === "blur" ? "blur" : kind; + toast.success( + t("regionClipboard.copied", { region: t(`regionClipboard.kinds.${labelKind}`) }), + { + id: "regionClipboard.copied", + }, + ); + return; + } + toast.info(t("regionClipboard.nothingToCopy")); + }, [ + selectedZoomId, + selectedSpeedId, + selectedAnnotationId, + selectedBlurId, + zoomRegions, + speedRegions, + annotationRegions, + t, + ]); + + const handlePaste = useCallback(() => { + const copied = getCopiedRegion(); + // If there's nothing in the clipboard, show a message and return early. + if (!copied) { + toast.info(t("regionClipboard.nothingToPaste")); + return; + } + + // Apply onto the selected region of the same kind, keeping its timing. + if (copied.kind === "zoom" && selectedZoomId) { + pushState((prev) => ({ + zoomRegions: prev.zoomRegions.map((r) => + r.id === selectedZoomId ? buildZoomRegion(r, copied) : r, + ), + })); + toast.success( + t("regionClipboard.pasted", { region: t(`regionClipboard.kinds.${copied.kind}`) }), + { + id: "regionClipboard.pasted", + }, + ); + return; + } + if (copied.kind === "speed" && selectedSpeedId) { + pushState((prev) => ({ + speedRegions: prev.speedRegions.map((r) => + r.id === selectedSpeedId ? buildSpeedRegion(r, copied) : r, + ), + })); + toast.success( + t("regionClipboard.pasted", { region: t(`regionClipboard.kinds.${copied.kind}`) }), + { + id: "regionClipboard.pasted", + }, + ); + return; + } + // Blurs live in annotationRegions (type "blur"), so a selected blur is a valid target too. + if (copied.kind === "annotation" && (selectedAnnotationId || selectedBlurId)) { + const targetId = selectedAnnotationId ?? selectedBlurId; + pushState((prev) => ({ + annotationRegions: prev.annotationRegions.map((r) => + r.id === targetId ? replaceAnnotationAttributes(r, copied) : r, + ), + })); + toast.success( + t("regionClipboard.pasted", { region: t(`regionClipboard.kinds.${copied.kind}`) }), + { + id: "regionClipboard.pasted", + }, + ); + return; + } + + // Nothing matching selected → create a new region at the playhead. + const totalMs = Math.round(duration * 1000); + if (totalMs <= 0) return; + const defaultDuration = Math.min(Math.max(1000, Math.round(totalMs * 0.05)), totalMs); + const startPos = Math.max(0, Math.min(Math.round(currentTime * 1000), totalMs)); + + if (copied.kind === "zoom") { + const { ok, gapMs } = findFreeGapAt(zoomRegions, startPos, totalMs); + if (!ok) { + toast.error(tt("errors.cannotPlaceZoom"), { + description: tt("errors.zoomExistsAtLocation"), + }); + return; + } + const id = `zoom-${nextZoomIdRef.current++}`; + const region = buildZoomRegion( + { + id, + startMs: startPos, + endMs: startPos + Math.min(defaultDuration, gapMs), + source: "manual", + }, + copied, + ); + pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, region] })); + handleSelectZoom(id); + toast.success( + t("regionClipboard.pasted", { region: t(`regionClipboard.kinds.${copied.kind}`) }), + { + id: "regionClipboard.pasted", + }, + ); + return; + } + + if (copied.kind === "speed") { + const { ok, gapMs } = findFreeGapAt(speedRegions, startPos, totalMs); + if (!ok) { + toast.error(tt("errors.cannotPlaceSpeed"), { + description: tt("errors.speedExistsAtLocation"), + }); + return; + } + const id = `speed-${nextSpeedIdRef.current++}`; + const region = buildSpeedRegion( + { + id, + startMs: startPos, + endMs: startPos + Math.min(defaultDuration, gapMs), + }, + copied, + ); + pushState((prev) => ({ speedRegions: [...prev.speedRegions, region] })); + handleSelectSpeed(id); + toast.success( + t("regionClipboard.pasted", { region: t(`regionClipboard.kinds.${copied.kind}`) }), + { + id: "regionClipboard.pasted", + }, + ); + return; + } + + // Annotation — overlaps are allowed. A brand-new region clones the full copy + // (type, content, styling, position), unlike the styling-only overwrite above. + const id = `annotation-${nextAnnotationIdRef.current++}`; + const region = buildPastedAnnotation( + { + id, + startMs: startPos, + endMs: Math.min(startPos + defaultDuration, totalMs), + zIndex: nextAnnotationZIndexRef.current++, + }, + copied, + ); + pushState((prev) => ({ annotationRegions: [...prev.annotationRegions, region] })); + handleSelectAnnotation(id); + toast.success( + t("regionClipboard.pasted", { region: t(`regionClipboard.kinds.${copied.kind}`) }), + { + id: "regionClipboard.pasted", + }, + ); + }, [ + selectedZoomId, + selectedSpeedId, + selectedAnnotationId, + selectedBlurId, + zoomRegions, + speedRegions, + duration, + currentTime, + pushState, + handleSelectZoom, + handleSelectSpeed, + handleSelectAnnotation, + t, + tt, + ]); + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const mod = e.ctrlKey || e.metaKey; @@ -1480,6 +1868,29 @@ export default function VideoEditor() { return; } + // Copy/paste region attributes. Skipped while typing in a field so native + // text copy/paste keeps working. Also only intercepted when there's an + // actual region selected (copy) or something on the clipboard (paste); + // otherwise the browser handles native copy/paste of any page selection. + const editingText = isTextEditingTarget(e.target); + if (!editingText) { + if (matchesShortcut(e, shortcuts.copySelected, isMac)) { + const hasRegionSelected = + selectedZoomId || selectedSpeedId || selectedAnnotationId || selectedBlurId; + if (hasRegionSelected) { + e.preventDefault(); + handleCopySelected(); + return; + } + } else if (matchesShortcut(e, shortcuts.paste, isMac)) { + if (getCopiedRegion()) { + e.preventDefault(); + handlePaste(); + return; + } + } + } + // Frame-step navigation (arrow keys, no modifiers) if ( (e.key === "ArrowLeft" || e.key === "ArrowRight") && @@ -1522,7 +1933,7 @@ export default function VideoEditor() { } if (matchesShortcut(e, shortcuts.playPause, isMac)) { - // Allow space only in inputs/textareas + // Let space pass through inside inputs/textareas. if (isInput) { return; } @@ -1536,7 +1947,18 @@ export default function VideoEditor() { window.addEventListener("keydown", handleKeyDown, { capture: true }); return () => window.removeEventListener("keydown", handleKeyDown, { capture: true }); - }, [undo, redo, shortcuts, isMac]); + }, [ + undo, + redo, + shortcuts, + isMac, + handleCopySelected, + handlePaste, + selectedZoomId, + selectedSpeedId, + selectedAnnotationId, + selectedBlurId, + ]); useEffect(() => { if (selectedZoomId && !zoomRegions.some((region) => region.id === selectedZoomId)) { @@ -1658,9 +2080,8 @@ export default function VideoEditor() { return; } - // Ask the user where to save BEFORE starting the export. This avoids the - // post-export save dialog getting hidden behind other windows after a - // long-running export. + // Pick the save path before exporting, otherwise the save dialog can end up + // hidden behind other windows after a long-running export. const isGifFormat = settings.format === "gif"; const targetFileName = `export-${Date.now()}.${isGifFormat ? "gif" : "mp4"}`; const pickResult = await window.electronAPI.pickExportSavePath( @@ -1696,7 +2117,7 @@ export default function VideoEditor() { ? getNativeAspectRatioValue(sourceWidth, sourceHeight, cropRegion) : getAspectRatioValue(aspectRatio); - // Get preview CONTAINER dimensions for scaling + // Preview container dimensions, used for scaling. const playbackRef = videoPlaybackRef.current; const containerElement = playbackRef?.containerRef?.current; const previewWidth = containerElement?.clientWidth || DEFAULT_SOURCE_DIMENSIONS.width; @@ -1730,9 +2151,12 @@ export default function VideoEditor() { cursorMotionBlur, cursorClickBounce, cursorClipToBounds, + cursorTheme, annotationRegions, webcamLayoutPreset, webcamMaskShape, + webcamMirrored, + webcamReactiveZoom, webcamSizePreset, webcamPosition, previewWidth, @@ -1821,9 +2245,12 @@ export default function VideoEditor() { cursorMotionBlur, cursorClickBounce, cursorClipToBounds, + cursorTheme, annotationRegions, webcamLayoutPreset, webcamMaskShape, + webcamMirrored, + webcamReactiveZoom, webcamSizePreset, webcamPosition, previewWidth, @@ -1899,8 +2326,8 @@ export default function VideoEditor() { } finally { setIsExporting(false); exporterRef.current = null; - // Reset dialog state to ensure it can be opened again on next export - // This fixes the bug where second export doesn't show save dialog + // Reset so the next export can reopen the dialog (second export + // otherwise wouldn't show the save dialog). setShowExportDialog(false); setExportProgress(null); } @@ -1925,6 +2352,8 @@ export default function VideoEditor() { aspectRatio, webcamLayoutPreset, webcamMaskShape, + webcamMirrored, + webcamReactiveZoom, webcamSizePreset, webcamPosition, exportQuality, @@ -1937,6 +2366,7 @@ export default function VideoEditor() { cursorMotionBlur, cursorClickBounce, cursorClipToBounds, + cursorTheme, t, ], ); @@ -2018,6 +2448,138 @@ export default function VideoEditor() { } }, []); + const generateAutoCaptions = useCallback( + async (minWords: number, maxWords: number) => { + if (!videoPath) { + toast.error(t("errors.noVideoLoaded")); + return; + } + if (isAutoCaptioningRef.current) { + toast.error(t("autoCaptions.busy")); + return; + } + const minW = Math.max(1, Math.min(minWords, maxWords)); + const maxW = Math.max(minW, maxWords); + + isAutoCaptioningRef.current = true; + setIsAutoCaptioning(true); + toast.loading(t("autoCaptions.generating"), { id: AUTO_CAPTION_PROGRESS_TOAST_ID }); + try { + const { samples, truncated, durationSec } = await extractMono16kFromVideoUrl(videoPath); + if (!Number.isFinite(durationSec) || durationSec <= 0 || samples.length < 800) { + toast.dismiss(AUTO_CAPTION_PROGRESS_TOAST_ID); + toast.error(t("autoCaptions.noAudio")); + return; + } + + const { samples: speechSamples, trimSec } = trimLeadingSilenceMono16k(samples); + if (speechSamples.length < 800) { + toast.dismiss(AUTO_CAPTION_PROGRESS_TOAST_ID); + toast.error(t("autoCaptions.noAudio")); + return; + } + + const trimMs = Math.round(trimSec * 1000); + const trimRegionsForTranscribe = shiftTrimRegionsMsForCaptionBuffer(trimRegions, trimMs); + + const transcribeOptions = { + onStatus: (phase: "model" | "transcribe") => { + if (phase === "model") { + toast.loading(t("autoCaptions.loadingModel"), { + id: AUTO_CAPTION_PROGRESS_TOAST_ID, + }); + } else { + toast.loading(t("autoCaptions.transcribing"), { + id: AUTO_CAPTION_PROGRESS_TOAST_ID, + }); + } + }, + }; + + let { segments: segmentsRaw, granularity } = await transcribeMono16kToSegments( + speechSamples, + { + trimRegions: trimRegionsForTranscribe, + ...transcribeOptions, + }, + ); + let transcribedFromTrimmedBuffer = true; + + // Leading-silence trimming can return empty even when the full source has + // speech. Retry once against the untrimmed buffer before giving up. + if (segmentsRaw.length === 0 && trimSec > 0) { + ({ segments: segmentsRaw, granularity } = await transcribeMono16kToSegments(samples, { + trimRegions, + ...transcribeOptions, + })); + transcribedFromTrimmedBuffer = false; + } + + const segments = + transcribedFromTrimmedBuffer && trimSec > 0 + ? segmentsRaw.map((s) => ({ + ...s, + startSec: s.startSec + trimSec, + endSec: s.endSec + trimSec, + })) + : segmentsRaw; + + let { regions, nextNumericId, nextZIndex } = captionSegmentsToAnnotationRegions( + segments, + nextAnnotationIdRef.current, + nextAnnotationZIndexRef.current, + { + minWordsPerCaption: minW, + maxWordsPerCaption: maxW, + timestampGranularity: granularity, + }, + ); + + if (regions.length === 0 && segments.length > 0) { + ({ regions, nextNumericId, nextZIndex } = captionSegmentsToAnnotationRegions( + segments, + nextAnnotationIdRef.current, + nextAnnotationZIndexRef.current, + { + minWordsPerCaption: 1, + maxWordsPerCaption: Number.MAX_SAFE_INTEGER, + timestampGranularity: granularity, + }, + )); + } + + if (regions.length === 0) { + toast.dismiss(AUTO_CAPTION_PROGRESS_TOAST_ID); + toast.info(t("autoCaptions.noneHeard")); + return; + } + + pushState((prev) => ({ annotationRegions: [...prev.annotationRegions, ...regions] })); + nextAnnotationIdRef.current = nextNumericId; + nextAnnotationZIndexRef.current = nextZIndex; + + toast.dismiss(AUTO_CAPTION_PROGRESS_TOAST_ID); + const minutesTrunc = String(Math.round(MAX_CAPTION_AUDIO_SEC / 60)); + if (truncated) { + toast.success(t("autoCaptions.done", { count: String(regions.length) }), { + description: t("autoCaptions.truncated", { minutes: minutesTrunc }), + }); + } else { + toast.success(t("autoCaptions.done", { count: String(regions.length) })); + } + } catch (e) { + console.error(e); + toast.dismiss(AUTO_CAPTION_PROGRESS_TOAST_ID); + const detail = e instanceof Error ? e.message : String(e); + toast.error(t("autoCaptions.failed"), { description: detail }); + } finally { + isAutoCaptioningRef.current = false; + setIsAutoCaptioning(false); + } + }, + [videoPath, trimRegions, pushState, t], + ); + const handleSaveDiagnostic = useCallback(async () => { const result = await window.electronAPI.saveDiagnostic({ error: exportError ?? "Manual diagnostic export", @@ -2060,7 +2622,7 @@ export default function VideoEditor() { {t("newRecording.title")} @@ -2085,13 +2647,92 @@ export default function VideoEditor() { + + + + {t("autoCaptions.dialogTitle")} + {t("autoCaptions.dialogDescription")} + +
+
+ + +
+
+ + +
+
+ + + + +
+
+
- {/* Empty state — shown when no video is loaded */} + {/* Empty state shown when no video is loaded */} {!videoPath && (
updateState({ webcamPosition: pos })} @@ -2243,6 +2886,7 @@ export default function VideoEditor() { cursorMotionBlur={cursorMotionBlur} cursorClickBounce={cursorClickBounce} cursorClipToBounds={cursorClipToBounds} + cursorTheme={cursorTheme} isPreviewingZoom={isPreviewingZoom} />
@@ -2291,6 +2935,7 @@ export default function VideoEditor() { onZoomFocusModeChange={(mode) => selectedZoomId && handleZoomFocusModeChange(mode) } + focusModeLocked={autoFocusAll} selectedZoomFocus={ selectedZoomId ? (zoomRegions.find((z) => z.id === selectedZoomId)?.focus ?? null) @@ -2340,6 +2985,12 @@ export default function VideoEditor() { } webcamMaskShape={webcamMaskShape} onWebcamMaskShapeChange={(shape) => pushState({ webcamMaskShape: shape })} + webcamMirrored={webcamMirrored} + webcamReactiveZoom={webcamReactiveZoom} + onWebcamMirroredChange={(mirrored) => pushState({ webcamMirrored: mirrored })} + onWebcamReactiveZoomChange={(reactive) => + pushState({ webcamReactiveZoom: reactive }) + } webcamSizePreset={webcamSizePreset} onWebcamSizePresetChange={(v) => updateState({ webcamSizePreset: v })} onWebcamSizePresetCommit={commitState} @@ -2423,6 +3074,8 @@ export default function VideoEditor() { onCursorClickBounceChange={setCursorClickBounce} cursorClipToBounds={cursorClipToBounds} onCursorClipToBoundsChange={setCursorClipToBounds} + cursorTheme={cursorTheme} + onCursorThemeChange={setCursorTheme} hasCursorData={ cursorTelemetry.length > 0 || hasNativeCursorRecordingData(cursorRecordingData) @@ -2444,10 +3097,12 @@ export default function VideoEditor() { videoDuration={duration} currentTime={currentTime} onSeek={handleSeek} - cursorTelemetry={cursorTelemetry} zoomRegions={zoomRegions} onZoomAdded={handleZoomAdded} - onZoomSuggested={handleZoomSuggested} + autoZoomEnabled={autoZoomEnabled} + onToggleAutoZoom={handleToggleAutoZoom} + autoFocusAll={autoFocusAll} + onToggleAutoFocusAll={handleToggleAutoFocusAll} onZoomSpanChange={handleZoomSpanChange} onZoomDelete={handleZoomDelete} selectedZoomId={selectedZoomId} @@ -2489,6 +3144,19 @@ export default function VideoEditor() { } videoUrl={videoPath ?? undefined} showTrimWaveform={showTrimWaveform} + captionsLabel={t("autoCaptions.button")} + isGeneratingCaptions={isAutoCaptioning} + onGenerateCaptions={() => { + if (!videoPath) { + toast.error(t("errors.noVideoLoaded")); + return; + } + if (isAutoCaptioningRef.current) { + toast.error(t("autoCaptions.busy")); + return; + } + setShowAutoCaptionsDialog(true); + }} />
diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 9f7d8a17d2..1b4ad5263a 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -20,14 +20,15 @@ import { } from "react"; import { getWebcamLayoutCssBoxShadow, + reactiveWebcamScale, type Size, type StyledRenderRect, type WebcamLayoutPreset, type WebcamSizePreset, } from "@/lib/compositeLayout"; +import { getSmoothedCursorPath } from "@/lib/cursor/cursorPathSmoothing"; import { createNativeCursorMotionBlurState, - createNativeCursorSmoothingState, getNativeCursorClickBounceProgress, getNativeCursorClickBounceScale, getNativeCursorMotionBlurPx, @@ -35,10 +36,8 @@ import { projectNativeCursorToLocal, projectNativeCursorToStage, resetNativeCursorMotionBlurState, - resetNativeCursorSmoothingState, resolveInterpolatedNativeCursorFrame, resolveNativeCursorRenderAsset, - smoothNativeCursorSample, } from "@/lib/cursor/nativeCursor"; import { classifyWallpaper, DEFAULT_WALLPAPER, resolveImageWallpaperUrl } from "@/lib/wallpaper"; import { getCssClipPath } from "@/lib/webcamMaskShapes"; @@ -66,19 +65,11 @@ import { rotation3DPerspective, type SpeedRegion, type TrimRegion, - ZOOM_DEPTH_SCALES, type ZoomFocus, type ZoomRegion, } from "./types"; -import { - AUTO_FOLLOW_RAMP_DISTANCE, - AUTO_FOLLOW_SMOOTHING_FACTOR, - AUTO_FOLLOW_SMOOTHING_FACTOR_MAX, - DEFAULT_FOCUS, - ZOOM_SCALE_DEADZONE, - ZOOM_TRANSLATION_DEADZONE_PX, -} from "./videoPlayback/constants"; -import { adaptiveSmoothFactor, smoothCursorFocus } from "./videoPlayback/cursorFollowUtils"; +import { AUTO_FOLLOW_PARAMS, DEFAULT_FOCUS } from "./videoPlayback/constants"; +import { advanceFollowFocus } from "./videoPlayback/cursorFollowUtils"; import { DEFAULT_CURSOR_CONFIG, PixiCursorOverlay, @@ -90,6 +81,7 @@ import { clamp01 } from "./videoPlayback/mathUtils"; import { updateOverlayIndicator } from "./videoPlayback/overlayUtils"; import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers"; import { findDominantRegion } from "./videoPlayback/zoomRegionUtils"; +import { createZoomSpringState, resetZoomSpring, stepZoomSpring } from "./videoPlayback/zoomSpring"; import { applyZoomTransform, computeFocusFromTransform, @@ -103,6 +95,8 @@ interface VideoPlaybackProps { webcamVideoPath?: string; webcamLayoutPreset: WebcamLayoutPreset; webcamMaskShape?: import("./types").WebcamMaskShape; + webcamMirrored?: boolean; + webcamReactiveZoom?: boolean; webcamSizePreset?: WebcamSizePreset; webcamPosition?: { cx: number; cy: number } | null; onWebcamPositionChange?: (position: { cx: number; cy: number }) => void; @@ -150,8 +144,9 @@ interface VideoPlaybackProps { cursorMotionBlur?: number; cursorClickBounce?: number; cursorClipToBounds?: boolean; - // When true, render the selected zoom at the playhead even while paused — - // lets the editor preview the zoom effect without leaving the focus-edit view. + cursorTheme?: string; + // Render the selected zoom at the playhead even while paused, so the editor can + // preview the effect without leaving the focus-edit view. isPreviewingZoom?: boolean; } @@ -227,6 +222,8 @@ const VideoPlayback = forwardRef( webcamVideoPath, webcamLayoutPreset, webcamMaskShape, + webcamMirrored = false, + webcamReactiveZoom = false, webcamSizePreset, webcamPosition, onWebcamPositionChange, @@ -274,6 +271,7 @@ const VideoPlayback = forwardRef( cursorMotionBlur = DEFAULT_CURSOR_SETTINGS.motionBlur, cursorClickBounce = DEFAULT_CURSOR_SETTINGS.clickBounce, cursorClipToBounds = DEFAULT_CURSOR_SETTINGS.clipToBounds, + cursorTheme = DEFAULT_CURSOR_SETTINGS.theme, isPreviewingZoom = false, }, ref, @@ -281,6 +279,10 @@ const VideoPlayback = forwardRef( const videoRef = useRef(null); const supplementalAudioRef = useRef(null); const webcamVideoRef = useRef(null); + const webcamWrapperRef = useRef(null); + const webcamReactiveZoomRef = useRef(webcamReactiveZoom); + const webcamLayoutPresetRef = useRef(webcamLayoutPreset); + const webcamPositionRef = useRef(webcamPosition); const containerRef = useRef(null); const appRef = useRef(null); const videoSpriteRef = useRef(null); @@ -313,6 +315,9 @@ const VideoPlayback = forwardRef( y: 0, appliedScale: 1, }); + // Spring that chases the eased zoom target so the camera glides instead of jerking. + const zoomSpringRef = useRef(createZoomSpringState()); + const prevZoomTimeMsRef = useRef(null); const blurFilterRef = useRef(null); const motionBlurFilterRef = useRef(null); const isDraggingFocusRef = useRef(false); @@ -346,6 +351,7 @@ const VideoPlayback = forwardRef( const cursorMotionBlurRef = useRef(cursorMotionBlur); const cursorClickBounceRef = useRef(cursorClickBounce); const cursorClipToBoundsRef = useRef(cursorClipToBounds); + const cursorThemeRef = useRef(cursorTheme); const isPreviewingZoomRef = useRef(isPreviewingZoom); const motionBlurStateRef = useRef(createMotionBlurState()); const onTimeUpdateRef = useRef(onTimeUpdate); @@ -363,7 +369,6 @@ const VideoPlayback = forwardRef( const nativeCursorTextureIdRef = useRef(null); const nativeCursorImageRef = useRef(null); const nativeCursorImageIdRef = useRef(null); - const nativeCursorSmoothingStateRef = useRef(createNativeCursorSmoothingState()); const nativeCursorMotionBlurStateRef = useRef(createNativeCursorMotionBlurState()); const nativeCursorClipRef = useRef(null); const borderRadiusRef = useRef(0); @@ -477,17 +482,8 @@ const VideoPlayback = forwardRef( [onDurationChange, syncResolvedDuration], ); - // IMPORTANT: must use clampFocusToScale(focus, getZoomScale(region)) here, - // NOT clampFocusToStage(focus, region.depth). - // - // region.depth is the preset slot (1×/2×/4×) and ignores customScale entirely. - // getZoomScale(region) returns customScale when set, falling back to the preset - // depth scale — so drag-to-reposition respects the actual zoom level the user - // configured, not the preset bucket it sits in. - // - // This was previously broken (invisible drag boundaries near canvas edges) and - // has been fixed twice. If you're refactoring this drag handler, keep this call - // as clampFocusForRegion(focus, region) — do not switch it back to region.depth. + // Clamp against getZoomScale(region), not region.depth: depth is just the preset + // slot (1x/2x/4x) and ignores customScale, which gives wrong drag bounds near the edges. const clampFocusForRegion = useCallback((focus: ZoomFocus, region: ZoomRegion) => { return clampFocusToScale(focus, getZoomScale(region)); }, []); @@ -501,7 +497,6 @@ const VideoPlayback = forwardRef( return; } - // Update stage size from overlay dimensions const stageWidth = overlayEl.clientWidth; const stageHeight = overlayEl.clientHeight; if (stageWidth && stageHeight) { @@ -821,7 +816,6 @@ const VideoPlayback = forwardRef( useEffect(() => { cursorRecordingDataRef.current = cursorRecordingData; - resetNativeCursorSmoothingState(nativeCursorSmoothingStateRef.current); resetNativeCursorMotionBlurState(nativeCursorMotionBlurStateRef.current); }, [cursorRecordingData]); @@ -849,6 +843,24 @@ const VideoPlayback = forwardRef( cursorClipToBoundsRef.current = cursorClipToBounds; }, [cursorClipToBounds]); + useEffect(() => { + cursorThemeRef.current = cursorTheme; + }, [cursorTheme]); + + useEffect(() => { + webcamReactiveZoomRef.current = webcamReactiveZoom; + webcamLayoutPresetRef.current = webcamLayoutPreset; + webcamPositionRef.current = webcamPosition; + // Clear any reactive transform when the effect is turned off or layout changes, + // so a stale shrink doesn't linger while the ticker isn't updating it. + if ( + webcamWrapperRef.current && + (!webcamReactiveZoom || webcamLayoutPreset !== "picture-in-picture") + ) { + webcamWrapperRef.current.style.transform = ""; + } + }, [webcamReactiveZoom, webcamLayoutPreset, webcamPosition]); + useEffect(() => { isPreviewingZoomRef.current = isPreviewingZoom; }, [isPreviewingZoom]); @@ -912,10 +924,9 @@ const VideoPlayback = forwardRef( }; }, [pixiReady, videoReady, layoutVideoContent]); - // Drop the PIXI canvas resolution to 1.0 while scrubbing (the user is - // navigating, not previewing) and restore native DPR on play/idle so the - // preview stays faithful. Mutating renderer.resolution per-frame would - // thrash texture uploads; we only do it on scrub-state transitions. + // Drop canvas resolution to 1.0 while scrubbing and restore native DPR on play/idle. + // Only on scrub-state transitions; mutating renderer.resolution per-frame thrashes + // texture uploads. useEffect(() => { if (!pixiReady) return; const app = appRef.current; @@ -1301,12 +1312,32 @@ const VideoPlayback = forwardRef( motionBlurAmount: motionBlurAmountRef.current, transformOverride: transform, motionBlurState: motionBlurStateRef.current, - frameTimeMs: performance.now(), + // Content time, not wall-clock, so motion-blur velocity matches export and stays + // correct under speed regions (frameRenderer passes the same content timeMs). + frameTimeMs: currentTimeRef.current, }); state.x = appliedTransform.x; state.y = appliedTransform.y; state.appliedScale = appliedTransform.scale; + + // Scale the PiP webcam inversely with the (eased) zoom, anchored to the docked + // corner (bottom-right by default) so it stays flush instead of drifting to center. + const webcamWrapper = webcamWrapperRef.current; + if (webcamWrapper) { + const reactive = + webcamReactiveZoomRef.current && webcamLayoutPresetRef.current === "picture-in-picture"; + const factor = reactive ? reactiveWebcamScale(state.appliedScale) : 1; + if (factor < 1) { + const pos = webcamPositionRef.current; + const originX = (pos ? pos.cx >= 0.5 : true) ? "100%" : "0%"; + const originY = (pos ? pos.cy >= 0.5 : true) ? "100%" : "0%"; + webcamWrapper.style.transformOrigin = `${originX} ${originY}`; + webcamWrapper.style.transform = `scale(${factor})`; + } else { + webcamWrapper.style.transform = ""; + } + } }; let lastMotionBlurActive: boolean | null = null; @@ -1327,53 +1358,55 @@ const VideoPlayback = forwardRef( let targetFocus = defaultFocus; let targetProgress = 0; - // If a zoom is selected but video is not playing, show default unzoomed view + // If a zoom is selected but not playing, show the default unzoomed view. const selectedId = selectedZoomIdRef.current; const hasSelectedZoom = selectedId !== null; const shouldShowUnzoomedView = hasSelectedZoom && !isPlayingRef.current && !isPreviewingZoomRef.current; if (region && strength > 0 && !shouldShowUnzoomedView) { - const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth]; + // Use getZoomScale (customScale-aware) to match export and the magnification + // findDominantRegion resolved focus at. Falling back to the depth preset would + // zoom/pan to a different level than export. + const zoomScale = blendedScale ?? getZoomScale(region); const regionFocus = region.focus; targetScaleFactor = zoomScale; targetFocus = regionFocus; targetProgress = strength; - // Apply adaptive smoothing for auto-follow mode + // Adaptive smoothing for auto-follow mode. if (region.focusMode === "auto" && !transition) { const raw = targetFocus; const isZoomingIn = targetProgress < 0.999 && targetProgress >= prevTargetProgressRef.current; + // Follow the cursor in content time (frame-rate independent) so the camera pans + // at the same speed in preview and export. Snap to target when not actively + // playing (paused/seek/scrub), matching the zoom spring's snap. + const focusAnimating = + isPlayingRef.current && !isSeekingRef.current && !isScrubbingRef.current; + const focusDtMs = + prevZoomTimeMsRef.current === null + ? 0 + : currentTimeRef.current - prevZoomTimeMsRef.current; if (targetProgress >= 0.999) { - // Full zoom: adaptive smoothing — moves faster when far, decelerates when close + // Full zoom: adaptive smoothing, faster when far, decelerating when close. const prev = smoothedAutoFocusRef.current ?? raw; - const factor = adaptiveSmoothFactor( - raw, - prev, - AUTO_FOLLOW_SMOOTHING_FACTOR, - AUTO_FOLLOW_SMOOTHING_FACTOR_MAX, - AUTO_FOLLOW_RAMP_DISTANCE, - ); - const smoothed = smoothCursorFocus(raw, prev, factor); + const smoothed = focusAnimating + ? advanceFollowFocus(prev, raw, focusDtMs, AUTO_FOLLOW_PARAMS) + : raw; smoothedAutoFocusRef.current = smoothed; targetFocus = smoothed; } else if (isZoomingIn) { - // Zoom-in: track cursor directly so zoom always aims at current cursor - // position; keep ref in sync to avoid snap when full-zoom begins + // Zoom-in: track cursor directly so zoom always aims at the current position; + // keep ref in sync to avoid a snap when full-zoom begins. smoothedAutoFocusRef.current = raw; } else { - // Zoom-out: keep smoothing for continuity — avoids snap at zoom-out start + // Zoom-out: keep smoothing for continuity to avoid a snap at zoom-out start. const prev = smoothedAutoFocusRef.current ?? raw; - const factor = adaptiveSmoothFactor( - raw, - prev, - AUTO_FOLLOW_SMOOTHING_FACTOR, - AUTO_FOLLOW_SMOOTHING_FACTOR_MAX, - AUTO_FOLLOW_RAMP_DISTANCE, - ); - const smoothed = smoothCursorFocus(raw, prev, factor); + const smoothed = focusAnimating + ? advanceFollowFocus(prev, raw, focusDtMs, AUTO_FOLLOW_PARAMS) + : raw; smoothedAutoFocusRef.current = smoothed; targetFocus = smoothed; } @@ -1382,7 +1415,7 @@ const VideoPlayback = forwardRef( } prevTargetProgressRef.current = targetProgress; - // Handle connected zoom transitions (pan between adjacent zoom regions) + // Connected zoom transitions: pan between adjacent regions. if (transition) { const startTransform = computeZoomTransform({ stageSize: stageSizeRef.current, @@ -1440,18 +1473,28 @@ const VideoPlayback = forwardRef( focusY: state.focusY, }); - const appliedScale = - Math.abs(projectedTransform.scale - prevScale) < ZOOM_SCALE_DEADZONE - ? projectedTransform.scale - : projectedTransform.scale; - const appliedX = - Math.abs(projectedTransform.x - prevX) < ZOOM_TRANSLATION_DEADZONE_PX - ? projectedTransform.x - : projectedTransform.x; - const appliedY = - Math.abs(projectedTransform.y - prevY) < ZOOM_TRANSLATION_DEADZONE_PX - ? projectedTransform.y - : projectedTransform.y; + // Chase the eased target with a spring so the camera glides (no jerk at the steep + // start of the ease, no snap at close-region seams). Step by content time while + // playing; snap to the exact target when paused/seeking/scrubbing for crisp frames. + const nowMs = currentTimeRef.current; + const prevMs = prevZoomTimeMsRef.current; + const animating = isPlayingRef.current && !isSeekingRef.current && !isScrubbingRef.current; + const dtMs = prevMs === null ? 0 : nowMs - prevMs; + let appliedScale: number; + let appliedX: number; + let appliedY: number; + if (!animating || prevMs === null || dtMs <= 0 || dtMs > 80) { + resetZoomSpring(zoomSpringRef.current, projectedTransform); + appliedScale = projectedTransform.scale; + appliedX = projectedTransform.x; + appliedY = projectedTransform.y; + } else { + const sprung = stepZoomSpring(zoomSpringRef.current, projectedTransform, dtMs); + appliedScale = sprung.scale; + appliedX = sprung.x; + appliedY = sprung.y; + } + prevZoomTimeMsRef.current = nowMs; const motionIntensity = Math.max( Math.abs(appliedScale - prevScale), @@ -1489,7 +1532,6 @@ const VideoPlayback = forwardRef( } } - // Update cursor overlay const cursorOverlay = cursorOverlayRef.current; if (cursorOverlay) { const timeMs = currentTimeRef.current; // already in ms @@ -1516,7 +1558,6 @@ const VideoPlayback = forwardRef( if (nativeCursorClipRef.current) { nativeCursorClipRef.current.style.clipPath = ""; } - resetNativeCursorSmoothingState(nativeCursorSmoothingStateRef.current); resetNativeCursorMotionBlurState(nativeCursorMotionBlurStateRef.current); }; if (nativeCursorImage) { @@ -1527,13 +1568,15 @@ const VideoPlayback = forwardRef( timeMs, ); if (frame) { - const displaySample = smoothNativeCursorSample({ - forceSnap: !isPlayingRef.current || isSeekingRef.current, - sample: frame.sample, - smoothing: cursorSmoothingRef.current, - state: nativeCursorSmoothingStateRef.current, - timeMs, - }); + // Position comes from the precomputed offline-smoothed path; the frame still + // supplies the cursor image, type, and click timing. + const smoothedPos = getSmoothedCursorPath( + cursorRecordingDataRef.current, + cursorSmoothingRef.current, + )?.sampleAt(timeMs); + const displaySample = smoothedPos + ? { ...frame.sample, cx: smoothedPos.cx, cy: smoothedPos.cy } + : frame.sample; const cameraContainer = cameraContainerRef.current; const videoContainer = videoContainerRef.current; const cropRegionValue = cropRegionRef.current ?? { x: 0, y: 0, width: 1, height: 1 }; @@ -1556,9 +1599,14 @@ const VideoPlayback = forwardRef( }) : null; if (projectedLocalPoint && projectedStagePoint) { - // Pass deviceScaleFactor=1 — asset.scaleFactor already encodes DPR. + // Pass deviceScaleFactor=1 since asset.scaleFactor already encodes DPR. // Size is normalized below so preview matches export proportionally. - const renderAsset = resolveNativeCursorRenderAsset(frame.asset, 1, displaySample); + const renderAsset = resolveNativeCursorRenderAsset( + frame.asset, + 1, + displaySample, + cursorThemeRef.current, + ); const bounceProgress = getNativeCursorClickBounceProgress( cursorRecordingDataRef.current, timeMs, @@ -1587,9 +1635,8 @@ const VideoPlayback = forwardRef( nativeCursorImageIdRef.current = renderAsset.id; } nativeCursorImage.style.display = "block"; - // Update clip-path on nativeCursorClipRef to the camera-aware video boundary. - // clip-path works correctly here because nativeCursorClipRef is outside preserve-3d. - // When cursorClipToBounds is off, allow the cursor to overflow the canvas. + // Clip to the camera-aware video boundary. Works here because nativeCursorClipRef + // sits outside preserve-3d. When cursorClipToBounds is off, let the cursor overflow. if (nativeCursorClipRef.current) { if (!cursorClipToBoundsRef.current) { nativeCursorClipRef.current.style.clipPath = "none"; @@ -1612,7 +1659,7 @@ const VideoPlayback = forwardRef( nativeCursorImage.style.filter = blurPx > 0 ? `blur(${blurPx.toFixed(2)}px)` : "none"; // translate3d is relative to nativeCursorClipRef (absolute inset-0 = stage origin). - // projectedStagePoint.x is the stage-space cursor position — no offset needed. + // projectedStagePoint.x is the stage-space cursor position, so no offset is needed. nativeCursorImage.style.transform = `translate3d(${ projectedStagePoint.x - renderAsset.hotspotX * transformedScale }px, ${projectedStagePoint.y - renderAsset.hotspotY * transformedScale}px, 0)`; @@ -1854,7 +1901,7 @@ const VideoPlayback = forwardRef( ), }} > - {/* Background layer - always render as DOM element with blur */} + {/* Background always renders as a DOM element so it can be blurred. */}
( const useClipPath = !!clipPath; return (
( clipPath: clipPath ?? undefined, boxShadow: useClipPath ? "none" : webcamCssBoxShadow, backgroundColor: "#000", + transform: webcamMirrored ? "scaleX(-1)" : undefined, }} onPointerDown={handleWebcamPointerDown} onPointerMove={handleWebcamPointerMove} @@ -1921,7 +1970,7 @@ const VideoPlayback = forwardRef(
); })()} - {/* Only render overlay after PIXI and video are fully initialized */} + {/* Render the overlay only once PIXI and video are ready. */} {pixiReady && videoReady && (
( })() : null; - // Handle click-through cycling: when clicking same annotation, cycle to next + // Re-clicking a selected annotation cycles through any overlapping ones. const handleAnnotationClick = (clickedId: string) => { if (!onSelectAnnotation) return; - // If clicking on already selected annotation and there are multiple overlapping if (clickedId === selectedAnnotationId && filteredAnnotations.length > 1) { - // Find current index and cycle to next const currentIndex = filteredAnnotations.findIndex((a) => a.id === clickedId); const nextIndex = (currentIndex + 1) % filteredAnnotations.length; onSelectAnnotation(filteredAnnotations[nextIndex].id); } else { - // First click or clicking different annotation onSelectAnnotation(clickedId); } }; @@ -2062,10 +2108,8 @@ const VideoPlayback = forwardRef(
)}
- {/* Clip the native cursor overlay to the exact video canvas boundary. - Placed OUTSIDE composite3DRef (preserve-3d) so clip-path works - correctly even during 3D zoom rotation regions. - clip-path is set dynamically to the camera-aware video bounds. */} + {/* Native cursor clip. Lives outside composite3DRef (preserve-3d) so clip-path + keeps working during 3D zoom rotations; bounds are set dynamically. */}
{ aspectRatio: "16:9", webcamLayoutPreset: "picture-in-picture", webcamMaskShape: "circle", + webcamMirrored: true, + webcamSizePreset: 25, webcamPosition: null, exportQuality: "good", exportFormat: "mp4", @@ -68,6 +70,12 @@ describe("projectPersistence media compatibility", () => { ).toBe("rectangle"); }); + it("normalizes webcam mirroring safely", () => { + expect(normalizeProjectEditor({ webcamMirrored: true }).webcamMirrored).toBe(true); + expect(normalizeProjectEditor({ webcamMirrored: false }).webcamMirrored).toBe(false); + expect(normalizeProjectEditor({ webcamMirrored: "yes" as never }).webcamMirrored).toBe(false); + }); + it("normalizes blur region type and mosaic block size safely", () => { const editor = normalizeProjectEditor({ annotationRegions: [ diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index ff59427f2d..258efdf6a1 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -1,5 +1,6 @@ import { normalizeTextAnimation } from "@/lib/annotationTextAnimation"; import { normalizeBlurColor, normalizeBlurType } from "@/lib/blurEffects"; +import { normalizeCursorThemeId } from "@/lib/cursor/cursorThemes"; import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter"; import type { ProjectMedia } from "@/lib/recordingSession"; import { normalizeProjectMedia } from "@/lib/recordingSession"; @@ -25,6 +26,8 @@ import { DEFAULT_BLUR_INTENSITY, DEFAULT_FIGURE_DATA, DEFAULT_PLAYBACK_SPEED, + DEFAULT_WEBCAM_MIRRORED, + DEFAULT_WEBCAM_REACTIVE_ZOOM, DEFAULT_ZOOM_DEPTH, DEFAULT_ZOOM_MOTION_BLUR, MAX_BLUR_BLOCK_SIZE, @@ -44,11 +47,10 @@ import { const VALID_BLUR_SHAPES = new Set(["rectangle", "oval", "freehand"] as const); -// Pre-fix projects could persist resolved file:// URLs (machine-specific) for -// bundled wallpapers. Rewrite only paths that match a known install layout -// (resources/[assets/]wallpapers for packaged, public/wallpapers for dev) so -// a legitimate user file that happens to live in a folder named "wallpapers" -// elsewhere is never silently replaced. +// Old projects persisted machine-specific file:// URLs for bundled wallpapers. +// Match only the known install layouts (packaged resources/[assets/]wallpapers, +// dev public/wallpapers) so a user's own file under some "wallpapers" folder isn't +// silently replaced. const LEGACY_FILE_WALLPAPER_RE = /^file:\/\/.*?\/(?:resources\/(?:assets\/)?|public\/)wallpapers\/(wallpaper\d+\.jpg)$/i; const CANONICAL_WALLPAPERS = new Set(WALLPAPER_PATHS); @@ -72,12 +74,16 @@ export interface ProjectEditorState { padding: number; cropRegion: CropRegion; zoomRegions: ZoomRegion[]; + autoZoomEnabled: boolean; + autoFocusAll: boolean; trimRegions: TrimRegion[]; speedRegions: SpeedRegion[]; annotationRegions: AnnotationRegion[]; aspectRatio: AspectRatio; webcamLayoutPreset: WebcamLayoutPreset; webcamMaskShape: WebcamMaskShape; + webcamMirrored: boolean; + webcamReactiveZoom: boolean; webcamSizePreset: WebcamSizePreset; webcamPosition: WebcamPosition | null; exportQuality: ExportQuality; @@ -85,6 +91,7 @@ export interface ProjectEditorState { gifFrameRate: GifFrameRate; gifLoop: boolean; gifSizePreset: GifSizePreset; + cursorTheme: string; } export interface EditorProjectData { @@ -258,6 +265,7 @@ export function normalizeProjectEditor(editor: Partial): Pro cy: clamp(isFiniteNumber(region.focus?.cy) ? region.focus.cy : 0.5, 0, 1), }, focusMode: region.focusMode === "auto" ? "auto" : "manual", + source: region.source === "auto" ? "auto" : "manual", ...(validPreset ? { rotationPreset: validPreset } : {}), }; }) @@ -333,6 +341,8 @@ export function normalizeProjectEditor(editor: Partial): Pro content: typeof region.content === "string" ? region.content : "", textContent: typeof region.textContent === "string" ? region.textContent : undefined, imageContent: typeof region.imageContent === "string" ? region.imageContent : undefined, + annotationSource: + region.annotationSource === "auto-caption" ? ("auto-caption" as const) : undefined, position: { x: clamp( isFiniteNumber(region.position?.x) @@ -436,6 +446,7 @@ export function normalizeProjectEditor(editor: Partial): Pro const cropHeight = clamp(rawCropHeight, 0.01, 1 - cropY); return { + cursorTheme: normalizeCursorThemeId(editor.cursorTheme), wallpaper: typeof editor.wallpaper === "string" ? normalizeWallpaperValue(editor.wallpaper) @@ -473,6 +484,10 @@ export function normalizeProjectEditor(editor: Partial): Pro height: cropHeight, }, zoomRegions: normalizedZoomRegions, + // Default on for legacy projects so re-opens match the new default. The + // on-load auto-suggest pass is gated separately, so this won't add zooms. + autoZoomEnabled: typeof editor.autoZoomEnabled === "boolean" ? editor.autoZoomEnabled : true, + autoFocusAll: typeof editor.autoFocusAll === "boolean" ? editor.autoFocusAll : false, trimRegions: normalizedTrimRegions, speedRegions: normalizedSpeedRegions, annotationRegions: normalizedAnnotationRegions, @@ -485,6 +500,12 @@ export function normalizeProjectEditor(editor: Partial): Pro editor.webcamMaskShape === "rounded" ? editor.webcamMaskShape : DEFAULT_WEBCAM_SETTINGS.maskShape, + webcamMirrored: + typeof editor.webcamMirrored === "boolean" ? editor.webcamMirrored : DEFAULT_WEBCAM_MIRRORED, + webcamReactiveZoom: + typeof editor.webcamReactiveZoom === "boolean" + ? editor.webcamReactiveZoom + : DEFAULT_WEBCAM_REACTIVE_ZOOM, webcamSizePreset: typeof editor.webcamSizePreset === "number" && isFiniteNumber(editor.webcamSizePreset) ? Math.max(10, Math.min(50, editor.webcamSizePreset)) diff --git a/src/components/video-editor/regionClipboard.test.ts b/src/components/video-editor/regionClipboard.test.ts new file mode 100644 index 0000000000..f99f17eb27 --- /dev/null +++ b/src/components/video-editor/regionClipboard.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from "vitest"; +import { + buildPastedAnnotation, + buildSpeedRegion, + buildZoomRegion, + extractAnnotationAttributes, + extractSpeedAttributes, + extractZoomAttributes, + replaceAnnotationAttributes, +} from "./regionClipboard"; +import { + type AnnotationRegion, + DEFAULT_ANNOTATION_POSITION, + DEFAULT_ANNOTATION_SIZE, + DEFAULT_ANNOTATION_STYLE, + DEFAULT_FIGURE_DATA, + type SpeedRegion, + type ZoomRegion, +} from "./types"; + +const zoom: ZoomRegion = { + id: "zoom-1", + startMs: 1000, + endMs: 3000, + depth: 4, + customScale: 2.75, + focus: { cx: 0.2, cy: 0.8 }, + focusMode: "manual", + rotationPreset: "iso", + source: "manual", +}; + +const speed: SpeedRegion = { id: "speed-1", startMs: 0, endMs: 500, speed: 2 }; + +const annotation: AnnotationRegion = { + id: "annotation-1", + startMs: 0, + endMs: 2000, + type: "figure", + content: "hello", + position: { x: 10, y: 90 }, + size: { width: 40, height: 25 }, + style: { ...DEFAULT_ANNOTATION_STYLE, color: "#ff0000", textAnimation: "pop" }, + zIndex: 3, + figureData: { ...DEFAULT_FIGURE_DATA, color: "#123456" }, +}; + +describe("zoom attribute copy/paste", () => { + it("round-trips the copyable attributes onto a different clip while keeping its identity/timing", () => { + const attrs = extractZoomAttributes(zoom); + const target: ZoomRegion = { + id: "zoom-2", + startMs: 9000, + endMs: 9500, + depth: 1, + focus: { cx: 0.5, cy: 0.5 }, + source: "manual", + }; + const result = buildZoomRegion(target, attrs); + + expect(result.id).toBe("zoom-2"); + expect(result.startMs).toBe(9000); + expect(result.endMs).toBe(9500); + expect(result.depth).toBe(4); + expect(result.customScale).toBe(2.75); + expect(result.focus).toEqual({ cx: 0.2, cy: 0.8 }); + expect(result.focusMode).toBe("manual"); + expect(result.rotationPreset).toBe("iso"); + }); + + it("deep-copies focus so the source and target are decoupled", () => { + const attrs = extractZoomAttributes(zoom); + const result = buildZoomRegion({ ...zoom, id: "zoom-2" }, attrs); + result.focus.cx = 0.99; + expect(zoom.focus.cx).toBe(0.2); + }); +}); + +describe("speed attribute copy/paste", () => { + it("copies only the speed value", () => { + const attrs = extractSpeedAttributes(speed); + const target: SpeedRegion = { id: "speed-2", startMs: 4000, endMs: 5000, speed: 1 }; + const result = buildSpeedRegion(target, attrs); + expect(result).toEqual({ id: "speed-2", startMs: 4000, endMs: 5000, speed: 2 }); + }); +}); + +describe("annotation copy captures everything", () => { + it("captures styling plus content, type, and position", () => { + const attrs = extractAnnotationAttributes(annotation); + expect(attrs.type).toBe("figure"); + expect(attrs.content).toBe("hello"); + expect(attrs.position).toEqual({ x: 10, y: 90 }); + expect(attrs.style.color).toBe("#ff0000"); + expect(attrs.figureData?.color).toBe("#123456"); + }); +}); + +describe("paste onto an existing annotation applies styling only", () => { + it("overwrites the look/feel but keeps the target's content, position, timing, and zIndex", () => { + const attrs = extractAnnotationAttributes(annotation); + const target: AnnotationRegion = { + id: "annotation-2", + startMs: 7000, + endMs: 8000, + type: "text", + content: "world", + position: { ...DEFAULT_ANNOTATION_POSITION }, + size: { ...DEFAULT_ANNOTATION_SIZE }, + style: { ...DEFAULT_ANNOTATION_STYLE }, + zIndex: 9, + }; + const result = replaceAnnotationAttributes(target, attrs); + + expect(result.content).toBe("world"); + expect(result.position).toEqual(DEFAULT_ANNOTATION_POSITION); + expect(result.startMs).toBe(7000); + expect(result.zIndex).toBe(9); + expect(result.style.color).toBe("#ff0000"); + expect(result.style.textAnimation).toBe("pop"); + expect(result.size).toEqual({ width: 40, height: 25 }); + // Target is text, so the copied figure's figureData must NOT attach (F5 guard). + expect(result.figureData).toBeUndefined(); + }); + + it("keeps the target's own figure data when the copied region has none", () => { + const textAttrs = extractAnnotationAttributes({ ...annotation, figureData: undefined }); + const figureTarget: AnnotationRegion = { ...annotation, id: "annotation-3" }; + const result = replaceAnnotationAttributes(figureTarget, textAttrs); + expect(result.figureData?.color).toBe("#123456"); + }); +}); + +describe("paste as a new annotation clones the full copy", () => { + it("clones type, content, styling, and position; takes timing/identity from the base", () => { + const attrs = extractAnnotationAttributes(annotation); + const result = buildPastedAnnotation( + { id: "annotation-4", startMs: 12000, endMs: 14000, zIndex: 5 }, + attrs, + ); + + expect(result.id).toBe("annotation-4"); + expect(result.startMs).toBe(12000); + expect(result.endMs).toBe(14000); + expect(result.zIndex).toBe(5); + expect(result.type).toBe("figure"); + expect(result.content).toBe("hello"); + expect(result.position).toEqual({ x: 10, y: 90 }); + expect(result.style.color).toBe("#ff0000"); + expect(result.figureData?.color).toBe("#123456"); + }); + + it("deep-copies position so source and clone are decoupled", () => { + const attrs = extractAnnotationAttributes(annotation); + const result = buildPastedAnnotation( + { id: "annotation-5", startMs: 0, endMs: 1000, zIndex: 1 }, + attrs, + ); + result.position.x = 99; + expect(annotation.position.x).toBe(10); + }); +}); + +describe("zoom paste replaces customScale destructively (F4)", () => { + it("clears the target's customScale when the copied zoom is preset-only", () => { + const presetOnly = extractZoomAttributes({ ...zoom, customScale: undefined }); + const target: ZoomRegion = { ...zoom, id: "zoom-3", customScale: 1.5 }; + const result = buildZoomRegion(target, presetOnly); + expect(result.customScale).toBeUndefined(); + }); +}); + +describe("figureData guard on paste-onto-existing (F5)", () => { + it("does not attach figureData onto a non-figure target", () => { + const figureAttrs = extractAnnotationAttributes(annotation); + const textTarget: AnnotationRegion = { + id: "annotation-6", + startMs: 0, + endMs: 1000, + type: "text", + content: "hi", + position: { ...DEFAULT_ANNOTATION_POSITION }, + size: { ...DEFAULT_ANNOTATION_SIZE }, + style: { ...DEFAULT_ANNOTATION_STYLE }, + zIndex: 1, + }; + const result = replaceAnnotationAttributes(textTarget, figureAttrs); + expect(result.figureData).toBeUndefined(); + // Styling still applies regardless of type. + expect(result.style.color).toBe("#ff0000"); + }); + + it("applies figureData when the target is itself a figure", () => { + const figureAttrs = extractAnnotationAttributes(annotation); + const figureTarget: AnnotationRegion = { + ...annotation, + id: "annotation-7", + figureData: { ...DEFAULT_FIGURE_DATA, color: "#000000" }, + }; + const result = replaceAnnotationAttributes(figureTarget, figureAttrs); + expect(result.figureData?.color).toBe("#123456"); + }); +}); diff --git a/src/components/video-editor/regionClipboard.ts b/src/components/video-editor/regionClipboard.ts new file mode 100644 index 0000000000..876da43875 --- /dev/null +++ b/src/components/video-editor/regionClipboard.ts @@ -0,0 +1,158 @@ +import type { + AnnotationPosition, + AnnotationRegion, + AnnotationSize, + AnnotationTextStyle, + AnnotationType, + BlurData, + FigureData, + PlaybackSpeed, + Rotation3DPreset, + SpeedRegion, + ZoomDepth, + ZoomFocus, + ZoomFocusMode, + ZoomRegion, +} from "./types"; + +/** The copyable attributes of each region, tagged with its `kind` so paste can discriminate. + * Trim has no attributes, so it isn't copyable. */ +export type CopiedZoom = { + kind: "zoom"; + depth: ZoomDepth; + customScale?: number; + focus: ZoomFocus; + focusMode?: ZoomFocusMode; + rotationPreset?: Rotation3DPreset; +}; + +export type CopiedSpeed = { kind: "speed"; speed: PlaybackSpeed }; + +/** Annotation copy captures everything; paste then uses only the styling for an existing + * region, or the full set for a brand-new one. */ +export type CopiedAnnotation = { + kind: "annotation"; + // Styling — applied both when pasting onto an existing region and onto a new one. + style: AnnotationTextStyle; + size: AnnotationSize; + figureData?: FigureData; + blurData?: BlurData; + // Content & placement — used only when pasting as a brand-new region. + type: AnnotationType; + content: string; + textContent?: string; + imageContent?: string; + position: AnnotationPosition; +}; + +export type CopiedRegion = CopiedZoom | CopiedSpeed | CopiedAnnotation; + +/** Session clipboard for "copy/paste region attributes" (not undoable, not persisted). + * Module-level so it's shared regardless of which editor instance copied. */ +let clipboard: CopiedRegion | null = null; + +export function getCopiedRegion(): CopiedRegion | null { + return clipboard; +} + +export function setCopiedRegion(region: CopiedRegion): void { + clipboard = region; +} + +export function extractZoomAttributes(region: ZoomRegion): CopiedZoom { + return { + kind: "zoom", + depth: region.depth, + customScale: region.customScale, + focus: { ...region.focus }, + focusMode: region.focusMode, + rotationPreset: region.rotationPreset, + }; +} + +export function extractSpeedAttributes(region: SpeedRegion): CopiedSpeed { + return { kind: "speed", speed: region.speed }; +} + +/** Deep-clones blur data, including its nested freehand points array. */ +function cloneBlurData(blurData?: BlurData): BlurData | undefined { + if (!blurData) return undefined; + return { + ...blurData, + freehandPoints: blurData.freehandPoints ? [...blurData.freehandPoints] : undefined, + }; +} + +export function extractAnnotationAttributes(region: AnnotationRegion): CopiedAnnotation { + return { + kind: "annotation", + style: { ...region.style }, + size: { ...region.size }, + figureData: region.figureData ? { ...region.figureData } : undefined, + blurData: cloneBlurData(region.blurData), + type: region.type, + content: region.content, + textContent: region.textContent, + imageContent: region.imageContent, + position: { ...region.position }, + }; +} + +/** Returns a region carrying the copied attributes. Identity, timing, and source come from + * `base` (so a full region keeps its own); every attribute comes from the copy, with nested + * objects deep-copied. Passing a stub `base` builds a brand-new region; passing an existing + * region overwrites ALL its attributes (e.g. a preset-only copy clears the target's customScale). */ +export function buildZoomRegion( + base: Pick, + attrs: CopiedZoom, +): ZoomRegion { + const { kind: _kind, ...zoomAttrs } = attrs; + return { ...base, ...zoomAttrs, focus: { ...zoomAttrs.focus } }; +} + +export function buildSpeedRegion( + base: Pick, + attrs: CopiedSpeed, +): SpeedRegion { + return { ...base, speed: attrs.speed }; +} + +/** Pastes onto an EXISTING annotation: only the styling is overwritten — the target keeps + * its own type, text/image content, position, timing, and stacking order. */ +export function replaceAnnotationAttributes( + region: AnnotationRegion, + attrs: CopiedAnnotation, +): AnnotationRegion { + return { + ...region, + style: { ...attrs.style }, + size: { ...attrs.size }, + // Only carry figure data onto a figure target; never attach it to a non-figure + // (e.g. pasting a figure's attributes onto a text annotation keeps the text figure-less). + figureData: + region.type === "figure" && attrs.figureData ? { ...attrs.figureData } : region.figureData, + // Likewise, only carry blur settings onto a blur target. + blurData: + region.type === "blur" && attrs.blurData ? cloneBlurData(attrs.blurData) : region.blurData, + }; +} + +/** Builds a BRAND-NEW annotation from a full copy: clones type, content, styling, size, + * figure data, and position. Identity, timing, and stacking order come from `base`. */ +export function buildPastedAnnotation( + base: Pick, + attrs: CopiedAnnotation, +): AnnotationRegion { + return { + ...base, + type: attrs.type, + content: attrs.content, + textContent: attrs.textContent, + imageContent: attrs.imageContent, + position: { ...attrs.position }, + size: { ...attrs.size }, + style: { ...attrs.style }, + figureData: attrs.figureData ? { ...attrs.figureData } : undefined, + blurData: cloneBlurData(attrs.blurData), + }; +} diff --git a/src/components/video-editor/regionPlacement.test.ts b/src/components/video-editor/regionPlacement.test.ts new file mode 100644 index 0000000000..ecddf2ff61 --- /dev/null +++ b/src/components/video-editor/regionPlacement.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { findFreeGapAt } from "./regionPlacement"; + +const totalMs = 10000; + +describe("findFreeGapAt", () => { + it("returns the gap to the end when there are no regions", () => { + const { ok, gapMs } = findFreeGapAt([], 2000, totalMs); + expect(ok).toBe(true); + expect(gapMs).toBe(8000); + }); + + it("rejects a playhead that lands inside an existing region", () => { + const regions = [{ startMs: 1000, endMs: 4000 }]; + const { ok } = findFreeGapAt(regions, 2000, totalMs); + expect(ok).toBe(false); + }); + + it("clamps the gap to the next region's start", () => { + const regions = [{ startMs: 5000, endMs: 7000 }]; + const { ok, gapMs } = findFreeGapAt(regions, 2000, totalMs); + expect(ok).toBe(true); + expect(gapMs).toBe(3000); + }); + + it("rejects placement with no room before the end", () => { + const { ok, gapMs } = findFreeGapAt([], totalMs, totalMs); + expect(ok).toBe(false); + expect(gapMs).toBe(0); + }); + + it("rejects placement that lands exactly on a region's startMs", () => { + const regions = [{ startMs: 5000, endMs: 7000 }]; + const { ok } = findFreeGapAt(regions, 5000, totalMs); + expect(ok).toBe(false); + }); + + it("allows placement adjacent to (exactly at the end of) an existing region", () => { + const regions = [{ startMs: 0, endMs: 2000 }]; + const { ok, gapMs } = findFreeGapAt(regions, 2000, totalMs); + expect(ok).toBe(true); + expect(gapMs).toBe(8000); + }); + + it("sorts unordered regions before computing the next gap", () => { + const regions = [ + { startMs: 8000, endMs: 9000 }, + { startMs: 3000, endMs: 4000 }, + ]; + const { ok, gapMs } = findFreeGapAt(regions, 1000, totalMs); + expect(ok).toBe(true); + expect(gapMs).toBe(2000); + }); +}); diff --git a/src/components/video-editor/regionPlacement.ts b/src/components/video-editor/regionPlacement.ts new file mode 100644 index 0000000000..673d3fe7f8 --- /dev/null +++ b/src/components/video-editor/regionPlacement.ts @@ -0,0 +1,24 @@ +/** + * Find the available gap at `startPos` in a list of regions. + * + * Looks at the span from `startPos` up to the start of the next region + * (or up to `totalMs` if there is no later region) and reports its size, + * along with whether placement at `startPos` is actually valid. + * + * Placement is valid as long as `startPos` does not fall inside an + * existing region and there is some room before the next one. Landing + * exactly on the end of an existing region is fine (adjacency is + * allowed); landing on a region's start or strictly between its start + * and end, or having zero space left before the next region, is not. + */ +export function findFreeGapAt( + regions: ReadonlyArray<{ startMs: number; endMs: number }>, + startPos: number, + totalMs: number, +): { ok: boolean; gapMs: number } { + const sorted = [...regions].sort((a, b) => a.startMs - b.startMs); + const nextRegion = sorted.find((r) => r.startMs > startPos); + const gapMs = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; + const overlapping = sorted.some((r) => startPos >= r.startMs && startPos < r.endMs); + return { ok: !overlapping && gapMs > 0, gapMs }; +} diff --git a/src/components/video-editor/timeline/BackgroundWaveform.tsx b/src/components/video-editor/timeline/BackgroundWaveform.tsx index 815b472d13..d89a14c401 100644 --- a/src/components/video-editor/timeline/BackgroundWaveform.tsx +++ b/src/components/video-editor/timeline/BackgroundWaveform.tsx @@ -1,36 +1,28 @@ import { useTimelineContext } from "dnd-timeline"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; + +// Perceptual curve on normalized amplitude; exponent < 1 lifts quiet passages so +// one loud spike doesn't flatten the rest. +const WAVEFORM_GAMMA = 0.6; export interface BackgroundWaveformProps { - /** Pre-computed peaks array: pairs of [min, max] per block (length = 2 * N). */ + /** Pre-computed peaks: pairs of [min, max] per block (length = 2 * N). */ peaks: Float32Array | null; videoDurationMs: number; - /** - * Pixels to inset the drawn waveform from the top of the canvas row, - * so it aligns with the item content top edge. Defaults to 0. - */ + /** Inset from canvas top so the waveform aligns with item content top. Defaults to 0. */ topInset?: number; - /** - * Pixels to inset the drawn waveform from the bottom of the canvas row, - * so it aligns with the item content bottom edge. Defaults to 0. - */ + /** Inset from canvas bottom so the waveform aligns with item content bottom. Defaults to 0. */ bottomInset?: number; } /** - * Renders a rectified (half-wave) audio waveform on a `` that fills - * its containing block. Designed to be passed as the `background` prop of - * ``, which already provides `relative overflow-hidden` — no wrapper - * element needed. - * - * The canvas always uses `inset-0` (full row height). Vertical alignment with - * the item content is achieved via `topInset`/`bottomInset` in the draw calls - * rather than CSS positioning, so the result is immune to sub-pixel CSS layout - * differences. + * Renders a rectified (half-wave) audio waveform on a canvas filling its block. + * Pass as the `background` prop of ``, which already provides + * `relative overflow-hidden`. * - * - Accepts pre-computed `peaks` from the caller (see `useAudioPeaks`). - * - Redraws whenever the timeline zoom/pan range changes. - * - `pointer-events: none` — never blocks drag-to-create interactions. + * Canvas is always `inset-0` (full row height); vertical alignment comes from + * `topInset`/`bottomInset` in the draw calls, not CSS, so it's immune to + * sub-pixel layout rounding. `pointer-events: none` keeps drag-to-create working. */ export default function BackgroundWaveform({ peaks, @@ -42,8 +34,21 @@ export default function BackgroundWaveform({ const canvasRef = useRef(null); const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 }); - // Observe the canvas itself — Row's `relative overflow-hidden` parent - // makes it fill the row exactly, so no wrapper div is needed. + // Normalize against the track's own loudest peak so quiet recordings (mic/system + // audio rarely hit full scale) still fill the row. Recomputed only on peaks change, + // not zoom/pan, so height stays stable while scrolling. + const normFactor = useMemo(() => { + if (!peaks || peaks.length === 0) return 0; + let globalMax = 0; + for (let i = 0; i < peaks.length; i++) { + const a = Math.abs(peaks[i]); + if (a > globalMax) globalMax = a; + } + return globalMax > 0 ? 1 / globalMax : 0; + }, [peaks]); + + // Observe the canvas directly; Row's `relative overflow-hidden` parent makes + // it fill the row exactly, so no wrapper div is needed. useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; @@ -55,7 +60,6 @@ export default function BackgroundWaveform({ return () => ro.disconnect(); }, []); - // Redraw whenever peaks, range, or canvas size changes. useEffect(() => { const canvas = canvasRef.current; if (!canvas || canvasSize.w <= 0 || canvasSize.h <= 0) return; @@ -70,7 +74,7 @@ export default function BackgroundWaveform({ ctx.scale(dpr, dpr); ctx.clearRect(0, 0, canvasSize.w, canvasSize.h); - if (!peaks || peaks.length === 0) return; + if (!peaks || peaks.length === 0 || normFactor === 0) return; const W = canvasSize.w; const H = canvasSize.h; @@ -78,7 +82,7 @@ export default function BackgroundWaveform({ if (rangeMs <= 0 || videoDurationMs <= 0) return; // Draw within [topY, bottomY] so the waveform aligns with item bounds - // regardless of CSS sub-pixel rounding on the canvas element itself. + // regardless of sub-pixel rounding on the canvas element. const topY = topInset; const bottomY = H - bottomInset; const drawHeight = bottomY - topY; @@ -87,8 +91,9 @@ export default function BackgroundWaveform({ const N = peaks.length / 2; const amp = drawHeight * 0.9; - // Rectified (half-wave): amplitude = max(|min|, |max|), drawn upward from bottomY. - const colAmp = new Float32Array(W); + // Rectified: amplitude = max(|min|, |max|), normalized to the loudest peak + // and gamma-curved, drawn upward from bottomY. + const colY = new Float32Array(W); for (let x = 0; x < W; x++) { const startMs = range.start + (x / W) * rangeMs; const endMs = range.start + ((x + 1) / W) * rangeMs; @@ -102,30 +107,32 @@ export default function BackgroundWaveform({ if (a > absMax) absMax = a; if (b > absMax) absMax = b; } - colAmp[x] = absMax; + const normalized = Math.min(1, absMax * normFactor); + const display = normalized > 0 ? normalized ** WAVEFORM_GAMMA : 0; + colY[x] = bottomY - display * amp; } - // Filled polygon: bottom-left → top silhouette → bottom-right. + // Filled polygon: bottom-left, up over the silhouette, down to bottom-right. ctx.beginPath(); ctx.moveTo(0, bottomY); for (let x = 0; x < W; x++) { - ctx.lineTo(x, bottomY - colAmp[x] * amp); + ctx.lineTo(x, colY[x]); } ctx.lineTo(W, bottomY); ctx.closePath(); ctx.fillStyle = "rgba(74, 222, 128, 0.55)"; ctx.fill(); - // Crisp top-edge stroke for the sharp silhouette. + // Crisp top-edge stroke. ctx.beginPath(); - ctx.moveTo(0, bottomY - colAmp[0] * amp); + ctx.moveTo(0, colY[0]); for (let x = 1; x < W; x++) { - ctx.lineTo(x, bottomY - colAmp[x] * amp); + ctx.lineTo(x, colY[x]); } ctx.strokeStyle = "rgba(74, 222, 128, 0.85)"; ctx.lineWidth = 1; ctx.stroke(); - }, [peaks, range, canvasSize, videoDurationMs, topInset, bottomInset]); + }, [peaks, normFactor, range, canvasSize, videoDurationMs, topInset, bottomInset]); return ; } diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx index 7251af6def..254c4f94b1 100644 --- a/src/components/video-editor/timeline/Item.tsx +++ b/src/components/video-editor/timeline/Item.tsx @@ -79,9 +79,8 @@ export default function Item({ [span.start, span.end], ); - // Minimum clickable width on the outer wrapper. - // Kept small (6px) so items visually distinguish their real positions; - // users should zoom in to interact with sub-second items precisely. + // Minimum clickable width on the outer wrapper. Kept small so items keep their real + // positions; zoom in to interact with sub-second items precisely. const MIN_ITEM_PX = 6; const safeItemStyle = { ...itemStyle, minWidth: MIN_ITEM_PX }; diff --git a/src/components/video-editor/timeline/Row.tsx b/src/components/video-editor/timeline/Row.tsx index 17a59e83c8..a0b00c9a54 100644 --- a/src/components/video-editor/timeline/Row.tsx +++ b/src/components/video-editor/timeline/Row.tsx @@ -9,9 +9,8 @@ interface RowProps extends RowDefinition { } /** - * A single horizontal lane in the timeline. Wraps the dnd-timeline `useRow` - * hook and adds an optional `background` layer (e.g. `BackgroundWaveform`), - * an empty-state hint label, and a minimum height. + * A horizontal timeline lane. Wraps dnd-timeline's `useRow` and adds an optional + * `background` layer, an empty-state hint label, and a minimum height. */ export default function Row({ id, children, hint, isEmpty, background }: RowProps) { const { setNodeRef, rowWrapperStyle, rowStyle } = useRow({ id }); diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index f84d038a96..96965a0120 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -1,11 +1,13 @@ import type { Range, Span } from "dnd-timeline"; import { useTimelineContext } from "dnd-timeline"; import { + Captions, Check, ChevronDown, Gauge, MessageSquare, Plus, + ScanEye, Scissors, WandSparkles, ZoomIn, @@ -23,24 +25,18 @@ import { import { useScopedT } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; import { useAudioPeaks } from "@/hooks/useAudioPeaks"; -import { matchesShortcut } from "@/lib/shortcuts"; +import { isTextEditingTarget, matchesShortcut } from "@/lib/shortcuts"; import { cn } from "@/lib/utils"; import { ASPECT_RATIOS, type AspectRatio, getAspectRatioLabel } from "@/utils/aspectRatioUtils"; import { formatShortcut } from "@/utils/platformUtils"; -import type { - AnnotationRegion, - CursorTelemetryPoint, - SpeedRegion, - TrimRegion, - ZoomFocus, - ZoomRegion, -} from "../types"; +import { BLUR_REGIONS_ENABLED } from "../featureFlags"; +import { findFreeGapAt } from "../regionPlacement"; +import type { AnnotationRegion, SpeedRegion, TrimRegion, ZoomRegion } from "../types"; import BackgroundWaveform from "./BackgroundWaveform"; import Item from "./Item"; import KeyframeMarkers from "./KeyframeMarkers"; import Row from "./Row"; import TimelineWrapper from "./TimelineWrapper"; -import { detectZoomDwellCandidates, normalizeCursorTelemetry } from "./zoomSuggestionUtils"; const ZOOM_ROW_ID = "row-zoom"; const TRIM_ROW_ID = "row-trim"; @@ -49,17 +45,20 @@ const BLUR_ROW_ID = "row-blur"; const SPEED_ROW_ID = "row-speed"; const FALLBACK_RANGE_MS = 1000; const TARGET_MARKER_COUNT = 12; -const SUGGESTION_SPACING_MS = 1800; interface TimelineEditorProps { videoDuration: number; hasVideoSource?: boolean; currentTime: number; onSeek?: (time: number) => void; - cursorTelemetry?: CursorTelemetryPoint[]; zoomRegions: ZoomRegion[]; onZoomAdded: (span: Span) => void; - onZoomSuggested?: (span: Span, focus: ZoomFocus) => void; + /** Magic-wand auto-zoom toggle state + handler. */ + autoZoomEnabled?: boolean; + onToggleAutoZoom?: (enabled: boolean) => void; + /** Global Auto-Focus toggle state + handler. */ + autoFocusAll?: boolean; + onToggleAutoFocusAll?: (on: boolean) => void; onZoomSpanChange: (id: string, span: Span) => void; onZoomDelete: (id: string) => void; selectedZoomId: string | null; @@ -92,6 +91,11 @@ interface TimelineEditorProps { onAspectRatioChange: (aspectRatio: AspectRatio) => void; videoUrl?: string; showTrimWaveform?: boolean; + /** Opens the auto-captions flow. When omitted, the captions button is hidden. */ + onGenerateCaptions?: () => void; + isGeneratingCaptions?: boolean; + /** Localized label for the auto-captions button (lives in the `editor` namespace). */ + captionsLabel?: string; } interface TimelineScaleConfig { @@ -133,9 +137,8 @@ const SCALE_CANDIDATES = [ ]; /** - * Picks the best axis interval for the currently visible time range. - * Called dynamically — re-runs on every zoom change so the axis always - * shows a meaningful density of markers regardless of video length. + * Picks the best axis interval for the currently visible time range, so marker + * density stays meaningful regardless of video length. */ function calculateAxisScale(visibleRangeMs: number): { intervalMs: number; gridMs: number } { const visibleSeconds = visibleRangeMs / 1000; @@ -153,19 +156,17 @@ function calculateAxisScale(visibleRangeMs: number): { intervalMs: number; gridM function calculateTimelineScale(durationSeconds: number): TimelineScaleConfig { const totalMs = Math.max(0, Math.round(durationSeconds * 1000)); - // Minimum item duration: fixed at 100ms (0.1s). - // Allows precise cuts while remaining interactive. + // 100ms, precise enough to cut but still grabbable. const minItemDurationMs = 100; - // Default placement size: 5% of video duration, clamped between 1s and 30s. + // 5% of duration, clamped to 1-30s. const defaultItemDurationMs = totalMs > 0 ? Math.max(minItemDurationMs, Math.min(Math.round(totalMs * 0.05), 30000)) : Math.max(minItemDurationMs, 1000); - // Minimum visible range: 300ms — allows comfortably viewing 0.1s items. - // Axis markers adapt dynamically via calculateAxisScale, so there is no - // upper constraint on how far the user can zoom in. + // 300ms, enough to view 0.1s items comfortably. Axis markers adapt via + // calculateAxisScale, so there's no cap on zoom-in. const minVisibleRangeMs = 300; return { @@ -296,11 +297,11 @@ function PlaybackCursor({ const clickX = e.clientX - rect.left - sidebarWidth; const contentWidth = Math.max(rect.width - sidebarWidth, 1); - // Allow dragging outside to 0 or max, but clamp the value + // Allow dragging past the edges, but clamp the value const relativeMs = pixelsToValue(clickX); let absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs)); - // Snap to nearby keyframe if within threshold (150ms) + // Snap to a keyframe within 150ms const snapThresholdMs = 150; const nearbyKeyframe = keyframes.find( (kf) => @@ -403,7 +404,7 @@ function PlaybackCursor({ className="absolute top-0 bottom-0 z-50 group/cursor" style={{ [sideProperty === "right" ? "marginRight" : "marginLeft"]: `${sidebarWidth - 1}px`, - pointerEvents: "none", // Allow clicks to pass through to timeline, but we'll enable pointer events on the handle + pointerEvents: "none", // pass clicks through to the timeline; the handle re-enables them }} >
calculateAxisScale(range.end - range.start), [range.end, range.start], @@ -479,13 +479,12 @@ function TimelineAxis({ .filter((time) => time <= maxTime) .sort((a, b) => a - b); - // Generate minor ticks (4 ticks between major intervals) + // 4 minor ticks between major intervals const minorTicks = []; const minorInterval = intervalMs / 5; for (let time = firstMarker; time <= maxTime; time += minorInterval) { if (time >= visibleStart && time <= visibleEnd) { - // Skip if it's close to a major marker const isMajor = Math.abs(time % intervalMs) < 1; if (!isMajor) { minorTicks.push(time); @@ -636,8 +635,7 @@ function Timeline({ const handleTimelineClick = useCallback( (e: React.MouseEvent) => { - // Only clear selection if clicking on empty space (not on items) - // This is handled by event propagation - items stop propagation + // Items stop propagation, so this only fires on empty space clearTimelineSelection(); seekTimelineAtClientX(e.currentTarget, e.clientX); }, @@ -847,21 +845,23 @@ function Timeline({ ))} - - {blurItems.map((item) => ( - onSelectBlur?.(item.id)} - variant={item.variant} - > - {item.label} - - ))} - + {BLUR_REGIONS_ENABLED && ( + + {blurItems.map((item) => ( + onSelectBlur?.(item.id)} + variant={item.variant} + > + {item.label} + + ))} + + )} {speedItems.map((item) => ( @@ -888,10 +888,12 @@ export default function TimelineEditor({ hasVideoSource = false, currentTime, onSeek, - cursorTelemetry = [], zoomRegions, onZoomAdded, - onZoomSuggested, + autoZoomEnabled = true, + onToggleAutoZoom, + autoFocusAll = false, + onToggleAutoFocusAll, onZoomSpanChange, onZoomDelete, selectedZoomId, @@ -924,6 +926,9 @@ export default function TimelineEditor({ onAspectRatioChange, videoUrl, showTrimWaveform = false, + onGenerateCaptions, + isGeneratingCaptions = false, + captionsLabel, }: TimelineEditorProps) { const t = useScopedT("timeline"); const totalMs = useMemo(() => Math.max(0, Math.round(videoDuration * 1000)), [videoDuration]); @@ -953,7 +958,6 @@ export default function TimelineEditor({ }); }, []); - // Add keyframe at current playhead position const addKeyframe = useCallback(() => { if (totalMs === 0) return; const time = Math.max(0, Math.min(currentTimeMs, totalMs)); @@ -961,14 +965,12 @@ export default function TimelineEditor({ setKeyframes((prev) => [...prev, { id: uuidv4(), time }]); }, [currentTimeMs, totalMs, keyframes]); - // Delete selected keyframe const deleteSelectedKeyframe = useCallback(() => { if (!selectedKeyframeId) return; setKeyframes((prev) => prev.filter((kf) => kf.id !== selectedKeyframeId)); setSelectedKeyframeId(null); }, [selectedKeyframeId]); - // Move keyframe to new time position const handleKeyframeMove = useCallback( (id: string, newTime: number) => { setKeyframes((prev) => @@ -980,14 +982,12 @@ export default function TimelineEditor({ [totalMs], ); - // Delete selected zoom item const deleteSelectedZoom = useCallback(() => { if (!selectedZoomId) return; onZoomDelete(selectedZoomId); onSelectZoom(null); }, [selectedZoomId, onZoomDelete, onSelectZoom]); - // Delete selected trim item const deleteSelectedTrim = useCallback(() => { if (!selectedTrimId || !onTrimDelete || !onSelectTrim) return; onTrimDelete(selectedTrimId); @@ -1016,9 +1016,8 @@ export default function TimelineEditor({ setRange(createInitialRange(totalMs)); }, [totalMs]); - // Normalize regions only when timeline bounds change (not on every region edit). - // Using refs to read current regions avoids a dependency-loop that re-fires - // this effect on every drag/resize and races with dnd-timeline's internal state. + // Normalize regions only when timeline bounds change. Reading via refs avoids a + // dependency loop that would re-fire on every drag and race dnd-timeline's state. const zoomRegionsRef = useRef(zoomRegions); const trimRegionsRef = useRef(trimRegions); const speedRegionsRef = useRef(speedRegions); @@ -1066,12 +1065,10 @@ export default function TimelineEditor({ onSpeedSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd }); } }); - // Only re-run when the timeline scale changes, not on every region edit }, [totalMs, safeMinDurationMs, onZoomSpanChange, onTrimSpanChange, onSpeedSpanChange]); const hasOverlap = useCallback( (newSpan: Span, excludeId?: string): boolean => { - // Determine which row the item belongs to const isZoomItem = zoomRegions.some((r) => r.id === excludeId); const isTrimItem = trimRegions.some((r) => r.id === excludeId); const isAnnotationItem = annotationRegions.some((r) => r.id === excludeId); @@ -1082,11 +1079,10 @@ export default function TimelineEditor({ return false; } - // Helper to check overlap against a specific set of regions const checkOverlap = (regions: (ZoomRegion | TrimRegion | SpeedRegion)[]) => { return regions.some((region) => { if (region.id === excludeId) return false; - // True overlap: regions actually intersect (not just adjacent) + // True intersection, adjacency is allowed return newSpan.end > region.startMs && newSpan.start < region.endMs; }); }; @@ -1108,8 +1104,7 @@ export default function TimelineEditor({ [zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions], ); - // At least 5% of the timeline or 1000ms, whichever is larger, so the region - // is always wide enough to grab and resize comfortably. + // 5% of the timeline or 1000ms, whichever is larger, so it's wide enough to grab. const defaultRegionDurationMs = useMemo( () => Math.max(1000, Math.round(totalMs * 0.05)), [totalMs], @@ -1125,125 +1120,19 @@ export default function TimelineEditor({ return; } - // Always place zoom at playhead const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); - // Find the next zoom region after the playhead - const sorted = [...zoomRegions].sort((a, b) => a.startMs - b.startMs); - const nextRegion = sorted.find((region) => region.startMs > startPos); - const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; - - // Check if playhead is inside any zoom region - const isOverlapping = sorted.some( - (region) => startPos >= region.startMs && startPos < region.endMs, - ); - if (isOverlapping || gapToNext <= 0) { + const { ok, gapMs } = findFreeGapAt(zoomRegions, startPos, totalMs); + if (!ok) { toast.error(t("errors.cannotPlaceZoom"), { description: t("errors.zoomExistsAtLocation"), }); return; } - const actualDuration = Math.min(defaultRegionDurationMs, gapToNext); + const actualDuration = Math.min(defaultRegionDurationMs, gapMs); onZoomAdded({ start: startPos, end: startPos + actualDuration }); }, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded, defaultRegionDurationMs, t]); - const handleSuggestZooms = useCallback(() => { - if (!videoDuration || videoDuration === 0 || totalMs === 0) { - return; - } - - if (!onZoomSuggested) { - toast.error(t("errors.zoomSuggestionUnavailable")); - return; - } - - if (cursorTelemetry.length < 2) { - toast.info(t("errors.noCursorTelemetry"), { - description: t("errors.noCursorTelemetryDescription"), - }); - return; - } - - const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); - if (defaultDuration <= 0) { - return; - } - - const reservedSpans = [...zoomRegions] - .map((region) => ({ start: region.startMs, end: region.endMs })) - .sort((a, b) => a.start - b.start); - - const normalizedSamples = normalizeCursorTelemetry(cursorTelemetry, totalMs); - - if (normalizedSamples.length < 2) { - toast.info(t("errors.noUsableTelemetry"), { - description: t("errors.noUsableTelemetryDescription"), - }); - return; - } - - const dwellCandidates = detectZoomDwellCandidates(normalizedSamples); - - if (dwellCandidates.length === 0) { - toast.info(t("errors.noDwellMoments"), { - description: t("errors.noDwellMomentsDescription"), - }); - return; - } - - const sortedCandidates = [...dwellCandidates].sort((a, b) => b.strength - a.strength); - const acceptedCenters: number[] = []; - - let addedCount = 0; - - sortedCandidates.forEach((candidate) => { - const tooCloseToAccepted = acceptedCenters.some( - (center) => Math.abs(center - candidate.centerTimeMs) < SUGGESTION_SPACING_MS, - ); - - if (tooCloseToAccepted) { - return; - } - - const centeredStart = Math.round(candidate.centerTimeMs - defaultDuration / 2); - const candidateStart = Math.max(0, Math.min(centeredStart, totalMs - defaultDuration)); - const candidateEnd = candidateStart + defaultDuration; - const hasOverlap = reservedSpans.some( - (span) => candidateEnd > span.start && candidateStart < span.end, - ); - - if (hasOverlap) { - return; - } - - reservedSpans.push({ start: candidateStart, end: candidateEnd }); - acceptedCenters.push(candidate.centerTimeMs); - onZoomSuggested({ start: candidateStart, end: candidateEnd }, candidate.focus); - addedCount += 1; - }); - - if (addedCount === 0) { - toast.info(t("errors.noAutoZoomSlots"), { - description: t("errors.noAutoZoomSlotsDescription"), - }); - return; - } - - toast.success( - addedCount === 1 - ? t("success.addedZoomSuggestions", { count: String(addedCount) }) - : t("success.addedZoomSuggestionsPlural", { count: String(addedCount) }), - ); - }, [ - videoDuration, - totalMs, - defaultRegionDurationMs, - zoomRegions, - onZoomSuggested, - cursorTelemetry, - t, - ]); - const handleAddTrim = useCallback(() => { if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onTrimAdded) { return; @@ -1254,25 +1143,16 @@ export default function TimelineEditor({ return; } - // Always place trim at playhead const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); - // Find the next trim region after the playhead - const sorted = [...trimRegions].sort((a, b) => a.startMs - b.startMs); - const nextRegion = sorted.find((region) => region.startMs > startPos); - const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; - - // Check if playhead is inside any trim region - const isOverlapping = sorted.some( - (region) => startPos >= region.startMs && startPos < region.endMs, - ); - if (isOverlapping || gapToNext <= 0) { + const { ok, gapMs } = findFreeGapAt(trimRegions, startPos, totalMs); + if (!ok) { toast.error(t("errors.cannotPlaceTrim"), { description: t("errors.trimExistsAtLocation"), }); return; } - const actualDuration = Math.min(defaultRegionDurationMs, gapToNext); + const actualDuration = Math.min(defaultRegionDurationMs, gapMs); onTrimAdded({ start: startPos, end: startPos + actualDuration }); }, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded, defaultRegionDurationMs, t]); @@ -1286,25 +1166,16 @@ export default function TimelineEditor({ return; } - // Always place speed region at playhead const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); - // Find the next speed region after the playhead - const sorted = [...speedRegions].sort((a, b) => a.startMs - b.startMs); - const nextRegion = sorted.find((region) => region.startMs > startPos); - const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; - - // Check if playhead is inside any speed region - const isOverlapping = sorted.some( - (region) => startPos >= region.startMs && startPos < region.endMs, - ); - if (isOverlapping || gapToNext <= 0) { + const { ok, gapMs } = findFreeGapAt(speedRegions, startPos, totalMs); + if (!ok) { toast.error(t("errors.cannotPlaceSpeed"), { description: t("errors.speedExistsAtLocation"), }); return; } - const actualDuration = Math.min(defaultRegionDurationMs, gapToNext); + const actualDuration = Math.min(defaultRegionDurationMs, gapMs); onSpeedAdded({ start: startPos, end: startPos + actualDuration }); }, [ videoDuration, @@ -1350,7 +1221,7 @@ export default function TimelineEditor({ useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + if (isTextEditingTarget(e.target)) { return; } @@ -1366,19 +1237,19 @@ export default function TimelineEditor({ if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) { handleAddAnnotation(); } - if (matchesShortcut(e, keyShortcuts.addBlur, isMac)) { + if (BLUR_REGIONS_ENABLED && matchesShortcut(e, keyShortcuts.addBlur, isMac)) { handleAddBlur(); } if (matchesShortcut(e, keyShortcuts.addSpeed, isMac)) { handleAddSpeed(); } - // Tab: Cycle through overlapping annotations at current time + // Tab cycles through overlapping annotations at the current time if (e.key === "Tab" && annotationRegions.length > 0) { const currentTimeMs = Math.round(currentTime * 1000); const overlapping = annotationRegions .filter((a) => currentTimeMs >= a.startMs && currentTimeMs <= a.endMs) - .sort((a, b) => a.zIndex - b.zIndex); // Sort by z-index + .sort((a, b) => a.zIndex - b.zIndex); if (overlapping.length > 0) { e.preventDefault(); @@ -1386,11 +1257,10 @@ export default function TimelineEditor({ if (!selectedAnnotationId || !overlapping.some((a) => a.id === selectedAnnotationId)) { onSelectAnnotation?.(overlapping[0].id); } else { - // Cycle to next annotation const currentIndex = overlapping.findIndex((a) => a.id === selectedAnnotationId); const nextIndex = e.shiftKey - ? (currentIndex - 1 + overlapping.length) % overlapping.length // Shift+Tab = backward - : (currentIndex + 1) % overlapping.length; // Tab = forward + ? (currentIndex - 1 + overlapping.length) % overlapping.length // Shift+Tab steps backward + : (currentIndex + 1) % overlapping.length; onSelectAnnotation?.(overlapping[nextIndex].id); } } @@ -1479,7 +1349,6 @@ export default function TimelineEditor({ let label: string; if (region.type === "text") { - // Show text preview const preview = region.content.trim() || t("labels.emptyText"); label = preview.length > 20 ? `${preview.substring(0, 20)}...` : preview; } else if (region.type === "image") { @@ -1517,10 +1386,8 @@ export default function TimelineEditor({ return [...zooms, ...trims, ...annotations, ...blurs, ...speeds]; }, [zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions, t]); - // Spans that participate in overlap resolution (clampToNeighbours). - // Excludes annotation/blur deliberately — those are allowed to overlap and - // must NOT act as hard constraints when a zoom/trim/speed drag is being - // resolved. + // Spans that participate in overlap resolution (clampToNeighbours). Annotation + // and blur are excluded since they may overlap and shouldn't constrain a drag. const allRegionSpans = useMemo(() => { const zooms = zoomRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs })); const trims = trimRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs })); @@ -1528,8 +1395,7 @@ export default function TimelineEditor({ return [...zooms, ...trims, ...speeds]; }, [zoomRegions, trimRegions, speedRegions]); - // Additional snap targets that are NOT clamping constraints. Their edges - // pull during snap, but they don't push anyone away. + // Snap targets whose edges pull during a snap but don't push anyone away. const softSnapSpans = useMemo(() => { const annotations = annotationRegions.map((r) => ({ id: r.id, @@ -1544,7 +1410,6 @@ export default function TimelineEditor({ const handleItemSpanChange = useCallback( (id: string, span: Span) => { - // Check if it's a zoom, trim, speed, or annotation item if (zoomRegions.some((r) => r.id === id)) { onZoomSpanChange(id, span); } else if (trimRegions.some((r) => r.id === id)) { @@ -1605,14 +1470,31 @@ export default function TimelineEditor({ + - + + + + + + + )} + {onGenerateCaptions && ( + + )}
diff --git a/src/components/video-editor/timeline/TimelineWrapper.tsx b/src/components/video-editor/timeline/TimelineWrapper.tsx index 2a44262afb..9cd71cb053 100644 --- a/src/components/video-editor/timeline/TimelineWrapper.tsx +++ b/src/components/video-editor/timeline/TimelineWrapper.tsx @@ -21,11 +21,9 @@ interface TimelineWrapperProps { minVisibleRangeMs: number; gridSizeMs?: number; onItemSpanChange: (id: string, span: Span) => void; - // Spans that act as hard overlap constraints (zoom/trim/speed). Used by - // clampToNeighbours AND as snap targets. + // Hard overlap constraints (zoom/trim/speed), used by clampToNeighbours and as snap targets. allRegionSpans?: { id: string; start: number; end: number }[]; - // Spans that act ONLY as snap targets (annotation/blur). They never push - // other items away during overlap resolution. + // Snap targets only (annotation/blur); never push other items during overlap resolution. softSnapSpans?: { id: string; start: number; end: number }[]; currentTimeMs?: number; keyframeTimesMs?: number[]; @@ -36,9 +34,8 @@ interface SnapGuideHandle { hide: () => void; } -// Lives inside TimelineContext so it can read valueToPixels. Updates DOM -// directly via an imperative handle — same pattern as the drag tooltip — to -// avoid re-rendering the timeline on every pointer move. +// Lives inside TimelineContext to read valueToPixels. Updates the DOM directly via +// an imperative handle (like the drag tooltip) to avoid re-rendering on every pointer move. const SnapGuide = forwardRef((_, ref) => { const { sidebarWidth, direction, range, valueToPixels } = useTimelineContext(); const elRef = useRef(null); @@ -198,10 +195,9 @@ export default function TimelineWrapper({ const snapGuideRef = useRef(null); - // Pull the active span's edges to nearby region boundaries, timeline bounds, - // the playhead, and keyframes. Threshold scales with zoom (~1% of visible - // range, min 50ms) so snap feels right at any zoom level. - // Returns the snapped span plus the actual snap target used (for guide rendering). + // Pull the active span's edges to nearby region boundaries, timeline bounds, playhead, + // and keyframes. Threshold scales with zoom (~1% of visible range, min 50ms). Returns + // the snapped span plus the snap target used (for guide rendering). const snapSpanToTargets = useCallback( ( span: Span, @@ -296,12 +292,10 @@ export default function TimelineWrapper({ ], ); - // dnd-timeline's resize event doesn't expose direction. Compare the live - // span to the committed one (committed spans only update on commit, so - // during a single resize they still reflect the pre-resize state). - // Returns null when the deltas are equal — including the common clamped - // case where both are 0 — because we can't tell which handle the user - // grabbed, and guessing wrong would snap the other edge. + // dnd-timeline's resize event doesn't expose direction, so compare the live span to + // the committed one (committed only updates on commit, so it's the pre-resize state). + // Returns null when deltas are equal (including the common clamped both-0 case): we + // can't tell which handle was grabbed, and guessing wrong snaps the other edge. const inferResizeMode = useCallback( (activeItemId: string, span: Span): "resize-left" | "resize-right" | null => { const old = diff --git a/src/components/video-editor/timeline/zoomSuggestionUtils.ts b/src/components/video-editor/timeline/zoomSuggestionUtils.ts index 9f807d32c2..f083a7f7c2 100644 --- a/src/components/video-editor/timeline/zoomSuggestionUtils.ts +++ b/src/components/video-editor/timeline/zoomSuggestionUtils.ts @@ -3,6 +3,8 @@ import type { CursorTelemetryPoint, ZoomFocus } from "../types"; export const MIN_DWELL_DURATION_MS = 450; export const MAX_DWELL_DURATION_MS = 2600; export const DWELL_MOVE_THRESHOLD = 0.02; +/** Minimum spacing between two accepted suggestion centres. */ +export const SUGGESTION_SPACING_MS = 1800; export interface ZoomDwellCandidate { centerTimeMs: number; @@ -79,3 +81,76 @@ export function detectZoomDwellCandidates(samples: CursorTelemetryPoint[]): Zoom return dwellCandidates; } + +export interface AutoZoomSuggestion { + span: { start: number; end: number }; + focus: ZoomFocus; +} + +/** + * Build non-overlapping zoom suggestions from cursor telemetry: detect dwell moments, + * rank by duration, space by SUGGESTION_SPACING_MS, drop any overlapping an existing + * region. Pure, shared by the magic-wand toggle and the on-load auto-suggest pass. + */ +export function buildAutoZoomSuggestions(options: { + cursorTelemetry: CursorTelemetryPoint[]; + totalMs: number; + existingRegions: { startMs: number; endMs: number }[]; + defaultDurationMs: number; +}): AutoZoomSuggestion[] { + const { cursorTelemetry, totalMs, existingRegions, defaultDurationMs } = options; + if (totalMs <= 0 || cursorTelemetry.length < 2) { + return []; + } + + const defaultDuration = Math.min(defaultDurationMs, totalMs); + if (defaultDuration <= 0) { + return []; + } + + const normalizedSamples = normalizeCursorTelemetry(cursorTelemetry, totalMs); + if (normalizedSamples.length < 2) { + return []; + } + + const dwellCandidates = detectZoomDwellCandidates(normalizedSamples); + if (dwellCandidates.length === 0) { + return []; + } + + const reservedSpans = existingRegions + .map((region) => ({ start: region.startMs, end: region.endMs })) + .sort((a, b) => a.start - b.start); + + const sortedCandidates = [...dwellCandidates].sort((a, b) => b.strength - a.strength); + const acceptedCenters: number[] = []; + const suggestions: AutoZoomSuggestion[] = []; + + for (const candidate of sortedCandidates) { + const tooCloseToAccepted = acceptedCenters.some( + (center) => Math.abs(center - candidate.centerTimeMs) < SUGGESTION_SPACING_MS, + ); + if (tooCloseToAccepted) { + continue; + } + + const centeredStart = Math.round(candidate.centerTimeMs - defaultDuration / 2); + const candidateStart = Math.max(0, Math.min(centeredStart, totalMs - defaultDuration)); + const candidateEnd = candidateStart + defaultDuration; + const hasOverlap = reservedSpans.some( + (span) => candidateEnd > span.start && candidateStart < span.end, + ); + if (hasOverlap) { + continue; + } + + reservedSpans.push({ start: candidateStart, end: candidateEnd }); + acceptedCenters.push(candidate.centerTimeMs); + suggestions.push({ + span: { start: candidateStart, end: candidateEnd }, + focus: candidate.focus, + }); + } + + return suggestions; +} diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 0f2267ccab..93ae002371 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -3,7 +3,7 @@ import type { WebcamLayoutPreset } from "@/lib/compositeLayout"; export type ZoomDepth = 1 | 2 | 3 | 4 | 5 | 6; export type ZoomFocusMode = "manual" | "auto"; export type { WebcamLayoutPreset }; -/** Webcam size as a percentage of the canvas reference dimension (10–50). */ +/** Webcam size as a percentage of the canvas reference dimension (10-50). */ export type WebcamSizePreset = number; export const DEFAULT_WEBCAM_SIZE_PRESET: WebcamSizePreset = 25; @@ -14,6 +14,11 @@ export type WebcamMaskShape = "rectangle" | "circle" | "square" | "rounded"; export const DEFAULT_WEBCAM_MASK_SHAPE: WebcamMaskShape = "rectangle"; +export const DEFAULT_WEBCAM_MIRRORED = false; + +/** When true, the picture-in-picture webcam scales inversely with zoom (shrinks as you zoom in). */ +export const DEFAULT_WEBCAM_REACTIVE_ZOOM = true; + export interface WebcamPosition { cx: number; // normalized horizontal center (0-1) cy: number; // normalized vertical center (0-1) @@ -48,15 +53,21 @@ export const ROTATION_3D_PRESETS: Record = { export const ROTATION_3D_PRESET_ORDER: Rotation3DPreset[] = ["iso", "left", "right"]; -/** Perspective distance in CSS px is computed at render-time as this factor times - * min(viewport width, viewport height). Same factor used in preview and export so - * the visual look is identical regardless of canvas resolution. */ +/** Perspective distance in CSS px is this factor times min(viewport w, h). Same + * factor in preview and export so the look matches at any canvas resolution. */ export const ROTATION_3D_PERSPECTIVE_FACTOR = 2.6; export function rotation3DPerspective(width: number, height: number): number { return Math.min(width, height) * ROTATION_3D_PERSPECTIVE_FACTOR; } +/** + * Origin of a zoom region. "auto" marks zooms from the magic-wand suggest pass; + * toggling the wand off removes only these. Editing an auto zoom promotes it to + * "manual" so it survives. Undefined is treated as "manual" for back-compat. + */ +export type ZoomRegionSource = "auto" | "manual"; + export interface ZoomRegion { id: string; startMs: number; @@ -65,8 +76,9 @@ export interface ZoomRegion { focus: ZoomFocus; focusMode?: ZoomFocusMode; rotationPreset?: Rotation3DPreset; - /** Custom scale overriding the preset depth (1.0–5.0, two decimal precision). */ + /** Custom scale overriding the preset depth (1.0-5.0, two decimal precision). */ customScale?: number; + source?: ZoomRegionSource; } export function getRotation3D(region: Pick): Rotation3D { @@ -87,13 +99,10 @@ export function lerpRotation3D(a: Rotation3D, b: Rotation3D, t: number): Rotatio } /** - * Compute the maximum uniform scale that, when applied alongside `rot` and a perspective - * of `perspective` CSS px, keeps the projected bounding box of a `width × height` element - * inside its original `width × height` rectangle. Returns 1 when no scaling is needed. - * - * Math: project each rotated corner onto the screen via x' = x·P/(P−z); take the worst-case - * |x'|/|y'| against the half-extents and return the limiting ratio. This makes the rotated - * recording sit *inside* the zoom window instead of bleeding past it. + * Max uniform scale that, with `rot` and a perspective of `perspective` CSS px, keeps + * the projected bounding box of a width x height element inside its original rectangle. + * Returns 1 when no scaling is needed. Projects each rotated corner (x' = x*P/(P-z)) and + * returns the limiting half-extent ratio so the rotated recording stays inside the zoom window. */ export function computeRotation3DContainScale( rot: Rotation3D, @@ -123,7 +132,7 @@ export function computeRotation3DContainScale( let maxAbsY = 0; for (const [x0, y0] of corners) { - // CSS "rotateX(α) rotateY(β) rotateZ(γ)" reads right-to-left: Z first, then Y, then X. + // CSS "rotateX rotateY rotateZ" applies right-to-left: Z first, then Y, then X. let px = x0; let py = y0; let pz = 0; @@ -146,11 +155,11 @@ export function computeRotation3DContainScale( py = xy; pz = xz; - // Perspective projection: viewer at (0, 0, P), looking toward −z. A point at z=pz - // is scaled by P / (P − pz). When perspective ≤ 0 we treat as orthographic. + // Viewer at (0, 0, P) looking toward -z; a point at z=pz scales by P/(P-pz). + // perspective <= 0 means orthographic. if (perspective > 0) { const denom = perspective - pz; - if (denom <= 0) return 1; // pathological — skip scaling rather than crash + if (denom <= 0) return 1; // pathological, skip scaling rather than crash const f = perspective / denom; px *= f; py *= f; @@ -195,8 +204,7 @@ export const DEFAULT_CURSOR_SIZE = 3.0; export const DEFAULT_CURSOR_SMOOTHING = 0.67; export const DEFAULT_CURSOR_MOTION_BLUR = 0.35; export const DEFAULT_CURSOR_CLICK_BOUNCE = 2.5; -// false = allow the cursor to overflow into the background by default. -// true = clip the native cursor to the video canvas bounds. +// false lets the cursor overflow into the background; true clips it to the canvas bounds. export const DEFAULT_CURSOR_CLIP_TO_BOUNDS = false; export const DEFAULT_ZOOM_MOTION_BLUR = 0.35; @@ -288,6 +296,8 @@ export interface AnnotationRegion { size: AnnotationSize; style: AnnotationTextStyle; zIndex: number; + /** When set, layout/style edits on one region can sync to all auto-caption siblings. */ + annotationSource?: "auto-caption"; figureData?: FigureData; blurData?: BlurData; } @@ -358,8 +368,7 @@ export const DEFAULT_CROP_REGION: CropRegion = { export type PlaybackSpeed = number; export const MIN_PLAYBACK_SPEED = 0.1; -// Anything above 16x causes the playhead to stall during preview -// due to the video decoder not being able to keep up. +// Above 16x the decoder can't keep up and the playhead stalls during preview. export const MAX_PLAYBACK_SPEED = 16; export function clampPlaybackSpeed(speed: number): PlaybackSpeed { diff --git a/src/components/video-editor/videoPlayback/constants.ts b/src/components/video-editor/videoPlayback/constants.ts index b5b4bd1e08..0cfa17c6b2 100644 --- a/src/components/video-editor/videoPlayback/constants.ts +++ b/src/components/video-editor/videoPlayback/constants.ts @@ -11,3 +11,14 @@ export const ZOOM_SCALE_DEADZONE = 0.002; export const AUTO_FOLLOW_SMOOTHING_FACTOR = 0.1; export const AUTO_FOLLOW_SMOOTHING_FACTOR_MAX = 0.25; export const AUTO_FOLLOW_RAMP_DISTANCE = 0.15; +// Reference frame interval so preview and export normalize their per-frame +// smoothing identically regardless of render fps. Lower fps = floatier follow +// (tuned to the live-preview feel). +export const AUTO_FOLLOW_REFERENCE_MS = 1000 / 40; +// Shared by preview and export so the camera follows the cursor identically. +export const AUTO_FOLLOW_PARAMS = { + minFactor: AUTO_FOLLOW_SMOOTHING_FACTOR, + maxFactor: AUTO_FOLLOW_SMOOTHING_FACTOR_MAX, + rampDistance: AUTO_FOLLOW_RAMP_DISTANCE, + referenceMs: AUTO_FOLLOW_REFERENCE_MS, +} as const; diff --git a/src/components/video-editor/videoPlayback/cursorFollowUtils.ts b/src/components/video-editor/videoPlayback/cursorFollowUtils.ts index 14dad24f12..12113970c4 100644 --- a/src/components/video-editor/videoPlayback/cursorFollowUtils.ts +++ b/src/components/video-editor/videoPlayback/cursorFollowUtils.ts @@ -1,9 +1,6 @@ import type { CursorTelemetryPoint, ZoomFocus } from "../types"; -/** - * Binary-search the sorted telemetry array and linearly interpolate - * the cursor position at the given playback time. - */ +/** Binary-search the sorted telemetry and lerp the cursor position at the given playback time. */ export function interpolateCursorAt( telemetry: CursorTelemetryPoint[], timeMs: number, @@ -44,7 +41,7 @@ export function interpolateCursorAt( /** * Exponential smoothing to reduce jitter from high-frequency cursor data. - * Lower factor = smoother / more lag, higher = more responsive. + * Lower factor = smoother/more lag, higher = more responsive. */ export function smoothCursorFocus(raw: ZoomFocus, prev: ZoomFocus, factor: number): ZoomFocus { return { @@ -53,10 +50,54 @@ export function smoothCursorFocus(raw: ZoomFocus, prev: ZoomFocus, factor: numbe }; } +export interface FollowParams { + minFactor: number; + maxFactor: number; + rampDistance: number; + referenceMs: number; +} + +/** + * Advance the auto-follow focus from `prev` toward target `raw` over `dtMs` of content time. The + * distance-adaptive factor is reframed against `referenceMs` so convergence is content-time based and + * matches between preview and export. Returns `prev` unchanged when paused so the camera holds still. + */ +export function advanceFollowFocus( + prev: ZoomFocus, + raw: ZoomFocus, + dtMs: number, + params: FollowParams, +): ZoomFocus { + if (!(dtMs > 0)) return prev; + const base = adaptiveSmoothFactor( + raw, + prev, + params.minFactor, + params.maxFactor, + params.rampDistance, + ); + const factor = timeCorrectedFollowFactor(base, dtMs, params.referenceMs); + return smoothCursorFocus(raw, prev, factor); +} + +/** + * Make a per-frame smoothing `baseFactor` frame-rate independent by reframing it in content time. + * The camera converges as `(1 - baseFactor)^(dtMs / referenceMs)` regardless of frame chunking, so + * preview (variable fps) and export (fixed fps) follow at the same speed. Larger `referenceMs` = + * floatier. Returns 0 when paused so the camera holds still. + */ +export function timeCorrectedFollowFactor( + baseFactor: number, + dtMs: number, + referenceMs: number, +): number { + if (!(dtMs > 0) || !(referenceMs > 0)) return 0; + return 1 - (1 - baseFactor) ** (dtMs / referenceMs); +} + /** - * Compute an adaptive smoothing factor that scales with distance: - * far from target → faster (maxFactor), close → slower (minFactor). - * This replaces the hard deadzone with a natural deceleration curve. + * Adaptive smoothing factor that scales with distance: far from target = faster (maxFactor), close = + * slower (minFactor). Replaces a hard deadzone with a natural deceleration curve. */ export function adaptiveSmoothFactor( raw: ZoomFocus, diff --git a/src/components/video-editor/videoPlayback/cursorRenderer.ts b/src/components/video-editor/videoPlayback/cursorRenderer.ts index dd3087bd28..2edcca97d3 100644 --- a/src/components/video-editor/videoPlayback/cursorRenderer.ts +++ b/src/components/video-editor/videoPlayback/cursorRenderer.ts @@ -43,11 +43,11 @@ export interface CursorRenderConfig { dotRadius: number; /** Cursor fill color (hex number for PixiJS) */ dotColor: number; - /** Cursor opacity (0–1) */ + /** Cursor opacity (0-1) */ dotAlpha: number; /** Unused, kept for interface compatibility */ trailLength: number; - /** Smoothing factor for cursor interpolation (0–1, lower = smoother/slower) */ + /** Smoothing factor for cursor interpolation (0-1, lower = smoother/slower) */ smoothingFactor: number; /** Directional cursor motion blur amount. */ motionBlur: number; @@ -122,10 +122,9 @@ function getNormalizedAnchor( } /** - * Loads an SVG at `sampleSize × sampleSize`, crops the trim region out of it, - * and returns a PNG data-URL of the cropped result. This is required because - * SVG files have their own natural pixel size (e.g. 32×32) which does not - * match the 1024-sample coordinate space used by the trim measurements. + * Loads an SVG at `sampleSize × sampleSize`, crops the trim region, and returns + * a PNG data-URL. Needed because an SVG's natural size (e.g. 32×32) doesn't match + * the 1024-sample coordinate space the trim measurements use. */ async function rasterizeAndCropSvg( url: string, @@ -137,14 +136,12 @@ async function rasterizeAndCropSvg( ): Promise<{ dataUrl: string; width: number; height: number }> { const img = await loadImage(url); - // Draw at full sample size const srcCanvas = document.createElement("canvas"); srcCanvas.width = sampleSize; srcCanvas.height = sampleSize; const srcCtx = srcCanvas.getContext("2d")!; srcCtx.drawImage(img, 0, 0, sampleSize, sampleSize); - // Crop to trim bounds const dstCanvas = document.createElement("canvas"); dstCanvas.width = trimWidth; dstCanvas.height = trimHeight; @@ -349,7 +346,7 @@ function findLatestInteractionSample(samples: CursorTelemetryPoint[], timeMs: nu } function findLatestStableCursorType(samples: CursorTelemetryPoint[], timeMs: number) { - // Binary search to find position at timeMs, then scan backwards + // Binary search to position at timeMs, then scan backwards let lo = 0; let hi = samples.length - 1; while (lo < hi) { @@ -361,8 +358,8 @@ function findLatestStableCursorType(samples: CursorTelemetryPoint[], timeMs: num } } - // Scan backwards from the position to find a sample with cursorType - // Skip click events only (not mouseup) to avoid transient re-type during clicks + // Scan back for a sample with cursorType. Skip click events (not mouseup) to + // avoid a transient re-type during clicks. for (let index = lo; index >= 0; index -= 1) { const sample = samples[index]; if (sample.timeMs > timeMs) { diff --git a/src/components/video-editor/videoPlayback/layoutUtils.ts b/src/components/video-editor/videoPlayback/layoutUtils.ts index 6bf0946348..bdedf6a2f2 100644 --- a/src/components/video-editor/videoPlayback/layoutUtils.ts +++ b/src/components/video-editor/videoPlayback/layoutUtils.ts @@ -73,10 +73,8 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null { app.canvas.style.width = "100%"; app.canvas.style.height = "100%"; - // Apply crop region const crop = cropRegion || { x: 0, y: 0, width: 1, height: 1 }; - // Calculate the cropped dimensions const croppedVideoWidth = videoWidth * crop.width; const croppedVideoHeight = videoHeight * crop.height; @@ -85,9 +83,8 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null { const cropEndX = cropStartX + croppedVideoWidth; const cropEndY = cropStartY + croppedVideoHeight; - // Calculate scale to fit the cropped area in the viewport - // Padding is a percentage (0-100), where 50 matches the original VIEWPORT_SCALE of 0.8 - // Vertical stack ignores padding — it's full-bleed + // Padding is a percent (0-100); 50 matches the original VIEWPORT_SCALE of 0.8. + // Vertical stack is full-bleed, so it ignores padding. const effectivePadding = webcamLayoutPreset === "vertical-stack" ? 0 : padding; const paddingScale = 1.0 - (effectivePadding / 100) * 0.4; const maxDisplayWidth = width * paddingScale; @@ -120,11 +117,10 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null { videoSprite.scale.set(scale); - // Calculate display size of the full video at this scale const fullVideoDisplayWidth = videoWidth * scale; const fullVideoDisplayHeight = videoHeight * scale; - // Position the video so the cropped region is centered within the screenRect + // Position the video so the cropped region is centered within screenRect. const croppedDisplayWidth = croppedVideoWidth * scale; const croppedDisplayHeight = croppedVideoHeight * scale; const offsetX = screenRect.x + (screenRect.width - croppedDisplayWidth) / 2; @@ -134,7 +130,7 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null { videoSprite.position.set(spriteX, spriteY); - // Apply border radius — mask clips the video to the screenRect + // Mask clips the video to screenRect, with border radius. maskGraphics.clear(); maskGraphics.roundRect( screenRect.x, diff --git a/src/components/video-editor/videoPlayback/mathUtils.ts b/src/components/video-editor/videoPlayback/mathUtils.ts index 78c9414f3b..6553c622ca 100644 --- a/src/components/video-editor/videoPlayback/mathUtils.ts +++ b/src/components/video-editor/videoPlayback/mathUtils.ts @@ -68,8 +68,7 @@ export function smoothStep(t: number) { } /** - * Gentle ease-in-out cubic — slow start, smooth middle, gentle landing. - * Used for zoom-in transitions. + * Ease-in-out cubic. Used for zoom-in transitions. */ export function easeInOutCubic(t: number) { const x = clamp01(t); @@ -77,8 +76,7 @@ export function easeInOutCubic(t: number) { } /** - * Ease-out cubic — starts at speed, then decelerates to a gentle stop. - * Used for zoom-out transitions so strength eases smoothly to zero. + * Ease-out cubic. Used for zoom-out transitions so strength eases to zero. */ export function easeOutCubic(t: number) { const x = clamp01(t); diff --git a/src/components/video-editor/videoPlayback/videoEventHandlers.ts b/src/components/video-editor/videoPlayback/videoEventHandlers.ts index a26107daac..3b26aa812e 100644 --- a/src/components/video-editor/videoPlayback/videoEventHandlers.ts +++ b/src/components/video-editor/videoPlayback/videoEventHandlers.ts @@ -1,9 +1,8 @@ import type React from "react"; import type { SpeedRegion, TrimRegion } from "../types"; -// Keep "scrub mode" on for a brief tail after `seeked` — rapid drag-scrubbing -// fires `seeking`/`seeked` dozens of times per second, and toggling effects -// each time would flicker. +// Keep "scrub mode" on for a brief tail after `seeked`: rapid drag-scrubbing fires +// `seeking`/`seeked` dozens of times a second and toggling effects each time would flicker. const SCRUB_END_DEBOUNCE_MS = 150; interface VideoEventHandlersParams { @@ -51,7 +50,6 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) { onTimeUpdate(timeValue); }; - // Helper function to check if current time is within a trim region const findActiveTrimRegion = (currentTimeMs: number): TrimRegion | null => { const trimRegions = trimRegionsRef.current; return ( @@ -61,7 +59,6 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) { ); }; - // Helper function to find the active speed region at the current time const findActiveSpeedRegion = (currentTimeMs: number): SpeedRegion | null => { return ( speedRegionsRef.current.find( @@ -76,11 +73,11 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) { const currentTimeMs = video.currentTime * 1000; const activeTrimRegion = findActiveTrimRegion(currentTimeMs); - // If we're in a trim region during playback, skip to the end of it + // In a trim region during playback: skip to its end if (activeTrimRegion && !video.paused && !video.ended) { const skipToTime = activeTrimRegion.endMs / 1000; - // If the skip would take us past the video duration, pause instead + // Pause if the skip would run past the end if (skipToTime >= video.duration) { video.pause(); } else { @@ -88,7 +85,6 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) { emitTime(skipToTime); } } else { - // Apply playback speed from active speed region const activeSpeedRegion = findActiveSpeedRegion(currentTimeMs); video.playbackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1; emitTime(video.currentTime); @@ -143,7 +139,7 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) { const currentTimeMs = video.currentTime * 1000; const activeTrimRegion = findActiveTrimRegion(currentTimeMs); - // If we seeked into a trim region while playing, skip to the end + // Seeked into a trim region while playing: skip to the end if (activeTrimRegion && isPlayingRef.current && !video.paused) { const skipToTime = activeTrimRegion.endMs / 1000; diff --git a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts index 1bfa4655b8..34fceffe7d 100644 --- a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts +++ b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts @@ -271,8 +271,8 @@ type DominantRegionResult = { }; // Single-slot cache: the ticker calls findDominantRegion at 60fps with mostly -// unchanged inputs (especially while paused). Reusing the previous result when -// inputs match avoids the per-frame O(N) region scan + allocations. +// unchanged inputs (especially while paused), so reusing the last result skips +// the per-frame O(N) scan and allocations. let dominantRegionCache: { regions: ZoomRegion[]; timeMsKey: number; diff --git a/src/components/video-editor/videoPlayback/zoomSpring.test.ts b/src/components/video-editor/videoPlayback/zoomSpring.test.ts new file mode 100644 index 0000000000..c211342623 --- /dev/null +++ b/src/components/video-editor/videoPlayback/zoomSpring.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { createZoomSpringState, resetZoomSpring, stepZoomSpring } from "./zoomSpring"; + +const DT = 1000 / 60; + +describe("zoom spring chase", () => { + it("resetZoomSpring snaps every axis exactly to the target", () => { + const state = createZoomSpringState(); + resetZoomSpring(state, { scale: 1.8, x: -120, y: 40 }); + expect(stepZoomSpring(state, { scale: 1.8, x: -120, y: 40 }, DT)).toEqual({ + scale: 1.8, + x: -120, + y: 40, + }); + }); + + it("eases into a jumped target instead of snapping (velocity continuity)", () => { + const state = createZoomSpringState(); + resetZoomSpring(state, { scale: 1, x: 0, y: 0 }); + // Target jumps from 1 to 2; a single step must NOT teleport there. + const first = stepZoomSpring(state, { scale: 2, x: 0, y: 0 }, DT); + expect(first.scale).toBeGreaterThan(1); + expect(first.scale).toBeLessThan(2); + }); + + it("converges to a static target without overshooting it", () => { + const state = createZoomSpringState(); + resetZoomSpring(state, { scale: 1, x: 0, y: 0 }); + const target = { scale: 2.2, x: 0, y: 0 }; + let maxScale = 1; + let last = 1; + for (let i = 0; i < 200; i++) { + last = stepZoomSpring(state, target, DT).scale; + maxScale = Math.max(maxScale, last); + } + expect(last).toBeCloseTo(2.2, 2); // settled onto the target + expect(maxScale).toBeLessThanOrEqual(2.2 + 1e-6); // never overshot past it + }); + + it("does not overshoot when the target reverses mid-motion", () => { + const state = createZoomSpringState(); + resetZoomSpring(state, { scale: 1, x: 0, y: 0 }); + // Build upward momentum chasing a high target... + for (let i = 0; i < 8; i++) stepZoomSpring(state, { scale: 3, x: 0, y: 0 }, DT); + // ...then reverse the target below the current value; momentum must not carry it past. + const reverseTarget = { scale: 1.5, x: 0, y: 0 }; + let min = Number.POSITIVE_INFINITY; + for (let i = 0; i < 200; i++) { + min = Math.min(min, stepZoomSpring(state, reverseTarget, DT).scale); + } + expect(min).toBeGreaterThanOrEqual(1.5 - 1e-6); // never dipped below the reversed target + }); + + it("steps each axis independently", () => { + const state = createZoomSpringState(); + resetZoomSpring(state, { scale: 1, x: 0, y: 0 }); + const out = stepZoomSpring(state, { scale: 1, x: 100, y: 0 }, DT); + expect(out.scale).toBe(1); // already at target → unchanged + expect(out.x).toBeGreaterThan(0); + expect(out.x).toBeLessThan(100); + expect(out.y).toBe(0); + }); +}); diff --git a/src/components/video-editor/videoPlayback/zoomSpring.ts b/src/components/video-editor/videoPlayback/zoomSpring.ts new file mode 100644 index 0000000000..94e19250ec --- /dev/null +++ b/src/components/video-editor/videoPlayback/zoomSpring.ts @@ -0,0 +1,85 @@ +import { + createSpringState, + getZoomSpringConfig, + type SpringState, + stepSpringValue, +} from "./motionSmoothing"; + +/** + * Spring-chase for the camera zoom transform. + * + * computeZoomTransform is a time-driven target shaped by an ease curve. Applying it + * straight to the camera reproduces every velocity discontinuity (the ease-in launch, + * seams between close regions), which reads as a jerk. Instead we chase the target with + * a per-axis spring: the target keeps the authored timing, the spring keeps the rendered + * motion velocity-continuous. + */ + +export interface ZoomTransform { + scale: number; + x: number; + y: number; +} + +export interface ZoomSpringState { + scale: SpringState; + x: SpringState; + y: SpringState; +} + +export function createZoomSpringState(): ZoomSpringState { + return { + scale: createSpringState(1), + x: createSpringState(0), + y: createSpringState(0), + }; +} + +/** Snap every axis straight to the target (used on seek / pause / first frame). */ +export function resetZoomSpring(state: ZoomSpringState, target: ZoomTransform): void { + for (const [axis, value] of [ + [state.scale, target.scale], + [state.x, target.x], + [state.y, target.y], + ] as const) { + axis.value = value; + axis.velocity = 0; + axis.initialized = true; + } +} + +/** + * Step one axis toward target with a moving-target overshoot clamp. The target moves + * every frame, so a fast spring can carry velocity past it on a reversal and wobble. If + * the step crosses the target, snap to it and zero the velocity to stay quick without jelly. + */ +function stepAxis( + axis: SpringState, + target: number, + deltaMs: number, + config: ReturnType, +): number { + const before = axis.initialized ? axis.value : target; + const after = stepSpringValue(axis, target, deltaMs, config); + const crossed = (before <= target && after > target) || (before >= target && after < target); + if (crossed) { + axis.value = target; + axis.velocity = 0; + return target; + } + return after; +} + +/** Advance the spring toward target by deltaMs (content time); returns the smoothed transform. */ +export function stepZoomSpring( + state: ZoomSpringState, + target: ZoomTransform, + deltaMs: number, +): ZoomTransform { + const config = getZoomSpringConfig(); + return { + scale: stepAxis(state.scale, target.scale, deltaMs, config), + x: stepAxis(state.x, target.x, deltaMs, config), + y: stepAxis(state.y, target.y, deltaMs, config), + }; +} diff --git a/src/components/video-editor/videoPlayback/zoomTransform.ts b/src/components/video-editor/videoPlayback/zoomTransform.ts index 800949f082..ea96077e28 100644 --- a/src/components/video-editor/videoPlayback/zoomTransform.ts +++ b/src/components/video-editor/videoPlayback/zoomTransform.ts @@ -8,7 +8,7 @@ const MAX_AMOUNT_BOOST = 2.2; function getMotionBlurAmountResponse(motionBlurAmount: number) { const clampedAmount = Math.min(1, Math.max(0, motionBlurAmount)); - // Keep the low end usable while giving the top of the slider substantially more headroom. + // Keep the low end usable while giving the top of the slider more headroom. return clampedAmount * (1 + (MAX_AMOUNT_BOOST - 1) * clampedAmount); } @@ -90,8 +90,7 @@ export function computeZoomTransform({ } const progress = Math.min(1, Math.max(0, zoomProgress)); - // Focus coordinates are stage-normalized (0-1 of full canvas), - // so map directly to stage pixels, not through baseMask. + // Focus coords are stage-normalized (0-1 of full canvas), so map directly to stage pixels, not via baseMask. const focusStagePxX = focusX * stageSize.width; const focusStagePxY = focusY * stageSize.height; const stageCenterX = stageSize.width / 2; @@ -173,7 +172,6 @@ export function applyZoomTransform({ focusY, }); - // Apply position & scale to camera container cameraContainer.scale.set(transform.scale); cameraContainer.position.set(transform.x, transform.y); diff --git a/src/contexts/I18nContext.tsx b/src/contexts/I18nContext.tsx index 5d7534b34b..894ba56d5a 100644 --- a/src/contexts/I18nContext.tsx +++ b/src/contexts/I18nContext.tsx @@ -106,7 +106,6 @@ export function I18nProvider({ children }: { children: ReactNode }) { // localStorage may be unavailable } document.documentElement.lang = newLocale; - // Notify Electron main process window.electronAPI?.setLocale?.(newLocale); }, []); diff --git a/src/hooks/audioPeaksWorker.ts b/src/hooks/audioPeaksWorker.ts index 27812fd96d..8a487193d1 100644 --- a/src/hooks/audioPeaksWorker.ts +++ b/src/hooks/audioPeaksWorker.ts @@ -1,11 +1,8 @@ /** * Web Worker: computes min/max peak pairs from raw audio channel data. - * - * Input message: { channels: Float32Array[]; duration: number } - * Output message: Float32Array of length 2*N — [min0, max0, min1, max1, …] - * - * Channel buffers are transferred (zero-copy) from the caller. - * The peaks buffer is transferred back. + * In: { channels: Float32Array[]; duration: number }. + * Out: Float32Array of length 2*N, [min0, max0, min1, max1, ...]. + * Channel buffers and the peaks buffer are transferred (zero-copy). */ self.onmessage = (event: MessageEvent<{ channels: Float32Array[]; duration: number }>) => { const { channels, duration } = event.data; @@ -18,7 +15,7 @@ self.onmessage = (event: MessageEvent<{ channels: Float32Array[]; duration: numb const totalSamples = channels[0].length; const N = Math.min(24000, Math.ceil(duration * 200)); const blockSize = totalSamples / N; - const peaks = new Float32Array(N * 2); // [min0, max0, min1, max1, …] + const peaks = new Float32Array(N * 2); // [min0, max0, min1, max1, ...] for (let i = 0; i < N; i++) { const start = Math.floor(i * blockSize); diff --git a/src/hooks/recorderHandle.ts b/src/hooks/recorderHandle.ts index e98000e6e1..6264b9be59 100644 --- a/src/hooks/recorderHandle.ts +++ b/src/hooks/recorderHandle.ts @@ -3,23 +3,19 @@ const RECORDER_TIMESLICE_MS = 1000; export type RecorderHandle = { recorder: MediaRecorder; /** - * Resolves once the recording has fully drained. For a streamed recording the - * blob is empty (the bytes are already on disk); for an in-memory recording it - * holds the full WebM. Rejects if a chunk failed to write to disk mid-stream, - * so a truncated recording surfaces as an error instead of a silent partial save. + * Resolves once the recording drains. Empty blob when streamed (bytes already on + * disk), full WebM when in-memory. Rejects on a mid-stream write failure so a + * truncated recording surfaces as an error instead of a silent partial save. */ recordedBlobPromise: Promise; /** - * Whether the recording's bytes went to disk via the streaming path. Computed - * at finalize time rather than construction, so a stream that fails to open is - * correctly reported as not-streamed and its in-memory fallback is used. + * Whether bytes went to disk via streaming. Computed at finalize, not construction, + * so a stream that fails to open reports as not-streamed and uses its memory fallback. */ isStreaming: () => boolean; /** - * Close the disk stream (if one opened) and delete its partial file. Called - * when a recording is discarded or fails before a successful save, so cancelled - * runs don't leak the stream or orphan a partial file. No-op for in-memory - * recorders. + * Close the disk stream (if any) and delete its partial file. Called when a recording + * is discarded or fails before save, so cancelled runs don't leak. No-op in-memory. */ discard: () => Promise; }; @@ -27,13 +23,10 @@ export type RecorderHandle = { /** * Wrap a MediaRecorder, optionally streaming its chunks to disk. * - * When `fileName` is given, chunks are written to disk in arrival order through - * the main process as they arrive, so a long recording never buffers the whole - * video in the renderer (the #616 fix). Until the disk stream confirms it is - * open, chunks are held in memory; if the open fails, that buffer becomes a - * complete in-memory fallback so nothing is lost. Native-capture webcam sidecars - * omit `fileName` and always buffer in memory, since their finalize path reads - * the blob directly to attach the webcam track. + * With `fileName`, chunks stream to disk through the main process so a long recording + * never buffers the whole video in the renderer (#616). Chunks held in memory until the + * stream confirms open; if the open fails, that buffer is the complete fallback. Webcam + * sidecars omit `fileName` and buffer in memory, since finalize reads the blob directly. */ export function createRecorderHandle( stream: MediaStream, @@ -44,26 +37,23 @@ export function createRecorderHandle( const mimeType = options.mimeType || "video/webm"; const api = window.electronAPI; - // Chunks held in memory: everything before the stream opens, plus everything - // when not streaming at all. On a successful open these flush to disk and are - // dropped; on open failure they remain as the complete fallback recording. + // Chunks held in memory before the stream opens, or for the whole recording when not + // streaming. On open they flush to disk and drop; on open failure they're the fallback. const memoryChunks: Blob[] = []; let mode: "pending" | "streaming" | "buffering" = fileName ? "pending" : "buffering"; let streamOpened = false; let appendError: Error | null = null; - // Serialize chunk writes so they land on disk in arrival order, and so stop - // can await every in-flight write before the main process closes the stream - // (otherwise a late chunk arrives after close and truncates the recording). + // Serialize writes so chunks land in arrival order and stop can await every in-flight + // write before the stream closes (a late chunk after close truncates the recording). let writeChain: Promise = Promise.resolve(); const enqueueWrite = (chunk: Blob) => { writeChain = writeChain.then(async () => { if (appendError || !fileName || !api?.appendRecordingChunk) { return; } - // Capture both outcomes — a `{ success: false }` result and an outright - // rejection (channel/handler error) — into appendError, so writeChain - // never rejects and isStreaming() stays consistent after a failure. + // Capture both a `{ success: false }` result and an outright rejection into + // appendError, so writeChain never rejects and isStreaming() stays consistent. try { const buffer = await chunk.arrayBuffer(); const result = await api.appendRecordingChunk(fileName, buffer); @@ -76,10 +66,9 @@ export function createRecorderHandle( }); }; - // Require BOTH stream IPC methods before attempting to stream. If only - // openRecordingStream exists (renderer/main version skew), streaming would - // open but every append would silently no-op, saving an empty file — so in - // that case fall through to in-memory buffering instead. + // Require both stream IPC methods before streaming. With only openRecordingStream + // (renderer/main version skew) the open succeeds but appends no-op, saving an empty + // file, so fall through to in-memory buffering instead. const openPromise: Promise<{ success: boolean; error?: string }> = fileName !== undefined && typeof api?.openRecordingStream === "function" && @@ -101,8 +90,7 @@ export function createRecorderHandle( } }, () => { - // The IPC call itself rejected (channel or handler error). Treat it the - // same as a failed open: keep buffering in memory so nothing is lost. + // IPC call rejected. Treat like a failed open: keep buffering in memory. mode = "buffering"; }, ); @@ -115,7 +103,7 @@ export function createRecorderHandle( if (mode === "streaming") { enqueueWrite(event.data); } else { - // "pending" (stream not open yet) or "buffering" (not streaming). + // pending (stream not open yet) or buffering (not streaming). memoryChunks.push(event.data); } }; @@ -130,9 +118,9 @@ export function createRecorderHandle( }); async function finalizeBlob(): Promise { - // Wait for the open attempt to settle so its flush (or fallback switch) has - // been applied, then for every queued write to land, so we never resolve - // while chunks are still in flight to the about-to-close disk stream. + // Wait for the open to settle (flush or fallback applied) then for every queued + // write to land, so we don't resolve while chunks are still in flight to the + // about-to-close stream. await openPromise.catch(() => undefined); await writeChain; if (appendError) { diff --git a/src/hooks/streamingAudioPeaks.ts b/src/hooks/streamingAudioPeaks.ts new file mode 100644 index 0000000000..8e906ad210 --- /dev/null +++ b/src/hooks/streamingAudioPeaks.ts @@ -0,0 +1,201 @@ +import { WebDemuxer } from "web-demuxer"; +import { audioDataFrameToMono } from "@/lib/captioning/extractMono16kWebDemuxer"; + +/** + * Streaming trim-waveform peaks for recordings too large to load into memory. + * + * The default waveform path reads the whole file and runs `decodeAudioData`, + * which needs the full bytes up front — impossible for multi-GB recordings. + * This module demuxes the audio track with web-demuxer (which reads the File + * on demand), decodes it chunk by chunk with WebCodecs `AudioDecoder`, and + * folds every decoded frame straight into min/max peak buckets, closing the + * frame immediately. Peak memory is the buckets array (≤ 24k blocks ≈ 192 kB) + * plus a handful of in-flight frames, regardless of recording length. + * + * Output matches `audioPeaksWorker.ts` exactly: Float32Array of length 2*N, + * `[min0, max0, min1, max1, ...]`, N = min(24000, ceil(duration * 200)), with + * min/max starting from 0 (silence baseline) and channels averaged per sample. + */ + +const DECODE_QUEUE_BACKPRESSURE = 20; +const LOAD_TIMEOUT_MS = 60_000; +const READ_END_PADDING_SEC = 0.5; +// Keep in sync with audioPeaksWorker.ts so both paths render identically. +const MAX_PEAK_BLOCKS = 24_000; +const PEAK_BLOCKS_PER_SEC = 200; +// Upper bound for the duration fallback scan when container metadata is +// unreliable (MediaRecorder WebM often reports 0/Infinity — see +// streamingDecoder's validateDuration). Same ceiling as the export scan. +const SCAN_UNBOUNDED_FALLBACK_SEC = 24 * 60 * 60; + +/** + * Ground-truth duration from audio packet timestamps, for containers whose + * metadata duration is missing or bogus. Demux-only (no decode), so it is a + * fast forward pass even for multi-GB files. + */ +async function scanAudioDurationSec(demuxer: WebDemuxer, signal?: AbortSignal): Promise { + const reader = demuxer.read("audio", 0, SCAN_UNBOUNDED_FALLBACK_SEC).getReader(); + let maxEndUs = 0; + try { + while (!signal?.aborted) { + const { done, value: chunk } = await reader.read(); + if (done || !chunk) break; + const endUs = chunk.timestamp + (chunk.duration ?? 0); + if (endUs > maxEndUs) maxEndUs = endUs; + } + } finally { + try { + await reader.cancel(); + } catch { + /* already closed */ + } + } + if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); + return maxEndUs / 1e6; +} + +function withTimeout(promise: Promise, ms: number, message: string): Promise { + return new Promise((resolve, reject) => { + const id = window.setTimeout(() => reject(new Error(message)), ms); + promise + .then((v) => { + window.clearTimeout(id); + resolve(v); + }) + .catch((e) => { + window.clearTimeout(id); + reject(e instanceof Error ? e : new Error(String(e))); + }); + }); +} + +/** + * Computes trim-waveform peaks from a (typically OPFS-backed) File without ever + * holding the decoded PCM in memory. Throws on no/unsupported audio track; the + * caller (useAudioPeaks) degrades to no waveform. + */ +export async function computePeaksFromFileStreaming( + file: File, + signal?: AbortSignal, +): Promise { + const wasmUrl = new URL("./wasm/web-demuxer.wasm", window.location.href).href; + const demuxer = new WebDemuxer({ wasmFilePath: wasmUrl }); + try { + await withTimeout( + demuxer.load(file), + LOAD_TIMEOUT_MS, + "Timed out while parsing the source video for the waveform.", + ); + if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); + + const mediaInfo = await withTimeout( + demuxer.getMediaInfo(), + LOAD_TIMEOUT_MS, + "Timed out while reading media info for the waveform.", + ); + + let audioConfig: AudioDecoderConfig; + try { + audioConfig = await demuxer.getDecoderConfig("audio"); + } catch { + throw new Error("No audio track found in this video."); + } + const codecCheck = await AudioDecoder.isConfigSupported(audioConfig); + if (!codecCheck.supported) { + throw new Error(`Audio codec not supported for waveform: ${audioConfig.codec}`); + } + const sampleRate = audioConfig.sampleRate || 48_000; + + // MediaRecorder WebM often reports a missing/bogus container duration + // (see streamingDecoder's validateDuration); fall back to a demux-only + // packet-timestamp scan so those recordings still get a waveform. + let durationSec = + Number.isFinite(mediaInfo.duration) && mediaInfo.duration > 0 ? mediaInfo.duration : 0; + if (durationSec <= 0) { + durationSec = await scanAudioDurationSec(demuxer, signal); + } + if (durationSec <= 0) { + throw new Error("Unknown duration; cannot bucket waveform peaks."); + } + + const blocks = Math.min(MAX_PEAK_BLOCKS, Math.ceil(durationSec * PEAK_BLOCKS_PER_SEC)); + const totalSamples = Math.max(1, Math.ceil(durationSec * sampleRate)); + const peaks = new Float32Array(blocks * 2); // [min0, max0, min1, max1, ...] + + const foldFrame = (frame: AudioData) => { + const startSample = Math.round((frame.timestamp / 1e6) * sampleRate); + const mono = audioDataFrameToMono(frame); + frame.close(); + for (let i = 0; i < mono.length; i++) { + const pos = startSample + i; + if (pos < 0 || pos >= totalSamples) continue; + let block = Math.floor((pos / totalSamples) * blocks); + if (block >= blocks) block = blocks - 1; + const sample = mono[i]; + if (sample < peaks[block * 2]) peaks[block * 2] = sample; + if (sample > peaks[block * 2 + 1]) peaks[block * 2 + 1] = sample; + } + }; + + let decodedFrames = 0; + let decodeError: DOMException | null = null; + const decoder = new AudioDecoder({ + output: (data: AudioData) => { + decodedFrames++; + foldFrame(data); + }, + error: (e: DOMException) => { + decodeError = e; + }, + }); + decoder.configure(audioConfig); + + try { + const reader = demuxer.read("audio", 0, durationSec + READ_END_PADDING_SEC).getReader(); + try { + while (!signal?.aborted && !decodeError) { + const { done, value: chunk } = await reader.read(); + if (done || !chunk) break; + decoder.decode(chunk); + while (decoder.decodeQueueSize > DECODE_QUEUE_BACKPRESSURE && !signal?.aborted) { + await new Promise((r) => setTimeout(r, 1)); + } + } + } finally { + try { + await reader.cancel(); + } catch { + /* already closed */ + } + } + + // Flush only on the clean path; an aborted or errored decode should + // not wait for the full pipeline to drain. + if (!signal?.aborted && !decodeError && decoder.state === "configured") { + await decoder.flush(); + } + } finally { + // Always release the decoder — a throw in the demux loop must not + // leak a configured AudioDecoder (they hold codec-native memory). + if (decoder.state !== "closed") { + try { + decoder.close(); + } catch { + /* already closed */ + } + } + } + if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); + if (decodeError) throw decodeError; + if (decodedFrames === 0) { + throw new Error("Decoded zero audio frames from this video."); + } + return peaks; + } finally { + try { + demuxer.destroy(); + } catch { + /* already destroyed */ + } + } +} diff --git a/src/hooks/useAudioPeaks.ts b/src/hooks/useAudioPeaks.ts index 3be6ac6132..daa0abf097 100644 --- a/src/hooks/useAudioPeaks.ts +++ b/src/hooks/useAudioPeaks.ts @@ -1,5 +1,8 @@ import { useEffect, useRef, useState } from "react"; +import { materializeLocalSourceFile, releaseLocalSourceFile } from "@/lib/exporter/localSourceFile"; +import { MAX_IN_MEMORY_SOURCE_BYTES } from "@/lib/exporter/sourceFileLimits"; import { loadFileAsArrayBuffer } from "@/lib/exporter/streamingDecoder"; +import { computePeaksFromFileStreaming } from "./streamingAudioPeaks"; let _audioCtx: AudioContext | null = null; /** Returns the shared AudioContext, creating it lazily on first call. */ @@ -10,8 +13,7 @@ function getAudioCtx(): AudioContext { /** * Offloads peak computation to a Web Worker (zero-copy via Transferable). - * Accepts an optional AbortSignal — if aborted, the worker is terminated - * immediately and the promise rejects with an AbortError. + * On abort, the worker is terminated and the promise rejects with AbortError. */ function computePeaksInWorker( audioBuffer: AudioBuffer, @@ -60,15 +62,38 @@ function computePeaksInWorker( } /** - * Decodes audio from `videoUrl` and returns a Float32Array of paired - * [min, max] peak values (length = 2 * N blocks). Returns `null` while - * decoding is in progress, and stays `null` when the file has no audio - * track or decoding fails (silent degradation). - * - * - File loading uses the Electron IPC bridge for local paths (same as the exporter). - * - Peak computation runs in a Web Worker to avoid blocking the main thread. - * - Results are cached in a ref scoped to the hook instance (survives re-renders - * and waveform toggle off/on, but not component unmount). + * Routes to the right peaks pipeline for the source size. Small/remote files + * use the original decodeAudioData → worker path. Local recordings above the + * in-memory limit stream instead: the file is materialized into OPFS (reused by + * the export afterwards) and its audio is decoded chunk-by-chunk into peaks, so + * the whole recording is never held in memory. + */ +async function computePeaksForUrl(videoUrl: string, signal?: AbortSignal): Promise { + const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); + if (!isRemoteUrl && window.electronAPI?.getReadableFileInfo) { + const info = await window.electronAPI.getReadableFileInfo(videoUrl); + if (info.success && typeof info.size === "number" && info.size > MAX_IN_MEMORY_SOURCE_BYTES) { + const filename = (videoUrl.split(/[\\/]/).pop() || "video").replace(/^file:/, ""); + // signal also aborts the OPFS copy (unless the export shares it). + const file = await materializeLocalSourceFile(videoUrl, filename, { signal }); + try { + return await computePeaksFromFileStreaming(file, signal); + } finally { + releaseLocalSourceFile(file.name); + } + } + } + + const { data: arrayBuffer } = await loadFileAsArrayBuffer(videoUrl); + const audioBuffer = await getAudioCtx().decodeAudioData(arrayBuffer); + return computePeaksInWorker(audioBuffer, signal); +} + +/** + * Decodes audio from `videoUrl` into paired [min, max] peaks (length = 2 * N + * blocks). Returns `null` while decoding, and stays `null` on no audio track or + * decode failure (silent degradation). Results are cached in a ref scoped to the + * hook instance, so they survive re-renders and waveform toggles but not unmount. */ export function useAudioPeaks(videoUrl?: string): Float32Array | null { const cacheRef = useRef>(new Map()); @@ -94,18 +119,16 @@ export function useAudioPeaks(videoUrl?: string): Float32Array | null { (async () => { try { - const { data: arrayBuffer } = await loadFileAsArrayBuffer(videoUrl); - if (cancelled) return; - const audioBuffer = await getAudioCtx().decodeAudioData(arrayBuffer); - if (cancelled) return; - const p = await computePeaksInWorker(audioBuffer, controller.signal); + const p = await computePeaksForUrl(videoUrl, controller.signal); if (cancelled) return; cacheRef.current.set(videoUrl, p); setPeaks(p); } catch (err) { - // AbortError means the effect cleaned up — no state update needed. + // AbortError means the effect cleaned up, so no state update needed. if (err instanceof DOMException && err.name === "AbortError") return; - // No audio track or unsupported format — clear stale data silently. + // No audio track or unsupported format: degrade to no waveform, but log + // so an unexpectedly-missing waveform is diagnosable. + console.warn("useAudioPeaks: could not decode audio for waveform:", err); if (!cancelled) setPeaks(null); } })(); diff --git a/src/hooks/useCameraDevices.test.ts b/src/hooks/useCameraDevices.test.ts index 5ca21bc857..e6ec8a9ed5 100644 --- a/src/hooks/useCameraDevices.test.ts +++ b/src/hooks/useCameraDevices.test.ts @@ -91,7 +91,7 @@ describe("useCameraDevices", () => { expect(result.current.selectedDeviceId).toBe("cam1"); }); - // Simulate cam1 being unplugged — only cam2 remains + // Simulate cam1 being unplugged, only cam2 remains const cam2Only = [ { kind: "videoinput", deviceId: "cam2", label: "Camera 2", groupId: "group1" }, ]; diff --git a/src/hooks/useCameraDevices.ts b/src/hooks/useCameraDevices.ts index a02e65aa29..bf402c7f00 100644 --- a/src/hooks/useCameraDevices.ts +++ b/src/hooks/useCameraDevices.ts @@ -23,8 +23,8 @@ export function useCameraDevices(enabled: boolean = false) { setIsLoading(true); setError(null); - // Enumerate without requesting a second stream — the recorder handles - // the real acquisition; unlabeled devices fall back to their device ID. + // Enumerate without requesting a second stream; the recorder handles + // the real acquisition. Unlabeled devices fall back to their device ID. const allDevices = await navigator.mediaDevices.enumerateDevices(); const videoInputs = allDevices .filter((device) => device.kind === "videoinput") diff --git a/src/hooks/useEditorHistory.ts b/src/hooks/useEditorHistory.ts index 25b6e21a1b..9ca00440b3 100644 --- a/src/hooks/useEditorHistory.ts +++ b/src/hooks/useEditorHistory.ts @@ -15,13 +15,22 @@ import type { WebcamSizePreset, ZoomRegion, } from "@/components/video-editor/types"; -import { DEFAULT_CROP_REGION } from "@/components/video-editor/types"; +import { + DEFAULT_CROP_REGION, + DEFAULT_WEBCAM_MIRRORED, + DEFAULT_WEBCAM_REACTIVE_ZOOM, +} from "@/components/video-editor/types"; import type { AspectRatio } from "@/utils/aspectRatioUtils"; -// Undoable state — selection IDs are intentionally excluded (undoing a -// selection change would feel surprising to the user). +// Undoable state. Selection IDs are excluded, since undoing a selection change +// would feel surprising. export interface EditorState { zoomRegions: ZoomRegion[]; + /** Magic-wand auto-zoom toggle. When on, fresh recordings get suggested zooms. */ + autoZoomEnabled: boolean; + /** Global Auto-Focus toggle: when on, all zooms follow the cursor and the + * per-zoom Focus Mode selector is locked. */ + autoFocusAll: boolean; trimRegions: TrimRegion[]; speedRegions: SpeedRegion[]; annotationRegions: AnnotationRegion[]; @@ -36,12 +45,16 @@ export interface EditorState { aspectRatio: AspectRatio; webcamLayoutPreset: WebcamLayoutPreset; webcamMaskShape: WebcamMaskShape; + webcamMirrored: boolean; + webcamReactiveZoom: boolean; webcamSizePreset: WebcamSizePreset; webcamPosition: WebcamPosition | null; } export const INITIAL_EDITOR_STATE: EditorState = { zoomRegions: [], + autoZoomEnabled: true, + autoFocusAll: false, trimRegions: [], speedRegions: [], annotationRegions: [], @@ -56,6 +69,8 @@ export const INITIAL_EDITOR_STATE: EditorState = { aspectRatio: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio, webcamLayoutPreset: DEFAULT_WEBCAM_SETTINGS.layoutPreset, webcamMaskShape: DEFAULT_WEBCAM_SETTINGS.maskShape, + webcamMirrored: DEFAULT_WEBCAM_MIRRORED, + webcamReactiveZoom: DEFAULT_WEBCAM_REACTIVE_ZOOM, webcamSizePreset: DEFAULT_WEBCAM_SETTINGS.sizePreset, webcamPosition: DEFAULT_WEBCAM_SETTINGS.position, }; @@ -86,8 +101,8 @@ function withCheckpoint(history: History, newPresent: EditorState): History { export function useEditorHistory(initial: EditorState = INITIAL_EDITOR_STATE) { const [history, setHistory] = useState({ past: [], present: initial, future: [] }); - // Tracks whether a live-update series (e.g. slider drag) is in progress. - // The first updateState call saves the pre-interaction state as a checkpoint. + // True while a live-update series (e.g. slider drag) is in progress. The first + // updateState call checkpoints the pre-interaction state. const dirtyRef = useRef(false); const pushState = useCallback((update: StateUpdate) => { diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index f5fb920323..72ddc529bc 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -50,6 +50,7 @@ const WEBCAM_TARGET_FRAME_RATE = 30; type UseScreenRecorderReturn = { recording: boolean; paused: boolean; + saving: boolean; elapsedSeconds: number; toggleRecording: () => void; togglePaused: () => void; @@ -91,6 +92,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const t = useScopedT("editor"); const [recording, setRecording] = useState(false); const [paused, setPaused] = useState(false); + const [saving, setSaving] = useState(false); const [elapsedSeconds, setElapsedSeconds] = useState(0); const [microphoneEnabled, setMicrophoneEnabled] = useState(false); const [microphoneDeviceId, setMicrophoneDeviceId] = useState(undefined); @@ -135,10 +137,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }, []); const selectMimeType = () => { - // H.264 first: hardware-accelerated on all modern devices, gives sharp - // real-time output. AV1/VP9 are great for distribution but too - // CPU-intensive for live 60 fps capture — they produce blurry frames - // when the software encoder can't keep up. + // H.264 first: hardware-accelerated, so sharp real-time output. AV1/VP9 are + // better for distribution but too CPU-heavy for live 60 fps capture (software + // encoder falls behind and produces blurry frames). const preferred = [ "video/webm;codecs=h264", "video/webm;codecs=vp8", @@ -311,6 +312,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return; } finalizingRecordingId.current = activeRecordingId; + // Only show the "Saving…" spinner for genuine saves — not for cancel/restart + // flows where discardRecordingId has already been set. + const isDiscarded = discardRecordingId.current === activeRecordingId; + if (!isDiscarded) { + setSaving(true); + } if (screenRecorder.current === activeScreenRecorder) { screenRecorder.current = null; @@ -339,7 +346,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { window.electronAPI?.discardCursorTelemetry(activeRecordingId); return; } - // When streaming succeeded the blob is empty — the data is already on disk. + // When streaming succeeded the blob is empty; the data is already on disk. if (!activeScreenRecorder.isStreaming() && screenBlob.size === 0) { return; } @@ -398,10 +405,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } catch (error) { console.error("Error saving recording:", error); } finally { - // Discard any recorder whose data was not part of a successful save - // — a discarded run, a failed save, or a webcam whose disk write - // failed (so it was omitted while the screen still saved) — so no - // stream or partial file is left open or orphaned. + // Discard any recorder whose data wasn't part of a successful save (discarded + // run, failed save, or a webcam whose disk write failed while the screen still + // saved) so no stream or partial file is left open or orphaned. if (!storeSucceeded) { await activeScreenRecorder.discard().catch(() => undefined); } @@ -414,6 +420,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (discardRecordingId.current === activeRecordingId) { discardRecordingId.current = null; } + setSaving(false); } })(); }, @@ -428,6 +435,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } activeNativeRecording.finalizing = true; + if (!discard) { + setSaving(true); + } const activeWebcamRecorder = activeNativeRecording.webcamRecorder; const duration = Math.max(0, getRecordingDurationMs()); if ( @@ -515,6 +525,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (discardRecordingId.current === activeNativeRecording.recordingId) { discardRecordingId.current = null; } + setSaving(false); } }, [cursorCaptureMode, getRecordingDurationMs], @@ -528,6 +539,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } activeNativeRecording.finalizing = true; + if (!discard) { + setSaving(true); + } const duration = Math.max(0, getRecordingDurationMs()); const activeWebcamRecorder = webcamRecorder.current; if (activeWebcamRecorder && webcamRecorder.current === activeWebcamRecorder) { @@ -615,6 +629,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (discardRecordingId.current === activeNativeRecording.recordingId) { discardRecordingId.current = null; } + setSaving(false); } }, [cursorCaptureMode, getRecordingDurationMs], @@ -1069,9 +1084,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn { try { const platform = await window.electronAPI.getPlatform(); if (platform === "darwin" && cursorCaptureMode === "editable-overlay") { + // The main process shows a native dialog that deep-links to the + // Accessibility settings pane when access is missing, so we just stop + // here and let the user grant it and press record again. const access = await window.electronAPI.requestNativeMacCursorAccess(); if (!access.granted) { - toast.info(t("recording.accessibilityAllowAndRetry")); return; } } @@ -1421,6 +1438,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (!result.success) { throw new Error(result.error ?? "Failed to resume native Windows recording"); } + if (activeNativeWindowsRecording.webcamRecorder?.recorder.state === "paused") { + activeNativeWindowsRecording.webcamRecorder.recorder.resume(); + } activeNativeWindowsRecording.paused = false; segmentStartedAt.current = Date.now(); setPaused(false); @@ -1432,6 +1452,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (!result.success) { throw new Error(result.error ?? "Failed to pause native Windows recording"); } + if (activeNativeWindowsRecording.webcamRecorder?.recorder.state === "recording") { + activeNativeWindowsRecording.webcamRecorder.recorder.pause(); + } activeNativeWindowsRecording.paused = true; accumulatedDurationMs.current = pausedAtMs; segmentStartedAt.current = null; @@ -1660,6 +1683,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return { recording, paused, + saving, elapsedSeconds, toggleRecording, togglePaused, diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json index b3e1222802..55ba4339c1 100644 --- a/src/i18n/locales/ar/editor.json +++ b/src/i18n/locales/ar/editor.json @@ -44,6 +44,25 @@ "permissionDenied": "تم رفض إذن التسجيل. يرجى السماح بتسجيل الشاشة.", "accessibilityAllowAndRetry": "اسمح بوصول تسهيلات الاستخدام لـ OpenScreen، ثم اضغط على التسجيل مرة أخرى لبدء العد التنازلي." }, + "autoCaptions": { + "button": "التسميات التوضيحية التلقائية", + "dialogTitle": "التسميات التوضيحية التلقائية", + "dialogDescription": "اختر تقريبا كم عدد الكلمات التي تظهر في كل تسمية توضيحية. يتم توزيع التوقيت عبر الكلمات في تلك العبارة.", + "minWords": "الحد الأدنى من الكلمات لكل تسمية", + "maxWords": "الحد الأقصى من الكلمات لكل تسمية", + "wordsCount": "{{count}} كلمة", + "generate": "توليد", + "dialogCancel": "إلغاء", + "generating": "جارٍ توليد التسميات من الصوت…", + "loadingModel": "جارٍ تحميل نموذج الكلام (سيتم تنزيل ~75 ميغابايت عند الاستخدام الأول)…", + "transcribing": "جارٍ نسخ الكلام إلى نص…", + "busy": "توليد التسميات قيد التنفيذ بالفعل.", + "done": "تمت إضافة {{count}} تسمية.", + "noneHeard": "لم يتم الكشف عن أي كلام.", + "noAudio": "لا يحتوي هذا الفيديو على صوت صالح للنسخ.", + "failed": "تعذّر توليد التسميات.", + "truncated": "تم نسخ الدقائق الأولى فقط: {{minutes}} دقيقة." + }, "emptyState": { "title": "لا يوجد مشروع مفتوح", "description": "استورد مقطع فيديو للبدء في التحرير، أو حمّل مشروع OpenScreen موجود.", @@ -58,5 +77,17 @@ "couldNotOpenTitle": "تعذّر فتح الملف", "couldNotOpenMessage": "تعذّر فتح ملف المشروع. ربما تم نقل الفيديو المرجعي أو حذفه." } + }, + "regionClipboard": { + "copied": "تم نسخ سمات {{region}}", + "pasted": "تم لصق سمات {{region}}", + "nothingToCopy": "حدد منطقة لنسخ سماتها", + "nothingToPaste": "لم يتم نسخ أي سمات بعد", + "kinds": { + "zoom": "تكبير", + "speed": "سرعة", + "annotation": "نص", + "blur": "تمويه" + } } } diff --git a/src/i18n/locales/ar/launch.json b/src/i18n/locales/ar/launch.json index ef000a8758..e3a855e656 100644 --- a/src/i18n/locales/ar/launch.json +++ b/src/i18n/locales/ar/launch.json @@ -9,7 +9,18 @@ "openVideoFile": "فتح ملف فيديو", "openProject": "فتح مشروع", "useVerticalTray": "استخدام الشريط العمودي", - "useHorizontalTray": "استخدام الشريط الأفقي" + "useHorizontalTray": "استخدام الشريط الأفقي", + "openNotes": "فتح الملاحظات", + "openNotesPlaceholder": "اكتب ملاحظاتك هنا...", + "notesToolbar": { + "bold": "غامق", + "italic": "مائل", + "strikethrough": "يتوسطه خط", + "bulletList": "قائمة نقطية", + "numberedList": "قائمة مرقمة", + "blockquote": "اقتباس", + "codeBlock": "كتلة تعليمات برمجية" + } }, "audio": { "enableSystemAudio": "تفعيل صوت النظام", @@ -33,7 +44,8 @@ "defaultSourceName": "الشاشة" }, "recording": { - "selectSource": "يرجى تحديد مصدر للتسجيل" + "selectSource": "يرجى تحديد مصدر للتسجيل", + "saving": "جاري الحفظ..." }, "language": "اللغة", "systemLanguagePrompt": { diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index 9ddc2ba7df..2e34bae8b3 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -45,7 +45,10 @@ "dualFrame": "إطار مزدوج", "webcamShape": "شكل الكاميرا", "webcamSize": "حجم كاميرا الويب", - "noWebcam": "بدون كاميرا" + "noWebcam": "بدون كاميرا", + "mirrorWebcam": "عكس كاميرا الويب", + "reactiveWebcam": "تصغير عند التكبير", + "reactiveWebcamDescription": "تتقلص الكاميرا بسلاسة أثناء تكبير الفيديو حتى لا تعيق الرؤية." }, "effects": { "title": "تأثيرات الفيديو", @@ -164,8 +167,7 @@ "tipShiftTabCycle": "استخدم Shift+Tab للتنقل للخلف.", "invalidImageType": "نوع ملف غير صالح", "imageFormatsOnly": "يرجى رفع ملف صورة JPG أو PNG أو GIF أو WebP.", - "imageUploadSuccess": "تم رفع الصورة بنجاح!", - "failedImageUpload": "فشل في رفع الصورة" + "imageUploadSuccess": "تم رفع الصورة بنجاح!" }, "fontStyles": { "classic": "كلاسيكي", @@ -197,6 +199,8 @@ "errorLoadFailed": "تعذر تحميل الخط. يرجى التحقق من صحة رابط خطوط Google." }, "cursor": { + "theme": "نمط المؤشر", + "themeDefault": "افتراضي", "show": "إظهار المؤشر", "size": "الحجم", "smoothing": "التنعيم", diff --git a/src/i18n/locales/ar/shortcuts.json b/src/i18n/locales/ar/shortcuts.json index b18e3195e1..44c2f22fdc 100644 --- a/src/i18n/locales/ar/shortcuts.json +++ b/src/i18n/locales/ar/shortcuts.json @@ -23,7 +23,9 @@ "addBlur": "إضافة تمويه", "addKeyframe": "إضافة إطار رئيسي", "deleteSelected": "حذف المحدد", - "playPause": "تشغيل / إيقاف مؤقت" + "playPause": "تشغيل / إيقاف مؤقت", + "copySelected": "نسخ المحدد", + "paste": "لصق" }, "fixedActions": { "undo": "تراجع", diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index ebd9a5d5ff..5810c45c6b 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -44,6 +44,25 @@ "permissionDenied": "Recording permission denied. Please allow screen recording.", "accessibilityAllowAndRetry": "Allow Accessibility access for OpenScreen, then press record again to start the countdown." }, + "autoCaptions": { + "button": "Auto captions", + "dialogTitle": "Auto captions", + "dialogDescription": "Choose roughly how many words each caption shows at once. Timing is spread across the words in that phrase.", + "minWords": "Minimum words per caption", + "maxWords": "Maximum words per caption", + "wordsCount": "{{count}} words", + "generate": "Generate", + "dialogCancel": "Cancel", + "generating": "Generating captions from audio…", + "loadingModel": "Loading speech model (first use downloads ~75 MB)…", + "transcribing": "Transcribing speech…", + "busy": "Caption generation is already in progress.", + "done": "Added {{count}} captions.", + "noneHeard": "No speech was detected.", + "noAudio": "This video has no usable audio to transcribe.", + "failed": "Could not generate captions.", + "truncated": "Only the first {{minutes}} minutes were transcribed." + }, "emptyState": { "title": "No project open", "description": "Import a video to start editing, or load an existing OpenScreen project.", @@ -58,5 +77,17 @@ "couldNotOpenTitle": "Could Not Open File", "couldNotOpenMessage": "The project file could not be opened. The video it references may have been moved or deleted." } + }, + "regionClipboard": { + "copied": "{{region}} attributes copied", + "pasted": "{{region}} attributes pasted", + "nothingToCopy": "Select a region to copy its attributes", + "nothingToPaste": "No attributes copied yet", + "kinds": { + "zoom": "Zoom", + "speed": "Speed", + "annotation": "Text", + "blur": "Blur" + } } } diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index 0509752ee0..0066a3fc9c 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -10,7 +10,18 @@ "openProject": "Open project", "useVerticalTray": "Use vertical tray", "useHorizontalTray": "Use horizontal tray", - "openStudio": "Open Studio" + "openStudio": "Open Studio", + "openNotes": "Open Notes", + "openNotesPlaceholder": "Take notes here...", + "notesToolbar": { + "bold": "Bold", + "italic": "Italic", + "strikethrough": "Strikethrough", + "bulletList": "Bullet list", + "numberedList": "Numbered list", + "blockquote": "Blockquote", + "codeBlock": "Code block" + } }, "audio": { "enableSystemAudio": "Enable system audio", @@ -41,7 +52,8 @@ "defaultSourceName": "Screen" }, "recording": { - "selectSource": "Please select a source to record" + "selectSource": "Please select a source to record", + "saving": "Saving..." }, "language": "Language", "systemLanguagePrompt": { diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 02d44c6585..93cef6d4cb 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -9,7 +9,8 @@ "title": "Focus Mode", "manual": "Manual", "auto": "Auto", - "autoDescription": "Camera follows the recorded cursor position" + "autoDescription": "Camera follows the recorded cursor position", + "lockedDisclaimer": "Controlled by the global Auto-Focus toggle in the timeline. Turn it off to set focus mode per zoom." }, "threeD": { "title": "3D Rotation", @@ -45,7 +46,10 @@ "dualFrame": "Dual Frame", "noWebcam": "No Webcam", "webcamShape": "Camera Shape", - "webcamSize": "Webcam Size" + "webcamSize": "Webcam Size", + "mirrorWebcam": "Mirror Webcam", + "reactiveWebcam": "Shrink on Zoom", + "reactiveWebcamDescription": "Camera smoothly shrinks while the video is zoomed in, so it stays out of the way." }, "effects": { "title": "Video Effects", @@ -162,8 +166,7 @@ "tipShiftTabCycle": "Use Shift+Tab to cycle backwards.", "invalidImageType": "Invalid file type", "imageFormatsOnly": "Please upload a JPG, PNG, GIF, or WebP image file.", - "imageUploadSuccess": "Image uploaded successfully!", - "failedImageUpload": "Failed to upload image" + "imageUploadSuccess": "Image uploaded successfully!" }, "fontStyles": { "classic": "Classic", @@ -206,12 +209,15 @@ "errorLoadFailed": "The font could not be loaded. Please verify the Google Fonts URL is correct." }, "cursor": { + "theme": "Cursor Style", + "themeDefault": "Default", "show": "Show Cursor", "size": "Size", "smoothing": "Smoothing", "motionBlur": "Motion Blur", "clickBounce": "Click Bounce", - "clipToBounds": "Clip to Canvas" + "clipToBounds": "Clip to Canvas", + "clipToBoundsDescription": "Keeps the cursor inside the video frame. Turn off to let the cursor extend past the edges - useful when zoomed in or panned." }, "language": { "title": "Language" diff --git a/src/i18n/locales/en/shortcuts.json b/src/i18n/locales/en/shortcuts.json index 8994df1300..d5a4b0dd83 100644 --- a/src/i18n/locales/en/shortcuts.json +++ b/src/i18n/locales/en/shortcuts.json @@ -23,7 +23,9 @@ "addBlur": "Add Blur", "addKeyframe": "Add Keyframe", "deleteSelected": "Delete Selected", - "playPause": "Play / Pause" + "playPause": "Play / Pause", + "copySelected": "Copy Selected", + "paste": "Paste" }, "fixedActions": { "undo": "Undo", diff --git a/src/i18n/locales/en/timeline.json b/src/i18n/locales/en/timeline.json index 389184b2b9..4fc250828d 100644 --- a/src/i18n/locales/en/timeline.json +++ b/src/i18n/locales/en/timeline.json @@ -2,6 +2,10 @@ "buttons": { "addZoom": "Add Zoom (Z)", "suggestZooms": "Suggest Zooms from Cursor", + "autoZoomOn": "Auto zoom suggestions on — click to remove suggested zooms", + "autoZoomOff": "Auto zoom suggestions off — click to suggest zooms from cursor", + "autoFocusAllOn": "Auto-Focus on for all zooms — click to switch all to manual", + "autoFocusAllOff": "Auto-Focus all zooms (camera follows the cursor)", "addTrim": "Add Trim (T)", "addAnnotation": "Add Annotation (A)", "addBlur": "Add Blur (B)", diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index 16a2c85478..c2c3a910ef 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -44,6 +44,25 @@ "cancel": "Cancelar", "confirm": "Confirmar" }, + "autoCaptions": { + "button": "Subtítulos automáticos", + "dialogTitle": "Subtítulos automáticos", + "dialogDescription": "Elige aproximadamente cuántas palabras muestra cada subtítulo a la vez. El tiempo se reparte entre las palabras de esa frase.", + "minWords": "Número mínimo de palabras por subtítulo", + "maxWords": "Número máximo de palabras por subtítulo", + "wordsCount": "{{count}} palabras", + "generate": "Generar", + "dialogCancel": "Cancelar", + "generating": "Generando subtítulos a partir del audio…", + "loadingModel": "Cargando el modelo de voz (el primer uso descarga ~75 MB)…", + "transcribing": "Transcribiendo el habla…", + "busy": "La generación de subtítulos ya está en curso.", + "done": "Se añadieron {{count}} subtítulos.", + "noneHeard": "No se detectó voz.", + "noAudio": "Este video no tiene audio utilizable para transcribir.", + "failed": "No se pudieron generar los subtítulos.", + "truncated": "Solo se transcribieron los primeros {{minutes}} minutos." + }, "emptyState": { "title": "No hay proyecto abierto", "description": "Importa un video para empezar a editar o carga un proyecto de OpenScreen existente.", @@ -58,5 +77,17 @@ "couldNotOpenTitle": "No se pudo abrir el archivo", "couldNotOpenMessage": "No se pudo abrir el archivo de proyecto. El video al que hace referencia puede haber sido movido o eliminado." } + }, + "regionClipboard": { + "copied": "Atributos de {{region}} copiados", + "pasted": "Atributos de {{region}} pegados", + "nothingToCopy": "Selecciona una región para copiar sus atributos", + "nothingToPaste": "Aún no se han copiado atributos", + "kinds": { + "zoom": "Zoom", + "speed": "Velocidad", + "annotation": "Anotación", + "blur": "Desenfoque" + } } } diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json index bc6ba52a89..90ef568654 100644 --- a/src/i18n/locales/es/launch.json +++ b/src/i18n/locales/es/launch.json @@ -9,7 +9,18 @@ "openVideoFile": "Abrir archivo de video", "openProject": "Abrir proyecto", "useVerticalTray": "Usar bandeja vertical", - "useHorizontalTray": "Usar bandeja horizontal" + "useHorizontalTray": "Usar bandeja horizontal", + "openNotes": "Abrir notas", + "openNotesPlaceholder": "Escribe tus notas aquí...", + "notesToolbar": { + "bold": "Negrita", + "italic": "Cursiva", + "strikethrough": "Tachado", + "bulletList": "Lista con viñetas", + "numberedList": "Lista numerada", + "blockquote": "Cita", + "codeBlock": "Bloque de código" + } }, "audio": { "enableSystemAudio": "Activar audio del sistema", @@ -37,7 +48,8 @@ "defaultSourceName": "Pantalla" }, "recording": { - "selectSource": "Por favor selecciona una fuente para grabar" + "selectSource": "Por favor selecciona una fuente para grabar", + "saving": "Guardando..." }, "language": "Idioma", "systemLanguagePrompt": { diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 21295bf809..ac9267b41d 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -45,7 +45,10 @@ "dualFrame": "Marco dual", "webcamShape": "Forma de cámara", "webcamSize": "Tamaño de cámara", - "noWebcam": "Sin cámara" + "noWebcam": "Sin cámara", + "mirrorWebcam": "Reflejar cámara", + "reactiveWebcam": "Reducir al ampliar", + "reactiveWebcamDescription": "La cámara se reduce suavemente mientras el vídeo está ampliado, para no estorbar." }, "effects": { "title": "Efectos de video", @@ -162,8 +165,7 @@ "tipShiftTabCycle": "Usa Shift+Tab para recorrer hacia atrás.", "invalidImageType": "Tipo de archivo no válido", "imageFormatsOnly": "Por favor sube un archivo de imagen JPG, PNG, GIF o WebP.", - "imageUploadSuccess": "¡Imagen subida exitosamente!", - "failedImageUpload": "Error al subir la imagen" + "imageUploadSuccess": "¡Imagen subida exitosamente!" }, "fontStyles": { "classic": "Clásico", @@ -206,6 +208,8 @@ "errorLoadFailed": "No se pudo cargar la fuente. Por favor verifica que la URL de Google Fonts sea correcta." }, "cursor": { + "theme": "Estilo del cursor", + "themeDefault": "Predeterminado", "show": "Mostrar cursor", "size": "Tamaño", "smoothing": "Suavizado", diff --git a/src/i18n/locales/es/shortcuts.json b/src/i18n/locales/es/shortcuts.json index 49767d5604..b23a1cbe01 100644 --- a/src/i18n/locales/es/shortcuts.json +++ b/src/i18n/locales/es/shortcuts.json @@ -23,7 +23,9 @@ "addBlur": "Agregar desenfoque", "addKeyframe": "Agregar fotograma clave", "deleteSelected": "Eliminar seleccionado", - "playPause": "Reproducir / Pausar" + "playPause": "Reproducir / Pausar", + "copySelected": "Copiar selección", + "paste": "Pegar" }, "fixedActions": { "undo": "Deshacer", diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index 4eb57a9ccf..60d35285fe 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -44,6 +44,25 @@ }, "loadingVideo": "Chargement de la vidéo...", "loadingEditor": "Chargement de l'éditeur...", + "autoCaptions": { + "button": "Sous-titres automatiques", + "dialogTitle": "Sous-titres automatiques", + "dialogDescription": "Choisissez approximativement combien de mots chaque sous-titre affiche à la fois. Le timing est réparti entre les mots de cette phrase.", + "minWords": "Nombre minimum de mots par sous-titre", + "maxWords": "Nombre maximum de mots par sous-titre", + "wordsCount": "{{count}} mots", + "generate": "Générer", + "dialogCancel": "Annuler", + "generating": "Génération des sous-titres à partir de l'audio…", + "loadingModel": "Chargement du modèle vocal (le premier usage télécharge ~75 MB)…", + "transcribing": "Transcription de la parole…", + "busy": "La génération des sous-titres est déjà en cours.", + "done": "{{count}} sous-titres ajoutés.", + "noneHeard": "Aucune parole n'a été détectée.", + "noAudio": "Cette vidéo ne contient pas d'audio exploitable pour la transcription.", + "failed": "Impossible de générer les sous-titres.", + "truncated": "Seules les {{minutes}} premières minutes ont été transcrites." + }, "emptyState": { "title": "Aucun projet ouvert", "description": "Importez une vidéo pour commencer à éditer, ou chargez un projet OpenScreen existant.", @@ -58,5 +77,17 @@ "couldNotOpenTitle": "Impossible d'ouvrir le fichier", "couldNotOpenMessage": "Le fichier de projet n'a pas pu être ouvert. La vidéo qu'il référence a peut-être été déplacée ou supprimée." } + }, + "regionClipboard": { + "copied": "Attributs de {{region}} copiés", + "pasted": "Attributs de {{region}} collés", + "nothingToCopy": "Sélectionnez une région pour copier ses attributs", + "nothingToPaste": "Aucun attribut copié pour l'instant", + "kinds": { + "zoom": "Zoom", + "speed": "Vitesse", + "annotation": "Annotation", + "blur": "Flou" + } } } diff --git a/src/i18n/locales/fr/launch.json b/src/i18n/locales/fr/launch.json index 0a89376c22..8c0b59e592 100644 --- a/src/i18n/locales/fr/launch.json +++ b/src/i18n/locales/fr/launch.json @@ -9,7 +9,18 @@ "openVideoFile": "Ouvrir un fichier vidéo", "openProject": "Ouvrir un projet", "useVerticalTray": "Utiliser la barre verticale", - "useHorizontalTray": "Utiliser la barre horizontale" + "useHorizontalTray": "Utiliser la barre horizontale", + "openNotes": "Ouvrir les notes", + "openNotesPlaceholder": "Prenez des notes ici...", + "notesToolbar": { + "bold": "Gras", + "italic": "Italique", + "strikethrough": "Barré", + "bulletList": "Liste à puces", + "numberedList": "Liste numérotée", + "blockquote": "Citation", + "codeBlock": "Bloc de code" + } }, "audio": { "enableSystemAudio": "Activer l'audio système", @@ -37,7 +48,8 @@ "defaultSourceName": "Écran" }, "recording": { - "selectSource": "Veuillez sélectionner une source à enregistrer" + "selectSource": "Veuillez sélectionner une source à enregistrer", + "saving": "Sauvegarde..." }, "language": "Langue", "systemLanguagePrompt": { diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index f5224afdd3..0b67cd2661 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -45,7 +45,10 @@ "dualFrame": "Double cadre", "webcamShape": "Forme de la caméra", "webcamSize": "Taille de la caméra", - "noWebcam": "Sans webcam" + "noWebcam": "Sans webcam", + "mirrorWebcam": "Inverser la webcam", + "reactiveWebcam": "Réduire au zoom", + "reactiveWebcamDescription": "La caméra rétrécit doucement pendant le zoom, pour ne pas gêner." }, "effects": { "title": "Effets vidéo", @@ -162,8 +165,7 @@ "tipShiftTabCycle": "Utilisez Shift+Tab pour cycler en sens inverse.", "invalidImageType": "Type de fichier invalide", "imageFormatsOnly": "Veuillez téléverser un fichier image JPG, PNG, GIF ou WebP.", - "imageUploadSuccess": "Image téléversée avec succès !", - "failedImageUpload": "Échec du téléversement de l'image" + "imageUploadSuccess": "Image téléversée avec succès !" }, "fontStyles": { "classic": "Classique", @@ -206,6 +208,8 @@ "errorLoadFailed": "La police n'a pas pu être chargée. Vérifiez que l'URL Google Fonts est correcte." }, "cursor": { + "theme": "Style du curseur", + "themeDefault": "Par défaut", "show": "Afficher le curseur", "size": "Taille", "smoothing": "Lissage", diff --git a/src/i18n/locales/fr/shortcuts.json b/src/i18n/locales/fr/shortcuts.json index eec8a5914d..56451fcca9 100644 --- a/src/i18n/locales/fr/shortcuts.json +++ b/src/i18n/locales/fr/shortcuts.json @@ -23,7 +23,9 @@ "addBlur": "Ajouter un flou", "addKeyframe": "Ajouter une image-clé", "deleteSelected": "Supprimer la sélection", - "playPause": "Lecture / Pause" + "playPause": "Lecture / Pause", + "copySelected": "Copier la sélection", + "paste": "Coller" }, "fixedActions": { "undo": "Annuler", diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json index 336d3e6ba8..41e6244ea8 100644 --- a/src/i18n/locales/it/editor.json +++ b/src/i18n/locales/it/editor.json @@ -42,5 +42,36 @@ "cameraNotFound": "Fotocamera non trovata.", "permissionDenied": "Autorizzazione di registrazione negata. Consenti la registrazione dello schermo.", "accessibilityAllowAndRetry": "Consenti l'accesso all'accessibilità per OpenScreen, poi premi di nuovo registra per avviare il conto alla rovescia." + }, + "autoCaptions": { + "button": "Sottotitoli automatici", + "dialogTitle": "Sottotitoli automatici", + "dialogDescription": "Scegli all'incirca quante parole mostrare per ogni sottotitolo. La temporizzazione viene distribuita tra le parole della frase.", + "minWords": "Numero minimo di parole per sottotitolo", + "maxWords": "Numero massimo di parole per sottotitolo", + "wordsCount": "{{count}} parole", + "generate": "Genera", + "dialogCancel": "Annulla", + "generating": "Generazione dei sottotitoli dall'audio…", + "loadingModel": "Caricamento del modello vocale (al primo utilizzo vengono scaricati ~75 MB)…", + "transcribing": "Trascrizione del parlato…", + "busy": "La generazione dei sottotitoli è già in corso.", + "done": "Aggiunti {{count}} sottotitoli.", + "noneHeard": "Nessun parlato rilevato.", + "noAudio": "Questo video non contiene audio utilizzabile per la trascrizione.", + "failed": "Impossibile generare i sottotitoli.", + "truncated": "Sono stati trascritti solo i primi {{minutes}} minuti." + }, + "regionClipboard": { + "copied": "Attributi di {{region}} copiati", + "pasted": "Attributi di {{region}} incollati", + "nothingToCopy": "Seleziona una regione per copiarne gli attributi", + "nothingToPaste": "Nessun attributo copiato", + "kinds": { + "zoom": "Zoom", + "speed": "Velocità", + "annotation": "Annotazione", + "blur": "Sfocatura" + } } } diff --git a/src/i18n/locales/it/launch.json b/src/i18n/locales/it/launch.json index 66c18bc9d2..b46adbd869 100644 --- a/src/i18n/locales/it/launch.json +++ b/src/i18n/locales/it/launch.json @@ -9,7 +9,18 @@ "openVideoFile": "Apri file video", "openProject": "Apri progetto", "useVerticalTray": "Usa barra verticale", - "useHorizontalTray": "Usa barra orizzontale" + "useHorizontalTray": "Usa barra orizzontale", + "openNotes": "Apri note", + "openNotesPlaceholder": "Scrivi le tue note qui...", + "notesToolbar": { + "bold": "Grassetto", + "italic": "Corsivo", + "strikethrough": "Barrato", + "bulletList": "Elenco puntato", + "numberedList": "Elenco numerato", + "blockquote": "Citazione", + "codeBlock": "Blocco di codice" + } }, "audio": { "enableSystemAudio": "Abilita audio di sistema", @@ -37,7 +48,8 @@ "defaultSourceName": "Schermo" }, "recording": { - "selectSource": "Seleziona una sorgente da registrare" + "selectSource": "Seleziona una sorgente da registrare", + "saving": "Salvataggio..." }, "language": "Lingua", "systemLanguagePrompt": { diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index a1c8c56476..9ee07a16a4 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -45,7 +45,10 @@ "dualFrame": "Doppio frame", "noWebcam": "Nessuna webcam", "webcamShape": "Forma fotocamera", - "webcamSize": "Dimensione webcam" + "webcamSize": "Dimensione webcam", + "mirrorWebcam": "Specchia webcam", + "reactiveWebcam": "Riduci con lo zoom", + "reactiveWebcamDescription": "La webcam si riduce dolcemente durante lo zoom, così non intralcia." }, "effects": { "title": "Effetti video", @@ -161,8 +164,7 @@ "tipShiftTabCycle": "Usa Maiusc+Tab per scorrere all'indietro.", "invalidImageType": "Tipo di file non valido", "imageFormatsOnly": "Carica un file immagine JPG, PNG, GIF o WebP.", - "imageUploadSuccess": "Immagine caricata con successo!", - "failedImageUpload": "Impossibile caricare l'immagine" + "imageUploadSuccess": "Immagine caricata con successo!" }, "fontStyles": { "classic": "Classico", @@ -205,6 +207,8 @@ "errorLoadFailed": "Impossibile caricare il font. Verifica che l'URL di Google Fonts sia corretto." }, "cursor": { + "theme": "Stile del cursore", + "themeDefault": "Predefinito", "show": "Mostra cursore", "size": "Dimensione", "smoothing": "Smussatura", diff --git a/src/i18n/locales/it/shortcuts.json b/src/i18n/locales/it/shortcuts.json index 051a88871c..528d149bcb 100644 --- a/src/i18n/locales/it/shortcuts.json +++ b/src/i18n/locales/it/shortcuts.json @@ -23,7 +23,9 @@ "addBlur": "Aggiungi sfocatura", "addKeyframe": "Aggiungi fotogramma chiave", "deleteSelected": "Elimina selezionato", - "playPause": "Riproduci / Pausa" + "playPause": "Riproduci / Pausa", + "copySelected": "Copia selezione", + "paste": "Incolla" }, "fixedActions": { "undo": "Annulla", diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json index 5151d1054e..9713c1a621 100644 --- a/src/i18n/locales/ja-JP/editor.json +++ b/src/i18n/locales/ja-JP/editor.json @@ -44,6 +44,25 @@ "cameraNotFound": "カメラが見つかりません。", "accessibilityAllowAndRetry": "OpenScreenにアクセシビリティアクセスを許可してから、もう一度録画を押してカウントダウンを開始してください。" }, + "autoCaptions": { + "button": "自動キャプション", + "dialogTitle": "自動キャプション", + "dialogDescription": "各キャプションに一度に表示する語数の目安を選びます。タイミングはそのフレーズ内の語に分配されます。", + "minWords": "キャプションあたりの最小語数", + "maxWords": "キャプションあたりの最大語数", + "wordsCount": "{{count}} 語", + "generate": "生成", + "dialogCancel": "キャンセル", + "generating": "音声からキャプションを生成しています…", + "loadingModel": "音声モデルを読み込んでいます(初回利用時は約 75 MB をダウンロードします)…", + "transcribing": "音声を文字起こししています…", + "busy": "キャプションの生成はすでに実行中です。", + "done": "{{count}} 件のキャプションを追加しました。", + "noneHeard": "音声が検出されませんでした。", + "noAudio": "この動画には書き起こしに使える音声がありません。", + "failed": "キャプションを生成できませんでした。", + "truncated": "最初の {{minutes}} 分のみが書き起こされました。" + }, "emptyState": { "title": "プロジェクトが開かれていません", "description": "動画をインポートして編集を開始するか、既存の OpenScreen プロジェクトを読み込んでください。", @@ -58,5 +77,17 @@ "couldNotOpenTitle": "ファイルを開けませんでした", "couldNotOpenMessage": "プロジェクトファイルを開けませんでした。参照している動画が移動または削除された可能性があります。" } + }, + "regionClipboard": { + "copied": "{{region}}の属性をコピーしました", + "pasted": "{{region}}の属性を貼り付けました", + "nothingToCopy": "属性をコピーする領域を選択してください", + "nothingToPaste": "コピーされた属性がありません", + "kinds": { + "zoom": "ズーム", + "speed": "速度", + "annotation": "注釈", + "blur": "ぼかし" + } } } diff --git a/src/i18n/locales/ja-JP/launch.json b/src/i18n/locales/ja-JP/launch.json index 66fd8ee6b8..a9291b02b7 100644 --- a/src/i18n/locales/ja-JP/launch.json +++ b/src/i18n/locales/ja-JP/launch.json @@ -9,7 +9,18 @@ "openVideoFile": "動画ファイルを開く", "openProject": "プロジェクトを開く", "useVerticalTray": "縦型トレイを使用", - "useHorizontalTray": "横型トレイを使用" + "useHorizontalTray": "横型トレイを使用", + "openNotes": "ノートを開く", + "openNotesPlaceholder": "ここにメモを入力...", + "notesToolbar": { + "bold": "太字", + "italic": "斜体", + "strikethrough": "取り消し線", + "bulletList": "箇条書きリスト", + "numberedList": "番号付きリスト", + "blockquote": "引用", + "codeBlock": "コードブロック" + } }, "audio": { "enableSystemAudio": "システム音声を有効にする", @@ -37,7 +48,8 @@ "defaultSourceName": "画面" }, "recording": { - "selectSource": "録画するソースを選択してください" + "selectSource": "録画するソースを選択してください", + "saving": "保存中..." }, "language": "言語", "systemLanguagePrompt": { diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index efecf27e2d..69af9b2e7c 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -45,7 +45,10 @@ "dualFrame": "デュアルフレーム", "webcamShape": "カメラの形状", "webcamSize": "カメラのサイズ", - "noWebcam": "Webカメラなし" + "noWebcam": "Webカメラなし", + "mirrorWebcam": "Webカメラを反転", + "reactiveWebcam": "ズーム時に縮小", + "reactiveWebcamDescription": "ズーム中はカメラがスムーズに縮小し、邪魔になりません。" }, "effects": { "title": "動画効果", @@ -162,8 +165,7 @@ "tipShiftTabCycle": "Shift+Tabキーを使用して逆順に切り替えます。", "invalidImageType": "無効なファイル形式", "imageFormatsOnly": "JPG、PNG、GIF、またはWebP画像ファイルを選択してください。", - "imageUploadSuccess": "画像を読み込みました。", - "failedImageUpload": "画像の読み込みに失敗しました" + "imageUploadSuccess": "画像を読み込みました。" }, "fontStyles": { "classic": "クラシック", @@ -206,6 +208,8 @@ "errorLoadFailed": "フォントを読み込めませんでした。GoogleフォントのURLが正しいことを確認してください。" }, "cursor": { + "theme": "カーソルのスタイル", + "themeDefault": "デフォルト", "show": "カーソルを表示", "size": "サイズ", "smoothing": "スムージング", diff --git a/src/i18n/locales/ja-JP/shortcuts.json b/src/i18n/locales/ja-JP/shortcuts.json index 1d574198e6..fc5d6de324 100644 --- a/src/i18n/locales/ja-JP/shortcuts.json +++ b/src/i18n/locales/ja-JP/shortcuts.json @@ -23,7 +23,9 @@ "addBlur": "ぼかしを追加", "addKeyframe": "キーフレームを追加", "deleteSelected": "選択を削除", - "playPause": "再生 / 一時停止" + "playPause": "再生 / 一時停止", + "copySelected": "選択をコピー", + "paste": "貼り付け" }, "fixedActions": { "undo": "元に戻す", diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json index 23990c3863..f3e995895f 100644 --- a/src/i18n/locales/ko-KR/editor.json +++ b/src/i18n/locales/ko-KR/editor.json @@ -44,6 +44,25 @@ "cameraNotFound": "카메라를 찾을 수 없습니다.", "accessibilityAllowAndRetry": "OpenScreen의 손쉬운 사용 접근을 허용한 다음, 카운트다운을 시작하려면 다시 녹화를 누르세요." }, + "autoCaptions": { + "button": "자동 자막", + "dialogTitle": "자동 자막", + "dialogDescription": "각 자막에 한 번에 표시할 단어 수의 대략적인 값을 선택하세요. 타이밍은 해당 구문의 단어들에 나뉩니다.", + "minWords": "자막당 최소 단어 수", + "maxWords": "자막당 최대 단어 수", + "wordsCount": "{{count}}개 단어", + "generate": "생성", + "dialogCancel": "취소", + "generating": "오디오에서 자막을 생성하는 중…", + "loadingModel": "음성 모델을 불러오는 중(첫 사용 시 약 75MB 다운로드)…", + "transcribing": "음성을 전사하는 중…", + "busy": "자막 생성이 이미 진행 중입니다.", + "done": "자막 {{count}}개를 추가했습니다.", + "noneHeard": "음성이 감지되지 않았습니다.", + "noAudio": "이 동영상에는 전사에 사용할 수 있는 음성이 없습니다.", + "failed": "자막을 생성할 수 없습니다.", + "truncated": "처음 {{minutes}}분만 전사되었습니다." + }, "emptyState": { "title": "열린 프로젝트 없음", "description": "동영상을 가져와 편집을 시작하거나 기존 OpenScreen 프로젝트를 불러오세요.", @@ -58,5 +77,17 @@ "couldNotOpenTitle": "파일을 열 수 없음", "couldNotOpenMessage": "프로젝트 파일을 열 수 없습니다. 참조된 동영상이 이동되었거나 삭제되었을 수 있습니다." } + }, + "regionClipboard": { + "copied": "{{region}} 속성을 복사했습니다", + "pasted": "{{region}} 속성을 붙여넣었습니다", + "nothingToCopy": "속성을 복사할 영역을 선택하세요", + "nothingToPaste": "복사된 속성이 없습니다", + "kinds": { + "zoom": "줌", + "speed": "속도", + "annotation": "주석", + "blur": "블러" + } } } diff --git a/src/i18n/locales/ko-KR/launch.json b/src/i18n/locales/ko-KR/launch.json index 361ce8657c..3c20f22363 100644 --- a/src/i18n/locales/ko-KR/launch.json +++ b/src/i18n/locales/ko-KR/launch.json @@ -9,7 +9,18 @@ "openVideoFile": "비디오 파일 열기", "openProject": "프로젝트 열기", "useVerticalTray": "세로 트레이 사용", - "useHorizontalTray": "가로 트레이 사용" + "useHorizontalTray": "가로 트레이 사용", + "openNotes": "노트 열기", + "openNotesPlaceholder": "여기에 메모를 입력하세요...", + "notesToolbar": { + "bold": "굵게", + "italic": "기울임꼴", + "strikethrough": "취소선", + "bulletList": "글머리 기호 목록", + "numberedList": "번호 매기기 목록", + "blockquote": "인용문", + "codeBlock": "코드 블록" + } }, "audio": { "enableSystemAudio": "시스템 오디오 활성화", @@ -37,7 +48,8 @@ "defaultSourceName": "화면" }, "recording": { - "selectSource": "녹화할 소스를 선택해 주세요" + "selectSource": "녹화할 소스를 선택해 주세요", + "saving": "저장 중..." }, "language": "언어", "systemLanguagePrompt": { diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index 5921ca3e2d..808fd71a9f 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -45,7 +45,10 @@ "webcamShape": "카메라 모양", "webcamSize": "웹캠 크기", "dualFrame": "듀얼 프레임", - "noWebcam": "웹캠 없음" + "noWebcam": "웹캠 없음", + "mirrorWebcam": "웹캠 미러링", + "reactiveWebcam": "확대 시 축소", + "reactiveWebcamDescription": "확대하는 동안 카메라가 부드럽게 작아져 방해되지 않습니다." }, "effects": { "title": "비디오 효과", @@ -162,8 +165,7 @@ "tipShiftTabCycle": "Shift+Tab으로 역방향 순환할 수 있습니다.", "invalidImageType": "지원하지 않는 파일 형식입니다", "imageFormatsOnly": "JPG, PNG, GIF 또는 WebP 이미지 파일을 업로드해 주세요.", - "imageUploadSuccess": "이미지가 성공적으로 업로드되었습니다!", - "failedImageUpload": "이미지 업로드에 실패했습니다" + "imageUploadSuccess": "이미지가 성공적으로 업로드되었습니다!" }, "fontStyles": { "classic": "클래식", @@ -206,6 +208,8 @@ "errorLoadFailed": "폰트를 불러올 수 없습니다. Google Fonts URL이 올바른지 확인해 주세요." }, "cursor": { + "theme": "커서 스타일", + "themeDefault": "기본", "show": "커서 표시", "size": "크기", "smoothing": "부드러움", diff --git a/src/i18n/locales/ko-KR/shortcuts.json b/src/i18n/locales/ko-KR/shortcuts.json index ddac29542d..53a2de9686 100644 --- a/src/i18n/locales/ko-KR/shortcuts.json +++ b/src/i18n/locales/ko-KR/shortcuts.json @@ -23,7 +23,9 @@ "addKeyframe": "키프레임 추가", "deleteSelected": "선택 항목 삭제", "playPause": "재생 / 일시정지", - "addBlur": "블러 추가" + "addBlur": "블러 추가", + "copySelected": "선택 항목 복사", + "paste": "붙여넣기" }, "fixedActions": { "undo": "실행 취소", diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json index 7e3f695314..2188ec15a1 100644 --- a/src/i18n/locales/pt-BR/editor.json +++ b/src/i18n/locales/pt-BR/editor.json @@ -41,5 +41,36 @@ "cameraDisconnected": "Webcam desconectada.", "cameraNotFound": "Câmera não encontrada.", "permissionDenied": "Permissão de gravação negada. Por favor, permita a gravação de tela." + }, + "autoCaptions": { + "button": "Legendas automáticas", + "dialogTitle": "Legendas automáticas", + "dialogDescription": "Escolha aproximadamente quantas palavras cada legenda mostra de cada vez. O tempo é distribuído entre as palavras da frase.", + "minWords": "Mínimo de palavras por legenda", + "maxWords": "Máximo de palavras por legenda", + "wordsCount": "{{count}} palavras", + "generate": "Gerar", + "dialogCancel": "Cancelar", + "generating": "Gerando legendas a partir do áudio…", + "loadingModel": "Carregando o modelo de fala (o primeiro uso baixa ~75 MB)…", + "transcribing": "Transcrevendo a fala…", + "busy": "A geração de legendas já está em andamento.", + "done": "{{count}} legendas adicionadas.", + "noneHeard": "Nenhuma fala foi detectada.", + "noAudio": "Este vídeo não tem áudio utilizável para transcrição.", + "failed": "Não foi possível gerar as legendas.", + "truncated": "Apenas os primeiros {{minutes}} minutos foram transcritos." + }, + "regionClipboard": { + "copied": "Atributos de {{region}} copiados", + "pasted": "Atributos de {{region}} colados", + "nothingToCopy": "Selecione uma região para copiar seus atributos", + "nothingToPaste": "Nenhum atributo copiado ainda", + "kinds": { + "zoom": "Zoom", + "speed": "Velocidade", + "annotation": "Anotação", + "blur": "Desfoque" + } } } diff --git a/src/i18n/locales/pt-BR/launch.json b/src/i18n/locales/pt-BR/launch.json index 1853809209..79d44fc619 100644 --- a/src/i18n/locales/pt-BR/launch.json +++ b/src/i18n/locales/pt-BR/launch.json @@ -7,7 +7,18 @@ "pauseRecording": "Pausar gravação", "resumeRecording": "Retomar gravação", "openVideoFile": "Abrir arquivo de vídeo", - "openProject": "Abrir projeto" + "openProject": "Abrir projeto", + "openNotes": "Abrir notas", + "openNotesPlaceholder": "Escreva suas notas aqui...", + "notesToolbar": { + "bold": "Negrito", + "italic": "Itálico", + "strikethrough": "Tachado", + "bulletList": "Lista com marcadores", + "numberedList": "Lista numerada", + "blockquote": "Citação", + "codeBlock": "Bloco de código" + } }, "audio": { "enableSystemAudio": "Ativar áudio do sistema", @@ -35,7 +46,8 @@ "defaultSourceName": "Tela" }, "recording": { - "selectSource": "Por favor, selecione uma fonte para gravar" + "selectSource": "Por favor, selecione uma fonte para gravar", + "saving": "Salvando..." }, "language": "Idioma", "systemLanguagePrompt": { diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index 6788bdb2ee..eddd1c43b3 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -44,7 +44,9 @@ "dualFrame": "Quadro Duplo", "noWebcam": "Sem Webcam", "webcamShape": "Formato da Câmera", - "webcamSize": "Tamanho da Webcam" + "webcamSize": "Tamanho da Webcam", + "reactiveWebcam": "Encolher ao ampliar", + "reactiveWebcamDescription": "A câmera diminui suavemente enquanto o vídeo está ampliado, para não atrapalhar." }, "effects": { "title": "Efeitos de Vídeo", @@ -159,8 +161,7 @@ "tipShiftTabCycle": "Use Shift+Tab para alternar para trás.", "invalidImageType": "Tipo de imagem inválido", "imageFormatsOnly": "Por favor, envie um arquivo de imagem JPG, PNG, GIF ou WebP.", - "imageUploadSuccess": "Imagem enviada com sucesso!", - "failedImageUpload": "Falha ao enviar imagem" + "imageUploadSuccess": "Imagem enviada com sucesso!" }, "fontStyles": { "classic": "Clássico", @@ -191,6 +192,17 @@ "errorTimeout": "A fonte demorou muito para carregar. Por favor, verifique a URL e tente novamente.", "errorLoadFailed": "A fonte não pôde ser carregada. Por favor, verifique se a URL do Google Fonts está correta." }, + "cursor": { + "theme": "Estilo do cursor", + "themeDefault": "Padrão", + "show": "Mostrar cursor", + "size": "Tamanho", + "smoothing": "Suavização", + "motionBlur": "Desfoque de movimento", + "clickBounce": "Rebote ao clicar", + "clipToBounds": "Recortar à tela", + "clipToBoundsDescription": "Mantém o cursor dentro do quadro do vídeo. Desative para permitir que o cursor ultrapasse as bordas — útil ao aplicar zoom ou ao deslocar." + }, "language": { "title": "Idioma" } diff --git a/src/i18n/locales/pt-BR/shortcuts.json b/src/i18n/locales/pt-BR/shortcuts.json index 208cd1dc87..0187ed554e 100644 --- a/src/i18n/locales/pt-BR/shortcuts.json +++ b/src/i18n/locales/pt-BR/shortcuts.json @@ -21,7 +21,9 @@ "addBlur": "Adicionar Desfoque", "addKeyframe": "Adicionar Quadro-chave", "deleteSelected": "Excluir Selecionado", - "playPause": "Reproduzir / Pausar" + "playPause": "Reproduzir / Pausar", + "copySelected": "Copiar seleção", + "paste": "Colar" }, "fixedActions": { "undo": "Desfazer", diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index ff0c80b8b4..c45501c32d 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -44,6 +44,25 @@ "permissionDenied": "Разрешение на запись запрещено. Пожалуйста, разрешите запись экрана.", "accessibilityAllowAndRetry": "Разрешите OpenScreen доступ к Универсальному доступу, затем снова нажмите запись, чтобы начать обратный отсчет." }, + "autoCaptions": { + "button": "Автосубтитры", + "dialogTitle": "Автосубтитры", + "dialogDescription": "Выберите, сколько примерно слов показывать в одном субтитре. Время распределяется между словами фразы.", + "minWords": "Минимум слов в субтитре", + "maxWords": "Максимум слов в субтитре", + "wordsCount": "{{count}} слов", + "generate": "Создать", + "dialogCancel": "Отмена", + "generating": "Создание субтитров из звука…", + "loadingModel": "Загрузка речевой модели (при первом запуске скачивается ~75 МБ)…", + "transcribing": "Распознавание речи…", + "busy": "Создание субтитров уже выполняется.", + "done": "Добавлено субтитров: {{count}}.", + "noneHeard": "Речь не обнаружена.", + "noAudio": "В этом видео нет звука, пригодного для расшифровки.", + "failed": "Не удалось создать субтитры.", + "truncated": "Расшифрованы только первые {{minutes}} мин." + }, "emptyState": { "title": "Нет открытых проектов", "description": "Импортируйте видео для начала редактирования или загрузите существующий проект OpenScreen.", @@ -58,5 +77,17 @@ "couldNotOpenTitle": "Не удалось открыть файл", "couldNotOpenMessage": "Не удалось открыть файл проекта. Видео, на которое он ссылается, возможно, было перемещено или удалено." } + }, + "regionClipboard": { + "copied": "Атрибуты «{{region}}» скопированы", + "pasted": "Атрибуты «{{region}}» вставлены", + "nothingToCopy": "Выберите регион, чтобы скопировать его атрибуты", + "nothingToPaste": "Атрибуты ещё не скопированы", + "kinds": { + "zoom": "Масштаб", + "speed": "Скорость", + "annotation": "Аннотация", + "blur": "Размытие" + } } } diff --git a/src/i18n/locales/ru/launch.json b/src/i18n/locales/ru/launch.json index 9b3361296f..00a227ca4a 100644 --- a/src/i18n/locales/ru/launch.json +++ b/src/i18n/locales/ru/launch.json @@ -9,7 +9,18 @@ "openVideoFile": "Открыть видеофайл", "openProject": "Открыть проект", "useVerticalTray": "Использовать вертикальную панель", - "useHorizontalTray": "Использовать горизонтальную панель" + "useHorizontalTray": "Использовать горизонтальную панель", + "openNotes": "Открыть заметки", + "openNotesPlaceholder": "Пишите заметки здесь...", + "notesToolbar": { + "bold": "Жирный", + "italic": "Курсив", + "strikethrough": "Зачеркнутый", + "bulletList": "Маркированный список", + "numberedList": "Нумерованный список", + "blockquote": "Цитата", + "codeBlock": "Блок кода" + } }, "audio": { "enableSystemAudio": "Включить системное аудио", @@ -33,7 +44,8 @@ "defaultSourceName": "Экран" }, "recording": { - "selectSource": "Пожалуйста, выберите источник для записи" + "selectSource": "Пожалуйста, выберите источник для записи", + "saving": "Сохранение..." }, "language": "Язык", "systemLanguagePrompt": { diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index e086844903..3b83a98dd2 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -45,7 +45,10 @@ "dualFrame": "Двойной кадр", "webcamShape": "Форма камеры", "webcamSize": "Размер веб-камеры", - "noWebcam": "Без веб-камеры" + "noWebcam": "Без веб-камеры", + "mirrorWebcam": "Зеркалить веб-камеру", + "reactiveWebcam": "Уменьшать при зуме", + "reactiveWebcamDescription": "Камера плавно уменьшается во время приближения, чтобы не мешать." }, "effects": { "title": "Видеоэффекты", @@ -162,8 +165,7 @@ "tipShiftTabCycle": "Используйте Shift+Tab для циклического переключения в обратном направлении.", "invalidImageType": "Неверный тип файла", "imageFormatsOnly": "Пожалуйста, загрузите изображение JPG, PNG, GIF или WebP.", - "imageUploadSuccess": "Изображение успешно загружено!", - "failedImageUpload": "Не удалось загрузить изображение" + "imageUploadSuccess": "Изображение успешно загружено!" }, "fontStyles": { "classic": "Классический", @@ -206,6 +208,8 @@ "errorLoadFailed": "Не удалось загрузить шрифт. Пожалуйста, проверьте правильность URL Google Fonts." }, "cursor": { + "theme": "Стиль курсора", + "themeDefault": "По умолчанию", "show": "Показывать курсор", "size": "Размер", "smoothing": "Сглаживание", diff --git a/src/i18n/locales/ru/shortcuts.json b/src/i18n/locales/ru/shortcuts.json index b6e1faa5cc..47ae35f247 100644 --- a/src/i18n/locales/ru/shortcuts.json +++ b/src/i18n/locales/ru/shortcuts.json @@ -23,7 +23,9 @@ "addBlur": "Добавить размытие", "addKeyframe": "Добавить ключевой кадр", "deleteSelected": "Удалить выбранное", - "playPause": "Воспроизведение / Пауза" + "playPause": "Воспроизведение / Пауза", + "copySelected": "Копировать выбранное", + "paste": "Вставить" }, "fixedActions": { "undo": "Отменить", diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json index de45a180f0..32f10b22de 100644 --- a/src/i18n/locales/tr/editor.json +++ b/src/i18n/locales/tr/editor.json @@ -44,6 +44,25 @@ "cancel": "İptal", "confirm": "Onayla" }, + "autoCaptions": { + "button": "Otomatik altyazılar", + "dialogTitle": "Otomatik altyazılar", + "dialogDescription": "Her altyazının aynı anda yaklaşık kaç kelime göstermesini istediğinizi seçin. Zamanlama, o ifadedeki kelimelere dağıtılır.", + "minWords": "Altyazı başına en az kelime", + "maxWords": "Altyazı başına en fazla kelime", + "wordsCount": "{{count}} kelime", + "generate": "Oluştur", + "dialogCancel": "İptal", + "generating": "Sesten altyazılar oluşturuluyor…", + "loadingModel": "Konuşma modeli yükleniyor (ilk kullanımda ~75 MB indirilir)…", + "transcribing": "Konuşma yazıya dökülüyor…", + "busy": "Altyazı oluşturma zaten devam ediyor.", + "done": "{{count}} altyazı eklendi.", + "noneHeard": "Konuşma algılanmadı.", + "noAudio": "Bu videoda yazıya dökülebilecek kullanılabilir bir ses yok.", + "failed": "Altyazılar oluşturulamadı.", + "truncated": "Yalnızca ilk {{minutes}} dakika yazıya döküldü." + }, "emptyState": { "title": "Açık proje yok", "description": "Düzenlemeye başlamak için bir video içe aktarın veya mevcut bir OpenScreen projesi yükleyin.", @@ -58,5 +77,17 @@ "couldNotOpenTitle": "Dosya Açılamadı", "couldNotOpenMessage": "Proje dosyası açılamadı. Başvurulan video taşınmış veya silinmiş olabilir." } + }, + "regionClipboard": { + "copied": "{{region}} öznitelikleri kopyalandı", + "pasted": "{{region}} öznitelikleri yapıştırıldı", + "nothingToCopy": "Özniteliklerini kopyalamak için bir bölge seçin", + "nothingToPaste": "Henüz öznitelik kopyalanmadı", + "kinds": { + "zoom": "Yakınlaştırma", + "speed": "Hız", + "annotation": "Açıklama", + "blur": "Bulanıklık" + } } } diff --git a/src/i18n/locales/tr/launch.json b/src/i18n/locales/tr/launch.json index 921a52634c..b670178a7a 100644 --- a/src/i18n/locales/tr/launch.json +++ b/src/i18n/locales/tr/launch.json @@ -9,7 +9,18 @@ "openVideoFile": "Video dosyası aç", "openProject": "Proje aç", "useVerticalTray": "Dikey araç çubuğunu kullan", - "useHorizontalTray": "Yatay araç çubuğunu kullan" + "useHorizontalTray": "Yatay araç çubuğunu kullan", + "openNotes": "Notları aç", + "openNotesPlaceholder": "Notlarınızı buraya yazın...", + "notesToolbar": { + "bold": "Kalın", + "italic": "İtalik", + "strikethrough": "Üstü çizili", + "bulletList": "Madde işaretli liste", + "numberedList": "Numaralı liste", + "blockquote": "Alıntı", + "codeBlock": "Kod bloğu" + } }, "audio": { "enableSystemAudio": "Sistem sesini etkinleştir", @@ -37,7 +48,8 @@ "defaultSourceName": "Ekran" }, "recording": { - "selectSource": "Lütfen kayıt için bir kaynak seçin" + "selectSource": "Lütfen kayıt için bir kaynak seçin", + "saving": "Kaydediliyor..." }, "language": "Dil", "systemLanguagePrompt": { diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index 7155a08421..b19fd80753 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -45,7 +45,10 @@ "webcamShape": "Kamera Şekli", "dualFrame": "Çift Kare", "webcamSize": "Webcam Boyutu", - "noWebcam": "Web kamerası yok" + "noWebcam": "Web kamerası yok", + "mirrorWebcam": "Web kamerasını aynala", + "reactiveWebcam": "Yakınlaştırınca küçült", + "reactiveWebcamDescription": "Video yakınlaştırıldığında kamera yumuşakça küçülür, böylece yoldan çekilir." }, "effects": { "title": "Video Efektleri", @@ -158,7 +161,6 @@ "invalidImageType": "Geçersiz dosya türü", "imageFormatsOnly": "Lütfen bir JPG, PNG, GIF veya WebP görüntü dosyası yükleyin.", "imageUploadSuccess": "Görüntü başarıyla yüklendi!", - "failedImageUpload": "Görüntü yüklenemedi", "blurColor": "Bulanıklık Rengi", "blurColorBlack": "Siyah", "blurColorWhite": "Beyaz", @@ -197,6 +199,8 @@ "errorLoadFailed": "Yazı tipi yüklenemedi. Lütfen Google Fonts URL'sinin doğruluğunu kontrol edin." }, "cursor": { + "theme": "İmleç Stili", + "themeDefault": "Varsayılan", "show": "İmleci Göster", "size": "Boyut", "smoothing": "Yumuşatma", diff --git a/src/i18n/locales/tr/shortcuts.json b/src/i18n/locales/tr/shortcuts.json index 62cdfaf5f1..24c1ea5021 100644 --- a/src/i18n/locales/tr/shortcuts.json +++ b/src/i18n/locales/tr/shortcuts.json @@ -23,7 +23,9 @@ "addBlur": "Bulanik Ekle", "addKeyframe": "Anahtar Kare Ekle", "deleteSelected": "Seçileni Sil", - "playPause": "Oynat / Duraklat" + "playPause": "Oynat / Duraklat", + "copySelected": "Seçileni Kopyala", + "paste": "Yapıştır" }, "fixedActions": { "undo": "Geri Al", diff --git a/src/i18n/locales/vi/editor.json b/src/i18n/locales/vi/editor.json index 1875bb5593..8385996469 100644 --- a/src/i18n/locales/vi/editor.json +++ b/src/i18n/locales/vi/editor.json @@ -44,6 +44,25 @@ "permissionDenied": "Quyền ghi hình bị từ chối. Vui lòng cho phép ghi màn hình.", "accessibilityAllowAndRetry": "Cho phép OpenScreen truy cập Trợ năng, sau đó nhấn ghi lại để bắt đầu đếm ngược." }, + "autoCaptions": { + "button": "Phụ đề tự động", + "dialogTitle": "Phụ đề tự động", + "dialogDescription": "Chọn khoảng bao nhiêu từ mỗi phụ đề hiển thị cùng lúc. Thời gian được phân bổ cho các từ trong cụm từ đó.", + "minWords": "Số từ tối thiểu mỗi phụ đề", + "maxWords": "Số từ tối đa mỗi phụ đề", + "wordsCount": "{{count}} từ", + "generate": "Tạo", + "dialogCancel": "Hủy", + "generating": "Đang tạo phụ đề từ âm thanh…", + "loadingModel": "Đang tải mô hình giọng nói (lần đầu sử dụng sẽ tải ~75 MB)…", + "transcribing": "Đang chuyển lời nói thành văn bản…", + "busy": "Việc tạo phụ đề đang được tiến hành.", + "done": "Đã thêm {{count}} phụ đề.", + "noneHeard": "Không phát hiện thấy lời nói.", + "noAudio": "Video này không có âm thanh dùng được để chuyển thành văn bản.", + "failed": "Không thể tạo phụ đề.", + "truncated": "Chỉ {{minutes}} phút đầu tiên được chuyển thành văn bản." + }, "emptyState": { "title": "Không có dự án nào được mở", "description": "Nhập video để bắt đầu chỉnh sửa hoặc tải một dự án OpenScreen hiện có.", @@ -58,5 +77,17 @@ "couldNotOpenTitle": "Không thể mở tệp", "couldNotOpenMessage": "Không thể mở tệp dự án. Video mà nó tham chiếu có thể đã bị di chuyển hoặc xóa." } + }, + "regionClipboard": { + "copied": "Đã sao chép thuộc tính {{region}}", + "pasted": "Đã dán thuộc tính {{region}}", + "nothingToCopy": "Chọn một vùng để sao chép thuộc tính của nó", + "nothingToPaste": "Chưa sao chép thuộc tính nào", + "kinds": { + "zoom": "Thu phóng", + "speed": "Tốc độ", + "annotation": "Văn bản", + "blur": "Làm mờ" + } } } diff --git a/src/i18n/locales/vi/launch.json b/src/i18n/locales/vi/launch.json index efbaad5d4d..203f2b4144 100644 --- a/src/i18n/locales/vi/launch.json +++ b/src/i18n/locales/vi/launch.json @@ -9,7 +9,18 @@ "openVideoFile": "Mở tệp video", "openProject": "Mở dự án", "useVerticalTray": "Dùng khay dọc", - "useHorizontalTray": "Dùng khay ngang" + "useHorizontalTray": "Dùng khay ngang", + "openNotes": "Mở ghi chú", + "openNotesPlaceholder": "Ghi chú của bạn tại đây...", + "notesToolbar": { + "bold": "In đậm", + "italic": "In nghiêng", + "strikethrough": "Gạch ngang", + "bulletList": "Danh sách dấu đầu dòng", + "numberedList": "Danh sách đánh số", + "blockquote": "Trích dẫn", + "codeBlock": "Khối mã" + } }, "audio": { "enableSystemAudio": "Bật âm thanh hệ thống", @@ -33,7 +44,8 @@ "defaultSourceName": "Màn hình" }, "recording": { - "selectSource": "Vui lòng chọn một nguồn để ghi" + "selectSource": "Vui lòng chọn một nguồn để ghi", + "saving": "Đang lưu..." }, "language": "Ngôn ngữ", "systemLanguagePrompt": { diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index 60139ccb65..fe1585e75c 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -45,7 +45,10 @@ "dualFrame": "Khung kép", "webcamShape": "Hình dạng máy ảnh", "webcamSize": "Kích thước Webcam", - "noWebcam": "Không có webcam" + "noWebcam": "Không có webcam", + "mirrorWebcam": "Lật webcam", + "reactiveWebcam": "Thu nhỏ khi phóng to", + "reactiveWebcamDescription": "Camera thu nhỏ mượt mà khi video được phóng to, để không che khuất." }, "effects": { "title": "Hiệu ứng video", @@ -163,7 +166,6 @@ "invalidImageType": "Loại tệp không hợp lệ", "imageFormatsOnly": "Vui lòng tải lên tệp hình ảnh JPG, PNG, GIF hoặc WebP.", "imageUploadSuccess": "Tải lên hình ảnh thành công!", - "failedImageUpload": "Tải lên hình ảnh thất bại", "colorPalette": "Bảng màu", "colorWheel": "Vòng màu" }, @@ -197,6 +199,8 @@ "errorLoadFailed": "Không thể tải phông chữ. Vui lòng xác minh URL Google Fonts là chính xác." }, "cursor": { + "theme": "Kiểu con trỏ", + "themeDefault": "Mặc định", "show": "Hiện con trỏ", "size": "Kích thước", "smoothing": "Làm mượt", diff --git a/src/i18n/locales/vi/shortcuts.json b/src/i18n/locales/vi/shortcuts.json index 46ec9b5e58..a1226b0ef8 100644 --- a/src/i18n/locales/vi/shortcuts.json +++ b/src/i18n/locales/vi/shortcuts.json @@ -23,7 +23,9 @@ "addBlur": "Thêm Làm mờ", "addKeyframe": "Thêm Khung hình chính", "deleteSelected": "Xóa mục đã chọn", - "playPause": "Phát / Tạm dừng" + "playPause": "Phát / Tạm dừng", + "copySelected": "Sao chép mục đã chọn", + "paste": "Dán" }, "fixedActions": { "undo": "Hoàn tác", diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index d11f1dd954..ae15accef5 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -44,6 +44,25 @@ "permissionDenied": "录屏权限被拒绝。请允许屏幕录制。", "accessibilityAllowAndRetry": "允许 OpenScreen 使用辅助功能权限,然后再次按录制以开始倒计时。" }, + "autoCaptions": { + "button": "自动字幕", + "dialogTitle": "自动字幕", + "dialogDescription": "大致选择每条字幕一次显示多少个字词。时间会在该语句内的字词之间分配。", + "minWords": "每条字幕的最少字数", + "maxWords": "每条字幕的最多字数", + "wordsCount": "{{count}} 个词", + "generate": "生成", + "dialogCancel": "取消", + "generating": "正在从音频生成字幕…", + "loadingModel": "正在加载语音模型(首次使用将下载约 75 MB)…", + "transcribing": "正在转写语音…", + "busy": "字幕生成已在进行中。", + "done": "已添加 {{count}} 条字幕。", + "noneHeard": "未检测到语音。", + "noAudio": "此视频没有可用于转写的音频。", + "failed": "无法生成字幕。", + "truncated": "仅转写了最前 {{minutes}} 分钟。" + }, "emptyState": { "title": "未打开任何项目", "description": "导入视频开始编辑,或加载已有的 OpenScreen 项目。", @@ -58,5 +77,17 @@ "couldNotOpenTitle": "无法打开文件", "couldNotOpenMessage": "无法打开项目文件。它引用的视频可能已被移动或删除。" } + }, + "regionClipboard": { + "copied": "已复制{{region}}属性", + "pasted": "已粘贴{{region}}属性", + "nothingToCopy": "选择一个区域以复制其属性", + "nothingToPaste": "尚未复制任何属性", + "kinds": { + "zoom": "缩放", + "speed": "速度", + "annotation": "标注", + "blur": "模糊" + } } } diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json index c089965856..f78a611c01 100644 --- a/src/i18n/locales/zh-CN/launch.json +++ b/src/i18n/locales/zh-CN/launch.json @@ -9,7 +9,18 @@ "openVideoFile": "打开视频文件", "openProject": "打开项目", "useVerticalTray": "使用竖向托盘", - "useHorizontalTray": "使用横向托盘" + "useHorizontalTray": "使用横向托盘", + "openNotes": "打开笔记", + "openNotesPlaceholder": "在此输入笔记...", + "notesToolbar": { + "bold": "加粗", + "italic": "斜体", + "strikethrough": "删除线", + "bulletList": "无序列表", + "numberedList": "有序列表", + "blockquote": "引用", + "codeBlock": "代码块" + } }, "audio": { "enableSystemAudio": "启用系统音频", @@ -37,7 +48,8 @@ "defaultSourceName": "屏幕" }, "recording": { - "selectSource": "请选择要录制的源" + "selectSource": "请选择要录制的源", + "saving": "正在保存..." }, "language": "语言", "systemLanguagePrompt": { diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 9455bf5813..d1e7c0136e 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -45,7 +45,10 @@ "dualFrame": "双画框", "webcamShape": "摄像头形状", "webcamSize": "摄像头大小", - "noWebcam": "无摄像头" + "noWebcam": "无摄像头", + "mirrorWebcam": "镜像摄像头", + "reactiveWebcam": "缩放时缩小", + "reactiveWebcamDescription": "放大视频时摄像头会平滑缩小,以免遮挡内容。" }, "effects": { "title": "视频效果", @@ -158,7 +161,6 @@ "invalidImageType": "无效的文件类型", "imageFormatsOnly": "请上传 JPG、PNG、GIF 或 WebP 格式的图片文件。", "imageUploadSuccess": "图片上传成功!", - "failedImageUpload": "上传图片失败", "blurColor": "模糊颜色", "blurColorBlack": "黑色", "blurColorWhite": "白色", @@ -197,6 +199,8 @@ "errorLoadFailed": "无法加载该字体。请确认 Google Fonts URL 是否正确。" }, "cursor": { + "theme": "光标样式", + "themeDefault": "默认", "show": "显示光标", "size": "大小", "smoothing": "平滑", diff --git a/src/i18n/locales/zh-CN/shortcuts.json b/src/i18n/locales/zh-CN/shortcuts.json index eb357e0e38..b3204cb2b4 100644 --- a/src/i18n/locales/zh-CN/shortcuts.json +++ b/src/i18n/locales/zh-CN/shortcuts.json @@ -23,7 +23,9 @@ "addBlur": "添加模糊", "addKeyframe": "添加关键帧", "deleteSelected": "删除所选", - "playPause": "播放 / 暂停" + "playPause": "播放 / 暂停", + "copySelected": "复制所选", + "paste": "粘贴" }, "fixedActions": { "undo": "撤销", diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index 1315187131..26d44016c3 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -44,6 +44,25 @@ "cameraNotFound": "找不到攝影機。", "accessibilityAllowAndRetry": "允許 OpenScreen 使用輔助使用權限,然後再次按下錄製以開始倒數。" }, + "autoCaptions": { + "button": "自動字幕", + "dialogTitle": "自動字幕", + "dialogDescription": "大致選擇每條字幕一次顯示多少字詞。時間會在該語句內的字詞之間分配。", + "minWords": "每條字幕的最少字數", + "maxWords": "每條字幕的最多字數", + "wordsCount": "{{count}} 個詞", + "generate": "產生", + "dialogCancel": "取消", + "generating": "正在從音訊產生字幕…", + "loadingModel": "正在載入語音模型(首次使用將下載約 75 MB)…", + "transcribing": "正在轉錄語音…", + "busy": "字幕產生已在進行中。", + "done": "已新增 {{count}} 條字幕。", + "noneHeard": "未偵測到語音。", + "noAudio": "此影片沒有可用於轉寫的音訊。", + "failed": "無法產生字幕。", + "truncated": "僅轉寫了最前 {{minutes}} 分鐘。" + }, "emptyState": { "title": "未開啟任何專案", "description": "匯入影片以開始編輯,或載入現有的 OpenScreen 專案。", @@ -58,5 +77,17 @@ "couldNotOpenTitle": "無法開啟檔案", "couldNotOpenMessage": "無法開啟專案檔案。它所參照的影片可能已被移動或刪除。" } + }, + "regionClipboard": { + "copied": "已複製{{region}}屬性", + "pasted": "已貼上{{region}}屬性", + "nothingToCopy": "選擇一個區域以複製其屬性", + "nothingToPaste": "尚未複製任何屬性", + "kinds": { + "zoom": "縮放", + "speed": "速度", + "annotation": "文字", + "blur": "模糊" + } } } diff --git a/src/i18n/locales/zh-TW/launch.json b/src/i18n/locales/zh-TW/launch.json index d0b529ebe9..6887546d92 100644 --- a/src/i18n/locales/zh-TW/launch.json +++ b/src/i18n/locales/zh-TW/launch.json @@ -9,7 +9,18 @@ "openVideoFile": "開啟影片檔案", "openProject": "開啟專案", "useVerticalTray": "使用直向托盤", - "useHorizontalTray": "使用橫向托盤" + "useHorizontalTray": "使用橫向托盤", + "openNotes": "開啟筆記", + "openNotesPlaceholder": "在此輸入筆記...", + "notesToolbar": { + "bold": "粗體", + "italic": "斜體", + "strikethrough": "刪除線", + "bulletList": "項目符號清單", + "numberedList": "編號清單", + "blockquote": "引言", + "codeBlock": "程式碼區塊" + } }, "audio": { "enableSystemAudio": "啟用系統音訊", @@ -37,7 +48,8 @@ "defaultSourceName": "螢幕" }, "recording": { - "selectSource": "請選擇要錄製的來源" + "selectSource": "請選擇要錄製的來源", + "saving": "正在儲存..." }, "language": "語言", "systemLanguagePrompt": { diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 44058e7b3c..5ec3158193 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -46,7 +46,10 @@ "dualFrame": "雙畫框", "webcamShape": "攝影機形狀", "webcamSize": "攝影機大小", - "noWebcam": "無網路攝影機" + "noWebcam": "無網路攝影機", + "mirrorWebcam": "鏡像攝影機", + "reactiveWebcam": "縮放時縮小", + "reactiveWebcamDescription": "放大影片時鏡頭會平滑縮小,以免遮擋內容。" }, "effects": { "title": "影片效果", @@ -159,7 +162,6 @@ "invalidImageType": "無效的檔案類型", "imageFormatsOnly": "請上傳 JPG、PNG、GIF 或 WebP 格式的圖片檔案。", "imageUploadSuccess": "圖片上傳成功!", - "failedImageUpload": "上傳圖片失敗", "blurColor": "模糊顏色", "blurColorBlack": "黑色", "blurColorWhite": "白色", @@ -198,6 +200,8 @@ "errorLoadFailed": "無法載入該字體。請確認 Google Fonts URL 是否正確。" }, "cursor": { + "theme": "游標樣式", + "themeDefault": "預設", "show": "顯示游標", "size": "大小", "smoothing": "平滑", diff --git a/src/i18n/locales/zh-TW/shortcuts.json b/src/i18n/locales/zh-TW/shortcuts.json index 34ab7de81f..65f148478d 100644 --- a/src/i18n/locales/zh-TW/shortcuts.json +++ b/src/i18n/locales/zh-TW/shortcuts.json @@ -23,7 +23,9 @@ "addBlur": "新增模糊", "addKeyframe": "新增關鍵影格", "deleteSelected": "刪除所選", - "playPause": "播放 / 暫停" + "playPause": "播放 / 暫停", + "copySelected": "複製所選", + "paste": "貼上" }, "fixedActions": { "undo": "復原", diff --git a/src/index.css b/src/index.css index 61621c7506..eb7cb83842 100644 --- a/src/index.css +++ b/src/index.css @@ -80,7 +80,11 @@ flex-direction: column; padding: 14px; background: - radial-gradient(circle at 18% 0%, rgba(52, 178, 123, 0.08), transparent 30%), + radial-gradient( + circle at 18% 0%, + rgba(52, 178, 123, 0.08), + transparent 30% + ), linear-gradient(180deg, #08090b 0%, #050606 100%); } @@ -101,7 +105,11 @@ .editor-timeline-panel, .editor-inspector-shell { background: - linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.012)), + linear-gradient( + 180deg, + rgba(255, 255, 255, 0.035), + rgba(255, 255, 255, 0.012) + ), #090a0c; border: 1px solid rgba(255, 255, 255, 0.075); border-radius: 18px; @@ -305,4 +313,19 @@ input[type="range"]::-moz-range-thumb:active { transform: scale(1.25); } + + /* Hide scrollbar in Chrome, Safari, and Opera */ + .no-scrollbar::-webkit-scrollbar { + display: none; + } + + /* Hide scrollbar in Firefox */ + .no-scrollbar { + scrollbar-width: none; + } + + /* Hide scrollbar in IE and Edge */ + .no-scrollbar { + -ms-overflow-style: none; + } } diff --git a/src/lib/captioning/annotationsFromCaptions.test.ts b/src/lib/captioning/annotationsFromCaptions.test.ts new file mode 100644 index 0000000000..bbf26fed29 --- /dev/null +++ b/src/lib/captioning/annotationsFromCaptions.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from "vitest"; + +import { + captionSegmentsToAnnotationRegions, + groupPhraseCaptionSegmentsIntoLines, + groupTimedCaptionWordsIntoLines, + reconcileAutoCaptionTimelineGaps, +} from "./annotationsFromCaptions"; + +describe("groupPhraseCaptionSegmentsIntoLines", () => { + it("preserves phrase boundaries when formatting phrase-timestamp captions", () => { + const lines = groupPhraseCaptionSegmentsIntoLines( + [ + { startSec: 0, endSec: 0.5, text: "alpha beta" }, + { startSec: 0.62, endSec: 1.6, text: "gamma delta" }, + ], + 2, + 2, + ); + + expect(lines).toHaveLength(2); + expect(lines[0]).toMatchObject({ text: "alpha beta", startSec: 0 }); + expect(lines[1]).toMatchObject({ text: "gamma delta", startSec: 0.62 }); + expect(lines[0]!.endSec).toBeLessThanOrEqual(0.62); + }); + + it("slices a single merged phrase into timed caption lines by word bounds", () => { + const lines = groupPhraseCaptionSegmentsIntoLines( + [{ startSec: 0, endSec: 1, text: "alpha beta gamma delta" }], + 2, + 2, + ); + + expect(lines).toHaveLength(2); + expect(lines[0]).toMatchObject({ + startSec: 0, + endSec: 0.5, + text: "alpha beta", + }); + expect(lines[1]).toMatchObject({ + startSec: 0.5, + endSec: 1, + text: "gamma delta", + }); + }); +}); + +describe("captionSegmentsToAnnotationRegions", () => { + it("uses raw phrase timing instead of shifting caption boundaries", () => { + const { regions } = captionSegmentsToAnnotationRegions( + [ + { startSec: 0, endSec: 0.5, text: "first second" }, + { startSec: 0.62, endSec: 1.2, text: "third fourth" }, + ], + 1, + 1, + { minWordsPerCaption: 2, maxWordsPerCaption: 2, timestampGranularity: "phrase" }, + ); + + expect(regions).toHaveLength(2); + expect(regions[0]).toMatchObject({ startMs: 0, endMs: 500 }); + expect(regions[1]).toMatchObject({ startMs: 620, endMs: 1200 }); + }); + + it("preserves empty timeline space when word timestamps contain a real pause", () => { + const lines = groupTimedCaptionWordsIntoLines( + [ + { startSec: 0, endSec: 0.12, text: "first" }, + { startSec: 0.13, endSec: 0.28, text: "caption" }, + { startSec: 0.7, endSec: 0.83, text: "second" }, + { startSec: 0.84, endSec: 0.98, text: "caption" }, + ], + 2, + 2, + ); + + expect(lines).toHaveLength(2); + expect(lines[0]).toMatchObject({ startSec: 0, endSec: 0.28, text: "first caption" }); + expect(lines[1]).toMatchObject({ startSec: 0.7, endSec: 0.98, text: "second caption" }); + }); + + it("preserves repeated words before grouping in word mode", () => { + const { regions } = captionSegmentsToAnnotationRegions( + [ + { startSec: 0, endSec: 0.12, text: "I" }, + { startSec: 0.13, endSec: 0.25, text: "I" }, + ], + 1, + 1, + { minWordsPerCaption: 2, maxWordsPerCaption: 2, timestampGranularity: "word" }, + ); + + expect(regions).toHaveLength(1); + expect(regions[0]).toMatchObject({ content: "I I" }); + }); +}); + +describe("reconcileAutoCaptionTimelineGaps", () => { + it("does not change regions when the minimum enforced gap is zero", () => { + const regions = reconcileAutoCaptionTimelineGaps([ + { + id: "annotation-1", + startMs: 0, + endMs: 120, + type: "text", + content: "one", + annotationSource: "auto-caption", + position: { x: 0, y: 0 }, + size: { width: 10, height: 10 }, + style: { + color: "#fff", + backgroundColor: "transparent", + fontSize: 24, + fontFamily: "Inter", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", + textAlign: "center", + }, + zIndex: 1, + }, + { + id: "manual-1", + startMs: 50, + endMs: 1000, + type: "text", + content: "manual", + position: { x: 10, y: 10 }, + size: { width: 10, height: 10 }, + style: { + color: "#fff", + backgroundColor: "transparent", + fontSize: 24, + fontFamily: "Inter", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", + textAlign: "center", + }, + zIndex: 2, + }, + { + id: "annotation-2", + startMs: 130, + endMs: 300, + type: "text", + content: "two", + annotationSource: "auto-caption", + position: { x: 0, y: 0 }, + size: { width: 10, height: 10 }, + style: { + color: "#fff", + backgroundColor: "transparent", + fontSize: 24, + fontFamily: "Inter", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", + textAlign: "center", + }, + zIndex: 3, + }, + ]); + + expect(regions.find((r) => r.id === "manual-1")).toMatchObject({ + startMs: 50, + endMs: 1000, + }); + expect(regions.find((r) => r.id === "annotation-1")).toMatchObject({ + startMs: 0, + endMs: 120, + }); + expect(regions.find((r) => r.id === "annotation-2")).toMatchObject({ + startMs: 130, + endMs: 300, + }); + }); +}); diff --git a/src/lib/captioning/annotationsFromCaptions.ts b/src/lib/captioning/annotationsFromCaptions.ts new file mode 100644 index 0000000000..c3e22b21d4 --- /dev/null +++ b/src/lib/captioning/annotationsFromCaptions.ts @@ -0,0 +1,604 @@ +import type { AnnotationRegion, AnnotationTextStyle } from "@/components/video-editor/types"; + +import type { CaptionSegment } from "./transcribe"; + +/** Wide lower-third bar; `position.x` is top-left as % of container, so center with (100 - width) / 2. */ +const CAPTION_WIDTH = 92; +const CAPTION_HEIGHT = 12; +const CAPTION_BOTTOM_MARGIN = 2; + +const CAPTION_POSITION = { + x: (100 - CAPTION_WIDTH) / 2, + y: 100 - CAPTION_HEIGHT - CAPTION_BOTTOM_MARGIN, +}; + +const CAPTION_SIZE = { width: CAPTION_WIDTH, height: CAPTION_HEIGHT }; + +const CAPTION_STYLE: AnnotationTextStyle = { + color: "#ffffff", + backgroundColor: "rgba(255, 255, 255, 0)", + fontSize: 24, + fontFamily: "Inter", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", + textAlign: "center", +}; + +/** Nudge caption starts earlier (seconds); Whisper onsets run slightly late. Do not offset ends too, that pulls lines off-screen early. */ +const AUTO_CAPTION_START_BIAS_SEC = 0; + +/** Extra hold after Whisper's segment end (seconds); model end times run early vs trailing vowels. Separate from the start bias. */ +const AUTO_CAPTION_END_HOLD_SEC = 0; + +/** Inside one Whisper phrase, sub-lines can be shorter (do not steal time from neighbors). */ +const WORD_SPLIT_MIN_SPAN_SEC = 0.02; + +/** Brief linger after the last word in a line (seconds); trimmed if it would overlap the next line. */ +const CAPTION_LINE_END_TAIL_SEC = 0; + +/** A real silence between word-level timestamps should start a new caption run. */ +const WORD_RUN_BREAK_GAP_SEC = 0.24; + +/** Min time between consecutive caption regions (seconds); keeps a visible gap so blocks don't read as one clip. Small so short pauses survive. */ +const MIN_CAPTION_TIMELINE_GAP_SEC = 0; + +/** Same text again with almost no gap or overlap; common Whisper/chunk artifact. */ +const DEDUPE_SAME_TEXT_MAX_GAP_SEC = 0.55; + +export const SAME_CONTENT_ECHO_MAX_GAP_SEC = 1.15; + +function normalizeCaptionKey(text: string): string { + return text + .trim() + .replace(/\s+/g, " ") + .replace(/[\u2018\u2019]/g, "'") + .replace(/[\u201C\u201D]/g, '"') + .toLowerCase() + .replace(/[.!?,;:]+$/g, ""); +} + +/** Legacy echo-collapse helper kept for reference while phrase timing uses raw model spans. */ +export function collapseSameContentEchoes(segments: CaptionSegment[]): CaptionSegment[] { + const sorted = [...segments] + .filter((s) => s.text.trim()) + .sort((a, b) => a.startSec - b.startSec || a.endSec - b.endSec); + const out: CaptionSegment[] = []; + const lastIndexByKey = new Map(); + + for (const seg of sorted) { + const key = normalizeCaptionKey(seg.text); + const hit = lastIndexByKey.get(key); + if (hit !== undefined) { + const prev = out[hit]!; + if (seg.startSec < prev.endSec + SAME_CONTENT_ECHO_MAX_GAP_SEC) { + prev.startSec = Math.min(prev.startSec, seg.startSec); + prev.endSec = Math.max(prev.endSec, seg.endSec); + continue; + } + } + out.push({ + startSec: seg.startSec, + endSec: seg.endSec, + text: seg.text.trim(), + }); + lastIndexByKey.set(key, out.length - 1); + } + return out; +} + +/** + * Collapse adjacent duplicate lines (overlapping or tiny gap). Does not merge the same phrase + * repeated later in the video when separated by real silence. + */ +function dedupeAdjacentCaptionRepeats(segments: CaptionSegment[]): CaptionSegment[] { + const sorted = [...segments] + .filter((s) => s.text.trim()) + .sort((a, b) => a.startSec - b.startSec || a.endSec - b.endSec); + const out: CaptionSegment[] = []; + for (const seg of sorted) { + const t = seg.text.trim(); + const prev = out[out.length - 1]; + if (prev && normalizeCaptionKey(prev.text) === normalizeCaptionKey(t)) { + const overlap = prev.endSec - seg.startSec; + const gap = seg.startSec - prev.endSec; + if (overlap > 0.015 || gap < DEDUPE_SAME_TEXT_MAX_GAP_SEC) { + prev.startSec = Math.min(prev.startSec, seg.startSec); + prev.endSec = Math.max(prev.endSec, seg.endSec); + continue; + } + } + out.push({ startSec: seg.startSec, endSec: seg.endSec, text: t }); + } + return out; +} + +/** Trim only real overlaps. Avoid synthetic lead/lag so caption timing matches model output. */ +function finalizeCaptionSegmentsForPlayback(segments: CaptionSegment[]): CaptionSegment[] { + const OVERLAP_TRIM_SEC = 0.002; + + const sortedRaw = [...segments] + .filter((s) => s.text.trim()) + .sort((a, b) => a.startSec - b.startSec || a.endSec - b.endSec); + + const a = sortedRaw.map((seg) => { + let s = seg.startSec + AUTO_CAPTION_START_BIAS_SEC; + let e = seg.endSec + AUTO_CAPTION_END_HOLD_SEC; + s = Math.max(0, s); + if (e <= s) e = s + 0.02; + return { startSec: s, endSec: e, text: seg.text.trim() }; + }); + + for (let i = 1; i < a.length; i++) { + if (a[i].startSec < a[i - 1].endSec - OVERLAP_TRIM_SEC) { + a[i - 1].endSec = Math.max(a[i - 1].startSec + 1e-4, a[i].startSec); + } + } + + return a; +} + +/** Default min gap between auto-caption blocks on the timeline (ms); matches `MIN_CAPTION_TIMELINE_GAP_SEC`. */ +export const DEFAULT_AUTO_CAPTION_MIN_GAP_MS = Math.round(MIN_CAPTION_TIMELINE_GAP_SEC * 1000); + +/** + * Enforce a min gap between consecutive `auto-caption` regions (by start time). Shortens the previous + * region's end when possible, else shifts the following region later so blocks can't sit completely flush. + */ +export function reconcileAutoCaptionTimelineGaps( + regions: AnnotationRegion[], + minGapMs: number = DEFAULT_AUTO_CAPTION_MIN_GAP_MS, +): AnnotationRegion[] { + const gap = Math.max(0, Math.round(minGapMs)); + if (regions.length === 0 || gap === 0) return regions; + + const autoCandidates = regions.filter((r) => r.annotationSource === "auto-caption"); + if (autoCandidates.length <= 1) return regions; + + const sorted = [...autoCandidates].sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs); + const fixed: AnnotationRegion[] = []; + let prev = { ...sorted[0]! }; + fixed.push(prev); + + for (let i = 1; i < sorted.length; i++) { + let cur = { ...sorted[i]! }; + const minStart = prev.endMs + gap; + + if (cur.startMs < minStart) { + const newPrevEnd = cur.startMs - gap; + if (newPrevEnd >= prev.startMs + 1) { + prev = { ...prev, endMs: newPrevEnd }; + fixed[fixed.length - 1] = prev; + } else { + const dur = Math.max(1, cur.endMs - cur.startMs); + cur = { ...cur, startMs: minStart, endMs: minStart + dur }; + } + } + + fixed.push(cur); + prev = cur; + } + + const fixedById = new Map(fixed.map((r) => [r.id, r])); + return regions.map((r) => fixedById.get(r.id) ?? r); +} + +/** Join phrases that are close in time so the editor does not create dozens of separate overlays. */ +export function mergeAdjacentCaptionSegments( + segments: CaptionSegment[], + options?: { maxGapSec?: number; maxChars?: number; maxBlockDurationSec?: number }, +): CaptionSegment[] { + const maxGapSec = options?.maxGapSec ?? 1.35; + const maxChars = options?.maxChars ?? 320; + const maxBlockDurationSec = options?.maxBlockDurationSec ?? 12; + + const sorted = [...segments].sort((a, b) => a.startSec - b.startSec); + const out: CaptionSegment[] = []; + + for (const seg of sorted) { + const text = seg.text.trim(); + if (!text) continue; + + const prev = out[out.length - 1]; + if (!prev) { + out.push({ startSec: seg.startSec, endSec: seg.endSec, text }); + continue; + } + + const gap = seg.startSec - prev.endSec; + const mergedText = `${prev.text} ${text}`.trim(); + const mergedEnd = Math.max(prev.endSec, seg.endSec); + const wouldSpan = mergedEnd - prev.startSec; + if (gap <= maxGapSec && mergedText.length <= maxChars && wouldSpan <= maxBlockDurationSec) { + prev.endSec = mergedEnd; + prev.text = mergedText; + } else { + out.push({ startSec: seg.startSec, endSec: seg.endSec, text }); + } + } + + return out; +} + +function partitionPhraseCaptionSegments( + segments: CaptionSegment[], + options?: { maxGapSec?: number; maxChars?: number; maxBlockDurationSec?: number }, +): CaptionSegment[][] { + const maxGapSec = options?.maxGapSec ?? 0; + const maxChars = options?.maxChars ?? Number.POSITIVE_INFINITY; + const maxBlockDurationSec = options?.maxBlockDurationSec ?? Number.POSITIVE_INFINITY; + + const sorted = [...segments] + .filter((s) => s.text.trim()) + .sort((a, b) => a.startSec - b.startSec || a.endSec - b.endSec); + if (sorted.length === 0) return []; + + const groups: CaptionSegment[][] = []; + let current: CaptionSegment[] = []; + + for (const seg of sorted) { + const text = seg.text.trim(); + if (!text) continue; + + if (current.length === 0) { + current.push({ ...seg, text }); + continue; + } + + const prev = current[current.length - 1]!; + const groupStart = current[0]!.startSec; + const gap = seg.startSec - prev.endSec; + const currentChars = current.reduce((sum, item) => sum + item.text.length, 0); + const wouldChars = currentChars + 1 + text.length; + const wouldSpan = Math.max(prev.endSec, seg.endSec) - groupStart; + + if (gap <= maxGapSec && wouldChars <= maxChars && wouldSpan <= maxBlockDurationSec) { + current.push({ ...seg, text }); + continue; + } + + groups.push(current); + current = [{ ...seg, text }]; + } + + if (current.length > 0) { + groups.push(current); + } + + return groups; +} + +export interface CaptionSegmentLayoutOptions { + /** Lower bound on words per on-screen caption (default 2). */ + minWordsPerCaption?: number; + /** Upper bound on words per on-screen caption (default 7). */ + maxWordsPerCaption?: number; + /** + * `word`: each `CaptionSegment` is a single token with Whisper word timestamps (default). + * `phrase`: merged phrase spans; use proportional line splitting inside each span. + */ + timestampGranularity?: "word" | "phrase"; +} + +function computeCaptionLineIndexRanges( + wordCount: number, + minWords: number, + maxWords: number, +): Array<{ from: number; to: number }> { + const minW = Math.max(1, Math.min(Math.floor(minWords), Math.floor(maxWords))); + const maxW = Math.max(minW, Math.floor(maxWords)); + const sliceRanges: Array<{ from: number; to: number }> = []; + let i = 0; + while (i < wordCount) { + const remaining = wordCount - i; + if (remaining <= maxW) { + if (sliceRanges.length > 0 && remaining < minW) { + sliceRanges[sliceRanges.length - 1]!.to = wordCount; + } else { + sliceRanges.push({ from: i, to: wordCount }); + } + break; + } + + let take = maxW; + const after = remaining - take; + if (after > 0 && after < minW) { + take = remaining - minW; + if (take < minW) { + sliceRanges.push({ from: i, to: wordCount }); + break; + } + if (take > maxW) { + take = maxW; + } + } + sliceRanges.push({ from: i, to: i + take }); + i += take; + } + return sliceRanges; +} + +/** + * Groups per-word segments into on-screen lines using each token's Whisper timestamps + * (no proportional stretching across a long phrase span). + */ +export function groupTimedCaptionWordsIntoLines( + segments: CaptionSegment[], + minWords: number, + maxWords: number, +): CaptionSegment[] { + const words = [...segments] + .filter((s) => s.text.trim()) + .sort((a, b) => a.startSec - b.startSec || a.endSec - b.endSec); + if (words.length === 0) return []; + + const minW = Math.max(1, Math.min(Math.floor(minWords), Math.floor(maxWords))); + const maxW = Math.max(minW, Math.floor(maxWords)); + const out: CaptionSegment[] = []; + + let runStart = 0; + const flushRun = (runEndExclusive: number) => { + const run = words.slice(runStart, runEndExclusive); + if (run.length === 0) return; + const ranges = computeCaptionLineIndexRanges(run.length, minW, maxW); + for (const { from, to } of ranges) { + const slice = run.slice(from, to); + const s = slice[0]!.startSec; + const rawEnd = slice[slice.length - 1]!.endSec; + const e = Math.max(s + WORD_SPLIT_MIN_SPAN_SEC, rawEnd + CAPTION_LINE_END_TAIL_SEC); + out.push({ + startSec: s, + endSec: e, + text: slice.map((w) => w.text.trim()).join(" "), + }); + } + }; + + for (let i = 1; i < words.length; i++) { + const prev = words[i - 1]!; + const cur = words[i]!; + const gap = cur.startSec - prev.endSec; + if (gap >= WORD_RUN_BREAK_GAP_SEC) { + flushRun(i); + runStart = i; + } + } + flushRun(words.length); + + for (let i = 0; i < out.length - 1; i++) { + if (out[i]!.endSec > out[i + 1]!.startSec + 1e-3) { + out[i]!.endSec = Math.max( + out[i]!.startSec + WORD_SPLIT_MIN_SPAN_SEC, + out[i + 1]!.startSec - 1e-4, + ); + } + } + return out; +} + +/** + * Splits each merged transcription span into shorter captions with about + * `minWords`-`maxWords` words. Times are interpolated by character weight inside the span. + */ +export function splitMergedCaptionsByWordBounds( + merged: CaptionSegment[], + minWords: number, + maxWords: number, +): CaptionSegment[] { + const minW = Math.max(1, Math.min(Math.floor(minWords), Math.floor(maxWords))); + const maxW = Math.max(minW, Math.floor(maxWords)); + const out: CaptionSegment[] = []; + + for (const seg of merged) { + const words = seg.text.trim().split(/\s+/).filter(Boolean); + if (words.length === 0) continue; + + if (words.length <= maxW) { + out.push({ + startSec: seg.startSec, + endSec: seg.endSec, + text: words.join(" "), + }); + continue; + } + + out.push(...splitOneSegmentByWordBounds(seg.startSec, seg.endSec, words, minW, maxW)); + } + + return out; +} + +function wrapCaptionTextByWordBounds(text: string, minWords: number, maxWords: number): string { + const words = text.trim().split(/\s+/).filter(Boolean); + if (words.length === 0) return ""; + const minW = Math.max(1, Math.min(Math.floor(minWords), Math.floor(maxWords))); + const maxW = Math.max(minW, Math.floor(maxWords)); + const ranges = computeCaptionLineIndexRanges(words.length, minW, maxW); + return ranges.map(({ from, to }) => words.slice(from, to).join(" ")).join("\n"); +} + +function expandPhraseSegmentToPseudoWords(segment: CaptionSegment): CaptionSegment[] { + const words = segment.text.trim().split(/\s+/).filter(Boolean); + if (words.length === 0) return []; + if (words.length === 1) { + return [ + { + startSec: segment.startSec, + endSec: segment.endSec, + text: words[0]!, + }, + ]; + } + + return splitOneSegmentByWordBounds(segment.startSec, segment.endSec, words, 1, 1); +} + +export function groupPhraseCaptionSegmentsIntoLines( + segments: CaptionSegment[], + minWords: number, + maxWords: number, + options?: { maxGapSec?: number; maxChars?: number; maxBlockDurationSec?: number }, +): CaptionSegment[] { + const groups = partitionPhraseCaptionSegments(segments, options); + const out: CaptionSegment[] = []; + + for (const group of groups) { + if (group.length === 1) { + const only = group[0]!; + const wrapped = wrapCaptionTextByWordBounds(only.text, minWords, maxWords).trim(); + if (!wrapped) continue; + const lineTexts = wrapped + .split("\n") + .map((t) => t.trim()) + .filter(Boolean); + const n = lineTexts.length; + const rawDur = only.endSec - only.startSec; + if (n > 1 && rawDur < n * WORD_SPLIT_MIN_SPAN_SEC) { + out.push({ + startSec: only.startSec, + endSec: only.endSec, + text: lineTexts.join(" "), + }); + continue; + } + const dur = Math.max(rawDur, WORD_SPLIT_MIN_SPAN_SEC * n); + if (n <= 1) { + out.push({ + startSec: only.startSec, + endSec: only.endSec, + text: lineTexts[0] ?? wrapped, + }); + continue; + } + for (let i = 0; i < n; i++) { + const startSec = only.startSec + (dur * i) / n; + const boundary = only.startSec + (dur * (i + 1)) / n; + const endSec = + i === n - 1 ? only.endSec : Math.max(startSec + WORD_SPLIT_MIN_SPAN_SEC, boundary); + out.push({ + startSec, + endSec, + text: lineTexts[i]!, + }); + } + continue; + } + + const pseudoWords = group.flatMap(expandPhraseSegmentToPseudoWords); + out.push(...groupTimedCaptionWordsIntoLines(pseudoWords, minWords, maxWords)); + } + + return out; +} + +function splitOneSegmentByWordBounds( + startSec: number, + endSec: number, + words: string[], + minWords: number, + maxWords: number, +): CaptionSegment[] { + const sliceRanges = computeCaptionLineIndexRanges(words.length, minWords, maxWords); + + const dur = Math.max(endSec - startSec, 0.05); + const weights = words.map((w) => Math.max(1, w.length)); + const totalW = weights.reduce((a, b) => a + b, 0); + + const weightSum = (from: number, to: number) => { + let s = 0; + for (let k = from; k < to; k++) s += weights[k] ?? 0; + return s; + }; + + const result: CaptionSegment[] = []; + let prevEnd = startSec; + for (const { from, to } of sliceRanges) { + const wb = weightSum(0, from); + const ws = weightSum(from, to); + let s = startSec + (wb / totalW) * dur; + let e = startSec + ((wb + ws) / totalW) * dur; + s = Math.max(s, prevEnd); + e = Math.max(s + WORD_SPLIT_MIN_SPAN_SEC, e); + e = Math.min(e, endSec); + if (e <= s) { + e = Math.min(endSec, s + WORD_SPLIT_MIN_SPAN_SEC); + } + prevEnd = e; + result.push({ + startSec: s, + endSec: e, + text: words.slice(from, to).join(" "), + }); + } + if (result.length > 0) { + result[result.length - 1].endSec = endSec; + for (let i = 0; i < result.length - 1; i++) { + if (result[i].endSec > result[i + 1].startSec + 0.002) { + result[i].endSec = Math.max(result[i].startSec + 1e-4, result[i + 1].startSec); + } + } + } + return result; +} + +export function captionSegmentsToAnnotationRegions( + segments: CaptionSegment[], + startNumericId: number, + startZIndex: number, + layout?: CaptionSegmentLayoutOptions, +): { regions: AnnotationRegion[]; nextNumericId: number; nextZIndex: number } { + // Don't echo-collapse raw word tokens before grouping: repeated words ("I … I") share a + // normalized key and would merge spans while keeping only the first token's text. + const minW = layout?.minWordsPerCaption ?? 2; + const maxW = layout?.maxWordsPerCaption ?? 7; + const granularity = layout?.timestampGranularity ?? "word"; + + const grouped = + granularity === "phrase" + ? groupPhraseCaptionSegmentsIntoLines(segments, minW, maxW) + : groupTimedCaptionWordsIntoLines(segments, minW, maxW); + + const dedupedOut = dedupeAdjacentCaptionRepeats(grouped); + const finalized = finalizeCaptionSegmentsForPlayback(dedupedOut); + + let nid = startNumericId; + let z = startZIndex; + const regions: AnnotationRegion[] = []; + + for (const seg of finalized) { + const startMs = Math.round(seg.startSec * 1000); + const endMs = Math.max(Math.round(seg.endSec * 1000), startMs + 1); + regions.push({ + id: `annotation-${nid++}`, + startMs, + endMs, + type: "text", + content: seg.text, + annotationSource: "auto-caption", + position: { ...CAPTION_POSITION }, + size: { ...CAPTION_SIZE }, + style: { ...CAPTION_STYLE }, + zIndex: z++, + }); + } + + return { + regions: reconcileAutoCaptionTimelineGaps(regions), + nextNumericId: nid, + nextZIndex: z, + }; +} + +export function maxAnnotationNumericId(regions: AnnotationRegion[]): number { + let max = 0; + for (const r of regions) { + const m = /^annotation-(\d+)$/.exec(r.id); + if (m) max = Math.max(max, Number.parseInt(m[1], 10)); + } + return max; +} + +export function maxAnnotationZIndex(regions: AnnotationRegion[]): number { + if (regions.length === 0) return 0; + return Math.max(...regions.map((r) => r.zIndex)); +} diff --git a/src/lib/captioning/captionConstants.ts b/src/lib/captioning/captionConstants.ts new file mode 100644 index 0000000000..1bacb7cc7a --- /dev/null +++ b/src/lib/captioning/captionConstants.ts @@ -0,0 +1,2 @@ +/** Max audio length for auto-captions (decode + transcribe); keep demuxer read aligned with this. */ +export const MAX_CAPTION_AUDIO_SEC = 4 * 60 * 60; diff --git a/src/lib/captioning/extractMono16k.ts b/src/lib/captioning/extractMono16k.ts new file mode 100644 index 0000000000..bf2c7320fc --- /dev/null +++ b/src/lib/captioning/extractMono16k.ts @@ -0,0 +1,187 @@ +import { materializeLocalSourceFile, releaseLocalSourceFile } from "@/lib/exporter/localSourceFile"; +import { MAX_IN_MEMORY_SOURCE_BYTES } from "@/lib/exporter/sourceFileLimits"; +import { MAX_CAPTION_AUDIO_SEC } from "./captionConstants"; +import { extractMonoPcmViaWebDemuxer } from "./extractMono16kWebDemuxer"; + +export { MAX_CAPTION_AUDIO_SEC }; + +const FETCH_TIMEOUT_MS = 120_000; +// The demuxer caption path holds every decoded AudioData frame plus full-rate +// merge buffers in memory (~50 MB per minute of 48 kHz audio all-in), so very +// long recordings would exhaust the renderer heap well before the 4 h caption +// ceiling. For sources too large to load in memory anyway, cap the decoded +// audio; captions come back truncated instead of crashing the renderer. +const LARGE_FILE_CAPTION_SEC = 30 * 60; + +async function fetchWithTimeout(url: string, signal?: AbortSignal): Promise { + const ctrl = new AbortController(); + const timer = window.setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS); + const onAbort = () => ctrl.abort(); + if (signal) { + if (signal.aborted) ctrl.abort(); + else signal.addEventListener("abort", onAbort, { once: true }); + } + try { + return await fetch(url, { signal: ctrl.signal }); + } finally { + window.clearTimeout(timer); + if (signal) signal.removeEventListener("abort", onAbort); + } +} + +/** + * Load the editor video like `StreamingVideoDecoder`: Electron `readBinaryFile` + * for local paths (fetch(file://) is unreliable in the renderer), otherwise + * HTTP/blob/data URLs via fetch. + */ +async function loadSourceVideoFile(videoUrl: string, signal?: AbortSignal): Promise { + const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); + + if (!isRemoteUrl && window.electronAPI) { + // Streams large recordings through OPFS instead of reading them whole, so + // captions work for multi-GB files just like export does. The signal also + // aborts the copy itself when the caption pass is cancelled. + const filename = (videoUrl.split(/[\\/]/).pop() || "video").replace(/^file:/, ""); + return materializeLocalSourceFile(videoUrl, filename, { signal }); + } + + const response = await fetchWithTimeout(videoUrl, signal); + if (!response.ok) { + throw new Error(`Failed to load video for captions: ${response.status} ${response.statusText}`); + } + const blob = await response.blob(); + if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); + const filename = videoUrl.split("/").pop() || "video"; + return new File([blob], filename, { type: blob.type || "video/webm" }); +} + +function mixToMono(audioBuffer: AudioBuffer): Float32Array { + const { length, numberOfChannels } = audioBuffer; + const out = new Float32Array(length); + if (numberOfChannels === 0) return out; + for (let i = 0; i < length; i++) { + let sum = 0; + for (let c = 0; c < numberOfChannels; c++) { + sum += audioBuffer.getChannelData(c)[i]; + } + out[i] = sum / numberOfChannels; + } + return out; +} + +async function resampleMono( + mono: Float32Array, + fromRate: number, + toRate: number, + signal?: AbortSignal, +): Promise { + if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); + if (fromRate === toRate) return mono; + const durationSec = mono.length / fromRate; + const outLength = Math.max(1, Math.ceil(durationSec * toRate)); + const offline = new OfflineAudioContext(1, outLength, toRate); + const buf = offline.createBuffer(1, mono.length, fromRate); + buf.copyToChannel(Float32Array.from(mono), 0); + const src = offline.createBufferSource(); + src.buffer = buf; + src.connect(offline.destination); + src.start(0); + const rendered = await offline.startRendering(); + if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); + return rendered.getChannelData(0).slice(); +} + +async function truncateAndResampleTo16k( + mono: Float32Array, + fromRate: number, + durationSec: number, + signal?: AbortSignal, +): Promise<{ samples: Float32Array; truncated: boolean; durationSec: number }> { + let truncated = false; + let work = mono; + if (durationSec > MAX_CAPTION_AUDIO_SEC) { + const maxSamples = Math.floor(MAX_CAPTION_AUDIO_SEC * fromRate); + work = mono.subarray(0, Math.min(mono.length, maxSamples)); + truncated = true; + } + + const samples = await resampleMono(work, fromRate, 16_000, signal); + return { samples, truncated, durationSec: samples.length / 16_000 }; +} + +/** + * Decode the video's audio track to mono 16 kHz float samples (Whisper input). + * Prefers `decodeAudioData` when the container is supported, else the same + * web-demuxer + AudioDecoder path as export. + */ +export async function extractMono16kFromVideoUrl( + videoUrl: string, + options?: { signal?: AbortSignal }, +): Promise<{ samples: Float32Array; truncated: boolean; durationSec: number }> { + const file = await loadSourceVideoFile(videoUrl, options?.signal); + + /** When this returns null, use web-demuxer + AudioDecoder (same as export). */ + const tryDecodeAudioDataPath = async (): Promise<{ + samples: Float32Array; + truncated: boolean; + durationSec: number; + } | null> => { + const audioContext = new AudioContext(); + try { + const ab = await file.arrayBuffer(); + if (options?.signal?.aborted) throw new DOMException("Aborted", "AbortError"); + const audioBuffer = await audioContext.decodeAudioData(ab.slice(0)); + if ( + audioBuffer.numberOfChannels === 0 || + audioBuffer.length === 0 || + !Number.isFinite(audioBuffer.duration) || + audioBuffer.duration <= 0 + ) { + return null; + } + const durationSec = audioBuffer.duration; + const mono = mixToMono(audioBuffer); + const fromRate = audioBuffer.sampleRate; + const out = await truncateAndResampleTo16k(mono, fromRate, durationSec, options?.signal); + // decodeAudioData can resolve for some WebM/Matroska inputs yet yield almost no usable + // PCM, and captions only fall back to the demuxer path on throw, so return null to recover. + if (out.samples.length < 800) { + return null; + } + return out; + } catch { + return null; + } finally { + await audioContext.close().catch(() => undefined); + } + }; + + try { + // Large recordings skip the in-memory decodeAudioData path (it would load + // the whole file) and go straight to the streaming web-demuxer path below. + const isLargeFile = file.size > MAX_IN_MEMORY_SOURCE_BYTES; + const primary = isLargeFile ? null : await tryDecodeAudioDataPath(); + if (primary) { + return primary; + } + + // For oversized sources, also cap how much audio the demuxer path decodes + // — its frame/merge buffers are in-memory and scale with duration. + const pcm = await extractMonoPcmViaWebDemuxer( + file, + options?.signal, + isLargeFile ? LARGE_FILE_CAPTION_SEC : undefined, + ); + const out = await truncateAndResampleTo16k( + pcm.mono, + pcm.sampleRate, + pcm.durationSec, + options?.signal, + ); + return { ...out, truncated: out.truncated || pcm.capped }; + } finally { + // Release the OPFS cache reference taken when streaming a large source. + // The File name is the cache-entry key (no-op for small/remote sources). + releaseLocalSourceFile(file.name); + } +} diff --git a/src/lib/captioning/extractMono16kWebDemuxer.ts b/src/lib/captioning/extractMono16kWebDemuxer.ts new file mode 100644 index 0000000000..641f263511 --- /dev/null +++ b/src/lib/captioning/extractMono16kWebDemuxer.ts @@ -0,0 +1,198 @@ +import { WebDemuxer } from "web-demuxer"; + +import { MAX_CAPTION_AUDIO_SEC } from "./captionConstants"; + +const DECODE_QUEUE_BACKPRESSURE = 20; +const SOURCE_LOAD_TIMEOUT_MS = 60_000; +const READ_END_PADDING_SEC = 0.5; + +function webDemuxerWasmUrl(): string { + return new URL("../exporter/wasm/web-demuxer.wasm", window.location.href).href; +} + +/** Mixes one WebCodecs AudioData frame down to mono (averaged across channels). */ +export function audioDataFrameToMono(frame: AudioData): Float32Array { + const frames = frame.numberOfFrames; + const ch = frame.numberOfChannels; + const out = new Float32Array(frames); + const fmt = frame.format || ""; + const planar = fmt.includes("planar"); + + if (planar) { + const plane = new Float32Array(frames); + for (let c = 0; c < ch; c++) { + frame.copyTo(plane, { planeIndex: c }); + for (let i = 0; i < frames; i++) { + out[i] += plane[i]; + } + } + for (let i = 0; i < frames; i++) { + out[i] /= ch; + } + } else { + const interleaved = new Float32Array(frames * ch); + frame.copyTo(interleaved, { planeIndex: 0 }); + for (let i = 0; i < frames; i++) { + let sum = 0; + for (let c = 0; c < ch; c++) { + sum += interleaved[i * ch + c]; + } + out[i] = sum / ch; + } + } + return out; +} + +function mergeAndConsumeDecodedAudioToMonoLinear( + frames: AudioData[], + sampleRate: number, + durationSec: number, +): Float32Array { + const sorted = [...frames].sort((a, b) => a.timestamp - b.timestamp); + const totalSamples = Math.max(1, Math.ceil(durationSec * sampleRate)); + const acc = new Float32Array(totalSamples); + const weight = new Float32Array(totalSamples); + + for (const frame of sorted) { + const startSample = Math.round((frame.timestamp / 1e6) * sampleRate); + const slice = audioDataFrameToMono(frame); + for (let i = 0; i < slice.length; i++) { + const pos = startSample + i; + if (pos >= 0 && pos < totalSamples) { + acc[pos] += slice[i]; + weight[pos] += 1; + } + } + frame.close(); + } + + for (let i = 0; i < totalSamples; i++) { + if (weight[i] > 0) { + acc[i] /= weight[i]; + } + } + return acc; +} + +function withTimeout(promise: Promise, ms: number, message: string): Promise { + return new Promise((resolve, reject) => { + const id = window.setTimeout(() => reject(new Error(message)), ms); + promise + .then((v) => { + window.clearTimeout(id); + resolve(v); + }) + .catch((e) => { + window.clearTimeout(id); + reject(e instanceof Error ? e : new Error(String(e))); + }); + }); +} + +/** + * Demux + WebCodecs audio decode (same stack as export). Use when `decodeAudioData` + * can't handle the container (e.g. WebM with video). + * + * @param maxReadSec Optional cap on how much audio to demux/decode. The decoded + * frames and merge buffers are held in memory, so very long recordings must be + * capped below MAX_CAPTION_AUDIO_SEC to avoid exhausting the renderer heap. + * `capped` reports whether the cap actually cut the track short. + */ +export async function extractMonoPcmViaWebDemuxer( + file: File, + signal?: AbortSignal, + maxReadSec?: number, +): Promise<{ mono: Float32Array; sampleRate: number; durationSec: number; capped: boolean }> { + const demuxer = new WebDemuxer({ wasmFilePath: webDemuxerWasmUrl() }); + await withTimeout( + demuxer.load(file), + SOURCE_LOAD_TIMEOUT_MS, + "Timed out while parsing the source video for captions.", + ); + + if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); + + const mediaInfo = await withTimeout( + demuxer.getMediaInfo(), + SOURCE_LOAD_TIMEOUT_MS, + "Timed out while reading media info for captions.", + ); + + const reportedDurationSec = + Number.isFinite(mediaInfo.duration) && mediaInfo.duration > 0 ? mediaInfo.duration : 0; + + let audioConfig: AudioDecoderConfig; + try { + audioConfig = await demuxer.getDecoderConfig("audio"); + } catch { + throw new Error("No audio track found in this video."); + } + + const codecCheck = await AudioDecoder.isConfigSupported(audioConfig); + if (!codecCheck.supported) { + throw new Error(`Audio codec not supported for captions: ${audioConfig.codec}`); + } + + const sampleRate = audioConfig.sampleRate || 48_000; + + // Many WebM/Matroska files report a too-short duration, so capping read at reported time stops + // demux early and clips everything past that. Read to the caption-decode ceiling instead; the + // demuxer stops when the track ends. + const readCapSec = Math.min(maxReadSec ?? MAX_CAPTION_AUDIO_SEC, MAX_CAPTION_AUDIO_SEC); + const readEndSec = readCapSec + READ_END_PADDING_SEC; + const decodedFrames: AudioData[] = []; + + const decoder = new AudioDecoder({ + output: (data: AudioData) => decodedFrames.push(data), + error: (e: DOMException) => console.error("[captioning] AudioDecoder error:", e), + }); + decoder.configure(audioConfig); + + const reader = demuxer.read("audio", 0, readEndSec).getReader(); + try { + while (!signal?.aborted) { + const { done, value: chunk } = await reader.read(); + if (done || !chunk) break; + decoder.decode(chunk); + while (decoder.decodeQueueSize > DECODE_QUEUE_BACKPRESSURE && !signal?.aborted) { + await new Promise((r) => setTimeout(r, 1)); + } + } + } finally { + try { + await reader.cancel(); + } catch { + /* already closed */ + } + } + + if (decoder.state === "configured") { + await decoder.flush(); + decoder.close(); + } + + if (signal?.aborted) { + for (const f of decodedFrames) f.close(); + throw new DOMException("Aborted", "AbortError"); + } + + if (decodedFrames.length === 0) { + throw new Error("Decoded zero audio frames from this video."); + } + + let maxEndUs = 0; + for (const f of decodedFrames) { + const end = f.timestamp + (f.duration ?? 0); + if (end > maxEndUs) maxEndUs = end; + } + const inferredDurationSec = maxEndUs / 1e6; + // Prefer extent implied by decoded frames (fixes bad container duration); fall back to reported + // metadata when frames lack duration. + const durationSec = inferredDurationSec > 0.02 ? inferredDurationSec : reportedDurationSec; + + // The cap cut the track short when the reported extent exceeds what we read. + const capped = reportedDurationSec > readCapSec + READ_END_PADDING_SEC; + + const mono = mergeAndConsumeDecodedAudioToMonoLinear(decodedFrames, sampleRate, durationSec); + return { mono, sampleRate, durationSec, capped }; +} diff --git a/src/lib/captioning/index.ts b/src/lib/captioning/index.ts new file mode 100644 index 0000000000..cc2e2a3a6e --- /dev/null +++ b/src/lib/captioning/index.ts @@ -0,0 +1,17 @@ +export type { CaptionSegmentLayoutOptions } from "./annotationsFromCaptions"; +export { + captionSegmentsToAnnotationRegions, + DEFAULT_AUTO_CAPTION_MIN_GAP_MS, + groupTimedCaptionWordsIntoLines, + mergeAdjacentCaptionSegments, + reconcileAutoCaptionTimelineGaps, + splitMergedCaptionsByWordBounds, +} from "./annotationsFromCaptions"; +export { extractMono16kFromVideoUrl, MAX_CAPTION_AUDIO_SEC } from "./extractMono16k"; +export { shiftTrimRegionsMsForCaptionBuffer, trimLeadingSilenceMono16k } from "./leadingSilence"; +export type { + CaptionSegment, + CaptionTimestampGranularity, + TranscribeMono16kResult, +} from "./transcribe"; +export { transcribeMono16kToSegments } from "./transcribe"; diff --git a/src/lib/captioning/leadingSilence.ts b/src/lib/captioning/leadingSilence.ts new file mode 100644 index 0000000000..4bd6a11aa3 --- /dev/null +++ b/src/lib/captioning/leadingSilence.ts @@ -0,0 +1,78 @@ +/** Caption path is always mono 16 kHz after `extractMono16kFromVideoUrl`. */ +import type { TrimRegion } from "@/components/video-editor/types"; + +const SAMPLE_RATE = 16_000; + +/** Window length for peak detection (~50 ms). */ +const WINDOW_SAMPLES = 800; + +/** Coarse hop so long intros scan quickly (~50 ms steps). */ +const HOP_SAMPLES = 800; + +/** Max |sample| in a window below this counts as silence (float PCM ~[-1, 1]). */ +const PEAK_THRESHOLD = 0.012; + +/** Keep a little audio before the first peak so word onsets are not clipped. */ +const PRE_ROLL_SEC = 0.12; + +/** Do not scan more than this much audio for leading silence (performance + pathological files). */ +const MAX_LEADING_SCAN_SEC = 15 * 60; + +/** + * Drops quiet audio at the beginning so Whisper is not fed a long silent prefix (which can skew + * the first phrase and wastes work). Returned `trimSec` must be added back to every segment time. + */ +export function trimLeadingSilenceMono16k(samples: Float32Array): { + samples: Float32Array; + trimSec: number; +} { + if (samples.length < WINDOW_SAMPLES) { + return { samples, trimSec: 0 }; + } + + const maxIndex = Math.min( + samples.length - WINDOW_SAMPLES, + Math.floor(MAX_LEADING_SCAN_SEC * SAMPLE_RATE), + ); + + let firstSpeechSample = -1; + for (let i = 0; i <= maxIndex; i += HOP_SAMPLES) { + let peak = 0; + for (let j = 0; j < WINDOW_SAMPLES; j++) { + peak = Math.max(peak, Math.abs(samples[i + j]!)); + } + if (peak > PEAK_THRESHOLD) { + firstSpeechSample = i; + break; + } + } + + if (firstSpeechSample <= 0) { + return { samples, trimSec: 0 }; + } + + const preRollSamples = Math.round(PRE_ROLL_SEC * SAMPLE_RATE); + const start = Math.max(0, firstSpeechSample - preRollSamples); + return { + samples: samples.subarray(start), + trimSec: start / SAMPLE_RATE, + }; +} + +/** + * When audio is trimmed from the front, Whisper times are relative to the shortened buffer. + * Shift trim regions by the same offset so `segmentOverlapsTrim` still uses consistent coordinates. + */ +export function shiftTrimRegionsMsForCaptionBuffer( + regions: TrimRegion[], + trimMs: number, +): TrimRegion[] { + if (trimMs <= 0) return regions; + return regions + .map((r) => ({ + ...r, + startMs: Math.max(0, r.startMs - trimMs), + endMs: Math.max(0, r.endMs - trimMs), + })) + .filter((r) => r.endMs > r.startMs); +} diff --git a/src/lib/captioning/transcribe.ts b/src/lib/captioning/transcribe.ts new file mode 100644 index 0000000000..a72a89673d --- /dev/null +++ b/src/lib/captioning/transcribe.ts @@ -0,0 +1,106 @@ +import type { TrimRegion } from "@/components/video-editor/types"; + +export interface CaptionSegment { + startSec: number; + endSec: number; + text: string; +} + +/** How caption layout should interpret `CaptionSegment` times from `transcribeMono16kToSegments`. */ +export type CaptionTimestampGranularity = "word" | "phrase"; + +export interface TranscribeMono16kResult { + segments: CaptionSegment[]; + granularity: CaptionTimestampGranularity; +} + +/** Request payload posted from the renderer to the transcription worker. */ +export interface TranscribeWorkerRequest { + samples: Float32Array; + trimRegions: TrimRegion[]; + /** + * Load the Whisper model + ORT wasm from bundled `caption-assets` instead of remote CDNs. + * Required in the packaged app (runs from `file://` where remote fetches fail). The worker + * can't read `window.electronAPI`, so the renderer resolves this here. + */ + useLocalModels: boolean; + /** Base URL of bundled resources (packaged: resourcesPath file:// URL); used when `useLocalModels`. */ + assetBaseUrl?: string; +} + +/** Messages the transcription worker posts back to the renderer. */ +export type TranscribeWorkerResponse = + | { type: "status"; phase: "model" | "transcribe" } + | { type: "result"; segments: CaptionSegment[]; granularity: CaptionTimestampGranularity } + | { type: "error"; message: string }; + +/** + * Transcribes mono 16 kHz audio into timed caption segments using in-browser Whisper. + * + * Runs in a Web Worker so the editor's main thread stays responsive (WASM inference + * doesn't yield). First run downloads model weights. Aborting via `options.signal` + * terminates the worker, since load/inference can't be cooperatively cancelled. + */ +export function transcribeMono16kToSegments( + samples: Float32Array, + options?: { + trimRegions?: TrimRegion[]; + onStatus?: (phase: "model" | "transcribe") => void; + signal?: AbortSignal; + }, +): Promise { + if (options?.signal?.aborted) { + return Promise.reject(new DOMException("Aborted", "AbortError")); + } + + return new Promise((resolve, reject) => { + const worker = new Worker(new URL("./transcribe.worker.ts", import.meta.url), { + type: "module", + }); + + let settled = false; + const finish = (fn: () => void) => { + if (settled) return; + settled = true; + options?.signal?.removeEventListener("abort", onAbort); + worker.terminate(); + fn(); + }; + + const onAbort = () => finish(() => reject(new DOMException("Aborted", "AbortError"))); + options?.signal?.addEventListener("abort", onAbort, { once: true }); + + worker.onmessage = (e: MessageEvent) => { + const msg = e.data; + if (msg.type === "status") { + options?.onStatus?.(msg.phase); + return; + } + if (msg.type === "result") { + finish(() => resolve({ segments: msg.segments, granularity: msg.granularity })); + return; + } + finish(() => reject(new Error(msg.message))); + }; + + worker.onerror = (e) => { + finish(() => reject(new Error(e.message || "Caption transcription worker failed"))); + }; + + // Packaged app runs from file:// (remote fetches fail), so load bundled assets. + // Dev runs from http://localhost where the remote path works. + const useLocalModels = typeof window !== "undefined" && window.location?.protocol === "file:"; + const assetBaseUrl = + typeof window !== "undefined" ? window.electronAPI?.assetBaseUrl : undefined; + + // Structured-clone copy, not a transfer: the caller may reuse `samples` for the + // full-buffer retry pass, so the buffer must stay valid here. + const request: TranscribeWorkerRequest = { + samples, + trimRegions: options?.trimRegions ?? [], + useLocalModels, + assetBaseUrl, + }; + worker.postMessage(request); + }); +} diff --git a/src/lib/captioning/transcribe.worker.ts b/src/lib/captioning/transcribe.worker.ts new file mode 100644 index 0000000000..ab65b2eeaa --- /dev/null +++ b/src/lib/captioning/transcribe.worker.ts @@ -0,0 +1,93 @@ +/** + * Web Worker running in-browser Whisper transcription off the renderer's main + * thread so the editor UI never blocks during model load or transcription. + * + * Input: { samples: Float32Array; trimRegions: TrimRegion[] } + * Output (see `TranscribeWorkerResponse`): status / result / error messages. + * + * The caller terminates this worker to abort (model load and inference can't be + * cooperatively cancelled), so there is no in-worker abort handling. + */ + +import type { TranscribeWorkerRequest, TranscribeWorkerResponse } from "./transcribe"; +import { runTranscription, type TranscriberFn } from "./transcribeCore"; + +function post(message: TranscribeWorkerResponse): void { + (self as unknown as Worker).postMessage(message); +} + +/** + * ONNX Runtime's wasm bundle treats `process.versions.node` (which can leak into + * an Electron worker) as Node and tries `require("fs")`, which Vite doesn't + * support. Mask it only while Transformers/ORT run. No-op when `process` is + * undefined (the usual case in a Web Worker). + */ +function withoutNodeVersion(fn: () => Promise): Promise { + const versions = + typeof process !== "undefined" && process.versions && typeof process.versions === "object" + ? process.versions + : null; + const hadNode = versions !== null && "node" in versions; + const savedNode = hadNode ? (versions as { node?: string }).node : undefined; + if (hadNode && versions) { + try { + Reflect.deleteProperty(versions, "node"); + } catch { + (versions as { node?: string }).node = undefined; + } + } + return fn().finally(() => { + if (hadNode && versions && savedNode !== undefined) { + (versions as { node: string }).node = savedNode; + } + }); +} + +async function loadTranscriber(opts: { + useLocalModels: boolean; + assetBaseUrl?: string; +}): Promise { + return withoutNodeVersion(async () => { + const { pipeline, env } = await import("@xenova/transformers"); + if (opts.useLocalModels && opts.assetBaseUrl) { + // Packaged app: load the bundled model and ORT wasm from disk so transcription + // needs no network and works under file:// (remote HuggingFace/CDN fetches fail there). + const base = new URL("caption-assets/", opts.assetBaseUrl).href; + env.allowLocalModels = true; + env.allowRemoteModels = false; + env.localModelPath = new URL("models/", base).href; + env.backends.onnx.wasm.wasmPaths = new URL("ort/", base).href; + // Non-threaded wasm: SharedArrayBuffer isn't available under file:// (no cross-origin isolation). + env.backends.onnx.wasm.numThreads = 1; + } else { + // Dev (http://localhost): fetch from the remote CDN, which works there. + env.allowLocalModels = false; + } + // Default tiny weights only: the `output_attentions` revision regresses inference in + // some environments (empty chunks, thrown errors) while phrase mode works on this model. + const transcriber = (await pipeline( + "automatic-speech-recognition", + "Xenova/whisper-tiny", + )) as unknown as TranscriberFn; + return transcriber; + }); +} + +self.onmessage = async (event: MessageEvent) => { + const { samples, trimRegions, useLocalModels, assetBaseUrl } = event.data; + try { + post({ type: "status", phase: "model" }); + const transcriber = await loadTranscriber({ useLocalModels, assetBaseUrl }); + + post({ type: "status", phase: "transcribe" }); + const { segments, granularity } = await runTranscription( + transcriber, + samples, + trimRegions ?? [], + ); + + post({ type: "result", segments, granularity }); + } catch (e) { + post({ type: "error", message: e instanceof Error ? e.message : String(e) }); + } +}; diff --git a/src/lib/captioning/transcribeCore.ts b/src/lib/captioning/transcribeCore.ts new file mode 100644 index 0000000000..9834e36541 --- /dev/null +++ b/src/lib/captioning/transcribeCore.ts @@ -0,0 +1,268 @@ +import type { TrimRegion } from "@/components/video-editor/types"; +import type { CaptionSegment, TranscribeMono16kResult } from "./transcribe"; + +/** + * Pure transcription algorithm for the captioning Web Worker: takes a built Whisper + * `transcriber` and turns mono 16 kHz audio into timed caption segments. No DOM or + * Transformers.js imports so it runs in a worker and unit-tests in isolation. + */ + +/** A Transformers.js automatic-speech-recognition pipeline call. */ +export type TranscriberFn = ( + audio: Float32Array, + opts: Record, +) => Promise; + +function segmentOverlapsTrim(startMs: number, endMs: number, trims: TrimRegion[]): boolean { + return trims.some((t) => startMs < t.endMs && endMs > t.startMs); +} + +/** Same trim-out rule as {@link segmentsFromTranscriberChunks}; for retry passes that used empty trims. */ +function dropSegmentsOverlappingTrimRegions( + segments: CaptionSegment[], + trimRegions: TrimRegion[], +): CaptionSegment[] { + if (trimRegions.length === 0) return segments; + return segments.filter((s) => { + const startMs = Math.round(s.startSec * 1000); + const endMs = Math.round(s.endSec * 1000); + return !segmentOverlapsTrim(startMs, endMs, trimRegions); + }); +} + +/** Whisper runs with internal 30s chunks; keep each forward pass bounded for WASM memory. */ +const TRANSCRIBE_SLICE_SAMPLES = 12 * 60 * 16_000; + +/** Very short slices are skipped in the multi-slice loop unless padded (see `padTailSliceForTranscribe`). */ +const MIN_TRANSCRIBE_SLICE_SAMPLES = 800; + +/** + * Pad a short tail slice so Whisper still runs; timestamps are clamped with `realDurationSec` so + * padding does not extend perceived audio on the timeline. + */ +function padTailSliceForTranscribe(samples: Float32Array): { + slice: Float32Array; + realDurationSec: number; +} { + const realDurationSec = samples.length / 16_000; + if (samples.length >= MIN_TRANSCRIBE_SLICE_SAMPLES) { + return { slice: samples, realDurationSec }; + } + const padded = new Float32Array(MIN_TRANSCRIBE_SLICE_SAMPLES); + padded.set(samples); + return { slice: padded, realDurationSec }; +} + +/** Converts raw Whisper chunk output into sorted, deduped, trim-filtered caption segments. */ +function segmentsFromTranscriberChunks( + chunks: Array<{ timestamp?: [number | null, number | null]; text?: unknown }>, + timeOffsetSec: number, + trims: TrimRegion[], + audioDurationSec: number, +): CaptionSegment[] { + const sorted = [...chunks].sort((x, y) => { + const ax = x.timestamp?.[0]; + const ay = y.timestamp?.[0]; + const na = typeof ax === "number" ? ax : -1; + const nb = typeof ay === "number" ? ay : -1; + return na - nb; + }); + + const segments: CaptionSegment[] = []; + + for (let idx = 0; idx < sorted.length; idx++) { + const c = sorted[idx]!; + const ts = c.timestamp as [number | null, number | null] | undefined; + if (!ts) continue; + let a = ts[0]; + let b = ts[1]; + if (a == null) a = 0; + a = Math.max(0, a); + if (b == null) { + let nextStart: number | null = null; + for (let j = idx + 1; j < sorted.length; j++) { + const na = sorted[j]?.timestamp?.[0]; + if (typeof na === "number") { + nextStart = na; + break; + } + } + b = nextStart ?? audioDurationSec; + } + if (b <= a) { + b = Math.min(a + 0.25, audioDurationSec); + } + b = Math.min(b, audioDurationSec); + + const text = String(c.text ?? "") + .replace(/\s+/g, " ") + .trim(); + if (!text) continue; + + const startSec = a + timeOffsetSec; + const sliceEnd = timeOffsetSec + audioDurationSec; + const endSec = Math.min(Math.max(startSec + 0.08, b + timeOffsetSec), sliceEnd); + const startMs = Math.round(startSec * 1000); + const endMs = Math.round(endSec * 1000); + if (segmentOverlapsTrim(startMs, endMs, trims)) continue; + + segments.push({ startSec, endSec, text }); + } + + segments.sort((u, v) => u.startSec - v.startSec || u.endSec - v.endSec); + const rawDeduped: CaptionSegment[] = []; + for (const seg of segments) { + const prev = rawDeduped[rawDeduped.length - 1]; + if (prev && prev.text === seg.text && seg.startSec <= prev.endSec) { + prev.endSec = Math.max(prev.endSec, seg.endSec); + prev.startSec = Math.min(prev.startSec, seg.startSec); + continue; + } + rawDeduped.push(seg); + } + return rawDeduped; +} + +/** Runs the transcriber on one audio slice, chunking only long clips. */ +async function runTranscriberOnSlice( + transcriber: TranscriberFn, + samples: Float32Array, + opts: { forceFullSequences: boolean; timestampMode: "word" | "phrase" }, +): Promise { + const durationSec = samples.length / 16_000; + // Only chunk long clips; short-audio chunking regressed some Whisper.js runs (empty chunks). + const chunking = durationSec > 30 ? { chunk_length_s: 30, stride_length_s: 5 } : {}; + return transcriber(samples, { + return_timestamps: opts.timestampMode === "word" ? "word" : true, + force_full_sequences: opts.forceFullSequences, + ...chunking, + }); +} + +/** Flattens the various shapes a Transformers.js ASR result can take into a chunk list. */ +function getChunksFromTranscriberResult(result: unknown): Array<{ + timestamp?: [number | null, number | null]; + text?: unknown; +}> { + if (result == null) return []; + if (Array.isArray(result)) { + const out: Array<{ timestamp?: [number | null, number | null]; text?: unknown }> = []; + for (const item of result) { + const chunks = (item as { chunks?: unknown })?.chunks; + if (Array.isArray(chunks)) out.push(...chunks); + } + return out; + } + const chunks = (result as { chunks?: unknown })?.chunks; + return Array.isArray(chunks) ? chunks : []; +} + +/** Prefer `chunks`; if the model only returned top-level `text`, synthesize one span for timing. */ +function extractChunksFromAsrResult(result: unknown): Array<{ + timestamp?: [number | null, number | null]; + text?: unknown; +}> { + const fromChunks = getChunksFromTranscriberResult(result); + if (fromChunks.length > 0) return fromChunks; + const single = Array.isArray(result) ? result[0] : result; + const text = + typeof (single as { text?: unknown })?.text === "string" + ? String((single as { text: string }).text).trim() + : ""; + if (text) { + return [{ timestamp: [0, null], text }]; + } + return []; +} + +/** + * Drives Whisper over (possibly sliced) mono 16 kHz audio and returns timed segments. + * Long audio is split so one pass doesn't exhaust WASM memory; timestamps are shifted + * back onto the full timeline. Tries word- then phrase-level timestamps, with a + * trim-ignoring retry, before giving up. + */ +export async function runTranscription( + transcriber: TranscriberFn, + samples: Float32Array, + trims: TrimRegion[], +): Promise { + const transcribeOne = async ( + ignoreTrims: boolean, + forceFullSequences: boolean, + timestampMode: "word" | "phrase", + ): Promise => { + try { + const activeTrims = ignoreTrims ? [] : trims; + if (samples.length <= TRANSCRIBE_SLICE_SAMPLES) { + const { slice, realDurationSec } = padTailSliceForTranscribe(samples); + const result = await runTranscriberOnSlice(transcriber, slice, { + forceFullSequences, + timestampMode, + }); + return segmentsFromTranscriberChunks( + extractChunksFromAsrResult(result), + 0, + activeTrims, + realDurationSec, + ); + } + + const all: CaptionSegment[] = []; + for (let offset = 0; offset < samples.length; offset += TRANSCRIBE_SLICE_SAMPLES) { + const end = Math.min(offset + TRANSCRIBE_SLICE_SAMPLES, samples.length); + const sliceRaw = samples.subarray(offset, end); + const isFinalSlice = end >= samples.length; + if (sliceRaw.length === 0) continue; + if (sliceRaw.length < MIN_TRANSCRIBE_SLICE_SAMPLES && !isFinalSlice) continue; + + const { slice, realDurationSec } = + sliceRaw.length < MIN_TRANSCRIBE_SLICE_SAMPLES && isFinalSlice + ? padTailSliceForTranscribe(sliceRaw) + : { slice: sliceRaw, realDurationSec: sliceRaw.length / 16_000 }; + + const result = await runTranscriberOnSlice(transcriber, slice, { + forceFullSequences, + timestampMode, + }); + const tOff = offset / 16_000; + all.push( + ...segmentsFromTranscriberChunks( + extractChunksFromAsrResult(result), + tOff, + activeTrims, + realDurationSec, + ), + ); + } + return all; + } catch (e) { + console.warn("[captioning] Whisper pass failed:", e); + return []; + } + }; + + const attemptModes: Array<"word" | "phrase"> = ["word", "phrase"]; + for (const timestampMode of attemptModes) { + let segments = await transcribeOne(false, true, timestampMode); + if (segments.length === 0) { + segments = await transcribeOne(false, false, timestampMode); + } + if (segments.length === 0 && trims.length > 0) { + segments = dropSegmentsOverlappingTrimRegions( + await transcribeOne(true, true, timestampMode), + trims, + ); + if (segments.length === 0) { + segments = dropSegmentsOverlappingTrimRegions( + await transcribeOne(true, false, timestampMode), + trims, + ); + } + } + if (segments.length > 0) { + return { segments, granularity: timestampMode }; + } + } + + return { segments: [], granularity: "phrase" }; +} diff --git a/src/lib/compositeLayout.test.ts b/src/lib/compositeLayout.test.ts index 65cdfd5734..51c3fd0b73 100644 --- a/src/lib/compositeLayout.test.ts +++ b/src/lib/compositeLayout.test.ts @@ -69,7 +69,7 @@ describe("computeCompositeLayout", () => { expect(landscape).not.toBeNull(); expect(portrait).not.toBeNull(); - // Same total pixel count — webcam area should be comparable + // Same total pixel count, so webcam area should be comparable. const landscapeArea = landscape!.webcamRect!.width * landscape!.webcamRect!.height; const portraitArea = portrait!.webcamRect!.width * portrait!.webcamRect!.height; expect(landscapeArea).toBe(portraitArea); @@ -135,10 +135,8 @@ describe("computeCompositeLayout", () => { webcamSizePreset: 100, }); - // Values below 10 should clamp to 10 expect(belowMin!.webcamRect!.width).toBe(atMin!.webcamRect!.width); expect(belowMin!.webcamRect!.height).toBe(atMin!.webcamRect!.height); - // Values above 50 should clamp to 50 expect(aboveMax!.webcamRect!.width).toBe(atMax!.webcamRect!.width); expect(aboveMax!.webcamRect!.height).toBe(atMax!.webcamRect!.height); }); diff --git a/src/lib/compositeLayout.ts b/src/lib/compositeLayout.ts index abb6b0f7b2..5eedb266d3 100644 --- a/src/lib/compositeLayout.ts +++ b/src/lib/compositeLayout.ts @@ -5,6 +5,20 @@ export interface RenderRect { height: number; } +/** Floor for the reactive webcam multiplier so the camera never shrinks below ~35% at deep zoom. */ +export const WEBCAM_REACTIVE_ZOOM_MIN_SCALE = 0.35; + +/** + * Maps the live zoom scale to a webcam size multiplier, inversely (2x zoom, half size; 3x, a + * third) so the camera stays out of the way while zoomed and returns to full size as zoom eases + * back. Clamped to a floor so it never disappears. appliedScale is already eased per frame, so + * the camera animates in sync for free. + */ +export function reactiveWebcamScale(zoomScale: number): number { + const safe = Number.isFinite(zoomScale) && zoomScale > 0 ? zoomScale : 1; + return Math.max(WEBCAM_REACTIVE_ZOOM_MIN_SCALE, Math.min(1, 1 / safe)); +} + export interface StyledRenderRect extends RenderRect { borderRadius: number; maskShape?: import("@/components/video-editor/types").WebcamMaskShape; @@ -192,7 +206,7 @@ export function computeCompositeLayout(params: { const { width: canvasWidth, height: canvasHeight } = canvasSize; const { width: screenWidth, height: screenHeight } = screenSize; - // "no-webcam" preset: hide the webcam entirely, screen fills the canvas normally + // no-webcam: hide the webcam, screen fills the canvas normally. if (layoutPreset === "no-webcam") { const screenRect = centerRect({ canvasSize, @@ -214,7 +228,7 @@ export function computeCompositeLayout(params: { if (preset.transform.type === "stack") { if (!webcamWidth || !webcamHeight || webcamWidth <= 0 || webcamHeight <= 0) { - // No webcam — screen fills the entire canvas (cover mode) + // No webcam, so screen fills the whole canvas (cover mode). return { screenRect: { x: 0, y: 0, width: canvasWidth, height: canvasHeight }, webcamRect: null, @@ -222,12 +236,12 @@ export function computeCompositeLayout(params: { }; } - // Webcam: full width at the bottom, maintaining its aspect ratio + // Webcam: full width at the bottom, keeping aspect ratio. const webcamAspect = webcamWidth / webcamHeight; const resolvedWebcamWidth = canvasWidth; const resolvedWebcamHeight = Math.round(canvasWidth / webcamAspect); - // Screen: fills remaining space at the top (cover mode — may crop sides) + // Screen: fills remaining space at the top (cover mode, may crop sides). const screenRectHeight = canvasHeight - resolvedWebcamHeight; return { @@ -326,8 +340,7 @@ export function computeCompositeLayout(params: { transform.minMargin, Math.round(Math.min(canvasWidth, canvasHeight) * transform.marginFraction), ); - // Use geometric mean so the webcam occupies a consistent visual proportion - // regardless of whether the canvas is portrait or landscape. + // Geometric mean so the webcam keeps a consistent visual proportion in portrait or landscape. const referenceDim = Math.sqrt(canvasWidth * canvasHeight); const maxWidth = Math.max(transform.minSize, referenceDim * MAX_STAGE_FRACTION); const maxHeight = Math.max(transform.minSize, referenceDim * MAX_STAGE_FRACTION); @@ -346,10 +359,10 @@ export function computeCompositeLayout(params: { let webcamY: number; if (webcamPosition) { - // Custom position: cx/cy represent the center of the webcam as a fraction of the canvas + // cx/cy are the webcam center as a fraction of the canvas. webcamX = Math.round(webcamPosition.cx * canvasWidth - width / 2); webcamY = Math.round(webcamPosition.cy * canvasHeight - height / 2); - // Clamp to stay within canvas bounds + // Clamp inside canvas bounds. webcamX = Math.max(0, Math.min(canvasWidth - width, webcamX)); webcamY = Math.max(0, Math.min(canvasHeight - height, webcamY)); } else { diff --git a/src/lib/cursor/cursorPathSmoothing.test.ts b/src/lib/cursor/cursorPathSmoothing.test.ts new file mode 100644 index 0000000000..e9bdd5e386 --- /dev/null +++ b/src/lib/cursor/cursorPathSmoothing.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import type { CursorRecordingData, CursorRecordingSample } from "@/native/contracts"; +import { getSmoothedCursorPath } from "./cursorPathSmoothing"; + +function makeRecording(samples: CursorRecordingSample[]): CursorRecordingData { + return { version: 2, provider: "native", assets: [], samples }; +} + +/** Roughness proxy: sum of squared second differences of x on a uniform grid. */ +function roughness( + sampleAt: (t: number) => { cx: number; cy: number } | null, + t0: number, + t1: number, +) { + const xs: number[] = []; + for (let t = t0; t <= t1; t += 5) { + const p = sampleAt(t); + if (p) xs.push(p.cx); + } + let acc = 0; + for (let i = 2; i < xs.length; i++) { + const d2 = xs[i] - 2 * xs[i - 1] + xs[i - 2]; + acc += d2 * d2; + } + return acc; +} + +describe("cursor path smoothing", () => { + it("removes high-frequency jitter while tracking the overall path", () => { + // A rightward drift with alternating zig-zag noise on cy, then a dwell at the end. + const samples: CursorRecordingSample[] = []; + for (let i = 0; i <= 40; i++) { + samples.push({ + timeMs: i * 33, + cx: 0.2 + (i / 40) * 0.6, + cy: 0.5 + (i % 2 === 0 ? 0.05 : -0.05), + visible: true, + }); + } + const driftEnd = samples[samples.length - 1].timeMs; + for (let i = 1; i <= 60; i++) { + samples.push({ timeMs: driftEnd + i * 33, cx: 0.8, cy: 0.5, visible: true }); + } + const data = makeRecording(samples); + const smoothed = getSmoothedCursorPath(data, 0.7)!; + const raw = getSmoothedCursorPath(makeRecording(samples), 0)!; + + // Compare jitter on the cy channel (where the zig-zag lives) over the moving portion. + const cyAt = (path: typeof smoothed) => (t: number) => { + const p = path.sampleAt(t); + return p ? { cx: p.cy, cy: p.cx } : null; + }; + const smoothRough = roughness(cyAt(smoothed), 0, driftEnd); + const rawRough = roughness(cyAt(raw), 0, driftEnd); + expect(smoothRough).toBeLessThan(rawRough * 0.25); + + // After the cursor rests, the spring settles onto the true target (click accuracy). + const end = samples[samples.length - 1].timeMs; + const last = smoothed.sampleAt(end)!; + expect(last.cx).toBeCloseTo(0.8, 2); + expect(last.cy).toBeCloseTo(0.5, 2); + }); + + it("is a passthrough at smoothing 0", () => { + const samples: CursorRecordingSample[] = [ + { timeMs: 0, cx: 0.1, cy: 0.1, visible: true }, + { timeMs: 100, cx: 0.9, cy: 0.4, visible: true }, + ]; + const path = getSmoothedCursorPath(makeRecording(samples), 0)!; + expect(path.sampleAt(0)).toEqual({ cx: 0.1, cy: 0.1 }); + expect(path.sampleAt(50)!.cx).toBeCloseTo(0.5, 5); + expect(path.sampleAt(100)).toEqual({ cx: 0.9, cy: 0.4 }); + }); + + it("respects visibility gaps and never smooths across them", () => { + const samples: CursorRecordingSample[] = [ + { timeMs: 0, cx: 0.2, cy: 0.2, visible: true }, + { timeMs: 100, cx: 0.3, cy: 0.3, visible: true }, + { timeMs: 150, cx: 0.3, cy: 0.3, visible: false }, + { timeMs: 200, cx: 0.8, cy: 0.8, visible: true }, + { timeMs: 300, cx: 0.9, cy: 0.9, visible: true }, + ]; + const path = getSmoothedCursorPath(makeRecording(samples), 0.6)!; + expect(path.sampleAt(50)).not.toBeNull(); + expect(path.sampleAt(160)).toBeNull(); // inside the hidden gap + expect(path.sampleAt(250)).not.toBeNull(); + }); + + it("is deterministic for identical inputs", () => { + const build = () => + getSmoothedCursorPath( + makeRecording([ + { timeMs: 0, cx: 0.1, cy: 0.5, visible: true }, + { timeMs: 50, cx: 0.4, cy: 0.55, visible: true }, + { timeMs: 120, cx: 0.7, cy: 0.45, visible: true }, + ]), + 0.65, + )!; + const a = build(); + const b = build(); + for (const t of [0, 25, 60, 90, 120]) { + expect(a.sampleAt(t)).toEqual(b.sampleAt(t)); + } + }); + + it("returns null when there is no cursor data", () => { + expect(getSmoothedCursorPath(null, 0.5)).toBeNull(); + expect(getSmoothedCursorPath(makeRecording([]), 0.5)).toBeNull(); + }); +}); diff --git a/src/lib/cursor/cursorPathSmoothing.ts b/src/lib/cursor/cursorPathSmoothing.ts new file mode 100644 index 0000000000..e89b9575d5 --- /dev/null +++ b/src/lib/cursor/cursorPathSmoothing.ts @@ -0,0 +1,237 @@ +import { getCursorSpringConfig } from "@/components/video-editor/videoPlayback/motionSmoothing"; +import type { CursorRecordingData, CursorRecordingSample } from "@/native/contracts"; + +/** + * Offline cursor-path smoothing for native recordings. + * + * We have the whole path up front, so instead of a per-frame causal filter we precompute once: + * resample to a fixed high rate, then run a spring-damper over it. The spring gives the motion + * inertia (it trails the real cursor) and is deterministic, so preview and export match exactly. + */ + +export interface SmoothedCursorPosition { + cx: number; + cy: number; +} + +export interface SmoothedCursorPath { + /** Smoothed normalized position at a time, or null when the cursor is hidden there. */ + sampleAt(timeMs: number): SmoothedCursorPosition | null; +} + +/** 240 steps/sec keeps the spring stable and crisp at any playback fps. */ +const STEP_MS = 1000 / 240; +const STEP_S = STEP_MS / 1000; + +interface SmoothedRun { + start: number; + end: number; + times: Float32Array; + xs: Float32Array; + ys: Float32Array; +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +function binarySearchAtOrBefore( + times: Float32Array | number[], + timeMs: number, + hi: number, +): number { + let low = 0; + let high = hi; + let result = -1; + while (low <= high) { + const mid = low + ((high - low) >> 1); + if (times[mid] <= timeMs) { + result = mid; + low = mid + 1; + } else { + high = mid - 1; + } + } + return result; +} + +/** Linear interpolation of a sample run's position at an arbitrary time. */ +function interpolateRun(samples: CursorRecordingSample[], timeMs: number): SmoothedCursorPosition { + const last = samples.length - 1; + if (timeMs <= samples[0].timeMs) return { cx: samples[0].cx, cy: samples[0].cy }; + if (timeMs >= samples[last].timeMs) return { cx: samples[last].cx, cy: samples[last].cy }; + const i = binarySearchAtOrBefore( + samples.map((s) => s.timeMs), + timeMs, + last, + ); + const a = samples[i]; + const b = samples[i + 1] ?? a; + const span = b.timeMs - a.timeMs; + if (span <= 0) return { cx: a.cx, cy: a.cy }; + const t = (timeMs - a.timeMs) / span; + return { cx: a.cx + (b.cx - a.cx) * t, cy: a.cy + (b.cy - a.cy) * t }; +} + +/** + * Drive a spring across `targets`, returning the smoothed series. Semi-implicit + * (symplectic) Euler, stable for these stiffness values at the 240Hz grid. + */ +function springSmooth( + targets: Float32Array, + stiffness: number, + damping: number, + mass: number, +): Float32Array { + const out = new Float32Array(targets.length); + if (targets.length === 0) return out; + let x = targets[0]; + let v = 0; + out[0] = x; + for (let i = 1; i < targets.length; i++) { + const accel = (-stiffness * (x - targets[i]) - damping * v) / mass; + v += accel * STEP_S; + x += v * STEP_S; + out[i] = x; + } + return out; +} + +/** Maximal runs of visible samples, so we never smooth across a hidden gap. */ +function splitVisibleRuns(samples: CursorRecordingSample[]): CursorRecordingSample[][] { + const runs: CursorRecordingSample[][] = []; + let current: CursorRecordingSample[] = []; + for (const sample of samples) { + if (sample.visible === false) { + if (current.length) runs.push(current); + current = []; + continue; + } + current.push(sample); + } + if (current.length) runs.push(current); + return runs; +} + +function buildSmoothedRun( + samples: CursorRecordingSample[], + stiffness: number, + damping: number, + mass: number, +): SmoothedRun { + const start = samples[0].timeMs; + const end = samples[samples.length - 1].timeMs; + const stepCount = Math.max(1, Math.round((end - start) / STEP_MS)); + const n = stepCount + 1; + const times = new Float32Array(n); + const rawX = new Float32Array(n); + const rawY = new Float32Array(n); + for (let i = 0; i < n; i++) { + const t = i === n - 1 ? end : start + i * STEP_MS; + times[i] = t; + const p = interpolateRun(samples, t); + rawX[i] = p.cx; + rawY[i] = p.cy; + } + // The spring is itself a strong low-pass (~3Hz cutoff), so it removes capture tremor without a + // separate denoise pass. Chasing the raw target keeps the cursor accurate near sharp stops (no + // acausal pull toward neighbouring samples that would offset clicks/dwells). + return { + start, + end, + times, + xs: springSmooth(rawX, stiffness, damping, mass), + ys: springSmooth(rawY, stiffness, damping, mass), + }; +} + +function sampleRun(run: SmoothedRun, timeMs: number): SmoothedCursorPosition { + const last = run.times.length - 1; + if (timeMs <= run.times[0]) return { cx: run.xs[0], cy: run.ys[0] }; + if (timeMs >= run.times[last]) return { cx: run.xs[last], cy: run.ys[last] }; + const i = binarySearchAtOrBefore(run.times, timeMs, last); + const span = run.times[i + 1] - run.times[i]; + if (span <= 0) return { cx: run.xs[i], cy: run.ys[i] }; + const t = (timeMs - run.times[i]) / span; + return { + cx: run.xs[i] + (run.xs[i + 1] - run.xs[i]) * t, + cy: run.ys[i] + (run.ys[i + 1] - run.ys[i]) * t, + }; +} + +/** Passthrough path (smoothing 0): raw linear interpolation, still respecting visibility gaps. */ +function buildRawPath(runs: CursorRecordingSample[][]): SmoothedCursorPath { + return { + sampleAt(timeMs) { + for (const run of runs) { + if (timeMs >= run[0].timeMs && timeMs <= run[run.length - 1].timeMs) { + return interpolateRun(run, timeMs); + } + } + return null; + }, + }; +} + +function buildSmoothedPath( + recordingData: CursorRecordingData, + smoothing01: number, +): SmoothedCursorPath { + const runs = splitVisibleRuns(recordingData.samples).filter((run) => run.length > 0); + if (runs.length === 0) { + return { sampleAt: () => null }; + } + if (smoothing01 <= 0) { + return buildRawPath(runs); + } + + // Use the slider value directly to match the live overlay's spring strength so both cursor + // systems lag identically (an extra multiplier here over-smoothed, causing a visible offset). + const config = getCursorSpringConfig(clamp(smoothing01, 0, 1)); + + const smoothedRuns = runs.map((run) => + run.length < 2 + ? { + start: run[0].timeMs, + end: run[0].timeMs, + times: new Float32Array([run[0].timeMs]), + xs: new Float32Array([run[0].cx]), + ys: new Float32Array([run[0].cy]), + } + : buildSmoothedRun(run, config.stiffness, config.damping, config.mass), + ); + + return { + sampleAt(timeMs) { + for (const run of smoothedRuns) { + if (timeMs >= run.start && timeMs <= run.end) return sampleRun(run, timeMs); + } + return null; + }, + }; +} + +const pathCache = new WeakMap>(); + +/** + * Returns the smoothed cursor path for a recording at a given strength, memoized per + * (recordingData, strength) so it's built once and shared by preview and export. + */ +export function getSmoothedCursorPath( + recordingData: CursorRecordingData | null | undefined, + smoothing01: number, +): SmoothedCursorPath | null { + if (!recordingData || recordingData.samples.length === 0) return null; + const key = (Number.isFinite(smoothing01) ? clamp(smoothing01, 0, 1) : 0).toFixed(2); + let byStrength = pathCache.get(recordingData); + if (!byStrength) { + byStrength = new Map(); + pathCache.set(recordingData, byStrength); + } + let path = byStrength.get(key); + if (!path) { + path = buildSmoothedPath(recordingData, Number.parseFloat(key)); + byStrength.set(key, path); + } + return path; +} diff --git a/src/lib/cursor/cursorThemes.ts b/src/lib/cursor/cursorThemes.ts new file mode 100644 index 0000000000..dd6fa7cc04 --- /dev/null +++ b/src/lib/cursor/cursorThemes.ts @@ -0,0 +1,421 @@ +import type { NativeCursorType } from "@/native/contracts"; + +/** + * A single themed cursor image override for one {@link NativeCursorType}. + * + * width/height/hotspot are in the same ~32-logical-pixel reference as the built-in + * PRETTY_NATIVE_CURSOR_ASSETS, so a theme asset matches the default cursor's on-screen + * size regardless of source PNG resolution. The PNG can be higher-res (e.g. 128x128) + * and is downscaled at draw time for crisper retina output. + */ +export interface CursorThemeAsset { + /** Path relative to the public asset root, e.g. "cursors/hello-kitty-watermelon/arrow.png". */ + assetPath: string; + width: number; + height: number; + hotspotX: number; + hotspotY: number; +} + +export interface CursorTheme { + id: string; + /** Display label. Proper nouns, so not run through i18n. */ + name: string; + /** Attribution / origin for the artwork. */ + source?: string; + /** + * Per-cursor-type overrides. Missing types fall back to the built-in default art. + * Sweezy packs only ship "arrow" and "pointer". + */ + assets: Partial>; +} + +/** Sentinel id for the built-in cursor art (no theme override). */ +export const DEFAULT_CURSOR_THEME_ID = "default"; + +/** + * Bundled cursor themes. To add a pack: drop arrow.png/pointer.png into + * public/cursors// and add an entry here with hotspots normalized to the + * 32-logical reference (divide a 128px-pack hotspot by 4). No renderer changes needed. + */ +export const CURSOR_THEMES: readonly CursorTheme[] = [ + { + id: "hello-kitty-watermelon", + name: "Hello Kitty & Watermelon", + source: "sweezy-cursors.com", + assets: { + arrow: { + assetPath: "cursors/hello-kitty-watermelon/arrow.png", + width: 32, + height: 32, + hotspotX: 1.5, + hotspotY: 0.5, + }, + pointer: { + assetPath: "cursors/hello-kitty-watermelon/pointer.png", + width: 32, + height: 32, + hotspotX: 4, + hotspotY: 2, + }, + }, + }, + { + id: "among-us-sus-knife-and-red-animated", + name: "Among Us Sus Knife & Red Animated", + source: "sweezy-cursors.com", + assets: { + arrow: { + assetPath: "cursors/among-us-sus-knife-and-red-animated/arrow.png", + width: 32, + height: 32, + hotspotX: 1.6, + hotspotY: 0.96, + }, + pointer: { + assetPath: "cursors/among-us-sus-knife-and-red-animated/pointer.png", + width: 32, + height: 32, + hotspotX: 12, + hotspotY: 2, + }, + }, + }, + { + id: "black-and-rainbow-stroke-gradient-animated", + name: "Black & Rainbow Stroke Gradient Animated", + source: "sweezy-cursors.com", + assets: { + arrow: { + assetPath: "cursors/black-and-rainbow-stroke-gradient-animated/arrow.png", + width: 32, + height: 32, + hotspotX: 1.6, + hotspotY: 0.96, + }, + pointer: { + assetPath: "cursors/black-and-rainbow-stroke-gradient-animated/pointer.png", + width: 32, + height: 32, + hotspotX: 8, + hotspotY: 1.5, + }, + }, + }, + { + id: "black-pixel", + name: "Black Pixel", + source: "sweezy-cursors.com", + assets: { + arrow: { + assetPath: "cursors/black-pixel/arrow.png", + width: 32, + height: 32, + hotspotX: 2, + hotspotY: 3.5, + }, + pointer: { + assetPath: "cursors/black-pixel/pointer.png", + width: 32, + height: 32, + hotspotX: 8, + hotspotY: 1.5, + }, + }, + }, + { + id: "christmas-miles-morales", + name: "Christmas Miles Morales", + source: "sweezy-cursors.com", + assets: { + arrow: { + assetPath: "cursors/christmas-miles-morales/arrow.png", + width: 32, + height: 32, + hotspotX: 1, + hotspotY: 0.5, + }, + pointer: { + assetPath: "cursors/christmas-miles-morales/pointer.png", + width: 32, + height: 32, + hotspotX: 5.5, + hotspotY: 3, + }, + }, + }, + { + id: "hollow-knight-and-game-arrow", + name: "Hollow Knight & Game Arrow", + source: "sweezy-cursors.com", + assets: { + arrow: { + assetPath: "cursors/hollow-knight-and-game-arrow/arrow.png", + width: 32, + height: 32, + hotspotX: 0.5, + hotspotY: 0.5, + }, + pointer: { + assetPath: "cursors/hollow-knight-and-game-arrow/pointer.png", + width: 32, + height: 32, + hotspotX: 5, + hotspotY: 0.5, + }, + }, + }, + { + id: "hollow-knight-nail-sword-and-mask", + name: "Hollow Knight Nail Sword & Mask", + source: "sweezy-cursors.com", + assets: { + arrow: { + assetPath: "cursors/hollow-knight-nail-sword-and-mask/arrow.png", + width: 32, + height: 32, + hotspotX: 0.5, + hotspotY: 0.5, + }, + pointer: { + assetPath: "cursors/hollow-knight-nail-sword-and-mask/pointer.png", + width: 32, + height: 32, + hotspotX: 3.5, + hotspotY: 2, + }, + }, + }, + { + id: "naruto-akatsuki-cloud-arrow", + name: "Naruto Akatsuki Cloud Arrow", + source: "sweezy-cursors.com", + assets: { + arrow: { + assetPath: "cursors/naruto-akatsuki-cloud-arrow/arrow.png", + width: 32, + height: 32, + hotspotX: 0.5, + hotspotY: 0.5, + }, + pointer: { + assetPath: "cursors/naruto-akatsuki-cloud-arrow/pointer.png", + width: 32, + height: 32, + hotspotX: 1, + hotspotY: 1, + }, + }, + }, + { + id: "old-roblox", + name: "Old Roblox", + source: "sweezy-cursors.com", + assets: { + arrow: { + assetPath: "cursors/old-roblox/arrow.png", + width: 32, + height: 32, + hotspotX: 2.5, + hotspotY: 1.5, + }, + pointer: { + assetPath: "cursors/old-roblox/pointer.png", + width: 32, + height: 32, + hotspotX: 3.5, + hotspotY: 1.5, + }, + }, + }, + { + id: "pink-glossy-arrow-and-hand-3d", + name: "Pink Glossy Arrow & Hand 3D", + source: "sweezy-cursors.com", + assets: { + arrow: { + assetPath: "cursors/pink-glossy-arrow-and-hand-3d/arrow.png", + width: 32, + height: 32, + hotspotX: 1.5, + hotspotY: 1.5, + }, + pointer: { + assetPath: "cursors/pink-glossy-arrow-and-hand-3d/pointer.png", + width: 32, + height: 32, + hotspotX: 3, + hotspotY: 1, + }, + }, + }, + { + id: "pinky-pixel", + name: "Pinky Pixel", + source: "sweezy-cursors.com", + assets: { + arrow: { + assetPath: "cursors/pinky-pixel/arrow.png", + width: 32, + height: 32, + hotspotX: 0.5, + hotspotY: 0.5, + }, + pointer: { + assetPath: "cursors/pinky-pixel/pointer.png", + width: 32, + height: 32, + hotspotX: 7, + hotspotY: 1, + }, + }, + }, + { + id: "pokemon-neon-gengar", + name: "Pokemon Neon Gengar", + source: "sweezy-cursors.com", + assets: { + arrow: { + assetPath: "cursors/pokemon-neon-gengar/arrow.png", + width: 32, + height: 32, + hotspotX: 1, + hotspotY: 0.5, + }, + pointer: { + assetPath: "cursors/pokemon-neon-gengar/pointer.png", + width: 32, + height: 32, + hotspotX: 2, + hotspotY: 2.5, + }, + }, + }, + { + id: "sanrio-gudetama-and-arrow-kawaii", + name: "Sanrio Gudetama & Arrow Kawaii", + source: "sweezy-cursors.com", + assets: { + arrow: { + assetPath: "cursors/sanrio-gudetama-and-arrow-kawaii/arrow.png", + width: 32, + height: 32, + hotspotX: 0.5, + hotspotY: 0.5, + }, + pointer: { + assetPath: "cursors/sanrio-gudetama-and-arrow-kawaii/pointer.png", + width: 32, + height: 32, + hotspotX: 8, + hotspotY: 4, + }, + }, + }, + { + id: "spring-gradient", + name: "Spring Gradient", + source: "sweezy-cursors.com", + assets: { + arrow: { + assetPath: "cursors/spring-gradient/arrow.png", + width: 32, + height: 32, + hotspotX: 1.5, + hotspotY: 0.5, + }, + pointer: { + assetPath: "cursors/spring-gradient/pointer.png", + width: 32, + height: 32, + hotspotX: 8, + hotspotY: 0.5, + }, + }, + }, + { + id: "mickey-mouse-black-hand-inflated-glove", + name: "Mickey Mouse Black Hand Inflated Glove", + source: "sweezy-cursors.com", + assets: { + arrow: { + assetPath: "cursors/mickey-mouse-black-hand-inflated-glove/arrow.png", + width: 32, + height: 32, + hotspotX: 2.5, + hotspotY: 0.5, + }, + pointer: { + assetPath: "cursors/mickey-mouse-black-hand-inflated-glove/pointer.png", + width: 32, + height: 32, + hotspotX: 10, + hotspotY: 0.5, + }, + }, + }, + { + id: "sanrio-kuromi-skull-arrow", + name: "Sanrio Kuromi Skull Arrow", + source: "sweezy-cursors.com", + assets: { + arrow: { + assetPath: "cursors/sanrio-kuromi-skull-arrow/arrow.png", + width: 32, + height: 32, + hotspotX: 1.5, + hotspotY: 0.5, + }, + pointer: { + assetPath: "cursors/sanrio-kuromi-skull-arrow/pointer.png", + width: 32, + height: 32, + hotspotX: 9.5, + hotspotY: 1, + }, + }, + }, + { + id: "solo-leveling-sung-jinwoo-dark-flames", + name: "Solo Leveling Sung Jinwoo Dark Flames", + source: "sweezy-cursors.com", + assets: { + arrow: { + assetPath: "cursors/solo-leveling-sung-jinwoo-dark-flames/arrow.png", + width: 32, + height: 32, + hotspotX: 2, + hotspotY: 1, + }, + pointer: { + assetPath: "cursors/solo-leveling-sung-jinwoo-dark-flames/pointer.png", + width: 32, + height: 32, + hotspotX: 7, + hotspotY: 4.5, + }, + }, + }, +]; + +/** All selectable theme ids, including the built-in default. */ +export const CURSOR_THEME_IDS: ReadonlySet = new Set([ + DEFAULT_CURSOR_THEME_ID, + ...CURSOR_THEMES.map((theme) => theme.id), +]); + +/** Returns the theme for `id`, or null for the default / unknown ids. */ +export function getCursorTheme(id: string | null | undefined): CursorTheme | null { + if (!id || id === DEFAULT_CURSOR_THEME_ID) { + return null; + } + return CURSOR_THEMES.find((theme) => theme.id === id) ?? null; +} + +/** + * Normalizes a persisted/incoming theme id to a known value, falling back to the + * default for anything unrecognized. + */ +export function normalizeCursorThemeId(id: unknown): string { + return typeof id === "string" && CURSOR_THEME_IDS.has(id) ? id : DEFAULT_CURSOR_THEME_ID; +} diff --git a/src/lib/cursor/nativeCursor.test.ts b/src/lib/cursor/nativeCursor.test.ts index 3e7a6760f2..3bc49a0a25 100644 --- a/src/lib/cursor/nativeCursor.test.ts +++ b/src/lib/cursor/nativeCursor.test.ts @@ -4,6 +4,7 @@ import { getNativeCursorClickBounceProgress, getNativeCursorClickBounceScale, hasNativeCursorRecordingData, + projectNativeCursorToLocal, resolveInterpolatedNativeCursorFrame, resolveNativeCursorRenderAsset, } from "./nativeCursor"; @@ -101,3 +102,184 @@ describe("native cursor click bounce", () => { expect(getNativeCursorClickBounceProgress(recordingData, 133)).toBeGreaterThan(0); }); }); + +describe("custom cursor themes", () => { + const arrowAsset: NativeCursorAsset = { + id: "telemetry-arrow", + platform: "darwin", + imageDataUrl: "default-arrow", + width: 32, + height: 32, + hotspotX: 16, + hotspotY: 15, + cursorType: "arrow", + }; + + it("substitutes the themed art for an overridden cursor type", () => { + const rendered = resolveNativeCursorRenderAsset( + arrowAsset, + 1, + { timeMs: 0, cx: 0.5, cy: 0.5, cursorType: "arrow" }, + "hello-kitty-watermelon", + ); + + expect(rendered.id).toBe("theme:hello-kitty-watermelon:arrow"); + expect(rendered.imageDataUrl).toContain("cursors/hello-kitty-watermelon/arrow.png"); + expect(rendered.width).toBe(32); + expect(rendered.hotspotX).toBeCloseTo(1.5); + }); + + it("classifies an untyped macOS arrow bitmap (top-left hotspot) as the themed arrow", () => { + const macArrow: NativeCursorAsset = { + id: "sha-arrow", + platform: "darwin", + imageDataUrl: "captured-bitmap", + width: 34, + height: 46, + hotspotX: 8, + hotspotY: 8, + scaleFactor: 2, + }; + const rendered = resolveNativeCursorRenderAsset( + macArrow, + 1, + { timeMs: 0, cx: 0.5, cy: 0.5 }, + "hello-kitty-watermelon", + ); + + expect(rendered.id).toBe("theme:hello-kitty-watermelon:arrow"); + expect(rendered.imageDataUrl).toContain("cursors/hello-kitty-watermelon/arrow.png"); + }); + + it("classifies an untyped macOS hand bitmap (upper-center hotspot) as the themed pointer", () => { + const macHand: NativeCursorAsset = { + id: "sha-hand", + platform: "darwin", + imageDataUrl: "captured-bitmap", + width: 64, + height: 64, + hotspotX: 26, + hotspotY: 16, + scaleFactor: 2, + }; + const rendered = resolveNativeCursorRenderAsset( + macHand, + 1, + { timeMs: 0, cx: 0.5, cy: 0.5 }, + "hello-kitty-watermelon", + ); + + expect(rendered.id).toBe("theme:hello-kitty-watermelon:pointer"); + expect(rendered.imageDataUrl).toContain("cursors/hello-kitty-watermelon/pointer.png"); + }); + + it("leaves an untyped text/crosshair bitmap (centered hotspot) as the real captured cursor", () => { + const macText: NativeCursorAsset = { + id: "sha-text", + platform: "darwin", + imageDataUrl: "captured-ibeam", + width: 18, + height: 36, + hotspotX: 8, + hotspotY: 18, + scaleFactor: 2, + }; + const rendered = resolveNativeCursorRenderAsset( + macText, + 1, + { timeMs: 0, cx: 0.5, cy: 0.5 }, + "hello-kitty-watermelon", + ); + + expect(rendered.id).toBe("sha-text"); + expect(rendered.imageDataUrl).toBe("captured-ibeam"); + }); + + it("keeps the default art for the default theme id", () => { + const rendered = resolveNativeCursorRenderAsset( + arrowAsset, + 1, + { timeMs: 0, cx: 0.5, cy: 0.5, cursorType: "arrow" }, + "default", + ); + + expect(rendered.id).toBe("pretty:arrow"); + expect(rendered.imageDataUrl).not.toContain("hello-kitty-watermelon"); + }); + + it("falls back to default art for a cursor type the theme does not override", () => { + const rendered = resolveNativeCursorRenderAsset( + { ...arrowAsset, cursorType: "text" }, + 1, + { timeMs: 0, cx: 0.5, cy: 0.5, cursorType: "text" }, + "hello-kitty-watermelon", + ); + + expect(rendered.id).toBe("pretty:text"); + }); +}); + +describe("projectNativeCursorToLocal", () => { + const identityCrop = { x: 0, y: 0, width: 1, height: 1 }; + + it("maps a sample onto the supplied painted rectangle 1:1 with no crop", () => { + const point = projectNativeCursorToLocal({ + cropRegion: identityCrop, + maskRect: { x: 100, y: 200, width: 1280, height: 720 }, + sample: { timeMs: 0, cx: 0.25, cy: 0.5, visible: true }, + }); + + expect(point?.x).toBeCloseTo(100 + 0.25 * 1280); + expect(point?.y).toBeCloseTo(200 + 0.5 * 720); + }); + + it("maps a sample into the cropped region of the painted rectangle", () => { + const point = projectNativeCursorToLocal({ + cropRegion: { x: 0.25, y: 0.0, width: 0.5, height: 1.0 }, + maskRect: { x: 0, y: 0, width: 1920, height: 1080 }, + sample: { timeMs: 0, cx: 0.5, cy: 0.5, visible: true }, + }); + + expect(point?.x).toBeCloseTo(0 + ((0.5 - 0.25) / 0.5) * 1920); + expect(point?.y).toBeCloseTo(0 + (0.5 / 1.0) * 1080); + }); + + it("projects onto the cropped (cover-overflowing) painted rect, not the mask rect", () => { + const screenRect = { x: 0, y: 0, width: 1920, height: 1080 }; + const croppedRect = { x: 0, y: -540, width: 1920, height: 2160 }; + + const point = projectNativeCursorToLocal({ + cropRegion: { x: 0.0, y: 0.0, width: 0.5, height: 1.0 }, + maskRect: croppedRect, + sample: { timeMs: 0, cx: 0.25, cy: 0.25, visible: true }, + }); + + const wrong = screenRect.y + 0.25 * screenRect.height; + expect(wrong).toBe(270); + + expect(point?.x).toBeCloseTo(croppedRect.x + ((0.25 - 0) / 0.5) * croppedRect.width); + expect(point?.y).toBeCloseTo(croppedRect.y + (0.25 / 1.0) * croppedRect.height); + expect(point?.y).toBe(0); + expect(point?.y).not.toBe(wrong); + }); + + it("returns null for a sample outside the cropped region", () => { + const point = projectNativeCursorToLocal({ + cropRegion: { x: 0.25, y: 0.25, width: 0.5, height: 0.5 }, + maskRect: { x: 0, y: 0, width: 1920, height: 1080 }, + sample: { timeMs: 0, cx: 0.1, cy: 0.5, visible: true }, + }); + + expect(point).toBeNull(); + }); + + it("returns null for a degenerate (zero-size) crop region", () => { + const point = projectNativeCursorToLocal({ + cropRegion: { x: 0, y: 0, width: 0, height: 1 }, + maskRect: { x: 0, y: 0, width: 1920, height: 1080 }, + sample: { timeMs: 0, cx: 0.5, cy: 0.5, visible: true }, + }); + + expect(point).toBeNull(); + }); +}); diff --git a/src/lib/cursor/nativeCursor.ts b/src/lib/cursor/nativeCursor.ts index f20fd4259a..dcf55eea50 100644 --- a/src/lib/cursor/nativeCursor.ts +++ b/src/lib/cursor/nativeCursor.ts @@ -16,6 +16,8 @@ import textUrl from "@/assets/cursors/Cursor=Text-Cursor.svg"; import upArrowUrl from "@/assets/cursors/Cursor=Up-Arrow.svg"; import waitUrl from "@/assets/cursors/Cursor=Wait.svg"; import type { CropRegion } from "@/components/video-editor/types"; +import { getAssetPath } from "@/lib/assetPath"; +import { DEFAULT_CURSOR_THEME_ID, getCursorTheme } from "@/lib/cursor/cursorThemes"; import type { CursorRecordingData, CursorRecordingSample, @@ -28,13 +30,6 @@ export interface ActiveNativeCursorFrame { sample: CursorRecordingSample; } -export interface NativeCursorSmoothingState { - cx: number; - cy: number; - lastTimeMs: number | null; - initialized: boolean; -} - export interface NativeCursorMotionBlurState { x: number; y: number; @@ -276,22 +271,6 @@ export function hasNativeCursorRecordingData( ); } -export function createNativeCursorSmoothingState(): NativeCursorSmoothingState { - return { - cx: 0, - cy: 0, - lastTimeMs: null, - initialized: false, - }; -} - -export function resetNativeCursorSmoothingState(state: NativeCursorSmoothingState) { - state.cx = 0; - state.cy = 0; - state.lastTimeMs = null; - state.initialized = false; -} - export function createNativeCursorMotionBlurState(): NativeCursorMotionBlurState { return { x: 0, @@ -308,49 +287,6 @@ export function resetNativeCursorMotionBlurState(state: NativeCursorMotionBlurSt state.initialized = false; } -export function smoothNativeCursorSample({ - forceSnap = false, - sample, - smoothing, - state, - timeMs, -}: { - forceSnap?: boolean; - sample: CursorRecordingSample; - smoothing: number; - state: NativeCursorSmoothingState; - timeMs: number; -}): CursorRecordingSample { - const clampedSmoothing = clamp(Number.isFinite(smoothing) ? smoothing : 0, 0, 0.98); - const previousTimeMs = state.lastTimeMs; - const shouldSnap = - forceSnap || - clampedSmoothing <= 0 || - !state.initialized || - previousTimeMs === null || - timeMs <= previousTimeMs; - - if (shouldSnap) { - state.cx = sample.cx; - state.cy = sample.cy; - state.lastTimeMs = timeMs; - state.initialized = true; - return sample; - } - - const frameCount = Math.max(1, (timeMs - previousTimeMs) / (1000 / 60)); - const alpha = 1 - Math.pow(clampedSmoothing, frameCount); - state.cx += (sample.cx - state.cx) * alpha; - state.cy += (sample.cy - state.cy) * alpha; - state.lastTimeMs = timeMs; - - return { - ...sample, - cx: state.cx, - cy: state.cy, - }; -} - export function getNativeCursorClickBounceProgress( recordingData: CursorRecordingData | null | undefined, timeMs: number, @@ -592,11 +528,81 @@ export function resolvePrettyNativeCursorAsset( : resolveUntypedPrettyNativeCursorAsset(asset); } +/** + * Infers "arrow" vs "pointer" from a captured bitmap's hotspot, for platforms (macOS) + * that don't tag samples with a `cursorType`. Arrow's hotspot is in the top-left tip; + * the pointing hand's fingertip is in the upper-center band. Anything else stays + * unclassified so it keeps its real captured cursor instead of a themed arrow/pointer. + */ +function classifyCapturedCursorType(asset: NativeCursorAsset): NativeCursorType | null { + if (asset.width <= 0 || asset.height <= 0) { + return null; + } + const hotspotXNorm = asset.hotspotX / asset.width; + const hotspotYNorm = asset.hotspotY / asset.height; + if (hotspotXNorm < 0.33 && hotspotYNorm < 0.33) { + return "arrow"; + } + if (hotspotYNorm < 0.4 && hotspotXNorm >= 0.33 && hotspotXNorm <= 0.6) { + return "pointer"; + } + return null; +} + +/** + * Resolves the theme override for a cursor type, or null when the default theme is active + * or has no art for that type. The asset URL resolves lazily (only when a theme is active) + * so this is safe from tests and non-renderer contexts; a failure degrades to default art. + */ +function resolveThemedCursorAsset( + themeId: string | null | undefined, + cursorType: NativeCursorType, +): PrettyNativeCursorAsset | null { + if (!themeId || themeId === DEFAULT_CURSOR_THEME_ID) { + return null; + } + const themeAsset = getCursorTheme(themeId)?.assets[cursorType]; + if (!themeAsset) { + return null; + } + try { + return { + imageDataUrl: getAssetPath(themeAsset.assetPath), + width: themeAsset.width, + height: themeAsset.height, + hotspotX: themeAsset.hotspotX, + hotspotY: themeAsset.hotspotY, + }; + } catch { + return null; + } +} + export function resolveNativeCursorRenderAsset( asset: NativeCursorAsset, deviceScaleFactor: number, sample?: CursorRecordingSample, + themeId?: string | null, ) { + const cursorType = sample?.cursorType ?? asset.cursorType ?? null; + if (themeId && themeId !== DEFAULT_CURSOR_THEME_ID) { + // A known type uses its override when the theme provides one. Untyped samples + // (common on macOS, where the type isn't tagged) are classified from the captured + // bitmap's hotspot so arrow becomes themed-arrow and hand becomes themed-pointer. + const themedType = cursorType ?? classifyCapturedCursorType(asset); + const themedAsset = themedType ? resolveThemedCursorAsset(themeId, themedType) : null; + if (themedAsset && themedType) { + return { + id: `theme:${themeId}:${themedType}`, + imageDataUrl: themedAsset.imageDataUrl, + width: themedAsset.width, + height: themedAsset.height, + hotspotX: themedAsset.hotspotX, + hotspotY: themedAsset.hotspotY, + }; + } + } + const prettyAsset = resolvePrettyNativeCursorAsset(asset, sample); if (prettyAsset) { return { diff --git a/src/lib/cursorTelemetryBuffer.test.ts b/src/lib/cursorTelemetryBuffer.test.ts index 17174accc8..dffb5eec13 100644 --- a/src/lib/cursorTelemetryBuffer.test.ts +++ b/src/lib/cursorTelemetryBuffer.test.ts @@ -2,8 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { type CursorTelemetryPoint, createCursorTelemetryBuffer } from "./cursorTelemetryBuffer"; function sample(tag: number): CursorTelemetryPoint { - // Decouple the timestamp tag from the coordinate fixture so cursor - // points stay inside the normalized [0, 1] range that real samples use. + // Decouple the tag from coordinates so points stay in the normalized [0, 1] range. const normalized = (tag % 100) / 100; return { timeMs: tag, cx: normalized, cy: normalized }; } @@ -121,11 +120,9 @@ describe("createCursorTelemetryBuffer", () => { }); it("discardBatch(id) targets the correct batch even when a later recording sits in front of it", () => { - // Regression test for the rapid Stop → Record → Discard sequence: - // recording A's finalize callback does async work (fixWebmDuration), - // recording B finishes in the meantime, then A's callback resolves - // with discard intent. The discard must drop A — not B, which - // happens to be the *latest* pending batch by the time discard runs. + // Regression for the rapid Stop/Record/Discard sequence: A's finalize callback does + // async work (fixWebmDuration), B finishes meanwhile, then A resolves with discard + // intent. The discard must drop A, not B, which is the latest pending batch by then. const buf = createCursorTelemetryBuffer({ maxActiveSamples: 10 }); buf.startSession(1); @@ -216,8 +213,8 @@ describe("createCursorTelemetryBuffer", () => { expect(buf.pendingCount).toBe(2); expect(warn).not.toHaveBeenCalled(); - // Simulate a misuse where a retry prepends without first draining: - // queue would grow to 3, so the oldest-trailing entry must be evicted. + // Misuse: a retry prepends without draining first, so the queue would grow to 3 + // and the oldest-trailing entry must be evicted. buf.prependBatch({ recordingId: 99, samples: [sample(99)] }); expect(buf.pendingCount).toBe(2); expect(warn).toHaveBeenCalledTimes(1); @@ -232,8 +229,7 @@ describe("createCursorTelemetryBuffer", () => { }); it("sanitizes non-finite or non-positive option values to safe defaults", () => { - // Infinity / NaN / negative would otherwise turn the trim loops - // into infinite loops. The buffer must fall back to defaults. + // Infinity/NaN/negative would turn the trim loops infinite; the buffer must fall back to defaults. const buf = createCursorTelemetryBuffer({ maxActiveSamples: Number.POSITIVE_INFINITY, maxPendingBatches: Number.NaN, diff --git a/src/lib/cursorTelemetryBuffer.ts b/src/lib/cursorTelemetryBuffer.ts index 0c7e0e10ec..02d6147bba 100644 --- a/src/lib/cursorTelemetryBuffer.ts +++ b/src/lib/cursorTelemetryBuffer.ts @@ -1,10 +1,7 @@ /** - * A single cursor telemetry sample captured during a recording session. - * - * Coordinates (`cx`, `cy`) are clamped ratios in the `[0, 1]` range, - * normalised against the captured surface's width and height by the - * main-process `sampleCursorPoint()` before being pushed. `timeMs` is the - * offset (in milliseconds) from the recording's start. + * A single cursor telemetry sample. cx/cy are clamped [0,1] ratios of the + * captured surface (normalised in the main process by sampleCursorPoint). + * timeMs is the offset from recording start. */ export interface CursorTelemetryPoint { timeMs: number; @@ -13,9 +10,9 @@ export interface CursorTelemetryPoint { } /** - * A completed batch of cursor samples, tagged with the recording id that - * produced them. The id is supplied at `startSession()` time and travels - * with the batch through the pending queue, retries, and discards. + * A completed batch of cursor samples, tagged with its recording id. The id + * (from startSession) travels with the batch through the queue, retries, and + * discards. */ export interface CursorTelemetryBatch { recordingId: number; @@ -25,87 +22,64 @@ export interface CursorTelemetryBatch { /** * Per-session cursor telemetry buffer with bounded memory. * - * Flow: `startSession(recordingId)` → `push(point)` N times → `endSession()` - * enqueues the collected samples as a completed batch tagged with that - * `recordingId`. The main process later drains batches in FIFO order via - * `takeNextBatch()` to persist them to disk, and can `prependBatch()` on - * write failure to retry without losing order. A discard request keys on - * the recording id so an asynchronous "discard recording A" decision that - * arrives after recording B has already enqueued its batch still drops - * the right one. + * Flow: startSession(recordingId), push(point) N times, endSession() enqueues + * the samples as a batch tagged with that id. The main process drains batches + * FIFO via takeNextBatch() to persist, and prependBatch() on write failure to + * retry without losing order. Discard keys on the recording id so an async + * "discard recording A" that arrives after recording B has enqueued still + * drops the right batch. * - * Memory is bounded by `maxActiveSamples` (ring buffer on the in-progress - * batch) and `maxPendingBatches` (FIFO cap across completed batches). + * Memory bounded by maxActiveSamples (ring buffer on the in-progress batch) + * and maxPendingBatches (FIFO cap across completed batches). */ export interface CursorTelemetryBuffer { /** - * Begin a new recording session under the given `recordingId`. Clears - * any in-progress active samples (without touching already-completed - * pending batches). Safe to call repeatedly — e.g. a rapid Stop → - * Record sequence — and the most recent id wins. + * Begin a new recording session. Clears in-progress active samples but + * leaves completed pending batches. Safe to call repeatedly (e.g. a rapid + * Stop then Record); the most recent id wins. */ startSession(recordingId: number): void; /** - * Append a telemetry sample to the current active session. When the - * active buffer exceeds `maxActiveSamples`, the oldest sample is - * dropped (ring behaviour). + * Append a sample to the active session. Over maxActiveSamples, the oldest + * sample is dropped (ring behaviour). */ push(point: CursorTelemetryPoint): void; /** - * Finalize the active session, moving its samples into the pending - * queue as a single batch tagged with the current recording id. Empty - * sessions are dropped (no empty batch is enqueued). - * - * If the pending queue would exceed `maxPendingBatches`, the oldest - * batches are evicted to bound memory. A `console.warn` is emitted - * whenever at least one batch is dropped so that pathological rapid- - * restart scenarios are observable. + * Finalize the active session into a single pending batch tagged with the + * current recording id. Empty sessions enqueue nothing. Over + * maxPendingBatches, oldest batches are evicted and a warn is logged so + * pathological rapid-restart cases are observable. * - * @returns the number of pending batches dropped by this call (0 under - * normal operation). + * @returns the number of pending batches dropped (0 normally). */ endSession(): number; - /** - * Remove and return the oldest pending batch, or `null` if the queue - * is empty. - */ + /** Remove and return the oldest pending batch, or null if empty. */ takeNextBatch(): CursorTelemetryBatch | null; /** - * Re-insert a batch at the front of the queue, preserving FIFO order - * on retry paths (e.g. when persisting the batch failed and the - * caller wants the next `takeNextBatch()` to yield it again). - * - * Empty batches are ignored. The pending cap is enforced defensively - * — if prepending would push the queue past `maxPendingBatches`, the - * oldest entries are evicted and a `console.warn` is emitted. In - * normal retry usage this trim is a no-op because the caller has just - * removed the batch via `takeNextBatch()`. + * Re-insert a batch at the front, preserving FIFO order on retry (e.g. + * persisting failed and the next takeNextBatch() should yield it again). + * Empty batches are ignored. The pending cap is enforced defensively; in + * normal retry usage the trim is a no-op since the caller just took it. */ prependBatch(batch: CursorTelemetryBatch): void; /** - * Drop the pending batch produced by the given `recordingId`. Used - * when a recording is discarded after its `endSession()` has run but - * before it has been persisted. Returns `true` if a batch was - * removed, `false` otherwise (no matching id, or the batch was - * already drained). + * Drop the pending batch for the given recordingId, when a recording is + * discarded after endSession() but before persistence. Returns true if a + * batch was removed. * - * Keying on the recording id (rather than "the latest pending batch") - * avoids a real bug: when finalizing a recording does asynchronous - * work like `fixWebmDuration`, a quick Stop → Record → Discard - * sequence can interleave such that the latest pending batch belongs - * to a *later* recording than the one being discarded. + * Keys on the recording id rather than "the latest pending batch" to avoid + * a bug: async finalize work (fixWebmDuration) means a quick Stop, Record, + * Discard can leave the latest pending batch belonging to a later recording + * than the one being discarded. */ discardBatch(recordingId: number): boolean; - /** - * Clear both the active and pending state. Intended for tests and - * full teardown paths. - */ + /** Clear active and pending state. For tests and full teardown. */ reset(): void; readonly activeCount: number; @@ -128,11 +102,8 @@ function sanitizeLimit(value: number | undefined, fallback: number): number { } /** - * Create a cursor telemetry buffer. - * - * Numeric options are sanitized: non-finite, negative, or zero values fall - * back to safe defaults so a bad caller cannot disable the memory bounds - * (which would turn the trim loops into infinite loops). + * Create a cursor telemetry buffer. Options are sanitized so a bad caller + * cannot disable the memory bounds (which would make the trim loops infinite). * * @see CursorTelemetryBuffer for the full lifecycle contract. */ diff --git a/src/lib/customFonts.ts b/src/lib/customFonts.ts index af332c1391..4cf00c6a30 100644 --- a/src/lib/customFonts.ts +++ b/src/lib/customFonts.ts @@ -1,16 +1,15 @@ -// Google Fonts loading and management utility +// Google Fonts loading and management export interface CustomFont { id: string; - name: string; // Display name - fontFamily: string; // CSS font-family value + name: string; + fontFamily: string; importUrl: string; // Google Fonts @import URL } const STORAGE_KEY = "openscreen_custom_fonts"; const loadedFonts = new Set(); -// Load custom fonts from localStorage export function getCustomFonts(): CustomFont[] { try { const stored = localStorage.getItem(STORAGE_KEY); @@ -21,7 +20,6 @@ export function getCustomFonts(): CustomFont[] { } } -// Save custom fonts to localStorage export function saveCustomFonts(fonts: CustomFont[]): void { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(fonts)); @@ -30,7 +28,7 @@ export function saveCustomFonts(fonts: CustomFont[]): void { } } -// Add a new custom font (throws error if font fails to load) +// Throws if the font fails to load export async function addCustomFont(font: CustomFont): Promise { const fonts = getCustomFonts(); const exists = fonts.some((f) => f.id === font.id || f.fontFamily === font.fontFamily); @@ -39,23 +37,20 @@ export async function addCustomFont(font: CustomFont): Promise { return fonts; } - // Try to load the font first - this will throw if it fails + // Load first so a failure throws before we persist it await loadFont(font); - // Only add to storage if font loaded successfully fonts.push(font); saveCustomFonts(fonts); return fonts; } -// Remove a custom font export function removeCustomFont(fontId: string): CustomFont[] { const fonts = getCustomFonts(); const filtered = fonts.filter((f) => f.id !== fontId); saveCustomFonts(filtered); - // Remove the style element const styleEl = document.getElementById(`custom-font-${fontId}`); if (styleEl) { styleEl.remove(); @@ -68,7 +63,6 @@ export function removeCustomFont(fontId: string): CustomFont[] { // Load a Google Font into the document export function loadFont(font: CustomFont): Promise { return new Promise((resolve, reject) => { - // Skip if already loaded if (loadedFonts.has(font.id)) { resolve(); return; @@ -77,19 +71,16 @@ export function loadFont(font: CustomFont): Promise { try { const styleId = `custom-font-${font.id}`; - // Remove existing style if present const existing = document.getElementById(styleId); if (existing) { existing.remove(); } - // Create style element with @import const style = document.createElement("style"); style.id = styleId; style.textContent = `@import url('${font.importUrl}');`; document.head.appendChild(style); - // Wait for font to load waitForFont(font.fontFamily) .then(() => { loadedFonts.add(font.id); @@ -103,17 +94,15 @@ export function loadFont(font: CustomFont): Promise { }); } -// Wait for a font to be available and verify it loaded +// Wait for a font to load and verify it's actually available function waitForFont(fontFamily: string, timeout = 5000): Promise { return new Promise((resolve, reject) => { - // Use CSS Font Loading API if available if ("fonts" in document) { Promise.race([ document.fonts.load(`16px "${fontFamily}"`), new Promise((_, rej) => setTimeout(() => rej(new Error("Font load timeout")), timeout)), ]) .then(() => { - // Verify the font actually loaded by checking if it's available const isAvailable = document.fonts.check(`16px "${fontFamily}"`); if (isAvailable) { resolve(); @@ -125,14 +114,13 @@ function waitForFont(fontFamily: string, timeout = 5000): Promise { reject(error); }); } else { - // Fallback for browsers without Font Loading API - // Wait a bit and hope for the best + // No Font Loading API: wait a bit and hope for the best setTimeout(() => resolve(), 1000); } }); } -// Load all stored custom fonts on app initialization +// Load all stored custom fonts on app init export function loadAllCustomFonts(): Promise { const fonts = getCustomFonts(); return Promise.all( @@ -144,22 +132,21 @@ export function loadAllCustomFonts(): Promise { ); } -// Generate a unique ID for a font export function generateFontId(name: string): string { return `${name.toLowerCase().replace(/\s+/g, "-")}-${Date.now()}`; } -// Parse Google Fonts @import URL to extract font family name +// Extract the font family from a Google Fonts @import URL export function parseFontFamilyFromImport(importUrl: string): string | null { try { - // Extract from URL like: https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap + // e.g. https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap const url = new URL(importUrl); const familyParam = url.searchParams.get("family"); if (familyParam) { - // Remove weight/style info: "Roboto:wght@400;700" -> "Roboto" + // "Roboto:wght@400;700" -> "Roboto" const fontName = familyParam.split(":")[0]; - // Replace + with spaces: "Open+Sans" -> "Open Sans" + // "Open+Sans" -> "Open Sans" return fontName.replace(/\+/g, " "); } @@ -170,7 +157,7 @@ export function parseFontFamilyFromImport(importUrl: string): string | null { } } -// Validate if a string looks like a Google Fonts import URL +// Does this look like a Google Fonts import URL? export function isValidGoogleFontsUrl(url: string): boolean { try { const urlObj = new URL(url); diff --git a/src/lib/exporter/annotationRenderer.ts b/src/lib/exporter/annotationRenderer.ts index a2ac08a198..1ef55d6723 100644 --- a/src/lib/exporter/annotationRenderer.ts +++ b/src/lib/exporter/annotationRenderer.ts @@ -11,11 +11,9 @@ import { let blurScratchCanvas: HTMLCanvasElement | null = null; let blurScratchCtx: CanvasRenderingContext2D | null = null; -// Matches a single code point whose script is Han (including non-BMP -// Extension A-F), Hiragana, Katakana (including halfwidth forms), or -// Hangul. Used to split CJK text at character boundaries during wrap, -// since CJK scripts have no word-separating whitespace. Unicode script -// property escapes require ES2018+; tsconfig target is ES2020. +// Han/Hiragana/Katakana/Hangul code points, to split CJK text at character +// boundaries during wrap (CJK has no word-separating whitespace). Script +// escapes need ES2018+; tsconfig targets ES2020. const CJK_CHAR = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u; type GraphemeSegmenter = { @@ -39,10 +37,9 @@ function splitGraphemes(value: string): string[] { } function tokenizeForWrap(line: string): string[] { - // Split Latin text on whitespace (preserving the whitespace as its own token, - // matching the original behavior), and split CJK runs into individual - // characters so each one becomes a breakable unit. This mirrors the editor's - // CSS `word-break: break-word` handling for CJK content. + // Split Latin on whitespace (kept as its own token) and split CJK runs into + // individual chars so each is breakable, mirroring the editor's CSS + // word-break: break-word for CJK. const tokens: string[] = []; let buffer = ""; const chars = Array.from(line); @@ -126,10 +123,8 @@ function renderArrow( const offsetX = padding + (availableWidth - 100 * scale) / 2; const offsetY = padding + (availableHeight - 100 * scale) / 2; - // Apply centering offset ctx.translate(offsetX, offsetY); - // Apply shadow filter ctx.shadowColor = "rgba(0, 0, 0, 0.3)"; ctx.shadowBlur = 8 * scale; ctx.shadowOffsetX = 0; @@ -140,7 +135,7 @@ function renderArrow( ctx.lineCap = "round"; ctx.lineJoin = "round"; - // Draw all paths as a single shape to avoid overlapping shadows/strokes + // One shape so shadows/strokes don't overlap ctx.beginPath(); for (const pathString of paths) { @@ -278,7 +273,7 @@ function renderText( ctx.translate(-transformOriginX, -transformOriginY); ctx.globalAlpha *= animationState.opacity; - // Clip text to annotation box bounds (matches editor's overflow: hidden) + // Clip to box bounds, matching editor's overflow: hidden ctx.beginPath(); ctx.rect(x, y, width, height); ctx.clip(); @@ -414,7 +409,7 @@ async function renderImage( return new Promise((resolve) => { const img = new Image(); img.onload = () => { - // Preserve aspect ratio - contain the image within the bounds + // Contain within bounds, preserving aspect ratio const imgAspect = img.width / img.height; const boxAspect = width / height; @@ -450,12 +445,11 @@ export async function renderAnnotations( currentTimeMs: number, scaleFactor: number = 1.0, ): Promise { - // Filter active annotations at current time const activeAnnotations = annotations.filter( (ann) => currentTimeMs >= ann.startMs && currentTimeMs < ann.endMs, ); - // Sort by z-index (lower first, so higher z-index draws on top) + // Lower z-index first so higher draws on top const sortedAnnotations = [...activeAnnotations].sort((a, b) => a.zIndex - b.zIndex); for (const annotation of sortedAnnotations) { diff --git a/src/lib/exporter/audioEncoder.ts b/src/lib/exporter/audioEncoder.ts index 95227844d7..7514907e2e 100644 --- a/src/lib/exporter/audioEncoder.ts +++ b/src/lib/exporter/audioEncoder.ts @@ -210,9 +210,8 @@ export class AudioProcessor { } /** - * Audio export has two modes: - * 1) no speed regions -> fast WebCodecs trim-only pipeline - * 2) speed regions present -> pitch-preserving rendered timeline pipeline + * Two modes: no speed regions uses the fast WebCodecs trim-only pipeline; speed + * regions use the pitch-preserving rendered timeline pipeline. */ async process( demuxer: WebDemuxer, @@ -230,7 +229,7 @@ export class AudioProcessor { .sort((a, b) => a.startMs - b.startMs) : []; - // Speed edits must use timeline playback to preserve pitch + // Speed edits need timeline playback to preserve pitch. if (sortedSpeedRegions.length > 0) { const renderedAudioBlob = await this.renderPitchPreservedTimelineAudio( videoUrl, @@ -245,14 +244,14 @@ export class AudioProcessor { return; } - // No speed edits: keep the original demux/decode/encode path with trim timestamp remap. - // The +0.5s buffer mirrors streamingDecoder.decodeAll's read window so the trim-only - // and speed-aware paths agree on how far to read past the validated duration boundary. + // No speed edits: demux/decode/encode with trim timestamp remap. The +0.5s mirrors + // streamingDecoder.decodeAll's read window so both paths read the same distance past + // the validated duration boundary. const readEndSec = validatedDurationSec + 0.5; await this.processTrimOnlyAudio(demuxer, muxer, sortedTrims, readEndSec, exportCodec); } - // Legacy trim-only path. This is still used for projects without speed regions. + // Trim-only path, used for projects without speed regions. private async processTrimOnlyAudio( demuxer: WebDemuxer, muxer: VideoMuxer, @@ -274,7 +273,7 @@ export class AudioProcessor { return; } - // Phase 1: Decode audio from source, skipping trimmed regions + // Phase 1: decode, skipping trimmed regions. const decodedFrames: AudioData[] = []; const decoder = new AudioDecoder({ @@ -325,7 +324,7 @@ export class AudioProcessor { return; } - // Phase 2: Re-encode with timestamps adjusted for trim gaps + // Phase 2: re-encode with timestamps adjusted for trim gaps. const encodedChunks: { chunk: EncodedAudioChunk; meta?: EncodedAudioChunkMetadata }[] = []; const encoder = new AudioEncoder({ @@ -391,7 +390,7 @@ export class AudioProcessor { encoder.close(); } - // Phase 3: Flush encoded chunks to muxer + // Phase 3: flush encoded chunks to muxer. for (const { chunk, meta } of encodedChunks) { if (this.cancelled) break; await muxer.addAudioChunk(chunk, meta); @@ -402,8 +401,8 @@ export class AudioProcessor { ); } - // Speed-aware path that mirrors preview semantics (trim skipping + playbackRate regions) - // preserve pitch through browser media playback behavior to avoid chipmunk effect. + // Speed-aware path mirroring preview semantics (trim skipping + playbackRate). Relies on + // browser media playback to preserve pitch and avoid the chipmunk effect. private async renderPitchPreservedTimelineAudio( videoUrl: string, trimRegions: TrimRegion[], @@ -442,9 +441,8 @@ export class AudioProcessor { await audioContext.resume(); } - // Skip past any initial trim region(s) before recording starts to avoid - // capturing trimmed audio during the first rAF frames of playback. - // Loops to handle back-to-back or overlapping trims at t=0. + // Skip initial trim region(s) before recording so the first rAF frames don't + // capture trimmed audio. Loops to handle back-to-back/overlapping trims at t=0. const effectiveEnd = validatedDurationSec; let startPosition = 0; for (let i = 0; i <= trimRegions.length; i++) { @@ -455,19 +453,19 @@ export class AudioProcessor { } if (startPosition >= effectiveEnd) { - // All content is trimmed — return silent blob + // Everything is trimmed; return a silent blob. return new Blob([], { type: "audio/webm" }); } await this.seekTo(media, startPosition); - // Set initial playback rate for the starting position + // Set initial playback rate for the starting position. const initialSpeedRegion = this.findActiveSpeedRegion(startPosition * 1000, speedRegions); if (initialSpeedRegion) { media.playbackRate = initialSpeedRegion.speed; } - // Start recording only AFTER seeking past trims + // Start recording only after seeking past trims. const recording = this.startAudioRecording(destinationNode.stream); recorder = recording.recorder; recordedBlobPromise = recording.recordedBlobPromise; @@ -500,8 +498,8 @@ export class AudioProcessor { return; } - // Stop playback at validated duration — browser's media.duration - // may be inflated from bad container metadata. + // Stop at validated duration; media.duration can be inflated by bad + // container metadata. if (media.currentTime >= validatedDurationSec) { media.pause(); cleanup(); @@ -520,8 +518,7 @@ export class AudioProcessor { resolve(); return; } - // Pause recording during trim seek to prevent capturing - // silence/noise as the audio element seeks. + // Pause recording during the seek so we don't capture silence/noise. media.pause(); if (recorder?.state === "recording") recorder.pause(); const onSeeked = () => { @@ -591,9 +588,8 @@ export class AudioProcessor { } if (!recordedBlobPromise) { - // Invariant: either an early return above fires, or startAudioRecording ran and - // populated recordedBlobPromise before the playback Promise resolved. Reaching - // here means that contract was broken — fail loud instead of returning silence. + // Either an early return fired or startAudioRecording set this before playback + // resolved. Reaching here means that broke; fail loud rather than return silence. throw new Error("Audio recorder finished without assigning recordedBlobPromise"); } const recordedBlob = await recordedBlobPromise; @@ -603,7 +599,7 @@ export class AudioProcessor { return recordedBlob; } - // Demuxes the rendered speed-adjusted blob and feeds encoded chunks into the MP4 muxer. + // Demux the rendered speed-adjusted blob and feed its chunks into the MP4 muxer. private async muxRenderedAudioBlob( blob: Blob, muxer: VideoMuxer, diff --git a/src/lib/exporter/frameRenderer.test.ts b/src/lib/exporter/frameRenderer.test.ts new file mode 100644 index 0000000000..16ba6bde05 --- /dev/null +++ b/src/lib/exporter/frameRenderer.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { drawWebcamFrameImage } from "./webcamFrameDrawing"; + +type DrawCall = + | ["drawImage", unknown, number, number, number, number, number, number, number, number] + | ["restore"] + | ["save"] + | ["scale", number, number] + | ["translate", number, number]; + +function createMockCanvasContext() { + const calls: DrawCall[] = []; + const ctx = { + drawImage: ( + image: CanvasImageSource, + sx: number, + sy: number, + sw: number, + sh: number, + dx: number, + dy: number, + dw: number, + dh: number, + ) => calls.push(["drawImage", image, sx, sy, sw, sh, dx, dy, dw, dh]), + restore: () => calls.push(["restore"]), + save: () => calls.push(["save"]), + scale: (x: number, y: number) => calls.push(["scale", x, y]), + translate: (x: number, y: number) => calls.push(["translate", x, y]), + }; + + return { calls, ctx }; +} + +describe("drawWebcamFrameImage", () => { + it("draws the webcam frame into the layout rect by default", () => { + const { calls, ctx } = createMockCanvasContext(); + const frame = {} as CanvasImageSource; + + drawWebcamFrameImage( + ctx, + frame, + { x: 12, y: 8, width: 640, height: 360 }, + { x: 100, y: 50, width: 320, height: 180 }, + ); + + expect(calls).toEqual([["drawImage", frame, 12, 8, 640, 360, 100, 50, 320, 180]]); + }); + + it("mirrors around the webcam rect without changing the crop", () => { + const { calls, ctx } = createMockCanvasContext(); + const frame = {} as CanvasImageSource; + + drawWebcamFrameImage( + ctx, + frame, + { x: 12, y: 8, width: 640, height: 360 }, + { x: 100, y: 50, width: 320, height: 180 }, + true, + ); + + expect(calls).toEqual([ + ["save"], + ["translate", 420, 50], + ["scale", -1, 1], + ["drawImage", frame, 12, 8, 640, 360, 0, 0, 320, 180], + ["restore"], + ]); + }); + + it("restores the canvas context if mirrored drawing fails", () => { + const { calls, ctx } = createMockCanvasContext(); + const frame = {} as CanvasImageSource; + const error = new Error("draw failed"); + ctx.drawImage = () => { + calls.push(["drawImage", frame, 12, 8, 640, 360, 0, 0, 320, 180]); + throw error; + }; + + expect(() => + drawWebcamFrameImage( + ctx, + frame, + { x: 12, y: 8, width: 640, height: 360 }, + { x: 100, y: 50, width: 320, height: 180 }, + true, + ), + ).toThrow(error); + + expect(calls).toEqual([ + ["save"], + ["translate", 420, 50], + ["scale", -1, 1], + ["drawImage", frame, 12, 8, 640, 360, 0, 0, 320, 180], + ["restore"], + ]); + }); +}); diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 858215815d..36e86a0654 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -24,19 +24,17 @@ import { lerpRotation3D, } from "@/components/video-editor/types"; import { - AUTO_FOLLOW_RAMP_DISTANCE, - AUTO_FOLLOW_SMOOTHING_FACTOR, - AUTO_FOLLOW_SMOOTHING_FACTOR_MAX, + AUTO_FOLLOW_PARAMS, DEFAULT_FOCUS, - ZOOM_SCALE_DEADZONE, - ZOOM_TRANSLATION_DEADZONE_PX, } from "@/components/video-editor/videoPlayback/constants"; -import { - adaptiveSmoothFactor, - smoothCursorFocus, -} from "@/components/video-editor/videoPlayback/cursorFollowUtils"; +import { advanceFollowFocus } from "@/components/video-editor/videoPlayback/cursorFollowUtils"; import { clampFocusToScale } from "@/components/video-editor/videoPlayback/focusUtils"; import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils"; +import { + createZoomSpringState, + resetZoomSpring, + stepZoomSpring, +} from "@/components/video-editor/videoPlayback/zoomSpring"; import { applyZoomTransform, computeFocusFromTransform, @@ -47,21 +45,20 @@ import { import { computeCompositeLayout, getWebcamLayoutPresetDefinition, + reactiveWebcamScale, type Size, type StyledRenderRect, } from "@/lib/compositeLayout"; +import { getSmoothedCursorPath } from "@/lib/cursor/cursorPathSmoothing"; import { createNativeCursorMotionBlurState, - createNativeCursorSmoothingState, getNativeCursorClickBounceProgress, getNativeCursorClickBounceScale, getNativeCursorMotionBlurPx, projectNativeCursorToLocal, resetNativeCursorMotionBlurState, - resetNativeCursorSmoothingState, resolveInterpolatedNativeCursorFrame, resolveNativeCursorRenderAsset, - smoothNativeCursorSample, } from "@/lib/cursor/nativeCursor"; import { BackgroundLoadError, classifyWallpaper, resolveImageWallpaperUrl } from "@/lib/wallpaper"; import { drawCanvasClipPath } from "@/lib/webcamMaskShapes"; @@ -74,6 +71,7 @@ import { resolveLinearGradientAngle, } from "./gradientParser"; import { createThreeDPass, type ThreeDPass } from "./threeDPass"; +import { drawWebcamFrameImage } from "./webcamFrameDrawing"; interface FrameRenderConfig { width: number; @@ -93,11 +91,14 @@ interface FrameRenderConfig { cursorMotionBlur?: number; cursorClickBounce?: number; cursorClipToBounds?: boolean; + cursorTheme?: string; videoWidth: number; videoHeight: number; webcamSize?: Size | null; webcamLayoutPreset?: WebcamLayoutPreset; webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape; + webcamMirrored?: boolean; + webcamReactiveZoom?: boolean; webcamSizePreset?: WebcamSizePreset; webcamPosition?: { cx: number; cy: number } | null; annotationRegions?: AnnotationRegion[]; @@ -125,6 +126,7 @@ interface LayoutCache { baseScale: number; baseOffset: { x: number; y: number }; maskRect: { x: number; y: number; width: number; height: number }; + croppedRect: { x: number; y: number; width: number; height: number }; maskBorderRadius: number; webcamRect: StyledRenderRect | null; } @@ -157,10 +159,10 @@ export class FrameRenderer { private layoutCache: LayoutCache | null = null; private currentVideoTime = 0; private motionBlurState: MotionBlurState = createMotionBlurState(); - private nativeCursorSmoothingState = createNativeCursorSmoothingState(); private nativeCursorMotionBlurState = createNativeCursorMotionBlurState(); private smoothedAutoFocus: { cx: number; cy: number } | null = null; private prevAnimationTimeMs: number | null = null; + private zoomSpringState = createZoomSpringState(); private prevTargetProgress = 0; private isLinux = false; @@ -179,22 +181,19 @@ export class FrameRenderer { } async initialize(): Promise { - // Create canvas for rendering const canvas = document.createElement("canvas"); canvas.width = this.config.width; canvas.height = this.config.height; - // Try to set colorSpace if supported (may not be available on all platforms) + // colorSpace isn't available on all platforms try { if (canvas && "colorSpace" in canvas) { canvas.colorSpace = "srgb"; } } catch (error) { - // Silently ignore colorSpace errors on platforms that don't support it console.warn("[FrameRenderer] colorSpace not supported on this platform:", error); } - // Initialize PixiJS with optimized settings for export performance this.app = new Application(); await this.app.init({ canvas, @@ -206,16 +205,14 @@ export class FrameRenderer { autoDensity: true, }); - // Setup containers this.cameraContainer = new Container(); this.videoContainer = new Container(); this.app.stage.addChild(this.cameraContainer); this.cameraContainer.addChild(this.videoContainer); - // Setup background (render separately, not in PixiJS) + // Background renders separately, not in PixiJS await this.setupBackground(); - // Setup blur filter for video container this.blurFilter = new BlurFilter(); this.blurFilter.quality = 5; this.blurFilter.resolution = this.app.renderer.resolution; @@ -223,12 +220,12 @@ export class FrameRenderer { this.motionBlurFilter = new MotionBlurFilter([0, 0], 5, 0); this.videoContainer.filters = [this.blurFilter, this.motionBlurFilter]; - // Setup composite canvas for final output with shadows + // Composite canvas: final output with shadows this.compositeCanvas = document.createElement("canvas"); this.compositeCanvas.width = this.config.width; this.compositeCanvas.height = this.config.height; - // On Linux, getImageData() is called frequently causing frequent CPU readback + // On Linux getImageData() runs frequently, so hint frequent CPU readback this.compositeCtx = this.compositeCanvas.getContext("2d", { willReadFrequently: this.isLinux, }); @@ -245,9 +242,8 @@ export class FrameRenderer { throw new Error("Failed to get 2D context for raster canvas"); } - // Foreground canvas: holds recording + shadow + webcam + cursor + annotations, - // transparent background. The 3D rotation pass operates only on this layer so - // the wallpaper stays flat behind the rotated content (matching preview). + // Foreground (transparent): recording + shadow + webcam + cursor + annotations. + // The 3D pass operates only on this layer so the wallpaper stays flat behind it. this.foregroundCanvas = document.createElement("canvas"); this.foregroundCanvas.width = this.config.width; this.foregroundCanvas.height = this.config.height; @@ -258,7 +254,6 @@ export class FrameRenderer { throw new Error("Failed to get 2D context for foreground canvas"); } - // Setup shadow canvas if needed if (this.config.showShadow) { this.shadowCanvas = document.createElement("canvas"); this.shadowCanvas.width = this.config.width; @@ -272,7 +267,6 @@ export class FrameRenderer { } } - // Setup mask this.maskGraphics = new Graphics(); this.videoContainer.addChild(this.maskGraphics); this.videoContainer.mask = this.maskGraphics; @@ -389,20 +383,18 @@ export class FrameRenderer { this.currentVideoTime = timestamp / 1000000; - // Create or update video sprite from VideoFrame if (!this.videoSprite) { const texture = Texture.from(videoFrame as unknown as TextureSourceLike); this.videoSprite = new Sprite(texture); this.videoContainer.addChild(this.videoSprite); } else { - // Destroy old texture to avoid memory leaks, then create new one + // Destroy old texture before swapping to avoid a leak const oldTexture = this.videoSprite.texture; const newTexture = Texture.from(videoFrame as unknown as TextureSourceLike); this.videoSprite.texture = newTexture; oldTexture.destroy(true); } - // Apply layout this.updateLayout(webcamFrame); const timeMs = this.currentVideoTime * 1000; @@ -419,7 +411,11 @@ export class FrameRenderer { throw new Error("Layout cache not initialized"); } - // Apply transform once with maximum motion intensity from all ticks + // Feed the spring-smoothed transform (appliedScale/x/y) via transformOverride, like the + // preview. Without it applyZoomTransform recomputes the camera from the raw eased target and + // the spring is discarded, so the export snaps to the target every frame while the preview + // glides (very visible for auto-focus, whose target pans with the cursor). It also keeps the + // camera, mask, and cursor (which already read appliedScale/x/y) consistent. applyZoomTransform({ cameraContainer: this.cameraContainer, blurFilter: this.blurFilter, @@ -435,19 +431,24 @@ export class FrameRenderer { motionBlurAmount: this.config.motionBlurAmount ?? 0, motionBlurState: this.motionBlurState, frameTimeMs: timeMs, + transformOverride: { + scale: this.animationState.appliedScale, + x: this.animationState.x, + y: this.animationState.y, + }, }); - // Render the PixiJS stage to its canvas (video only, transparent background) + // Render the PixiJS stage (video only, transparent background) this.app.renderer.render(this.app.stage); - // Skip baking the shadow when the WebGL rotation pass will run — it'd alias to - // a hard edge through bilinear sampling. We re-apply shadow fresh after rotation. + // Skip baking the shadow when the rotation pass will run; bilinear sampling would + // alias it to a hard edge. Re-applied fresh after rotation. const willRotate = !isRotation3DIdentity(this.currentRotation3D); this.compositeWithShadows(webcamFrame, !willRotate); await this.drawNativeCursor(timeMs); - // Render annotations on top of foreground (so they rotate with recording). + // Annotations go on top of foreground so they rotate with the recording if ( this.config.annotationRegions && this.config.annotationRegions.length > 0 && @@ -469,14 +470,14 @@ export class FrameRenderer { ); } - // Apply 3D rotation to foreground only. Wallpaper (on compositeCanvas) is untouched. + // Rotate foreground only; wallpaper (on compositeCanvas) stays untouched if (willRotate && this.threeDPass && this.foregroundCanvas && this.foregroundCtx) { const passCanvas = this.threeDPass.apply(this.foregroundCanvas, this.currentRotation3D); const w = this.foregroundCanvas.width; const h = this.foregroundCanvas.height; this.foregroundCtx.clearRect(0, 0, w, h); if (this.isLinux) { - // drawImage(webglCanvas) is unreliable on Linux/Wayland — use readPixels. + // drawImage(webglCanvas) is unreliable on Linux/Wayland, so use readPixels const pixels = this.threeDPass.readPixels(); const imageData = this.foregroundCtx.createImageData(w, h); imageData.data.set(pixels); @@ -486,9 +487,9 @@ export class FrameRenderer { } } - // Apply shadow fresh on the rotated silhouette (flat path already baked it - // in compositeWithShadows, so guard on willRotate to avoid doubling). - // Same 3-layer filter chain as `main` — keeps the soft Gaussian intact. + // Apply shadow fresh on the rotated silhouette. Flat path already baked it in + // compositeWithShadows, so guard on willRotate to avoid doubling. Same 3-layer + // filter chain as the flat path to keep the soft Gaussian intact. if ( willRotate && this.config.showShadow && @@ -517,24 +518,23 @@ export class FrameRenderer { this.compositeCtx.drawImage(this.shadowCanvas, 0, 0); } } else if (this.compositeCtx && this.foregroundCanvas) { - // Flat path or 3D-without-shadow: stamp foreground directly. + // Flat path or 3D-without-shadow: stamp foreground directly this.compositeCtx.drawImage(this.foregroundCanvas, 0, 0); } } - // The video's actual on-screen boundary, accounting for the zoom camera - // transform. The PIXI mask lives inside cameraContainer, so during zoom the - // visible video extends beyond the static maskRect — a static clip would crop - // it. Mirrors the preview, which clips via the same camera-scaled bounds. + // Video's on-screen boundary including the zoom camera transform. The PIXI mask + // lives inside cameraContainer, so during zoom the visible video extends beyond + // the static maskRect and a static clip would crop it. Mirrors the preview. private cameraAwareMaskRect() { if (!this.layoutCache) return null; const { x: maskX, y: maskY, width: maskW, height: maskH } = this.layoutCache.maskRect; const camS = this.animationState.appliedScale; const camX = this.animationState.x; const camY = this.animationState.y; - // No stage clamping: canvas naturally clips to its bounds, matching CSS inset() behavior. - // Clamping x/y would shift rounded corners to the stage edge rather than the true mask - // boundary, causing preview/export mismatch when zoom/pan pushes the mask off-stage. + // No stage clamping: the canvas clips to its own bounds, matching CSS inset(). + // Clamping x/y would pin rounded corners to the stage edge instead of the true + // mask boundary, mismatching preview/export when zoom/pan pushes the mask off-stage. return { x: camX + camS * maskX, y: camY + camS * maskY, @@ -550,7 +550,6 @@ export class FrameRenderer { } if ((this.config.cursorScale ?? 1) <= 0) { - resetNativeCursorSmoothingState(this.nativeCursorSmoothingState); resetNativeCursorMotionBlurState(this.nativeCursorMotionBlurState); return; } @@ -560,29 +559,35 @@ export class FrameRenderer { timeMs, ); if (!activeNativeCursor) { - resetNativeCursorSmoothingState(this.nativeCursorSmoothingState); resetNativeCursorMotionBlurState(this.nativeCursorMotionBlurState); return; } - const displaySample = smoothNativeCursorSample({ - sample: activeNativeCursor.sample, - smoothing: this.config.cursorSmoothing ?? 0, - state: this.nativeCursorSmoothingState, - timeMs, - }); + // Position comes from the precomputed smoothed path (deterministic, matches preview); + // the frame still supplies the cursor image, type, and click timing. + const smoothedPos = getSmoothedCursorPath( + this.config.cursorRecordingData, + this.config.cursorSmoothing ?? 0, + )?.sampleAt(timeMs); + const displaySample = smoothedPos + ? { ...activeNativeCursor.sample, cx: smoothedPos.cx, cy: smoothedPos.cy } + : activeNativeCursor.sample; const projectedPoint = projectNativeCursorToLocal({ cropRegion: this.config.cropRegion, - maskRect: this.layoutCache.maskRect, + maskRect: this.layoutCache.croppedRect, sample: displaySample, }); if (!projectedPoint) { - resetNativeCursorSmoothingState(this.nativeCursorSmoothingState); resetNativeCursorMotionBlurState(this.nativeCursorMotionBlurState); return; } - const renderAsset = resolveNativeCursorRenderAsset(activeNativeCursor.asset, 1, displaySample); + const renderAsset = resolveNativeCursorRenderAsset( + activeNativeCursor.asset, + 1, + displaySample, + this.config.cursorTheme, + ); let image: HTMLImageElement; try { image = await this.getCursorImage(renderAsset); @@ -597,8 +602,10 @@ export class FrameRenderer { getNativeCursorClickBounceProgress(this.config.cursorRecordingData, timeMs), ); const appliedScale = this.animationState.appliedScale; - // Normalize cursor size so it appears at the same fraction of the video width - // as in the preview — both paths now use maskRect.width / croppedVideoWidth. + // Normalize cursor size to croppedRect.width (the painted video width). + // The preview path still uses screenRect.width; they agree in cover mode but + // differ in fit-to-height letterbox — known asymmetry pending the preview-path + // follow-up to project the cursor onto the cropped sub-rect as well. const sizeNorm = this.layoutCache.videoSize.width > 0 ? this.layoutCache.maskRect.width / this.layoutCache.videoSize.width @@ -611,7 +618,7 @@ export class FrameRenderer { state: this.nativeCursorMotionBlurState, timeMs, }); - // Clip only when explicitly enabled; by default the cursor may overflow the canvas. + // Clip only when explicitly enabled; by default the cursor may overflow the canvas const cursorClip = this.config.cursorClipToBounds === true ? this.cameraAwareMaskRect() : null; this.foregroundCtx.save(); this.foregroundCtx.beginPath(); @@ -673,7 +680,6 @@ export class FrameRenderer { const videoWidth = this.config.videoWidth; const videoHeight = this.config.videoHeight; - // Calculate cropped video dimensions const cropStartX = cropRegion.x; const cropStartY = cropRegion.y; const cropEndX = cropRegion.x + cropRegion.width; @@ -682,9 +688,8 @@ export class FrameRenderer { const croppedVideoWidth = videoWidth * (cropEndX - cropStartX); const croppedVideoHeight = videoHeight * (cropEndY - cropStartY); - // Calculate scale to fit in viewport - // Padding is a percentage (0-100), where 50% ~ 0.8 scale - // Vertical stack ignores padding — it's full-bleed + // Padding is a percentage (0-100), where 50% ~ 0.8 scale. + // Vertical stack is full-bleed, so it ignores padding. const effectivePadding = this.config.webcamLayoutPreset === "vertical-stack" ? 0 : padding; const paddingScale = 1.0 - (effectivePadding / 100) * 0.4; const viewportWidth = width * paddingScale; @@ -703,7 +708,7 @@ export class FrameRenderer { const screenRect = compositeLayout.screenRect; - // Cover mode: scale to fill the rect (may crop), otherwise fit-to-width + // Cover mode scales to fill the rect (may crop), otherwise fit-to-width let scale: number; if (compositeLayout.screenCover) { scale = Math.max( @@ -714,7 +719,6 @@ export class FrameRenderer { scale = screenRect.width / croppedVideoWidth; } - // Position video sprite this.videoSprite.width = videoWidth * scale; this.videoSprite.height = videoHeight * scale; @@ -729,11 +733,10 @@ export class FrameRenderer { this.videoSprite.x = -cropPixelX + coverOffsetX; this.videoSprite.y = -cropPixelY + coverOffsetY; - // Position video container this.videoContainer.x = screenRect.x; this.videoContainer.y = screenRect.y; - // scale border radius by export/preview canvas ratio + // Scale border radius by the export/preview canvas ratio const previewWidth = this.config.previewWidth ?? this.config.width; const previewHeight = this.config.previewHeight ?? this.config.height; const canvasScaleFactor = Math.min(width / previewWidth, height / previewHeight); @@ -748,10 +751,9 @@ export class FrameRenderer { this.maskGraphics.roundRect(0, 0, screenRect.width, screenRect.height, scaledBorderRadius); this.maskGraphics.fill({ color: 0xffffff }); - // Cache layout info. baseOffset is the stage position of the FULL - // (uncropped) video sprite's top-left — matches preview semantics so - // downstream consumers (e.g. cursor highlight) can map normalized - // recording-space coordinates to stage coordinates uniformly: + // baseOffset is the stage position of the full (uncropped) sprite's top-left, matching + // preview semantics, so consumers (e.g. cursor highlight) can map normalized + // recording-space coords to stage coords uniformly: // stagePos = baseOffset + (cx, cy) * (videoWidth, videoHeight) * baseScale this.layoutCache = { stageSize: { width, height }, @@ -762,6 +764,12 @@ export class FrameRenderer { y: compositeLayout.screenRect.y + coverOffsetY - cropPixelY, }, maskRect: compositeLayout.screenRect, + croppedRect: { + x: compositeLayout.screenRect.x + coverOffsetX, + y: compositeLayout.screenRect.y + coverOffsetY, + width: croppedDisplayWidth, + height: croppedDisplayHeight, + }, maskBorderRadius: scaledBorderRadius, webcamRect: compositeLayout.webcamRect, }; @@ -794,42 +802,25 @@ export class FrameRenderer { targetFocus = regionFocus; targetProgress = strength; - // Apply adaptive smoothing for auto-follow mode + // Adaptive smoothing for auto-follow mode if (region.focusMode === "auto" && !transition) { const raw = targetFocus; const dtMs = this.prevAnimationTimeMs != null ? timeMs - this.prevAnimationTimeMs : 0; - const framesElapsed = dtMs > 0 ? dtMs / (1000 / 60) : 1; const isZoomingIn = targetProgress < 0.999 && targetProgress >= this.prevTargetProgress; if (targetProgress >= 0.999) { - // Full zoom: adaptive smoothing — moves faster when far, decelerates when close + // Full zoom: move faster when far, decelerate when close const prev = this.smoothedAutoFocus ?? raw; - const baseFactor = adaptiveSmoothFactor( - raw, - prev, - AUTO_FOLLOW_SMOOTHING_FACTOR, - AUTO_FOLLOW_SMOOTHING_FACTOR_MAX, - AUTO_FOLLOW_RAMP_DISTANCE, - ); - const factor = 1 - Math.pow(1 - baseFactor, Math.max(1, framesElapsed)); - const smoothed = smoothCursorFocus(raw, prev, factor); + const smoothed = advanceFollowFocus(prev, raw, dtMs, AUTO_FOLLOW_PARAMS); this.smoothedAutoFocus = smoothed; targetFocus = smoothed; } else if (isZoomingIn) { - // Zoom-in: track cursor directly so zoom always aims at current cursor - // position; keep ref in sync to avoid snap when full-zoom begins + // Track cursor directly while zooming in; keep ref in sync to avoid a snap + // when full-zoom begins this.smoothedAutoFocus = raw; } else { - // Zoom-out: keep smoothing for continuity — avoids snap at zoom-out start + // Zoom-out: keep smoothing to avoid a snap at the start const prev = this.smoothedAutoFocus ?? raw; - const baseFactor = adaptiveSmoothFactor( - raw, - prev, - AUTO_FOLLOW_SMOOTHING_FACTOR, - AUTO_FOLLOW_SMOOTHING_FACTOR_MAX, - AUTO_FOLLOW_RAMP_DISTANCE, - ); - const factor = 1 - Math.pow(1 - baseFactor, Math.max(1, framesElapsed)); - const smoothed = smoothCursorFocus(raw, prev, factor); + const smoothed = advanceFollowFocus(prev, raw, dtMs, AUTO_FOLLOW_PARAMS); this.smoothedAutoFocus = smoothed; targetFocus = smoothed; } @@ -896,18 +887,24 @@ export class FrameRenderer { focusY: state.focusY, }); - const appliedScale = - Math.abs(projectedTransform.scale - prevScale) < ZOOM_SCALE_DEADZONE - ? projectedTransform.scale - : projectedTransform.scale; - const appliedX = - Math.abs(projectedTransform.x - prevX) < ZOOM_TRANSLATION_DEADZONE_PX - ? projectedTransform.x - : projectedTransform.x; - const appliedY = - Math.abs(projectedTransform.y - prevY) < ZOOM_TRANSLATION_DEADZONE_PX - ? projectedTransform.y - : projectedTransform.y; + // Spring-chase the eased target (same as preview) so exported motion glides past the jerk + // at the steep start of the ease. Stepped by content time; snapped on the first frame or + // any large time jump. + const dtMs = this.prevAnimationTimeMs != null ? timeMs - this.prevAnimationTimeMs : 0; + let appliedScale: number; + let appliedX: number; + let appliedY: number; + if (this.prevAnimationTimeMs == null || dtMs <= 0 || dtMs > 80) { + resetZoomSpring(this.zoomSpringState, projectedTransform); + appliedScale = projectedTransform.scale; + appliedX = projectedTransform.x; + appliedY = projectedTransform.y; + } else { + const sprung = stepZoomSpring(this.zoomSpringState, projectedTransform, dtMs); + appliedScale = sprung.scale; + appliedX = sprung.x; + appliedY = sprung.y; + } state.x = appliedX; state.y = appliedY; @@ -922,10 +919,9 @@ export class FrameRenderer { ); } - // On Linux/Wayland the implicit GPU→2D texture-sharing path - // used by drawImage(webglCanvas) can fail silently (EGL/Ozone), - // producing green/empty frames. Explicit gl.readPixels always - // copies from GPU to CPU memory, bypassing that path. + // On Linux/Wayland the implicit GPU-to-2D texture-sharing path behind + // drawImage(webglCanvas) can fail silently (EGL/Ozone), giving green/empty + // frames. gl.readPixels copies GPU to CPU directly, bypassing that path. private readbackVideoCanvas(): HTMLCanvasElement { const glCanvas = this.app!.canvas as HTMLCanvasElement; const gl = @@ -958,8 +954,8 @@ export class FrameRenderer { return this.rasterCanvas; } - // `applyShadowToRecording` is false when the 3D pass will rotate this canvas - // next — the shadow gets re-applied after rotation to avoid aliasing. + // applyShadowToRecording is false when the 3D pass will rotate this canvas next; + // the shadow is re-applied after rotation to avoid aliasing. private compositeWithShadows( webcamFrame: VideoFrame | null | undefined, applyShadowToRecording: boolean, @@ -982,8 +978,8 @@ export class FrameRenderer { const w = this.compositeCanvas.width; const h = this.compositeCanvas.height; - // Background layer (compositeCanvas): wallpaper only. Stays flat — never - // touched by the 3D rotation pass, matching preview behavior. + // Background (compositeCanvas): wallpaper only. Stays flat, never touched by the + // 3D rotation pass, matching the preview. bgCtx.clearRect(0, 0, w, h); if (this.backgroundSprite) { const bgCanvas = this.backgroundSprite; @@ -999,8 +995,8 @@ export class FrameRenderer { console.warn("[FrameRenderer] No background sprite found during compositing!"); } - // Foreground (transparent): recording + webcam. Shadow only baked here on - // the flat path; the 3D path applies it after rotation (see renderFrame). + // Foreground (transparent): recording + webcam. Shadow baked here only on the + // flat path; the 3D path applies it after rotation (see renderFrame). fgCtx.clearRect(0, 0, w, h); if ( applyShadowToRecording && @@ -1026,54 +1022,34 @@ export class FrameRenderer { shadowCtx.drawImage(videoCanvas, 0, 0, w, h); shadowCtx.restore(); fgCtx.drawImage(this.shadowCanvas, 0, 0, w, h); - // Erase square corners left by PIXI WebGL alpha, then redraw video with explicit - // 2D clip so shadow extends beyond the rounded area but video is precisely clipped. - // The clip is camera-aware so zoom doesn't crop the magnified video. - const shadowClip = - (this.layoutCache?.maskBorderRadius ?? 0) > 0 ? this.cameraAwareMaskRect() : null; - if (shadowClip) { - const { x: smx, y: smy, width: smw, height: smh, br: sbr } = shadowClip; - fgCtx.save(); - fgCtx.globalCompositeOperation = "destination-out"; - fgCtx.beginPath(); - fgCtx.rect(smx, smy, smw, smh); - fgCtx.roundRect(smx, smy, smw, smh, sbr); - fgCtx.fill("evenodd"); - fgCtx.restore(); - fgCtx.save(); - fgCtx.beginPath(); - fgCtx.roundRect(smx, smy, smw, smh, sbr); - fgCtx.clip(); - fgCtx.drawImage(videoCanvas, 0, 0, w, h); - fgCtx.restore(); - } } else { - // Direct path: explicit 2D clip guarantees rounded corners regardless of PIXI - // WebGL alpha. Camera-aware so zoom doesn't crop the magnified video. - const directClip = - (this.layoutCache?.maskBorderRadius ?? 0) > 0 ? this.cameraAwareMaskRect() : null; - if (directClip) { - fgCtx.save(); - fgCtx.beginPath(); - fgCtx.roundRect( - directClip.x, - directClip.y, - directClip.width, - directClip.height, - directClip.br, - ); - fgCtx.clip(); - fgCtx.drawImage(videoCanvas, 0, 0, w, h); - fgCtx.restore(); - } else { - fgCtx.drawImage(videoCanvas, 0, 0, w, h); - } + fgCtx.drawImage(videoCanvas, 0, 0, w, h); } const webcamRect = this.layoutCache?.webcamRect ?? null; if (webcamFrame && webcamRect) { const preset = getWebcamLayoutPresetDefinition(this.config.webcamLayoutPreset); const shape = webcamRect.maskShape ?? this.config.webcamMaskShape ?? "rectangle"; + // Scale the PiP webcam inversely with the eased zoom, anchoring the shrink to the + // docked corner (bottom-right by default) like the preview, so it stays flush to the + // edges instead of drifting toward center. + const reactiveFactor = + this.config.webcamReactiveZoom && this.config.webcamLayoutPreset === "picture-in-picture" + ? reactiveWebcamScale(this.animationState.appliedScale) + : 1; + const camPos = this.config.webcamPosition; + const biasX = (camPos ? camPos.cx >= 0.5 : true) ? 1 : 0; + const biasY = (camPos ? camPos.cy >= 0.5 : true) ? 1 : 0; + const drawRect = + reactiveFactor < 1 + ? { + width: webcamRect.width * reactiveFactor, + height: webcamRect.height * reactiveFactor, + x: webcamRect.x + webcamRect.width * (1 - reactiveFactor) * biasX, + y: webcamRect.y + webcamRect.height * (1 - reactiveFactor) * biasY, + borderRadius: webcamRect.borderRadius * reactiveFactor, + } + : webcamRect; const sourceWidth = ("displayWidth" in webcamFrame && webcamFrame.displayWidth > 0 ? webcamFrame.displayWidth @@ -1093,12 +1069,12 @@ export class FrameRenderer { fgCtx.save(); drawCanvasClipPath( fgCtx, - webcamRect.x, - webcamRect.y, - webcamRect.width, - webcamRect.height, + drawRect.x, + drawRect.y, + drawRect.width, + drawRect.height, shape, - webcamRect.borderRadius, + drawRect.borderRadius, ); if (preset.shadow) { fgCtx.shadowColor = preset.shadow.color; @@ -1109,16 +1085,22 @@ export class FrameRenderer { fgCtx.fillStyle = "#000000"; fgCtx.fill(); fgCtx.clip(); - fgCtx.drawImage( + drawWebcamFrameImage( + fgCtx, webcamFrame as unknown as CanvasImageSource, - sourceCropX, - sourceCropY, - sourceCropWidth, - sourceCropHeight, - webcamRect.x, - webcamRect.y, - webcamRect.width, - webcamRect.height, + { + x: sourceCropX, + y: sourceCropY, + width: sourceCropWidth, + height: sourceCropHeight, + }, + { + x: drawRect.x, + y: drawRect.y, + width: drawRect.width, + height: drawRect.height, + }, + this.config.webcamMirrored, ); fgCtx.restore(); } diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts index 7c0d2a6678..9b06fcd30f 100644 --- a/src/lib/exporter/gifExporter.ts +++ b/src/lib/exporter/gifExporter.ts @@ -46,6 +46,8 @@ interface GifExporterConfig { cropRegion: CropRegion; webcamLayoutPreset?: WebcamLayoutPreset; webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape; + webcamMirrored?: boolean; + webcamReactiveZoom?: boolean; webcamSizePreset?: WebcamSizePreset; webcamPosition?: { cx: number; cy: number } | null; cursorRecordingData?: CursorRecordingData | null; @@ -54,6 +56,7 @@ interface GifExporterConfig { cursorMotionBlur?: number; cursorClickBounce?: number; cursorClipToBounds?: boolean; + cursorTheme?: string; annotationRegions?: AnnotationRegion[]; previewWidth?: number; previewHeight?: number; @@ -135,7 +138,6 @@ export class GifExporter { this.cleanup(); this.cancelled = false; - // Initialize streaming decoder and load video metadata this.streamingDecoder = new StreamingVideoDecoder(); const videoInfo = await this.streamingDecoder.loadMetadata(this.config.videoUrl); let webcamInfo: Awaited> | null = null; @@ -144,7 +146,6 @@ export class GifExporter { webcamInfo = await this.webcamDecoder.loadMetadata(this.config.webcamVideoUrl); } - // Initialize frame renderer this.renderer = new FrameRenderer({ width: this.config.width, height: this.config.height, @@ -163,11 +164,14 @@ export class GifExporter { cursorMotionBlur: this.config.cursorMotionBlur, cursorClickBounce: this.config.cursorClickBounce, cursorClipToBounds: this.config.cursorClipToBounds, + cursorTheme: this.config.cursorTheme, videoWidth: videoInfo.width, videoHeight: videoInfo.height, webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null, webcamLayoutPreset: this.config.webcamLayoutPreset, webcamMaskShape: this.config.webcamMaskShape, + webcamMirrored: this.config.webcamMirrored, + webcamReactiveZoom: this.config.webcamReactiveZoom, webcamSizePreset: this.config.webcamSizePreset, webcamPosition: this.config.webcamPosition, annotationRegions: this.config.annotationRegions, @@ -180,8 +184,7 @@ export class GifExporter { }); await this.renderer.initialize(); - // Initialize GIF encoder - // Loop: 0 = infinite loop, 1 = play once (no loop) + // gif.js repeat: 0 = infinite loop, 1 = play once const repeat = this.config.loop ? 0 : 1; const cores = navigator.hardwareConcurrency || 4; const WORKER_COUNT = Math.max(1, Math.min(8, cores - 1)); @@ -197,14 +200,14 @@ export class GifExporter { dither: "FloydSteinberg", }); - // Calculate effective duration and frame count (excluding trim regions) + // Effective duration and frame count, excluding trim regions const { effectiveDuration, totalFrames } = this.streamingDecoder.getExportMetrics( this.config.frameRate, this.config.trimRegions, this.config.speedRegions, ); - // Calculate frame delay in milliseconds (gif.js uses ms) + // gif.js wants frame delay in ms const frameDelay = Math.round(1000 / this.config.frameRate); console.log("[GifExporter] Original duration:", videoInfo.duration, "s"); @@ -254,7 +257,7 @@ export class GifExporter { })() : null; - // Stream decode and process frames — no seeking! + // Stream decode and process frames, no seeking await this.streamingDecoder.decodeAll( this.config.frameRate, this.config.trimRegions, @@ -274,19 +277,15 @@ export class GifExporter { return; } - // Render the frame with all effects using source timestamp - const sourceTimestampUs = sourceTimestampMs * 1000; // Convert to microseconds + const sourceTimestampUs = sourceTimestampMs * 1000; // us await renderer.renderFrame(videoFrame, sourceTimestampUs, webcamFrame); - // Get the rendered canvas and add to GIF const canvas = renderer.getCanvas(); - // Add frame to GIF encoder with delay this.gif!.addFrame(canvas, { delay: frameDelay, copy: true }); frameIndex++; - // Update progress if (this.config.onProgress) { this.config.onProgress({ currentFrame: frameIndex, @@ -312,7 +311,7 @@ export class GifExporter { this.webcamDecoder?.cancel(); await webcamDecodePromise; - // Update progress to show we're now in the finalizing phase + // Now in the finalizing phase if (this.config.onProgress) { this.config.onProgress({ currentFrame: totalFrames, @@ -323,13 +322,11 @@ export class GifExporter { }); } - // Render the GIF const blob = await new Promise((resolve, _reject) => { this.gif!.on("finished", (blob: Blob) => { resolve(blob); }); - // Track rendering progress this.gif!.on("progress", (progress: number) => { if (this.config.onProgress) { this.config.onProgress({ @@ -343,7 +340,7 @@ export class GifExporter { } }); - // gif.js doesn't have a typed 'error' event, but we can catch errors in the try/catch + // gif.js has no typed 'error' event; the outer try/catch handles failures this.gif!.render(); }); diff --git a/src/lib/exporter/localSourceFile.test.ts b/src/lib/exporter/localSourceFile.test.ts new file mode 100644 index 0000000000..7d4b14163e --- /dev/null +++ b/src/lib/exporter/localSourceFile.test.ts @@ -0,0 +1,457 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { materializeLocalSourceFile, releaseLocalSourceFile } from "./localSourceFile"; + +function stubElectronAPI(api: Record) { + vi.stubGlobal("window", { ...globalThis.window, electronAPI: api } as unknown); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("materializeLocalSourceFile (small file path)", () => { + it("reads a small file in one shot and returns a File with its bytes", async () => { + const bytes = new Uint8Array([1, 2, 3, 4, 5]); + stubElectronAPI({ + getReadableFileInfo: vi.fn().mockResolvedValue({ + success: true, + size: bytes.byteLength, + mtimeMs: 1, + path: "/tmp/small.mp4", + }), + readBinaryFile: vi.fn().mockResolvedValue({ + success: true, + data: bytes.buffer, + path: "/tmp/small.mp4", + }), + }); + + const file = await materializeLocalSourceFile("/tmp/small.mp4", "small.mp4"); + + expect(file).toBeInstanceOf(File); + expect(file.size).toBe(bytes.byteLength); + expect(new Uint8Array(await file.arrayBuffer())).toEqual(bytes); + }); + + it("does not stream a small file through readFileChunk", async () => { + const readFileChunk = vi.fn(); + stubElectronAPI({ + getReadableFileInfo: vi + .fn() + .mockResolvedValue({ success: true, size: 10, mtimeMs: 1, path: "/tmp/s.mp4" }), + readBinaryFile: vi + .fn() + .mockResolvedValue({ success: true, data: new Uint8Array(10).buffer, path: "/tmp/s.mp4" }), + readFileChunk, + }); + + await materializeLocalSourceFile("/tmp/s.mp4", "s.mp4"); + + expect(readFileChunk).not.toHaveBeenCalled(); + }); + + it("throws when the file cannot be stat-ed", async () => { + stubElectronAPI({ + getReadableFileInfo: vi + .fn() + .mockResolvedValue({ success: false, message: "File path is not approved" }), + readBinaryFile: vi.fn(), + }); + + await expect(materializeLocalSourceFile("/tmp/missing.mp4", "x.mp4")).rejects.toThrow( + /not approved/, + ); + }); + + it("throws when the single-shot read fails", async () => { + stubElectronAPI({ + getReadableFileInfo: vi + .fn() + .mockResolvedValue({ success: true, size: 10, mtimeMs: 1, path: "/tmp/s.mp4" }), + readBinaryFile: vi + .fn() + .mockResolvedValue({ success: false, message: "Failed to read binary file" }), + }); + + await expect(materializeLocalSourceFile("/tmp/s.mp4", "s.mp4")).rejects.toThrow( + /Failed to read binary file/, + ); + }); +}); + +// ---- Minimal in-memory OPFS fake for the large-file streaming path ---- + +class FakeWritable { + private parts: Uint8Array[] = []; + constructor(private readonly onClose: (bytes: Uint8Array) => void) {} + async write(data: ArrayBuffer | Uint8Array) { + this.parts.push(data instanceof Uint8Array ? new Uint8Array(data) : new Uint8Array(data)); + } + async close() { + const total = this.parts.reduce((n, p) => n + p.byteLength, 0); + const merged = new Uint8Array(total); + let offset = 0; + for (const p of this.parts) { + merged.set(p, offset); + offset += p.byteLength; + } + this.onClose(merged); + } + async abort() { + this.parts = []; + } +} + +class FakeFileHandle { + bytes = new Uint8Array(0); + constructor(readonly name: string) {} + async getFile() { + return new File([this.bytes], this.name); + } + async createWritable() { + return new FakeWritable((b) => { + this.bytes = b; + }); + } +} + +class FakeDir { + files = new Map(); + subdirs = new Map(); + async getDirectoryHandle(name: string, opts?: { create?: boolean }) { + let dir = this.subdirs.get(name); + if (!dir && opts?.create) { + dir = new FakeDir(); + this.subdirs.set(name, dir); + } + if (!dir) throw new DOMException("NotFound", "NotFoundError"); + return dir as unknown as FileSystemDirectoryHandle; + } + async getFileHandle(name: string, opts?: { create?: boolean }) { + let file = this.files.get(name); + if (!file && opts?.create) { + file = new FakeFileHandle(name); + this.files.set(name, file); + } + if (!file) throw new DOMException("NotFound", "NotFoundError"); + return file as unknown as FileSystemFileHandle; + } + async *keys() { + yield* this.files.keys(); + } + async removeEntry(name: string) { + this.files.delete(name); + } +} + +function stubOpfs(root: FakeDir) { + vi.stubGlobal("navigator", { storage: { getDirectory: async () => root } } as unknown); +} + +function cacheDir(root: FakeDir): FakeDir | undefined { + return root.subdirs.get("openscreen-source-cache"); +} + +/** electronAPI whose readFileChunk serves slices of `source`. */ +function largeSourceApi(url: string, source: Uint8Array, mtimeMs = 1) { + return { + getReadableFileInfo: vi + .fn() + .mockResolvedValue({ success: true, size: source.byteLength, mtimeMs, path: url }), + readBinaryFile: vi.fn(), + readFileChunk: vi.fn(async (_url: string, offset: number, length: number) => ({ + success: true, + data: source.slice(offset, offset + length).buffer, + bytesRead: Math.min(length, source.byteLength - offset), + })), + }; +} + +describe("materializeLocalSourceFile (large file OPFS path)", () => { + const OPTS = { thresholdBytes: 4, chunkBytes: 3 }; + + it("streams a large file into OPFS in chunks and returns the exact bytes", async () => { + const source = new Uint8Array([10, 20, 30, 40, 50, 60, 70]); + const api = largeSourceApi("/rec/a.mp4", source); + stubElectronAPI(api); + stubOpfs(new FakeDir()); + + const file = await materializeLocalSourceFile("/rec/a.mp4", "a.mp4", OPTS); + + expect(file.size).toBe(source.byteLength); + expect(new Uint8Array(await file.arrayBuffer())).toEqual(source); + // 7 bytes / 3-byte chunks => 3 reads. + expect(api.readFileChunk).toHaveBeenCalledTimes(3); + expect(api.readBinaryFile).not.toHaveBeenCalled(); + + releaseLocalSourceFile(file.name); + }); + + it("reuses the cached copy on a second call without re-streaming", async () => { + const source = new Uint8Array([1, 2, 3, 4, 5, 6]); + const api = largeSourceApi("/rec/b.mp4", source); + stubElectronAPI(api); + stubOpfs(new FakeDir()); + + const first = await materializeLocalSourceFile("/rec/b.mp4", "b.mp4", OPTS); + const firstReads = api.readFileChunk.mock.calls.length; + const second = await materializeLocalSourceFile("/rec/b.mp4", "b.mp4", OPTS); + + expect(api.readFileChunk.mock.calls.length).toBe(firstReads); // no new reads + expect(second.name).toBe(first.name); + + releaseLocalSourceFile(first.name); + releaseLocalSourceFile(second.name); + }); + + it("keeps a cache entry that is still referenced by another active source", async () => { + const root = new FakeDir(); + stubOpfs(root); + + stubElectronAPI(largeSourceApi("/rec/a.mp4", new Uint8Array([1, 2, 3, 4, 5]))); + const a = await materializeLocalSourceFile("/rec/a.mp4", "a.mp4", OPTS); // A retained + + stubElectronAPI(largeSourceApi("/rec/b.mp4", new Uint8Array([6, 7, 8, 9, 10]))); + const b = await materializeLocalSourceFile("/rec/b.mp4", "b.mp4", OPTS); // prunes, A active + + // A must NOT have been pruned while still in use. + expect(cacheDir(root)?.files.size).toBe(2); + + releaseLocalSourceFile(a.name); + releaseLocalSourceFile(b.name); + }); + + it("prunes a cache entry once it has been released", async () => { + const root = new FakeDir(); + stubOpfs(root); + + stubElectronAPI(largeSourceApi("/rec/a.mp4", new Uint8Array([1, 2, 3, 4, 5]))); + const a = await materializeLocalSourceFile("/rec/a.mp4", "a.mp4", OPTS); // A retained + + stubElectronAPI(largeSourceApi("/rec/b.mp4", new Uint8Array([6, 7, 8, 9, 10]))); + const b = await materializeLocalSourceFile("/rec/b.mp4", "b.mp4", OPTS); // B retained + + releaseLocalSourceFile(a.name); // A no longer in use + + stubElectronAPI(largeSourceApi("/rec/c.mp4", new Uint8Array([11, 12, 13, 14, 15]))); + const c = await materializeLocalSourceFile("/rec/c.mp4", "c.mp4", OPTS); // prunes A, keeps B+C + + // A pruned; B (still active) and C remain. + expect(cacheDir(root)?.files.size).toBe(2); + + releaseLocalSourceFile(b.name); + releaseLocalSourceFile(c.name); + }); + + it("removes the partial cache entry when a chunk read fails mid-copy", async () => { + const root = new FakeDir(); + stubOpfs(root); + + const source = new Uint8Array([1, 2, 3, 4, 5, 6, 7]); + const api = largeSourceApi("/rec/err.mp4", source); + let reads = 0; + api.readFileChunk = vi.fn(async (_url: string, offset: number, length: number) => { + reads += 1; + if (reads === 2) return { success: false, message: "disk read failed" }; + return { + success: true, + data: source.slice(offset, offset + length).buffer, + bytesRead: Math.min(length, source.byteLength - offset), + }; + }); + stubElectronAPI(api); + + await expect(materializeLocalSourceFile("/rec/err.mp4", "err.mp4", OPTS)).rejects.toThrow( + /disk read failed/, + ); + + // The partial copy is cleaned up and holds no live reference. + expect(cacheDir(root)?.files.size ?? 0).toBe(0); + }); + + it("does not prune an entry that is still being written by a concurrent copy", async () => { + const root = new FakeDir(); + stubOpfs(root); + + const sourceA = new Uint8Array([1, 2, 3, 4, 5, 6]); + const sourceB = new Uint8Array([7, 8, 9, 10, 11]); + let releaseFirstChunk!: () => void; + const gate = new Promise((resolve) => { + releaseFirstChunk = resolve; + }); + const sources: Record = { + "/rec/a.mp4": sourceA, + "/rec/b.mp4": sourceB, + }; + // One shared API serving both URLs; A's first chunk read blocks on the gate + // so A sits mid-copy while B runs to completion (including B's prune pass). + const api = { + getReadableFileInfo: vi.fn(async (url: string) => ({ + success: true, + size: sources[url].byteLength, + mtimeMs: 1, + path: url, + })), + readBinaryFile: vi.fn(), + readFileChunk: vi.fn(async (url: string, offset: number, length: number) => { + if (url === "/rec/a.mp4" && offset === 0) await gate; + const bytes = sources[url]; + return { + success: true, + data: bytes.slice(offset, offset + length).buffer, + bytesRead: Math.min(length, bytes.byteLength - offset), + }; + }), + }; + stubElectronAPI(api); + + const aPromise = materializeLocalSourceFile("/rec/a.mp4", "a.mp4", OPTS); + // Wait until A is inside its gated first chunk read (past retain + prune). + while (!api.readFileChunk.mock.calls.some(([url]) => url === "/rec/a.mp4")) { + await new Promise((r) => setTimeout(r, 0)); + } + + const b = await materializeLocalSourceFile("/rec/b.mp4", "b.mp4", OPTS); + // B's prune must have kept A's in-progress entry alive. + expect(cacheDir(root)?.files.size).toBe(2); + + releaseFirstChunk(); + const a = await aPromise; + expect(new Uint8Array(await a.arrayBuffer())).toEqual(sourceA); + + releaseLocalSourceFile(a.name); + releaseLocalSourceFile(b.name); + }); +}); + +describe("materializeLocalSourceFile (in-flight dedup & abort)", () => { + const OPTS = { thresholdBytes: 4, chunkBytes: 3 }; + + it("deduplicates concurrent copies of the same entry into one stream", async () => { + const root = new FakeDir(); + stubOpfs(root); + const source = new Uint8Array([1, 2, 3, 4, 5, 6, 7]); + const api = largeSourceApi("/rec/dup.mp4", source); + stubElectronAPI(api); + + const [a, b] = await Promise.all([ + materializeLocalSourceFile("/rec/dup.mp4", "dup.mp4", OPTS), + materializeLocalSourceFile("/rec/dup.mp4", "dup.mp4", OPTS), + ]); + + // One shared copy: 7 bytes / 3-byte chunks => exactly 3 reads, not 6. + expect(api.readFileChunk).toHaveBeenCalledTimes(3); + expect(new Uint8Array(await a.arrayBuffer())).toEqual(source); + expect(new Uint8Array(await b.arrayBuffer())).toEqual(source); + expect(a.name).toBe(b.name); + + // Each caller took one reference; after both release, a later + // materialization of another entry prunes it. + releaseLocalSourceFile(a.name); + releaseLocalSourceFile(b.name); + stubElectronAPI(largeSourceApi("/rec/other.mp4", new Uint8Array([9, 9, 9, 9, 9]))); + const other = await materializeLocalSourceFile("/rec/other.mp4", "other.mp4", OPTS); + expect(cacheDir(root)?.files.size).toBe(1); + releaseLocalSourceFile(other.name); + }); + + it("aborts the copy via the AbortSignal and cleans up the partial entry", async () => { + const root = new FakeDir(); + stubOpfs(root); + const source = new Uint8Array([1, 2, 3, 4, 5, 6, 7]); + const api = largeSourceApi("/rec/ab.mp4", source); + let releaseFirstChunk!: () => void; + const gate = new Promise((resolve) => { + releaseFirstChunk = resolve; + }); + api.readFileChunk = vi.fn(async (_url: string, offset: number, length: number) => { + if (offset === 0) await gate; + return { + success: true, + data: source.slice(offset, offset + length).buffer, + bytesRead: Math.min(length, source.byteLength - offset), + }; + }); + stubElectronAPI(api); + + const controller = new AbortController(); + const promise = materializeLocalSourceFile("/rec/ab.mp4", "ab.mp4", { + ...OPTS, + signal: controller.signal, + }); + // Let the copy enter its gated first chunk, then abort mid-copy. + while (api.readFileChunk.mock.calls.length === 0) { + await new Promise((r) => setTimeout(r, 0)); + } + controller.abort(); + releaseFirstChunk(); + + await expect(promise).rejects.toThrow(/abort/i); + // Give the shared flight's cleanup a tick to settle. + await new Promise((r) => setTimeout(r, 0)); + expect(cacheDir(root)?.files.size ?? 0).toBe(0); + + // A retry after abort works from scratch. + const retry = await materializeLocalSourceFile("/rec/ab.mp4", "ab.mp4", OPTS); + expect(new Uint8Array(await retry.arrayBuffer())).toEqual(source); + releaseLocalSourceFile(retry.name); + }); + + it("keeps the shared copy alive while another joined caller is still interested", async () => { + const root = new FakeDir(); + stubOpfs(root); + const source = new Uint8Array([1, 2, 3, 4, 5, 6]); + const api = largeSourceApi("/rec/share.mp4", source); + let releaseFirstChunk!: () => void; + const gate = new Promise((resolve) => { + releaseFirstChunk = resolve; + }); + api.readFileChunk = vi.fn(async (_url: string, offset: number, length: number) => { + if (offset === 0) await gate; + return { + success: true, + data: source.slice(offset, offset + length).buffer, + bytesRead: Math.min(length, source.byteLength - offset), + }; + }); + stubElectronAPI(api); + + const controller = new AbortController(); + const abortable = materializeLocalSourceFile("/rec/share.mp4", "share.mp4", { + ...OPTS, + signal: controller.signal, + }); + const steady = materializeLocalSourceFile("/rec/share.mp4", "share.mp4", OPTS); + while (api.readFileChunk.mock.calls.length === 0) { + await new Promise((r) => setTimeout(r, 0)); + } + // One of two joined callers aborts: the shared copy must keep going. + controller.abort(); + releaseFirstChunk(); + + await expect(abortable).rejects.toThrow(/abort/i); + const file = await steady; + expect(new Uint8Array(await file.arrayBuffer())).toEqual(source); + releaseLocalSourceFile(file.name); + }); +}); + +describe("materializeLocalSourceFile (MIME inference)", () => { + it.each([ + ["/tmp/clip.webm", "video/webm"], + ["/tmp/clip.mp4", "video/mp4"], + ["/tmp/clip.mov", "video/quicktime"], + ["/tmp/clip.bin", "application/octet-stream"], + ])("infers the MIME type of %s as %s", async (path, expected) => { + stubElectronAPI({ + getReadableFileInfo: vi.fn().mockResolvedValue({ success: true, size: 4, mtimeMs: 1, path }), + readBinaryFile: vi + .fn() + .mockResolvedValue({ success: true, data: new Uint8Array(4).buffer, path }), + }); + + const file = await materializeLocalSourceFile(path, "clip"); + + expect(file.type).toBe(expected); + }); +}); diff --git a/src/lib/exporter/localSourceFile.ts b/src/lib/exporter/localSourceFile.ts new file mode 100644 index 0000000000..8f4fbb8c3e --- /dev/null +++ b/src/lib/exporter/localSourceFile.ts @@ -0,0 +1,362 @@ +import { MAX_IN_MEMORY_SOURCE_BYTES } from "./sourceFileLimits"; + +/** + * Loads a local recording as a `File` suitable for `web-demuxer`, without ever + * holding the whole recording in memory. + * + * The naive path — `electronAPI.readBinaryFile` → `new File([arrayBuffer])` — + * breaks for long recordings in two ways: + * 1. The main process reads with Node's `fs.readFile`, which throws + * `ERR_FS_FILE_TOO_LARGE` for any file above 2 GiB (a hard cap on a single + * read). A 2h 1080p60 recording is ~6-7 GB, so it can never be read. + * 2. Even if it could, a multi-GB `ArrayBuffer`/`Blob` in the renderer would + * exhaust memory on typical machines (e.g. 16 GB RAM). + * + * `web-demuxer` reads a `File` on demand (it slices the file inside its worker), + * so it does not need the bytes up front. For recordings above a safe threshold + * we stream the file into an OPFS-backed file in fixed-size chunks and hand back + * the disk-backed `File` from `getFile()`. Memory stays flat regardless of size. + * + * Concurrency: copies are deduplicated per cache entry — concurrent callers of + * the same recording (e.g. the trim waveform and an export) share one in-flight + * copy instead of racing two writables on the same OPFS handle. Each caller can + * pass an `AbortSignal`; the underlying copy is aborted only once every joined + * caller has aborted. Successful callers take one cache reference each and must + * call {@link releaseLocalSourceFile} with the returned File's `.name`. + * + * Small recordings keep the original in-memory path — it is simpler and avoids + * an extra on-disk copy for the common case. + */ + +// Chunk size for streaming a large file into OPFS. Large enough to keep IPC +// overhead low, small enough that peak memory stays bounded. +const COPY_CHUNK_BYTES = 32 * 1024 * 1024; + +const OPFS_CACHE_DIR = "openscreen-source-cache"; + +export interface MaterializeProgress { + copiedBytes: number; + totalBytes: number; +} + +export interface MaterializeOptions { + onProgress?: (progress: MaterializeProgress) => void; + /** Aborts the wait; the shared copy stops once every joined caller aborts. */ + signal?: AbortSignal; + /** Override the in-memory threshold (testing only). */ + thresholdBytes?: number; + /** Override the OPFS copy chunk size (testing only). */ + chunkBytes?: number; +} + +/** + * Reference counts of OPFS cache entries currently read by a live demuxer, keyed + * by cache-entry name (which equals the returned File's `.name`). Pruning never + * removes a name with a live reference, so a concurrent export or caption pass + * reading a different recording — or a different revision of the same one — + * cannot have its copy deleted. Keying by cache name (not source URL) keeps + * revisions independent: releasing one never touches another's count. + */ +const activeCacheRefs = new Map(); + +/** One shared in-flight copy per cache entry; concurrent callers join it. */ +interface InflightCopy { + promise: Promise; + controller: AbortController; + consumers: number; + progressListeners: Set<(progress: MaterializeProgress) => void>; + lastProgress?: MaterializeProgress; +} + +const inflightCopies = new Map(); + +function retainCache(cacheName: string): void { + activeCacheRefs.set(cacheName, (activeCacheRefs.get(cacheName) ?? 0) + 1); +} + +/** + * Releases a reference taken by {@link materializeLocalSourceFile}. Pass the + * returned File's `.name`. No-op for small/remote sources, whose names were + * never retained. + */ +export function releaseLocalSourceFile(cacheName: string): void { + const refs = activeCacheRefs.get(cacheName); + if (refs === undefined) return; + if (refs <= 1) activeCacheRefs.delete(cacheName); + else activeCacheRefs.set(cacheName, refs - 1); +} + +/** Names that must survive pruning: referenced by a demuxer or mid-copy. */ +function keepSet(): Set { + const keep = new Set(activeCacheRefs.keys()); + for (const name of inflightCopies.keys()) keep.add(name); + return keep; +} + +/** + * Removes cache entries left behind by a previous session (or by exports whose + * source was never materialized again). Call once at app startup: nothing is + * referenced or mid-copy at that point, so everything stale is reclaimed. + */ +export async function clearStaleSourceCache(): Promise { + const getDirectory = navigator.storage?.getDirectory?.bind(navigator.storage); + if (!getDirectory) return; + try { + const root = await getDirectory(); + const dir = await root.getDirectoryHandle(OPFS_CACHE_DIR); + await pruneStaleEntries(dir, keepSet()); + } catch { + // No cache directory yet — nothing to clean. + } +} + +const MIME_BY_EXTENSION: Record = { + mp4: "video/mp4", + m4v: "video/mp4", + mov: "video/quicktime", + webm: "video/webm", + mkv: "video/x-matroska", + avi: "video/x-msvideo", +}; + +/** Infers a video MIME type from the file extension (recordings can be mp4 or webm). */ +function mimeTypeForPath(p: string): string { + const clean = p.toLowerCase().split(/[?#]/, 1)[0]; + const dot = clean.lastIndexOf("."); + const ext = dot >= 0 ? clean.slice(dot + 1) : ""; + return MIME_BY_EXTENSION[ext] ?? "application/octet-stream"; +} + +/** Stable non-cryptographic hash for building a cache key from a path. */ +function hashString(input: string): string { + let hash = 5381; + for (let i = 0; i < input.length; i++) { + hash = ((hash << 5) + hash + input.charCodeAt(i)) | 0; + } + return (hash >>> 0).toString(36); +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); +} + +/** + * Returns a `File` for a local recording path/URL, streaming large files through + * OPFS so nothing multi-GB is ever held in memory. + * + * @param videoUrl Local file path or `file://` URL of the recording. + * @param filename Preferred file name for the returned `File`. + * @param options Progress callback, abort signal, (testing) size overrides. + */ +export async function materializeLocalSourceFile( + videoUrl: string, + filename: string, + options?: MaterializeOptions, +): Promise { + const api = window.electronAPI; + if (!api) { + throw new Error("Local source loading is only available in the desktop app."); + } + + throwIfAborted(options?.signal); + const threshold = options?.thresholdBytes ?? MAX_IN_MEMORY_SOURCE_BYTES; + + const info = await api.getReadableFileInfo(videoUrl); + if (!info.success || typeof info.size !== "number") { + throw new Error(info.message || info.error || "Failed to read source video"); + } + throwIfAborted(options?.signal); + + // Common case: small enough to read in one shot. + if (info.size <= threshold) { + const result = await api.readBinaryFile(videoUrl); + if (!result.success || !result.data) { + throw new Error(result.message || result.error || "Failed to read source video"); + } + throwIfAborted(options?.signal); + const name = (result.path || filename).split(/[\\/]/).pop() || filename; + return new File([result.data], name, { type: mimeTypeForPath(name) }); + } + + // Large recording: stream into OPFS and hand back a disk-backed File. + // web-demuxer detects the container from content, so the File name is + // irrelevant here — the OPFS entry keeps its cache-key name. + return copyToOpfsFile(videoUrl, info.size, info.mtimeMs ?? 0, options); +} + +async function copyToOpfsFile( + videoUrl: string, + size: number, + mtimeMs: number, + options?: MaterializeOptions, +): Promise { + const getDirectory = navigator.storage?.getDirectory?.bind(navigator.storage); + if (!getDirectory) { + throw new Error( + "This recording is larger than 2 GB and cannot be exported: " + + "local storage (OPFS) is unavailable to stream it.", + ); + } + + const root = await getDirectory(); + const dir = await root.getDirectoryHandle(OPFS_CACHE_DIR, { create: true }); + + // Cache key ties the copy to this exact file revision so repeated exports of + // the same recording reuse the cached copy instead of re-streaming gigabytes. + const cacheName = `${hashString(videoUrl)}-${size}-${Math.round(mtimeMs)}.bin`; + const signal = options?.signal; + + // Join (or start) the shared in-flight copy for this entry. Loop so a caller + // that races a flight aborted by its last consumer can start a fresh one. + for (;;) { + throwIfAborted(signal); + + let flight = inflightCopies.get(cacheName); + if (flight?.controller.signal.aborted) { + await flight.promise.catch(() => undefined); + continue; + } + if (!flight) { + const controller = new AbortController(); + const fresh: InflightCopy = { + controller, + consumers: 0, + progressListeners: new Set(), + promise: Promise.resolve(), + }; + // Register BEFORE starting the copy so its own name is in keepSet() + // when the copy prunes, and so concurrent prunes keep mid-write entries. + inflightCopies.set(cacheName, fresh); + fresh.promise = runCopy( + dir, + cacheName, + videoUrl, + size, + controller.signal, + options?.chunkBytes ?? COPY_CHUNK_BYTES, + (p) => { + fresh.lastProgress = p; + for (const listener of fresh.progressListeners) listener(p); + }, + ).finally(() => { + if (inflightCopies.get(cacheName) === fresh) inflightCopies.delete(cacheName); + }); + // A flight abandoned by every consumer would otherwise be an unhandled rejection. + fresh.promise.catch(() => undefined); + flight = fresh; + } + + flight.consumers += 1; + if (options?.onProgress) { + flight.progressListeners.add(options.onProgress); + if (flight.lastProgress) options.onProgress(flight.lastProgress); + } + const joined = flight; + const onAbort = () => { + joined.consumers -= 1; + // Last interested caller gone: stop the underlying copy. + if (joined.consumers <= 0) joined.controller.abort(); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + try { + await joined.promise; + throwIfAborted(signal); + const handle = await dir.getFileHandle(cacheName); + const file = await handle.getFile(); + retainCache(cacheName); + return file; + } finally { + signal?.removeEventListener("abort", onAbort); + if (options?.onProgress) joined.progressListeners.delete(options.onProgress); + if (!signal?.aborted) joined.consumers -= 1; + } + } +} + +/** Streams the source into the cache entry; resolves once it is complete on disk. */ +async function runCopy( + dir: FileSystemDirectoryHandle, + cacheName: string, + videoUrl: string, + size: number, + signal: AbortSignal, + chunkBytes: number, + emitProgress: (progress: MaterializeProgress) => void, +): Promise { + try { + await pruneStaleEntries(dir, keepSet()); + + const handle = await dir.getFileHandle(cacheName, { create: true }); + + // Reuse a complete prior copy. + const existing = await handle.getFile(); + if (existing.size === size) { + emitProgress({ copiedBytes: size, totalBytes: size }); + return; + } + + const writable = await handle.createWritable(); + try { + let offset = 0; + while (offset < size) { + throwIfAborted(signal); + const length = Math.min(chunkBytes, size - offset); + const chunk = await window.electronAPI.readFileChunk(videoUrl, offset, length); + if (!chunk.success || !chunk.data) { + throw new Error(chunk.message || chunk.error || "Failed to read source video chunk"); + } + // Guard against a short read that would otherwise loop forever. + if (chunk.data.byteLength === 0) { + throw new Error("Source video read returned no data before reaching the end."); + } + await writable.write(chunk.data); + offset += chunk.data.byteLength; + emitProgress({ copiedBytes: offset, totalBytes: size }); + } + await writable.close(); + } catch (error) { + try { + await writable.abort(); + } catch { + // ignore abort failure; surface the original error + } + throw error; + } + + const file = await handle.getFile(); + if (file.size !== size) { + throw new Error( + `Streamed copy is incomplete (${file.size} of ${size} bytes); the source video may still be in use.`, + ); + } + } catch (error) { + // Drop the partial copy so a retry does not resume from a corrupt file. + // (No caller has retained this entry — retains happen only after success — + // but keep the guard in case a prior complete copy of the same name is + // still being read.) + if (!activeCacheRefs.has(cacheName)) { + try { + await dir.removeEntry(cacheName); + } catch { + // ignore cleanup failure + } + } + throw error; + } +} + +/** Removes cached copies in the directory whose names are not in `keep`. */ +async function pruneStaleEntries(dir: FileSystemDirectoryHandle, keep: Set): Promise { + // FileSystemDirectoryHandle async iteration is available in Chromium/Electron. + const entries = ( + dir as unknown as { + keys?: () => AsyncIterableIterator; + } + ).keys?.(); + if (!entries) return; + const toRemove: string[] = []; + for await (const name of entries) { + if (!keep.has(name)) toRemove.push(name); + } + await Promise.all(toRemove.map((name) => dir.removeEntry(name).catch(() => undefined))); +} diff --git a/src/lib/exporter/muxer.ts b/src/lib/exporter/muxer.ts index 95d41ad2ec..d2a2a93b91 100644 --- a/src/lib/exporter/muxer.ts +++ b/src/lib/exporter/muxer.ts @@ -26,7 +26,6 @@ export class VideoMuxer { } async initialize(): Promise { - // Create the buffer target this.target = new BufferTarget(); this.output = new Output({ @@ -36,19 +35,17 @@ export class VideoMuxer { target: this.target, }); - // Create video source - codec will be deduced from metadata + // Codec is deduced from the chunk metadata. this.videoSource = new EncodedVideoPacketSource("avc"); this.output.addVideoTrack(this.videoSource, { frameRate: this.config.frameRate, }); - // Create audio source if needed if (this.hasAudio) { this.audioSource = new EncodedAudioPacketSource(this.audioCodec); this.output.addAudioTrack(this.audioSource); } - // Start the output to begin accepting media data await this.output.start(); } @@ -57,10 +54,8 @@ export class VideoMuxer { throw new Error("Muxer not initialized"); } - // Convert WebCodecs chunk to Mediabunny packet const packet = EncodedPacket.fromEncodedChunk(chunk); - // Add metadata with the first chunk await this.videoSource.add(packet, meta); } @@ -69,10 +64,8 @@ export class VideoMuxer { throw new Error("Audio not configured for this muxer"); } - // Convert WebCodecs chunk to Mediabunny packet const packet = EncodedPacket.fromEncodedChunk(chunk); - // Add metadata with the first chunk await this.audioSource.add(packet, meta); } diff --git a/src/lib/exporter/sourceFileLimits.ts b/src/lib/exporter/sourceFileLimits.ts new file mode 100644 index 0000000000..778e2ab501 --- /dev/null +++ b/src/lib/exporter/sourceFileLimits.ts @@ -0,0 +1,16 @@ +/** + * Largest source file we are willing to read whole via the `read-binary-file` + * IPC and hand around as a single in-memory `ArrayBuffer`/`Blob`. + * + * `read-binary-file` reads the file with Node's `fs.readFile` (which itself + * throws above 2 GiB) and returns the bytes over IPC, where Electron + * structured-clones them — copying the whole buffer in the main process. For a + * large recording this transiently needs ~2× the file size in the main process + * and crashes it on a memory-constrained machine (observed: a ~1 GB recording + * hard-crashes a 16 GB Mac). So the safe cutoff is far below the 2 GiB read cap. + * + * Above this size, recordings are streamed on demand instead — into OPFS in + * fixed-size chunks for demuxing (export/captions), and the in-memory + * source-copy and waveform paths are skipped. + */ +export const MAX_IN_MEMORY_SOURCE_BYTES = 256 * 1024 * 1024; diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index 752d5cd4c3..34cfcd5681 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -1,13 +1,22 @@ import { WebDemuxer } from "web-demuxer"; import type { SpeedRegion, TrimRegion } from "@/components/video-editor/types"; +import { + type MaterializeProgress, + materializeLocalSourceFile, + releaseLocalSourceFile, +} from "./localSourceFile"; +import { MAX_IN_MEMORY_SOURCE_BYTES } from "./sourceFileLimits"; const SOURCE_LOAD_TIMEOUT_MS = 60_000; +// Large local recordings are streamed into OPFS before demuxing, which is +// bounded by disk/IPC throughput rather than a network round-trip. Allow far +// more time than a remote fetch so a multi-GB copy is not cut off mid-way. +const LOCAL_SOURCE_LOAD_TIMEOUT_MS = 15 * 60_000; const EPSILON_SEC = 0.001; /** * Build a full WebCodecs-compatible AV1 codec string from the AV1CodecConfigurationRecord. - * web-demuxer may return a bare "av01" when the WASM-side parser fails to read - * the extradata (e.g. raw OBU sequence header from WebM instead of ISOBMFF av1C box). - * This function parses the record if present, otherwise returns a safe default. + * web-demuxer can return a bare "av01" when the WASM parser fails to read the extradata. + * Parses the record if present, otherwise returns a safe default. * * @see https://aomediacodec.github.io/av1-isobmff/#av1codecconfigurationbox-section */ @@ -25,9 +34,8 @@ function buildAV1CodecString(description?: BufferSource): string { // Byte 0: marker (1) | version (7) // Byte 1: seq_profile (3) | seq_level_idx_0 (5) // Byte 2: seq_tier_0 (1) | high_bitdepth (1) | twelve_bit (1) | ... - // The spec says version should be 1, but Chrome/Electron's MediaRecorder - // may write version 127 (0xFF first byte). We accept any version as long - // as the marker bit is set and the record is long enough. + // Spec says version 1, but Chrome/Electron MediaRecorder may write 127 (0xFF), + // so accept any version as long as the marker bit is set and the record is long enough. if (bytes.length < 4) return fallback; if (!(bytes[0] & 0x80)) return fallback; // marker bit must be 1 @@ -71,26 +79,22 @@ const EARLY_DECODE_END_THRESHOLD_SEC = 1; const METADATA_TAIL_TOLERANCE_SEC = 2; const STREAM_DURATION_MATCH_TOLERANCE_SEC = 0.25; const DURATION_DIVERGENCE_THRESHOLD_SEC = 1.5; -// Fallback upper bound for the packet scan when no reliable duration hint is -// available. Explicit end is required (some containers are truncated without -// one), but the hint-derived bound would cap the scan prematurely when -// container/stream duration are missing or corrupt. +// Fallback upper bound for the packet scan when no reliable duration hint exists. +// An explicit end is required (some containers are truncated without one), but a +// hint-derived bound would cap the scan early when duration is missing or corrupt. const SCAN_UNBOUNDED_FALLBACK_SEC = 24 * 60 * 60; /** - * Validate container duration against actual packet timestamps. - * - * Chrome/Electron's MediaRecorder writes WebM containers with unreliable - * Duration fields (often Infinity, 0, or inflated) — especially on Linux. - * This function picks the most trustworthy duration value. + * Pick the most trustworthy duration. Chrome/Electron MediaRecorder writes WebM + * with unreliable Duration fields (often Infinity, 0, or inflated), especially on Linux. * * @param containerDuration Duration from the container-level metadata * @param scannedDuration Duration derived from actual packet timestamps (ground truth) */ export function validateDuration(containerDuration: number, scannedDuration: number): number { if (scannedDuration <= 0) { - // Zero scanned duration means corrupted/empty file — fall back to container - // (downstream shouldFailDecodeEndedEarly will catch truly empty files) + // Corrupted/empty file, fall back to container. + // (downstream shouldFailDecodeEndedEarly catches truly empty files) return Number.isFinite(containerDuration) ? Math.max(containerDuration, 0) : 0; } if (!Number.isFinite(containerDuration) || containerDuration <= 0) { @@ -138,11 +142,8 @@ export function shouldFailDecodeEndedEarly({ } /** - * Loads a video file as an ArrayBuffer, delegating to - * `StreamingVideoDecoder.loadLocalSourceFile` for local paths (Electron IPC) - * and `StreamingVideoDecoder.loadRemoteSourceFile` for remote / blob / data URLs. - * Also returns the `contentType` derived from the blob (empty string for local - * IPC reads where no Content-Type is available). + * Loads a video file as an ArrayBuffer via the local (Electron IPC) or remote loader. + * contentType is empty for local IPC reads, which carry no Content-Type. */ export async function loadFileAsArrayBuffer( videoUrl: string, @@ -150,12 +151,25 @@ export async function loadFileAsArrayBuffer( const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); if (!isRemoteUrl && window.electronAPI) { - const { blob } = await StreamingVideoDecoder.loadLocalSourceFile(videoUrl); - return { data: await blob.arrayBuffer(), contentType: "" }; + // This path loads the entire file into an ArrayBuffer for decodeAudioData, + // and readBinaryFile also copies the bytes in the main process during IPC, + // so a large recording would exhaust memory and crash. Callers must route + // oversized files elsewhere (useAudioPeaks streams peaks via + // computePeaksFromFileStreaming); this guard is a safety net for any + // caller that does not. + const info = await window.electronAPI.getReadableFileInfo?.(videoUrl); + if (info?.success && typeof info.size === "number" && info.size > MAX_IN_MEMORY_SOURCE_BYTES) { + throw new Error("Recording is too large to load into memory for waveform rendering."); + } + const result = await window.electronAPI.readBinaryFile(videoUrl); + if (!result.success || !result.data) { + throw new Error(result.message || result.error || "Failed to read source video"); + } + return { data: result.data, contentType: "" }; } - const { blob } = await StreamingVideoDecoder.loadRemoteSourceFile(videoUrl); - return { data: await blob.arrayBuffer(), contentType: blob.type }; + const file = await StreamingVideoDecoder.loadRemoteSourceFile(videoUrl); + return { data: await file.arrayBuffer(), contentType: file.type }; } /** Caller must close the VideoFrame after use. */ @@ -177,15 +191,28 @@ export class StreamingVideoDecoder { private decoder: VideoDecoder | null = null; private cancelled = false; private metadata: DecodedVideoInfo | null = null; + // Name of the OPFS cache entry backing a large local source (equals the + // File's .name), released on destroy() so the copy can be pruned once no + // demuxer is reading it. Null for small/remote sources (never retained). + private sourceCacheName: string | null = null; + // Aborts an in-flight OPFS materialization when the decoder is cancelled, + // destroyed, or the load times out — otherwise a cancelled export would keep + // streaming gigabytes in the background and leak the cache reference. + private readonly loadAbort = new AbortController(); /** Routes to the appropriate loader based on whether the source is local or remote. */ - private async loadSourceFile(videoUrl: string): Promise<{ file: File; blob: Blob }> { + private async loadSourceFile( + videoUrl: string, + onProgress?: (progress: MaterializeProgress) => void, + ): Promise { const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); if (!isRemoteUrl && window.electronAPI) { return this.withTimeout( - StreamingVideoDecoder.loadLocalSourceFile(videoUrl), - SOURCE_LOAD_TIMEOUT_MS, + StreamingVideoDecoder.loadLocalSourceFile(videoUrl, onProgress, this.loadAbort.signal), + LOCAL_SOURCE_LOAD_TIMEOUT_MS, "Timed out while loading the source video.", + // Stop the underlying copy too; a bare reject would leave it running. + () => this.loadAbort.abort(), ); } return this.withTimeout( @@ -195,41 +222,41 @@ export class StreamingVideoDecoder { ); } - /** Loads a local video file via the Electron IPC bridge. */ - static async loadLocalSourceFile(videoUrl: string): Promise<{ file: File; blob: Blob }> { - const result = await window.electronAPI.readBinaryFile(videoUrl); - if (!result.success || !result.data) { - throw new Error(result.message || result.error || "Failed to read source video"); - } - - const filename = (result.path || videoUrl).split(/[\\/]/).pop() || "video"; - const blob = new Blob([result.data]); - return { - blob, - file: new File([blob], filename, { - type: blob.type || "application/octet-stream", - }), - }; + /** + * Loads a local video file for demuxing. Large recordings are streamed into + * an OPFS-backed File so nothing multi-GB is held in memory; web-demuxer reads + * the File on demand. See {@link materializeLocalSourceFile}. + */ + static async loadLocalSourceFile( + videoUrl: string, + onProgress?: (progress: MaterializeProgress) => void, + signal?: AbortSignal, + ): Promise { + const filename = (videoUrl.split(/[\\/]/).pop() || "video").replace(/^file:/, ""); + return materializeLocalSourceFile(videoUrl, filename, { onProgress, signal }); } /** Loads a remote or blob video URL via fetch. */ - static async loadRemoteSourceFile(videoUrl: string): Promise<{ file: File; blob: Blob }> { + static async loadRemoteSourceFile(videoUrl: string): Promise { const response = await fetch(videoUrl); if (!response.ok) { throw new Error(`Failed to fetch source video: ${response.status} ${response.statusText}`); } const blob = await response.blob(); const filename = videoUrl.split("/").pop() || "video"; - return { - blob, - file: new File([blob], filename, { type: blob.type }), - }; + return new File([blob], filename, { type: blob.type }); } - async loadMetadata(videoUrl: string): Promise { - const { file } = await this.loadSourceFile(videoUrl); + async loadMetadata( + videoUrl: string, + onSourceProgress?: (progress: MaterializeProgress) => void, + ): Promise { + const file = await this.loadSourceFile(videoUrl, onSourceProgress); + // For OPFS-streamed sources the File name is the cache-entry key; retained + // by materialize and released in destroy(). No-op key for small/remote. + this.sourceCacheName = file.name; - // Relative URL so it resolves correctly in both dev (http) and packaged (file://) builds + // Relative URL so it resolves in both dev (http) and packaged (file://) builds const wasmUrl = new URL("./wasm/web-demuxer.wasm", window.location.href).href; this.demuxer = new WebDemuxer({ wasmFilePath: wasmUrl }); await this.withTimeout( @@ -257,12 +284,11 @@ export class StreamingVideoDecoder { const audioStream = mediaInfo.streams.find((s) => s.codec_type_string === "audio"); - // Scan video packets to find the true content boundary. - // MediaRecorder (especially on Linux) writes unreliable container durations. - // Packet timestamps are ground truth — no decode needed, just timestamp reads. - // Pass explicit range because some containers are truncated without one. - // Sanitize because mediaInfo.duration can be NaN/Infinity (Chromium Linux bug), - // which would propagate into demuxer.read() as an invalid endpoint. + // Scan video packets for the true content boundary; MediaRecorder (especially on + // Linux) writes unreliable container durations and packet timestamps are ground truth. + // Pass an explicit range because some containers are truncated without one. + // Sanitize because mediaInfo.duration can be NaN/Infinity (Chromium Linux bug), which + // would reach demuxer.read() as an invalid endpoint. const containerDurationSec = Number.isFinite(mediaInfo.duration) ? mediaInfo.duration : 0; const streamDurationSec = typeof videoStream?.duration === "number" && Number.isFinite(videoStream.duration) @@ -307,12 +333,10 @@ export class StreamingVideoDecoder { return this.metadata; } /** - * Decodes all video frames from the loaded source and invokes a callback for each. - * Handles trimming and speed adjustments, and resamples to the target frame rate. - * On Windows, early decode termination is tolerated to work around driver quirks. + * Decodes all video frames, applying trim/speed and resampling to the target frame rate. * @param targetFrameRate - Desired output frame rate. - * @param trimRegions - Array of time regions to keep (others discarded). - * @param speedRegions - Array of speed adjustments for specific time ranges. + * @param trimRegions - Time regions to keep (others discarded). + * @param speedRegions - Speed adjustments for specific time ranges. * @param onFrame - Async callback receiving each decoded VideoFrame. */ async decodeAll( @@ -331,9 +355,8 @@ export class StreamingVideoDecoder { console.log("[StreamingVideoDecoder] decoderConfig.codec:", decoderConfig.codec); console.log("[StreamingVideoDecoder] decoderConfig.description:", decoderConfig.description); - // web-demuxer may return bare four-character code strings ("av01", "vp08", - // "vp09", "avc1") that WebCodecs rejects. Normalize them to the short or - // full parametrized forms that VideoDecoder accepts. + // web-demuxer can return bare fourcc strings ("av01", "vp08", "vp09", "avc1") + // that WebCodecs rejects; normalize to forms VideoDecoder accepts. if (/^av01$/i.test(decoderConfig.codec)) { decoderConfig.codec = buildAV1CodecString( decoderConfig.description as BufferSource | undefined, @@ -373,7 +396,7 @@ export class StreamingVideoDecoder { ); const frameDurationUs = 1_000_000 / targetFrameRate; - // Async frame queue — decoder pushes, consumer pulls + // Async frame queue: decoder pushes, consumer pulls. const pendingFrames: VideoFrame[] = []; let frameResolve: ((frame: VideoFrame | null) => void) | null = null; let decodeError: Error | null = null; @@ -443,12 +466,12 @@ export class StreamingVideoDecoder { }); }; - // One forward stream through the whole file. - // Pass explicit range because some containers are truncated when no end is provided. + // One forward stream through the whole file. Pass an explicit range because + // some containers are truncated when no end is provided. const readEndSec = this.metadata.duration + 0.5; const reader = this.demuxer.read("video", 0, readEndSec).getReader(); - // Feed chunks to decoder in background with backpressure + // Feed chunks to the decoder in the background with backpressure. const feedPromise = (async () => { try { while (!this.cancelled) { @@ -482,7 +505,7 @@ export class StreamingVideoDecoder { } })(); - // Route decoded frames into segments by timestamp, then deliver with VFR→CFR resampling + // Route decoded frames into segments by timestamp, then deliver with VFR to CFR resampling. let segmentIdx = 0; let segmentFrameIndex = 0; let exportFrameIndex = 0; @@ -681,9 +704,8 @@ export class StreamingVideoDecoder { } /** - * Calculates the effective output duration (in seconds) and total frame count - * for a given combination of trim and speed regions at the target frame rate. - * Requires `loadMetadata()` to have been called first. + * Effective output duration (seconds) and total frame count for the given trim/speed + * regions at the target frame rate. Requires loadMetadata() first. */ getExportMetrics( targetFrameRate: number, @@ -749,11 +771,13 @@ export class StreamingVideoDecoder { /** Signals the decoder to stop processing at the next cancellation checkpoint. */ cancel(): void { this.cancelled = true; + this.loadAbort.abort(); } /** Cancels decoding and releases the VideoDecoder and WebDemuxer resources. */ destroy(): void { this.cancelled = true; + this.loadAbort.abort(); if (this.decoder) { try { @@ -772,12 +796,25 @@ export class StreamingVideoDecoder { } this.demuxer = null; } + + if (this.sourceCacheName) { + releaseLocalSourceFile(this.sourceCacheName); + this.sourceCacheName = null; + } } /** Wraps a promise with a hard timeout, rejecting with `message` if it exceeds `timeoutMs`. */ - private withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + private withTimeout( + promise: Promise, + timeoutMs: number, + message: string, + onTimeout?: () => void, + ): Promise { return new Promise((resolve, reject) => { - const timer = window.setTimeout(() => reject(new Error(message)), timeoutMs); + const timer = window.setTimeout(() => { + onTimeout?.(); + reject(new Error(message)); + }, timeoutMs); promise.then( (value) => { window.clearTimeout(timer); diff --git a/src/lib/exporter/threeDPass.ts b/src/lib/exporter/threeDPass.ts index 5733bd05f2..657c5ff2aa 100644 --- a/src/lib/exporter/threeDPass.ts +++ b/src/lib/exporter/threeDPass.ts @@ -5,10 +5,9 @@ import { rotation3DPerspective, } from "@/components/video-editor/types"; -// CSS uses +y down, WebGL clip space uses +y up. We do all rotation math in CSS -// convention (top-left origin, +y down) to match the preview, then flip -// gl_Position.y at the end so WebGL's clip space lands the input's top edge at -// the top of the output viewport. +// Rotation math is done in CSS convention (+y down) to match the preview, then +// gl_Position.y is flipped so WebGL clip space (+y up) lands the input's top edge +// at the top of the viewport. const VERTEX_SHADER = `#version 300 es in vec2 aPos; in vec2 aUV; @@ -151,10 +150,7 @@ function createProgram(gl: WebGL2RenderingContext): WebGLProgram { export interface ThreeDPass { apply(srcCanvas: HTMLCanvasElement | OffscreenCanvas, rot: Rotation3D): HTMLCanvasElement; - /** - * Reads back the most recent apply() result into a Uint8ClampedArray suitable - * for ImageData. Use this on platforms where drawImage(webglCanvas) is unreliable. - */ + /** Read the last apply() result as ImageData-ready pixels, for platforms where drawImage(webglCanvas) is unreliable. */ readPixels(): Uint8ClampedArray; resize(width: number, height: number): void; destroy(): void; @@ -180,10 +176,9 @@ export function createThreeDPass(width: number, height: number): ThreeDPass { const vao = gl.createVertexArray(); gl.bindVertexArray(vao); - // Quad: two triangles sharing UVs consistently per corner. - // pos.y ranges 0 (top of input) → 1 (bottom of input) following CSS convention. - // UV.y is inverted (1 - pos.y) so that with UNPACK_FLIP_Y_WEBGL the texture - // sample at the top of the input lands at the top of the rendered quad. + // Quad as two triangles. pos.y is 0 (top) to 1 (bottom) per CSS convention; UV.y + // is inverted so that with UNPACK_FLIP_Y_WEBGL the top of the input lands at the + // top of the rendered quad. // TL: pos(0,0) uv(0,1) TR: pos(1,0) uv(1,1) // BL: pos(0,1) uv(0,0) BR: pos(1,1) uv(1,0) const verts = new Float32Array([ @@ -207,7 +202,7 @@ export function createThreeDPass(width: number, height: number): ThreeDPass { 1, 0, 1, - 1, // TR (was 1,0,1,0 — broken) + 1, // TR (was 1,0,1,0, broken) 1, 1, 1, @@ -224,20 +219,17 @@ export function createThreeDPass(width: number, height: number): ThreeDPass { const texture = gl.createTexture(); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, texture); - // Plain bilinear, NO mipmaps. Mipmaps pre-blur the texture for downsampling, but - // at our moderate rotation angles (≤22°) the receding edge would still pick a - // smaller mipmap level, which softens fine details — specifically the few-pixel - // rounded-corner anti-alias ramp and the shadow's Gaussian falloff. The result - // is "rounding looks like a hard corner / shadow looks grimy". Sampling level 0 - // directly preserves the source crispness. + // Plain bilinear, no mipmaps. Even at our moderate angles (<=22deg) the receding + // edge picks a smaller mip level, softening the rounded-corner AA ramp and shadow + // falloff (corners look hard, shadows grimy). Sampling level 0 keeps source crispness. gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); - // Anisotropic filtering still helps without mipmaps: at oblique viewing angles - // it samples multiple texels along the gradient direction at level 0, recovering - // detail that plain bilinear would lose. Cap to the device max (16× typical). + // Anisotropic filtering still helps without mipmaps: at oblique angles it samples + // multiple texels along the gradient at level 0, recovering detail bilinear loses. + // Cap to the device max (16x typical). const anisoExt = gl.getExtension("EXT_texture_filter_anisotropic") || gl.getExtension("MOZ_EXT_texture_filter_anisotropic") || @@ -263,15 +255,10 @@ export function createThreeDPass(width: number, height: number): ThreeDPass { gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, texture); gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); - // CRITICAL: premultiply on upload. The source 2D canvas stores non-premultiplied - // RGBA (alpha=0 areas have RGB=0). Bilinear filtering between an inside-the-shape - // texel (alpha=1, RGB=color) and an outside texel (alpha=0, RGB=0) in - // non-premultiplied space yields (color/2, alpha=0.5), which the - // premultipliedAlpha:true canvas then interprets as half-strength color — visible - // as a dark halo around rounded corners and softened/grimy shadows. Premultiplying - // at upload time makes the bilinear math operate in linear-light premultiplied - // space, which is exactly the math used for compositing. Edges and shadows then - // reproduce the source crisply. + // Premultiply on upload. The source 2D canvas is non-premultiplied (alpha=0 areas + // have RGB=0), so bilinear filtering across a shape edge in that space gives + // half-strength color, showing as a dark halo on rounded corners and grimy shadows. + // Premultiplying makes the filter math match compositing, so edges stay crisp. gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true); gl.texImage2D( gl.TEXTURE_2D, @@ -308,11 +295,9 @@ export function createThreeDPass(width: number, height: number): ThreeDPass { const h = currentSize.height; const buf = new Uint8Array(w * h * 4); gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, buf); - // gl.readPixels is bottom-up; flip to top-down for ImageData. We also need - // to un-premultiply the alpha here: the framebuffer holds premultiplied RGBA - // (we set UNPACK_PREMULTIPLY_ALPHA_WEBGL=true on upload), but ImageData / - // putImageData expect non-premultiplied. Without this divide, semi-transparent - // pixels get interpreted as darker than they should be. + // readPixels is bottom-up, so flip to top-down. Also un-premultiply: the + // framebuffer is premultiplied (UNPACK_PREMULTIPLY_ALPHA_WEBGL on upload) but + // ImageData expects non-premultiplied, else semi-transparent pixels read too dark. const rowSize = w * 4; const out = new Uint8ClampedArray(buf.length); for (let row = 0; row < h; row += 1) { diff --git a/src/lib/exporter/types.ts b/src/lib/exporter/types.ts index 387334177e..e779a27b6e 100644 --- a/src/lib/exporter/types.ts +++ b/src/lib/exporter/types.ts @@ -10,9 +10,9 @@ export interface ExportProgress { currentFrame: number; totalFrames: number; percentage: number; - estimatedTimeRemaining: number; // in seconds - phase?: "extracting" | "finalizing"; // Phase of export - renderProgress?: number; // 0-100, progress of GIF rendering phase + estimatedTimeRemaining: number; // seconds + phase?: "preparing" | "extracting" | "finalizing"; + renderProgress?: number; // 0-100, GIF render phase } export interface ExportResult { diff --git a/src/lib/exporter/videoDecoder.ts b/src/lib/exporter/videoDecoder.ts index 4ed1157ac1..505e94f2e8 100644 --- a/src/lib/exporter/videoDecoder.ts +++ b/src/lib/exporter/videoDecoder.ts @@ -36,9 +36,7 @@ export class VideoFileDecoder { }); } - /** - * Get video element for seeking - */ + /** The underlying video element, used for seeking. */ getVideoElement(): HTMLVideoElement | null { return this.videoElement; } diff --git a/src/lib/exporter/videoExporter.test.ts b/src/lib/exporter/videoExporter.test.ts index 1b64255a26..bd424548b9 100644 --- a/src/lib/exporter/videoExporter.test.ts +++ b/src/lib/exporter/videoExporter.test.ts @@ -1,8 +1,9 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { getSourceCopyFastPathBlockers, isSourceCopyFastPathEligible, type VideoExporterConfig, + waitForEncoderQueueSpace, } from "./videoExporter"; function createConfig(overrides: Partial = {}): VideoExporterConfig { @@ -119,3 +120,110 @@ describe("getSourceCopyFastPathBlockers", () => { ).toContain("output-size 1920x1080 differs from source 1920x1032"); }); }); + +// The original bug measured the timeout from the encoder's last *output* event +// (lastEncoderOutputAt), which went stale while the decoder discarded frames inside +// a trim region. waitForEncoderQueueSpace fixes this by starting the clock fresh on +// each call instead of accepting any such external timestamp — by construction, there +// is no "last output" state to go stale, so that regression can't be reintroduced +// without changing this function's signature. +describe("waitForEncoderQueueSpace", () => { + function fakeClock(start = 0) { + let elapsedMs = start; + return { + now: () => elapsedMs, + sleep: async (ms: number) => { + elapsedMs += ms; + }, + }; + } + + it("resolves immediately when the queue already has space", async () => { + const clock = fakeClock(); + const sleep = vi.fn(clock.sleep); + + await waitForEncoderQueueSpace({ + getQueueSize: () => 0, + maxEncodeQueue: 8, + isCancelled: () => false, + encoderPreference: "prefer-hardware", + now: clock.now, + sleep, + }); + + expect(sleep).not.toHaveBeenCalled(); + }); + + it("waits for the queue to drain and then resolves", async () => { + const clock = fakeClock(); + let queueSize = 8; + // Queue drains well within the timeout. + const sleep = vi.fn(async (ms: number) => { + await clock.sleep(ms); + queueSize = 0; + }); + + await waitForEncoderQueueSpace({ + getQueueSize: () => queueSize, + maxEncodeQueue: 8, + isCancelled: () => false, + encoderPreference: "prefer-hardware", + now: clock.now, + sleep, + }); + + expect(sleep).toHaveBeenCalledTimes(1); + }); + + it("throws a hardware-specific error once the queue stays full past the timeout", async () => { + const clock = fakeClock(); + + await expect( + waitForEncoderQueueSpace({ + getQueueSize: () => 8, + maxEncodeQueue: 8, + isCancelled: () => false, + encoderPreference: "prefer-hardware", + now: clock.now, + sleep: clock.sleep, + }), + ).rejects.toThrow( + "The hardware video encoder stopped responding. Retrying with a safer encoder.", + ); + }); + + it("throws a generic error for the software encoder once the queue stays full past the timeout", async () => { + const clock = fakeClock(); + + await expect( + waitForEncoderQueueSpace({ + getQueueSize: () => 8, + maxEncodeQueue: 8, + isCancelled: () => false, + encoderPreference: "prefer-software", + now: clock.now, + sleep: clock.sleep, + }), + ).rejects.toThrow("The video encoder stopped responding during export."); + }); + + it("stops waiting without throwing once cancelled", async () => { + const clock = fakeClock(); + let cancelled = false; + const sleep = vi.fn(async (ms: number) => { + await clock.sleep(ms); + cancelled = true; + }); + + await expect( + waitForEncoderQueueSpace({ + getQueueSize: () => 8, + maxEncodeQueue: 8, + isCancelled: () => cancelled, + encoderPreference: "prefer-hardware", + now: clock.now, + sleep, + }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index 35c3d559d2..b89dfb74b1 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -13,6 +13,7 @@ import { getPlatform } from "@/utils/platformUtils"; import { AudioProcessor } from "./audioEncoder"; import { FrameRenderer } from "./frameRenderer"; import { VideoMuxer } from "./muxer"; +import { MAX_IN_MEMORY_SOURCE_BYTES } from "./sourceFileLimits"; import { StreamingVideoDecoder } from "./streamingDecoder"; import { TimestampedVideoFrameQueue } from "./timestampedVideoFrameQueue"; import type { ExportConfig, ExportProgress, ExportResult } from "./types"; @@ -20,6 +21,37 @@ import type { ExportConfig, ExportProgress, ExportResult } from "./types"; const ENCODER_STALL_TIMEOUT_MS = 15_000; const ENCODER_FLUSH_TIMEOUT_MS = 20_000; +/** + * Waits for the encoder's queue to drain below maxEncodeQueue before returning. + * + * The stall timer starts fresh on each call (not from the encoder's last output), so a + * long gap before this call — e.g. the decoder discarding frames inside a trim region — + * doesn't get blamed on the encoder once real frames resume. + */ +export async function waitForEncoderQueueSpace(params: { + getQueueSize: () => number; + maxEncodeQueue: number; + isCancelled: () => boolean; + encoderPreference: HardwareAcceleration; + now?: () => number; + sleep?: (ms: number) => Promise; +}): Promise { + const now = params.now ?? Date.now; + const sleep = params.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + + const stallWaitStartAt = now(); + while (params.getQueueSize() >= params.maxEncodeQueue && !params.isCancelled()) { + if (now() - stallWaitStartAt > ENCODER_STALL_TIMEOUT_MS) { + throw new Error( + params.encoderPreference === "prefer-hardware" + ? "The hardware video encoder stopped responding. Retrying with a safer encoder." + : "The video encoder stopped responding during export.", + ); + } + await sleep(5); + } +} + export interface VideoExporterConfig extends ExportConfig { videoUrl: string; webcamVideoUrl?: string; @@ -37,6 +69,8 @@ export interface VideoExporterConfig extends ExportConfig { cropRegion: CropRegion; webcamLayoutPreset?: WebcamLayoutPreset; webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape; + webcamMirrored?: boolean; + webcamReactiveZoom?: boolean; webcamSizePreset?: WebcamSizePreset; webcamPosition?: { cx: number; cy: number } | null; cursorRecordingData?: CursorRecordingData | null; @@ -45,6 +79,7 @@ export interface VideoExporterConfig extends ExportConfig { cursorMotionBlur?: number; cursorClickBounce?: number; cursorClipToBounds?: boolean; + cursorTheme?: string; annotationRegions?: AnnotationRegion[]; previewWidth?: number; previewHeight?: number; @@ -149,7 +184,6 @@ export class VideoExporter { private videoColorSpace: VideoColorSpaceInit | undefined; private muxingPromises: Promise[] = []; private chunkCount = 0; - private lastEncoderOutputAt = 0; private fatalEncoderError: Error | null = null; constructor(config: VideoExporterConfig) { @@ -212,7 +246,20 @@ export class VideoExporter { const streamingDecoder = new StreamingVideoDecoder(); this.streamingDecoder = streamingDecoder; - const videoInfo = await streamingDecoder.loadMetadata(this.config.videoUrl); + const videoInfo = await streamingDecoder.loadMetadata( + this.config.videoUrl, + ({ copiedBytes, totalBytes }) => { + // Large recordings are streamed into OPFS before demuxing; surface + // that copy as a "preparing" phase so the dialog is not stuck at 0%. + this.reportProgress({ + currentFrame: 0, + totalFrames: 0, + percentage: totalBytes > 0 ? (copiedBytes / totalBytes) * 100 : 0, + estimatedTimeRemaining: 0, + phase: "preparing", + }); + }, + ); const sourceCopyResult = await this.trySourceCopyFastPath(videoInfo); if (sourceCopyResult) { return sourceCopyResult; @@ -243,11 +290,14 @@ export class VideoExporter { cursorMotionBlur: this.config.cursorMotionBlur, cursorClickBounce: this.config.cursorClickBounce, cursorClipToBounds: this.config.cursorClipToBounds, + cursorTheme: this.config.cursorTheme, videoWidth: videoInfo.width, videoHeight: videoInfo.height, webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null, webcamLayoutPreset: this.config.webcamLayoutPreset, webcamMaskShape: this.config.webcamMaskShape, + webcamMirrored: this.config.webcamMirrored, + webcamReactiveZoom: this.config.webcamReactiveZoom, webcamSizePreset: this.config.webcamSizePreset, webcamPosition: this.config.webcamPosition, annotationRegions: this.config.annotationRegions, @@ -378,20 +428,16 @@ export class VideoExporter { exportFrame = new VideoFrame(canvas, { timestamp, duration: frameDuration }); } - while ( - this.encoder && - this.encoder.encodeQueueSize >= maxEncodeQueue && - !this.cancelled - ) { - if (Date.now() - this.lastEncoderOutputAt > ENCODER_STALL_TIMEOUT_MS) { - exportFrame.close(); - throw new Error( - encoderPreference === "prefer-hardware" - ? "The hardware video encoder stopped responding. Retrying with a safer encoder." - : "The video encoder stopped responding during export.", - ); - } - await new Promise((resolve) => setTimeout(resolve, 5)); + try { + await waitForEncoderQueueSpace({ + getQueueSize: () => this.encoder?.encodeQueueSize ?? 0, + maxEncodeQueue, + isCancelled: () => this.cancelled, + encoderPreference, + }); + } catch (error) { + exportFrame.close(); + throw error; } if (this.encoder && this.encoder.state === "configured") { @@ -490,14 +536,11 @@ export class VideoExporter { this.encodeQueue = 0; this.muxingPromises = []; this.chunkCount = 0; - this.lastEncoderOutputAt = Date.now(); this.fatalEncoderError = null; let videoDescription: Uint8Array | undefined; this.encoder = new VideoEncoder({ output: (chunk, meta) => { - this.lastEncoderOutputAt = Date.now(); - if (meta?.decoderConfig?.description && !videoDescription) { const desc = meta.decoderConfig.description; if (desc instanceof ArrayBuffer || desc instanceof SharedArrayBuffer) { @@ -642,7 +685,6 @@ export class VideoExporter { this.chunkCount = 0; this.videoDescription = undefined; this.videoColorSpace = undefined; - this.lastEncoderOutputAt = 0; this.fatalEncoderError = null; } @@ -700,6 +742,20 @@ export class VideoExporter { const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); if (!isRemoteUrl && window.electronAPI?.readBinaryFile) { + // The source-copy fast path reads the whole file into a Blob. That is + // impossible for recordings above Node's 2 GiB single-read cap, so bail + // out and let the (streaming) re-encode path handle them instead. + if (window.electronAPI.getReadableFileInfo) { + const info = await window.electronAPI.getReadableFileInfo(videoUrl); + if ( + info.success && + typeof info.size === "number" && + info.size > MAX_IN_MEMORY_SOURCE_BYTES + ) { + return null; + } + } + const result = await window.electronAPI.readBinaryFile(videoUrl); if (!result.success || !result.data) { return null; diff --git a/src/lib/exporter/webcamFrameDrawing.ts b/src/lib/exporter/webcamFrameDrawing.ts new file mode 100644 index 0000000000..41f98d8f69 --- /dev/null +++ b/src/lib/exporter/webcamFrameDrawing.ts @@ -0,0 +1,43 @@ +interface WebcamFrameCrop { + x: number; + y: number; + width: number; + height: number; +} + +export type WebcamCanvasContext = Pick< + CanvasRenderingContext2D, + "drawImage" | "restore" | "save" | "scale" | "translate" +>; + +export function drawWebcamFrameImage( + ctx: WebcamCanvasContext, + image: CanvasImageSource, + crop: WebcamFrameCrop, + dest: WebcamFrameCrop, + mirrored = false, +) { + if (mirrored) { + ctx.save(); + try { + ctx.translate(dest.x + dest.width, dest.y); + ctx.scale(-1, 1); + ctx.drawImage(image, crop.x, crop.y, crop.width, crop.height, 0, 0, dest.width, dest.height); + } finally { + ctx.restore(); + } + return; + } + + ctx.drawImage( + image, + crop.x, + crop.y, + crop.width, + crop.height, + dest.x, + dest.y, + dest.width, + dest.height, + ); +} diff --git a/src/lib/frameStep.ts b/src/lib/frameStep.ts index dc42d78cbc..29d8f49fad 100644 --- a/src/lib/frameStep.ts +++ b/src/lib/frameStep.ts @@ -1,10 +1,7 @@ -/** Duration of a single frame in seconds at 60 FPS (~16.67ms). */ +/** One frame in seconds at 60 FPS (~16.67ms). */ export const FRAME_DURATION_SEC = 1 / 60; -/** - * Compute the new playhead time after stepping one frame forward or backward. - * The result is clamped to the range [0, duration]. - */ +/** New playhead time after stepping one frame, clamped to [0, duration]. */ export function computeFrameStepTime( currentTime: number, duration: number, diff --git a/src/lib/nativeMacRecording.ts b/src/lib/nativeMacRecording.ts index 4202132f94..e5137c3de7 100644 --- a/src/lib/nativeMacRecording.ts +++ b/src/lib/nativeMacRecording.ts @@ -56,6 +56,7 @@ export type NativeMacHelperReadyEvent = { export type NativeMacHelperRecordingStartedEvent = { event: "recording-started"; timestampMs: number; + captureBounds?: Rectangle; }; export type NativeMacHelperRecordingStoppedEvent = { diff --git a/src/lib/recordingSession.ts b/src/lib/recordingSession.ts index 12a6afd224..f7d69cbf71 100644 --- a/src/lib/recordingSession.ts +++ b/src/lib/recordingSession.ts @@ -21,11 +21,10 @@ export interface StoreRecordedSessionInput { createdAt?: number; cursorCaptureMode?: CursorCaptureMode; /** - * Recording wall-clock duration in milliseconds. Used by the main process - * to patch the WebM Duration header on streamed recordings, since the - * renderer no longer holds the bytes. Browser MediaRecorder writes WebM - * with no/zero duration; without this patch, the editor's seek bar and - * timeline break for any recording that took the streaming path. + * Recording wall-clock duration (ms). The main process patches the WebM Duration + * header on streamed recordings (the renderer no longer holds the bytes). Browser + * MediaRecorder writes no/zero duration, which breaks the editor seek bar and + * timeline for anything that took the streaming path. */ durationMs?: number; } diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index 485fe89df9..2ebdb64c54 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -8,6 +8,8 @@ export const SHORTCUT_ACTIONS = [ "addKeyframe", "deleteSelected", "playPause", + "copySelected", + "paste", ] as const; export type ShortcutAction = (typeof SHORTCUT_ACTIONS)[number]; @@ -115,6 +117,8 @@ export const DEFAULT_SHORTCUTS: ShortcutsConfig = { addKeyframe: { key: "f" }, deleteSelected: { key: "d", ctrl: true }, playPause: { key: " " }, + copySelected: { key: "c", ctrl: true }, + paste: { key: "v", ctrl: true }, }; export const SHORTCUT_LABELS: Record = { @@ -127,6 +131,8 @@ export const SHORTCUT_LABELS: Record = { addKeyframe: "Add Keyframe", deleteSelected: "Delete Selected", playPause: "Play / Pause", + copySelected: "Copy Selected", + paste: "Paste", }; export function matchesShortcut( @@ -145,6 +151,15 @@ export function matchesShortcut( return true; } +/** True when the event target is a text-editing surface where shortcuts should not fire. */ +export function isTextEditingTarget(target: EventTarget | null): boolean { + return ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + (target instanceof HTMLElement && target.isContentEditable) + ); +} + const KEY_LABELS: Record = { " ": "Space", delete: "Del", diff --git a/src/lib/userPreferences.test.ts b/src/lib/userPreferences.test.ts index 87ed259f27..8a64295c53 100644 --- a/src/lib/userPreferences.test.ts +++ b/src/lib/userPreferences.test.ts @@ -1,5 +1,11 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { loadUserPreferences, parentDirectoryOf, saveUserPreferences } from "./userPreferences"; +import { + DEFAULT_PREFS, + getProjectFolder, + loadUserPreferences, + parentDirectoryOf, + saveUserPreferences, +} from "./userPreferences"; describe("parentDirectoryOf", () => { it("returns the directory for a POSIX path", () => { @@ -25,6 +31,62 @@ describe("parentDirectoryOf", () => { }); }); +describe("projectFolder preference", () => { + // jsdom's localStorage isn't exposed as a global in this vitest setup, so + // stub it with an in-memory shim before each test. Mirrors what the real + // browser localStorage exposes, scoped to the keys we touch. + beforeEach(() => { + const store = new Map(); + const stub = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, String(value)); + }, + removeItem: (key: string) => { + store.delete(key); + }, + clear: () => store.clear(), + key: (i: number) => Array.from(store.keys())[i] ?? null, + get length() { + return store.size; + }, + }; + Object.defineProperty(globalThis, "localStorage", { + value: stub, + configurable: true, + }); + }); + + it("defaults to null when nothing is persisted", () => { + expect(loadUserPreferences().projectFolder).toBeNull(); + expect(getProjectFolder()).toBeUndefined(); + }); + + it("round-trips a saved project folder", () => { + saveUserPreferences({ projectFolder: "/Users/me/Projects/demos" }); + expect(loadUserPreferences().projectFolder).toBe("/Users/me/Projects/demos"); + expect(getProjectFolder()).toBe("/Users/me/Projects/demos"); + }); + + it("ignores non-string persisted values and falls back to the default", () => { + localStorage.setItem("openscreen_user_preferences", JSON.stringify({ projectFolder: 42 })); + expect(loadUserPreferences().projectFolder).toBe(DEFAULT_PREFS.projectFolder); + }); + + it("ignores empty-string persisted values and falls back to the default", () => { + localStorage.setItem("openscreen_user_preferences", JSON.stringify({ projectFolder: "" })); + expect(loadUserPreferences().projectFolder).toBe(DEFAULT_PREFS.projectFolder); + }); + + it("is independent of exportFolder", () => { + saveUserPreferences({ exportFolder: "/Users/me/Downloads" }); + saveUserPreferences({ projectFolder: "/Users/me/Projects/demos" }); + const prefs = loadUserPreferences(); + expect(prefs.exportFolder).toBe("/Users/me/Downloads"); + expect(prefs.projectFolder).toBe("/Users/me/Projects/demos"); + }); +}); + describe("user preferences", () => { beforeEach(() => { localStorage.clear(); diff --git a/src/lib/userPreferences.ts b/src/lib/userPreferences.ts index 128eb73b89..66c5a66a4b 100644 --- a/src/lib/userPreferences.ts +++ b/src/lib/userPreferences.ts @@ -29,6 +29,8 @@ export interface UserPreferences { exportFormat: ExportFormat; /** Folder used for the most recent successful export, if any */ exportFolder: string | null; + /** Folder of the most recently opened project, if any */ + projectFolder: string | null; /** Recording HUD control layout */ trayLayout: "horizontal" | "vertical"; } @@ -39,6 +41,7 @@ export const DEFAULT_PREFS: UserPreferences = { exportQuality: DEFAULT_EXPORT_SETTINGS.quality, exportFormat: DEFAULT_EXPORT_SETTINGS.format, exportFolder: null, + projectFolder: null, trayLayout: "horizontal", }; @@ -52,10 +55,7 @@ function safeJsonParse(text: string | null): Record | null { } } -/** - * Load persisted user preferences from localStorage. - * Returns defaults for any missing or invalid fields. - */ +/** Load preferences from localStorage, falling back to defaults for missing or invalid fields. */ export function loadUserPreferences(): UserPreferences { let raw: Record | null = null; try { @@ -91,6 +91,10 @@ export function loadUserPreferences(): UserPreferences { typeof raw.exportFolder === "string" && raw.exportFolder.length > 0 ? raw.exportFolder : DEFAULT_PREFS.exportFolder, + projectFolder: + typeof raw.projectFolder === "string" && raw.projectFolder.length > 0 + ? raw.projectFolder + : DEFAULT_PREFS.projectFolder, trayLayout: raw.trayLayout === "horizontal" || raw.trayLayout === "vertical" ? raw.trayLayout @@ -99,15 +103,10 @@ export function loadUserPreferences(): UserPreferences { } /** - * Extracts the parent directory from a saved file path. Handles both POSIX - * and Windows separators since the path comes from the OS save dialog. - * - * Root directories are preserved with their trailing separator so that the - * value is still a valid directory path: - * "/video.mp4" -> "/" - * "C:\\video.mp4" -> "C:\\" - * - * Returns null if no separator is found. + * Parent directory of a saved file path. Handles both POSIX and Windows + * separators since the path comes from the OS save dialog. Root dirs keep their + * trailing separator so the result stays a valid directory ("/video.mp4" -> "/", + * "C:\\video.mp4" -> "C:\\"). Returns null if no separator is found. */ export function parentDirectoryOf(filePath: string): string | null { const lastSep = Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\")); @@ -124,24 +123,23 @@ export function parentDirectoryOf(filePath: string): string | null { return filePath.slice(0, lastSep); } -/** - * Returns the remembered export folder as `string | undefined`, suitable for - * passing directly to IPC handlers that treat absence as "use the default". - */ +/** Remembered export folder as `string | undefined`, for IPC handlers that treat absence as "use the default". */ export function getExportFolder(): string | undefined { return loadUserPreferences().exportFolder ?? undefined; } -/** - * Persist user preferences to localStorage. - * Only the explicitly provided fields are updated. - */ +/** Remembered open-project folder as `string | undefined`, for IPC handlers that treat absence as "use the default". */ +export function getProjectFolder(): string | undefined { + return loadUserPreferences().projectFolder ?? undefined; +} + +/** Persist preferences to localStorage; only the provided fields are updated. */ export function saveUserPreferences(partial: Partial): void { const current = loadUserPreferences(); const merged = { ...current, ...partial }; try { localStorage.setItem(PREFS_KEY, JSON.stringify(merged)); } catch { - // localStorage may be unavailable (e.g. private browsing quota exceeded) + // localStorage may be unavailable (e.g. private browsing, quota exceeded) } } diff --git a/src/lib/vite-stubs/empty-node-module.ts b/src/lib/vite-stubs/empty-node-module.ts new file mode 100644 index 0000000000..00d8207f79 --- /dev/null +++ b/src/lib/vite-stubs/empty-node-module.ts @@ -0,0 +1,7 @@ +/** + * Empty default export, used as the Vite alias target for Node builtins that + * @xenova/transformers imports. Its env.js reads an empty object as "no filesystem" + * and stays on the browser/remote paths. + */ +const empty = Object.create(null) as Record; +export default empty; diff --git a/src/lib/vite-stubs/onnxruntime-node-stub.ts b/src/lib/vite-stubs/onnxruntime-node-stub.ts new file mode 100644 index 0000000000..f13969c226 --- /dev/null +++ b/src/lib/vite-stubs/onnxruntime-node-stub.ts @@ -0,0 +1,10 @@ +/** + * Transformers imports `onnxruntime-node`, then picks web vs node from + * `process.release.name`, which is often `"node"` in Electron's renderer even + * though we need the WASM build. The real `onnxruntime-node` is aliased away (it + * pulls `fs`), so re-export `onnxruntime-web` to give the node branch a working ORT. + */ +import * as ortWeb from "onnxruntime-web"; + +const ort = (ortWeb as { default?: typeof ortWeb }).default ?? ortWeb; +export default ort; diff --git a/src/lib/webcamMaskShapes.ts b/src/lib/webcamMaskShapes.ts index f90e727d2d..44d2ca6be2 100644 --- a/src/lib/webcamMaskShapes.ts +++ b/src/lib/webcamMaskShapes.ts @@ -16,8 +16,8 @@ export function getCssClipPath(shape: WebcamMaskShape): string | null { } /** - * Draws a Canvas 2D clip path for the given webcam mask shape. - * Call ctx.beginPath() is handled internally; caller should call ctx.clip() after. + * Draws a Canvas 2D clip path for the given webcam mask shape. beginPath is + * handled internally; caller should call ctx.clip() after. */ export function drawCanvasClipPath( ctx: CanvasRenderingContext2D, diff --git a/src/main.tsx b/src/main.tsx index 365bdc7b2e..28d128507d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,10 +2,22 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App.tsx"; import { I18nProvider } from "./contexts/I18nContext"; +import { clearStaleSourceCache } from "./lib/exporter/localSourceFile"; import "./index.css"; const windowType = new URLSearchParams(window.location.search).get("windowType") || ""; + +// Reclaim multi-GB OPFS source copies left behind by a previous session (they +// are only pruned opportunistically during the next large-file load otherwise). +// Nothing is referenced at startup, so everything stale is safe to remove. +if (!windowType) { + window.setTimeout(() => { + clearStaleSourceCache().catch(() => undefined); + }, 5_000); +} +const showNotes = new URLSearchParams(window.location.search).get("showNotes") === "true"; if ( + showNotes || windowType === "hud-overlay" || windowType === "source-selector" || windowType === "countdown-overlay" diff --git a/src/native/client.ts b/src/native/client.ts index 9ff60d3570..8d15c324d6 100644 --- a/src/native/client.ts +++ b/src/native/client.ts @@ -84,10 +84,11 @@ export const nativeBridgeClient = { existingProjectPath, }, }), - loadProjectFile: () => + loadProjectFile: (projectFolder?: string) => requireNativeBridgeData({ domain: "project", action: "loadProjectFile", + payload: { projectFolder }, }), loadCurrentProjectFile: () => requireNativeBridgeData({ diff --git a/src/native/contracts.ts b/src/native/contracts.ts index 77afa6f48a..60075d397d 100644 --- a/src/native/contracts.ts +++ b/src/native/contracts.ts @@ -165,7 +165,11 @@ export type NativeBridgeRequest = | { domain: "project"; action: "loadProjectFile"; - payload?: EmptyPayload; + payload?: { + /** Folder to pre-fill the open dialog with, usually the user's + * last-opened project folder from userPreferences. */ + projectFolder?: string; + }; requestId?: string; } | { diff --git a/src/utils/aspectRatioUtils.ts b/src/utils/aspectRatioUtils.ts index 5e174ab3ce..3fbdcd07c0 100644 --- a/src/utils/aspectRatioUtils.ts +++ b/src/utils/aspectRatioUtils.ts @@ -14,9 +14,8 @@ export type AspectRatio = (typeof ASPECT_RATIOS)[number]; const NATIVE_ASPECT_RATIO_FALLBACK = 16 / 9; /** - * Returns the numeric value of an aspect ratio. - * For "native", returns a fallback ratio of 16/9. - * Callers with source/crop context should use getNativeAspectRatioValue(). + * Numeric value of an aspect ratio. "native" returns the 16/9 fallback; + * callers with source/crop context should use getNativeAspectRatioValue(). */ export function getAspectRatioValue(aspectRatio: AspectRatio): number { switch (aspectRatio) { diff --git a/src/utils/platformUtils.ts b/src/utils/platformUtils.ts index 2fb57e1b9b..e41145ee46 100644 --- a/src/utils/platformUtils.ts +++ b/src/utils/platformUtils.ts @@ -12,7 +12,7 @@ export const getPlatform = async (): Promise => { return platform; } catch (error) { console.warn("Failed to get platform from Electron, falling back to navigator:", error); - // Fallback for development/testing + // Fallback for dev/testing let fallbackPlatform = "win32"; if (typeof navigator !== "undefined") { if (/Mac|iPhone|iPad|iPod/.test(navigator.platform)) { diff --git a/tests/e2e/windows-native-checklist.spec.ts b/tests/e2e/windows-native-checklist.spec.ts index d19a1fd64c..d1fdf58be7 100644 --- a/tests/e2e/windows-native-checklist.spec.ts +++ b/tests/e2e/windows-native-checklist.spec.ts @@ -112,7 +112,7 @@ test.describe("Windows native checklist smoke tests", () => { await hudWindow.waitForLoadState("domcontentloaded"); await dismissLanguagePrompt(hudWindow); - await expect(hudWindow.getByTestId("launch-record-button")).toBeDisabled(); + await expect(hudWindow.getByTestId("launch-record-button")).toBeEnabled(); await expect(hudWindow.getByTestId("launch-source-selector-button")).toBeVisible(); await expect(hudWindow.getByTestId("launch-system-audio-button")).toBeEnabled(); await expect(hudWindow.getByTestId("launch-microphone-button")).toBeEnabled(); diff --git a/vite.config.ts b/vite.config.ts index 0779e1358a..213e447115 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -28,8 +28,22 @@ export default defineConfig({ resolve: { alias: { "@": path.resolve(__dirname, "src"), + // @xenova/transformers: env.js statically imports fs/path/url; onnx.js imports + // onnxruntime-node (must not be bundled in the renderer — it requires fs). + fs: path.resolve(__dirname, "src/lib/vite-stubs/empty-node-module.ts"), + path: path.resolve(__dirname, "src/lib/vite-stubs/empty-node-module.ts"), + url: path.resolve(__dirname, "src/lib/vite-stubs/empty-node-module.ts"), + "onnxruntime-node": path.resolve(__dirname, "src/lib/vite-stubs/onnxruntime-node-stub.ts"), // re-exports web ORT }, }, + optimizeDeps: { + exclude: ["@xenova/transformers"], + }, + // The captioning worker dynamically imports @xenova/transformers, which makes the + // worker bundle code-split — unsupported by the default "iife" worker format. + worker: { + format: "es", + }, build: { target: "esnext", minify: "terser", diff --git a/vitest.config.ts b/vitest.config.ts index 5a52a9bea4..e6b1497f93 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ test: { globals: true, environment: "jsdom", - include: ["{src,electron}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], + include: ["{src,electron,.github}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], exclude: ["src/**/*.browser.test.{ts,tsx}"], }, resolve: {