Merge branch 'main' into detect-system-lang

This commit is contained in:
Sid
2026-04-15 23:02:34 -07:00
committed by GitHub
41 changed files with 2292 additions and 63 deletions
+1
View File
@@ -0,0 +1 @@
use flake
+501
View File
@@ -0,0 +1,501 @@
name: PR to Discord Forum
on:
pull_request:
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, edited, labeled, unlabeled, closed]
pull_request_review:
types: [submitted]
issue_comment:
types: [created]
schedule:
- cron: "0 12 * * 1"
workflow_dispatch:
permissions:
contents: read
pull-requests: write
issues: read
jobs:
notify:
if: github.event_name != 'schedule'
runs-on: ubuntu-latest
steps:
- name: Sync PR activity to Discord forum thread
id: sync
uses: actions/github-script@v7
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_PR_FORUM_WEBHOOK }}
DISCORD_WEBHOOK_USERNAME: ${{ secrets.DISCORD_WEBHOOK_USERNAME }}
DISCORD_WEBHOOK_AVATAR_URL: ${{ secrets.DISCORD_WEBHOOK_AVATAR_URL }}
DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }}
DISCORD_REVIEWER_ROLE_ID: ${{ secrets.DISCORD_REVIEWER_ROLE_ID }}
DISCORD_ALERT_WEBHOOK_URL: ${{ secrets.DISCORD_ALERT_WEBHOOK_URL }}
with:
script: |
const WEBHOOK_USERNAME = (process.env.DISCORD_WEBHOOK_USERNAME || "OpenScreen").trim();
const WEBHOOK_AVATAR = (process.env.DISCORD_WEBHOOK_AVATAR_URL || "").trim();
const THREAD_MARKER_REGEX = /<!--\s*discord-thread-id:(\d+)\s*-->/i;
const webhookUrl = (process.env.DISCORD_WEBHOOK_URL || "").trim();
const botToken = (process.env.DISCORD_BOT_TOKEN || "").trim();
const reviewerRoleId = (process.env.DISCORD_REVIEWER_ROLE_ID || "").trim();
const alertWebhookUrl = (process.env.DISCORD_ALERT_WEBHOOK_URL || "").trim();
const TAGS = {
open: "1493976692967080096",
draft: "1493976782028935279",
ready: "1493976833626996756",
changes: "1493976909875515564",
approved: "1493976951038152764",
merged: "1493977049709281320",
closed: "1493977108102516786",
};
const labelTagMap = {
bug: "1493977562773458975",
enhancement: "1493977619216207993",
documentation: "1493978565153394830",
};
function cleanDescription(text, maxLen = 3500) {
if (!text) return "No description provided.";
const normalized = text
.replace(/\r\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
if (normalized.length <= maxLen) return normalized;
return `${normalized.slice(0, maxLen - 1)}…`;
}
function trimThreadName(name) {
return name.length > 95 ? name.slice(0, 95) : name;
}
function extractThreadId(body) {
if (!body) return null;
const match = body.match(THREAD_MARKER_REGEX);
return match ? match[1] : null;
}
function upsertThreadMarker(body, threadId) {
const cleaned = (body || "").replace(THREAD_MARKER_REGEX, "").trim();
return `${cleaned}\n\n<!-- discord-thread-id:${threadId} -->`.trim();
}
async function discordPost(payload, options = {}) {
const endpoint = new URL(webhookUrl);
endpoint.searchParams.set("wait", "true");
if (options.threadId) endpoint.searchParams.set("thread_id", String(options.threadId));
const response = await fetch(endpoint.toString(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: WEBHOOK_USERNAME,
avatar_url: WEBHOOK_AVATAR,
allowed_mentions: { parse: [] },
...payload,
})
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Discord API error ${response.status}: ${text}`);
}
const text = await response.text();
return text ? JSON.parse(text) : {};
}
async function patchDiscordThread(threadId, patchBody) {
if (!botToken || !threadId) return;
const response = await fetch(`https://discord.com/api/v10/channels/${threadId}`, {
method: "PATCH",
headers: {
"Authorization": `Bot ${botToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(patchBody),
});
if (!response.ok) {
const text = await response.text();
core.warning(`Discord thread patch failed (${response.status}): ${text}`);
}
}
function desiredStatusTag(prState) {
if (prState.merged && TAGS.merged) return TAGS.merged;
if (prState.closed && !prState.merged && TAGS.closed) return TAGS.closed;
if (prState.reviewState === "CHANGES_REQUESTED" && TAGS.changes) return TAGS.changes;
if (prState.reviewState === "APPROVED" && TAGS.approved) return TAGS.approved;
if (prState.draft && TAGS.draft) return TAGS.draft;
if (!prState.draft && TAGS.ready) return TAGS.ready;
return TAGS.open || null;
}
function tagIdsFromLabels(labels) {
const out = [];
for (const label of labels) {
const mapped = labelTagMap[label.toLowerCase()] || labelTagMap[label];
if (mapped) out.push(String(mapped));
}
return out;
}
async function getPullRequest() {
if (context.eventName === "pull_request" || context.eventName === "pull_request_review") {
return context.payload.pull_request || null;
}
if (context.eventName === "issue_comment") {
const issue = context.payload.issue;
if (!issue?.pull_request) return null;
const { data } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: issue.number,
});
return data;
}
return null;
}
async function getReviewState(owner, repo, pullNumber) {
const { data } = await github.rest.pulls.listReviews({ owner, repo, pull_number: pullNumber, per_page: 100 });
let hasChanges = false;
let hasApproved = false;
for (const r of data) {
const s = (r.state || "").toUpperCase();
if (s === "CHANGES_REQUESTED") hasChanges = true;
if (s === "APPROVED") hasApproved = true;
}
if (hasChanges) return "CHANGES_REQUESTED";
if (hasApproved) return "APPROVED";
return "NONE";
}
async function sendFailureAlert(message) {
if (!alertWebhookUrl) return;
try {
await fetch(alertWebhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: "OpenScreen",
avatar_url: WEBHOOK_AVATAR,
content: `⚠️ PR Discord sync failed\n${message}\nRun: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
allowed_mentions: { parse: [] }
})
});
} catch {
core.warning("Failed to send failure alert webhook.");
}
}
try {
if (!webhookUrl) {
core.setFailed("Missing webhook URL (DISCORD_PR_FORUM_WEBHOOK).");
return;
}
const pr = await getPullRequest();
if (!pr) {
core.info("No PR context found. Skipping.");
return;
}
const action = context.payload.action || "";
const owner = context.repo.owner;
const repo = context.repo.repo;
const number = pr.number;
const title = pr.title;
const author = pr.user?.login || "unknown";
const url = pr.html_url;
const authorUrl = pr.user?.html_url || "";
const authorAvatar = pr.user?.avatar_url || "";
const base = pr.base?.ref || "";
const head = pr.head?.ref || "";
const repoFullName = pr.base?.repo?.full_name || `${owner}/${repo}`;
const labels = (pr.labels || []).map((l) => l.name);
const body = (pr.body || "").trim();
const reviewState = await getReviewState(owner, repo, number);
let threadId = extractThreadId(body);
const shouldCreateThread =
context.eventName === "pull_request" &&
["opened", "reopened", "ready_for_review"].includes(action) &&
!threadId;
if (shouldCreateThread) {
const fields = [
{ name: "PR", value: `[#${number}](${url})`, inline: true },
{ name: "Author", value: `[${author}](${authorUrl || url})`, inline: true },
{ name: "Status", value: pr.draft ? "Draft" : "Open", inline: true },
{ name: "Branches", value: `\`${head}\` -> \`${base}\``, inline: true },
{ name: "Changes", value: `+${pr.additions} / -${pr.deletions}`, inline: true },
{ name: "Files Changed", value: String(pr.changed_files), inline: true }
];
if (labels.length) {
fields.push({
name: "Labels",
value: labels.map((l) => `\`${l}\``).join(" "),
inline: false,
});
}
const statusTag = desiredStatusTag({ draft: pr.draft, reviewState, merged: false, closed: false });
const mappedLabelTags = tagIdsFromLabels(labels);
const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))];
const createPayload = {
content: action === "ready_for_review" ? "🔔 PR is now ready for review" : "🔔 New pull request opened",
thread_name: trimThreadName(`PR #${number} - ${title}`),
applied_tags: appliedTags,
embeds: [
{
title: `PR #${number}: ${title}`,
url,
description: cleanDescription(body),
color: pr.draft ? 15105570 : 1998671,
author: {
name: author,
url: authorUrl || undefined,
icon_url: authorAvatar || undefined,
},
fields,
footer: { text: repoFullName },
timestamp: new Date().toISOString(),
},
],
};
const result = await discordPost(createPayload);
const createdThreadId = result.channel_id || null;
if (createdThreadId) {
const updatedBody = upsertThreadMarker(body, createdThreadId);
await github.rest.pulls.update({ owner, repo, pull_number: number, body: updatedBody });
core.info(`Created Discord thread ${createdThreadId} and stored mapping.`);
} else {
core.warning("Discord thread created but channel_id missing in response.");
}
return;
}
if (!threadId) {
core.info("No mapped Discord thread ID found; skipping update event.");
return;
}
if (context.eventName === "pull_request" && ["edited", "labeled", "unlabeled", "ready_for_review", "converted_to_draft"].includes(action)) {
const statusTag = desiredStatusTag({
draft: action === "converted_to_draft" ? true : pr.draft,
reviewState,
merged: false,
closed: false,
});
const mappedLabelTags = tagIdsFromLabels(labels);
const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))];
await patchDiscordThread(threadId, {
name: trimThreadName(`PR #${number} - ${title}`),
...(appliedTags.length ? { applied_tags: appliedTags } : {}),
});
}
let updateMessage = null;
let updateEmbed = null;
if (context.eventName === "pull_request") {
if (action === "synchronize") {
const { data: commits } = await github.rest.pulls.listCommits({ owner, repo, pull_number: number, per_page: 5 });
const list = commits.map((c) => `- \`${c.sha.slice(0, 7)}\` ${c.commit.message.split("\n")[0]}`).join("\n") || "- No commit details";
updateMessage = `🧩 New commits pushed to PR #${number}`;
updateEmbed = {
title: `Commit Update • PR #${number}`,
url: `${url}/files`,
description: `${list}`,
color: 1998671,
footer: { text: repoFullName },
timestamp: new Date().toISOString(),
};
} else if (action === "edited") {
updateMessage = `✏️ PR #${number} details were edited`;
updateEmbed = {
title: `PR Updated • #${number}`,
url,
description: cleanDescription(body, 1200),
color: 1998671,
timestamp: new Date().toISOString(),
};
} else if (action === "closed") {
const isMerged = !!pr.merged;
const statusTag = desiredStatusTag({ draft: false, reviewState, merged: isMerged, closed: true });
const mappedLabelTags = tagIdsFromLabels(labels);
const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))];
await patchDiscordThread(threadId, {
...(appliedTags.length ? { applied_tags: appliedTags } : {}),
...(isMerged ? { archived: true, locked: true } : {}),
});
updateMessage = isMerged
? `✅ PR #${number} was merged`
: `🛑 PR #${number} was closed without merge`;
updateEmbed = {
title: isMerged ? `Merged • PR #${number}` : `Closed • PR #${number}`,
url,
description: isMerged ? "This PR has been merged into the base branch." : "This PR was closed before merge.",
color: isMerged ? 5763719 : 15158332,
timestamp: new Date().toISOString(),
};
} else if (action === "ready_for_review") {
updateMessage = `🚀 PR #${number} moved from draft to ready for review`;
if (reviewerRoleId) updateMessage += ` <@&${reviewerRoleId}>`;
} else if (action === "converted_to_draft") {
updateMessage = `📝 PR #${number} converted to draft`;
}
} else if (context.eventName === "pull_request_review") {
const review = context.payload.review;
if (review) {
const state = (review.state || "commented").toUpperCase();
const reviewer = review.user?.login || "reviewer";
updateMessage = `🧪 Review ${state} by **${reviewer}** on PR #${number}`;
if (state === "CHANGES_REQUESTED" && reviewerRoleId) updateMessage += ` <@&${reviewerRoleId}>`;
updateEmbed = {
title: `Review ${state} • PR #${number}`,
url: review.html_url || url,
description: cleanDescription(review.body || "No review note.", 1000),
color: state === "APPROVED" ? 5763719 : state === "CHANGES_REQUESTED" ? 15158332 : 1998671,
timestamp: new Date().toISOString(),
};
if (state === "CHANGES_REQUESTED" || state === "APPROVED") {
const statusTag = desiredStatusTag({ draft: pr.draft, reviewState: state, merged: false, closed: false });
const mappedLabelTags = tagIdsFromLabels(labels);
const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))];
await patchDiscordThread(threadId, {
...(appliedTags.length ? { applied_tags: appliedTags } : {}),
});
}
}
} else if (context.eventName === "issue_comment") {
const comment = context.payload.comment;
if (comment) {
const commenter = comment.user?.login || "user";
updateMessage = `💬 New comment by **${commenter}** on PR #${number}`;
updateEmbed = {
title: `New PR Comment • #${number}`,
url: comment.html_url || url,
description: cleanDescription(comment.body || "No comment body.", 1000),
color: 1998671,
timestamp: new Date().toISOString(),
};
}
}
if (!updateMessage && !updateEmbed) {
core.info("No Discord update message for this event/action. Skipping.");
return;
}
const payload = { content: updateMessage || "" };
if (updateEmbed) payload.embeds = [updateEmbed];
await discordPost(payload, { threadId });
core.info(`Posted update to Discord thread ${threadId}.`);
} catch (err) {
const msg = err && err.message ? err.message : String(err);
core.setFailed(msg);
const alertWebhook = process.env.DISCORD_ALERT_WEBHOOK_URL;
if (alertWebhook) {
try {
await fetch(alertWebhook, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: "OpenScreen",
avatar_url: WEBHOOK_AVATAR,
content: `⚠️ PR->Discord sync failed\n${msg}\nRun: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
allowed_mentions: { parse: [] }
})
});
} catch {
core.warning("Failed to send alert webhook.");
}
}
}
weekly-contributor-leaderboard:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- name: Post weekly contributor leaderboard
uses: actions/github-script@v7
env:
DISCORD_SPOTLIGHT_WEBHOOK_URL: ${{ secrets.DISCORD_SPOTLIGHT_WEBHOOK_URL }}
DISCORD_WEBHOOK_USERNAME: ${{ secrets.DISCORD_WEBHOOK_USERNAME }}
DISCORD_WEBHOOK_AVATAR_URL: ${{ secrets.DISCORD_WEBHOOK_AVATAR_URL }}
with:
script: |
const spotlightWebhook = (process.env.DISCORD_SPOTLIGHT_WEBHOOK_URL || "").trim();
const webhookUsername = (process.env.DISCORD_WEBHOOK_USERNAME || "OpenScreen").trim();
const webhookAvatar = (process.env.DISCORD_WEBHOOK_AVATAR_URL || "").trim();
if (!spotlightWebhook) {
core.info("DISCORD_SPOTLIGHT_WEBHOOK_URL missing. Skipping leaderboard post.");
return;
}
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
const owner = context.repo.owner;
const repo = context.repo.repo;
const q = `repo:${owner}/${repo} is:pr is:merged merged:>=${since.substring(0, 10)}`;
const search = await github.rest.search.issuesAndPullRequests({
q,
per_page: 100,
});
const counter = new Map();
for (const item of search.data.items) {
const login = item.user?.login;
if (!login) continue;
counter.set(login, (counter.get(login) || 0) + 1);
}
const ranked = [...counter.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 10);
const totalMerged = search.data.items.length;
const lines = ranked.length
? ranked.map(([user, count], idx) => `${idx + 1}. **${user}** - ${count} merged PR(s)`).join("\n")
: "No merged PRs this week.";
const payload = {
username: webhookUsername,
...(webhookAvatar ? { avatar_url: webhookAvatar } : {}),
embeds: [
{
title: "🌟 Weekly Contributor Leaderboard",
description: lines,
color: 1998671,
fields: [
{ name: "Merged PRs (7d)", value: String(totalMerged), inline: true },
{ name: "Repository", value: `${owner}/${repo}`, inline: true },
{ name: "Period", value: "Last 7 days", inline: true }
],
timestamp: new Date().toISOString()
}
],
allowed_mentions: { parse: [] }
};
const res = await fetch(`${spotlightWebhook}?wait=true`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (!res.ok) {
const txt = await res.text();
core.setFailed(`Leaderboard post failed ${res.status}: ${txt}`);
}
+6 -1
View File
@@ -33,4 +33,9 @@ test-results
playwright-report/
# Vitest browser mode screenshots
__screenshots__/
__screenshots__/
# Nix
result
result-*
.direnv/
+5
View File
@@ -93,6 +93,11 @@ System audio capture relies on Electron's [desktopCapturer](https://www.electron
_I'm new to open source, idk what I'm doing lol. If something is wrong please raise an issue 🙏_
## Documentation
See the documentation here:
[OpenScreen Docs](https://deepwiki.com/siddharthvaddem/openscreen)
## Contributing
Contributions are welcome! If youd like to help out or see whats currently being worked on, take a look at the open issues and the [project roadmap](https://github.com/users/siddharthvaddem/projects/3) to understand the current direction of the project and find ways to contribute.
+3 -1
View File
@@ -501,8 +501,9 @@ export function registerIpcHandlers(
});
ipcMain.handle("read-binary-file", async (_, inputPath: string) => {
let normalizedPath: string | null = null;
try {
const normalizedPath = normalizeVideoSourcePath(inputPath);
normalizedPath = normalizeVideoSourcePath(inputPath);
if (!normalizedPath) {
return { success: false, message: "Invalid file path" };
}
@@ -527,6 +528,7 @@ export function registerIpcHandlers(
success: false,
message: "Failed to read binary file",
error: String(error),
path: normalizedPath,
};
}
});
Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1775710090,
"narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "4c1018dae018162ec878d42fec712642d214fdfa",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+122
View File
@@ -0,0 +1,122 @@
{
description = "OpenScreen desktop screen recorder with built-in editor";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs =
{ self, nixpkgs }:
let
systems = [
"x86_64-linux"
"aarch64-linux"
];
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
in
{
# -- Per-system outputs (packages, dev shells) --
packages = forAllSystems (pkgs: {
openscreen = pkgs.callPackage ./nix/package.nix { };
default = self.packages.${pkgs.stdenv.hostPlatform.system}.openscreen;
});
devShells = forAllSystems (
pkgs:
let
electron = pkgs.electron;
# Libraries Electron needs at runtime on Linux
runtimeLibs = with pkgs; [
# X11
libx11
libxcomposite
libxdamage
libxext
libxfixes
libxrandr
libxtst
libxcb
libxshmfence
# Wayland
wayland
# GTK / UI toolkit
gtk3
glib
pango
cairo
gdk-pixbuf
atk
at-spi2-atk
at-spi2-core
# Graphics
mesa
libGL
libdrm
vulkan-loader
# Networking / crypto (NSS for Chromium)
nss
nspr
# Audio
alsa-lib
pipewire
pulseaudio
# System
dbus
cups
expat
libnotify
libsecret
util-linux # libuuid
];
in
{
default = pkgs.mkShell {
packages = with pkgs; [
nodejs_22
electron
# Native module compilation
python3
pkg-config
gcc
# Playwright browser tests
playwright-driver.browsers
];
# Electron's prebuilt binary needs these at runtime
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath runtimeLibs;
# Tell the npm `electron` package to use the Nix-provided binary
# instead of downloading its own. vite-plugin-electron respects this.
ELECTRON_OVERRIDE_DIST_PATH = "${electron}/libexec/electron";
# Playwright browser path for test:browser / test:e2e
PLAYWRIGHT_BROWSERS_PATH = "${pkgs.playwright-driver.browsers}";
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
shellHook = ''
echo "OpenScreen dev shell node $(node --version), electron v$(electron --version 2>/dev/null | tr -d 'v')"
'';
};
}
);
# -- System-wide outputs (modules, overlay) --
overlays.default = final: _prev: {
openscreen = self.packages.${final.stdenv.hostPlatform.system}.openscreen;
};
nixosModules.default = import ./nix/module.nix self;
homeManagerModules.default = import ./nix/hm-module.nix self;
};
}
+36
View File
@@ -0,0 +1,36 @@
# Home Manager module for OpenScreen
# Usage in flake-based Home Manager config:
#
# inputs.openscreen.url = "github:siddharthvaddem/openscreen";
#
# { inputs, ... }: {
# imports = [ inputs.openscreen.homeManagerModules.default ];
# programs.openscreen.enable = true;
# }
self:
{
config,
lib,
pkgs,
...
}:
let
cfg = config.programs.openscreen;
in
{
options.programs.openscreen = {
enable = lib.mkEnableOption "OpenScreen screen recorder";
package = lib.mkOption {
type = lib.types.package;
default = self.packages.${pkgs.stdenv.hostPlatform.system}.openscreen;
defaultText = lib.literalExpression "inputs.openscreen.packages.\${pkgs.stdenv.hostPlatform.system}.openscreen";
description = "The OpenScreen package to use.";
};
};
config = lib.mkIf cfg.enable {
home.packages = [ cfg.package ];
};
}
+42
View File
@@ -0,0 +1,42 @@
# NixOS module for OpenScreen
# Usage in flake-based NixOS config:
#
# inputs.openscreen.url = "github:siddharthvaddem/openscreen";
#
# { inputs, ... }: {
# imports = [ inputs.openscreen.nixosModules.default ];
# programs.openscreen.enable = true;
# }
self:
{
config,
lib,
pkgs,
...
}:
let
cfg = config.programs.openscreen;
in
{
options.programs.openscreen = {
enable = lib.mkEnableOption "OpenScreen screen recorder";
package = lib.mkOption {
type = lib.types.package;
default = self.packages.${pkgs.stdenv.hostPlatform.system}.openscreen;
defaultText = lib.literalExpression "inputs.openscreen.packages.\${pkgs.stdenv.hostPlatform.system}.openscreen";
description = "The OpenScreen package to use.";
};
};
config = lib.mkIf cfg.enable {
environment.systemPackages = [ cfg.package ];
# Screen capture on Wayland requires xdg-desktop-portal.
# We enable the base portal; users should also enable a
# desktop-specific portal (e.g. xdg-desktop-portal-gtk,
# xdg-desktop-portal-hyprland) in their DE config.
xdg.portal.enable = lib.mkDefault true;
};
}
+124
View File
@@ -0,0 +1,124 @@
{
lib,
buildNpmPackage,
nodejs_22,
electron,
makeWrapper,
makeDesktopItem,
copyDesktopItems,
}:
buildNpmPackage {
nodejs = nodejs_22;
pname = "openscreen";
version = "1.3.0";
src =
let
fs = lib.fileset;
# gitTracked fails when source is already a store path (path: flake inputs).
# Detect this and fall back to cleanSource which handles both cases.
isStorePath = builtins.storeDir == builtins.substring 0 (builtins.stringLength builtins.storeDir) (toString ../.);
baseFiles = if isStorePath then fs.fromSource (lib.cleanSource ../.) else fs.gitTracked ../.;
in
fs.toSource {
root = ../.;
fileset = fs.difference baseFiles (
fs.unions [
../nix
../flake.nix
../flake.lock
(fs.fileFilter (file: file.hasExt "md") ../.)
]
);
};
npmDepsHash = "sha256-Pd6J9TuggA9vM4s/LjdoK4MoBEivSzAWc/G2+pFOM2U=";
env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
# electron-builder is not needed — we wrap system electron directly
npmFlags = [ "--ignore-scripts" ];
makeCacheWritable = true;
# vite-plugin-electron compiles electron/ sources into dist-electron/
# tsconfig has noEmit — tsc is type-check only
buildPhase = ''
runHook preBuild
npx vite build
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p "$out/lib/openscreen"
# Renderer build output (index.html, JS chunks, copied public/ assets)
cp -r dist "$out/lib/openscreen/"
# Main process + preload (compiled by vite-plugin-electron)
cp -r dist-electron "$out/lib/openscreen/"
# Package manifest (electron reads "main" field to find entry point)
cp package.json "$out/lib/openscreen/"
# Strip devDependencies (electron, vitest, biome, playwright, etc.)
npm prune --omit=dev --no-save
cp -r node_modules "$out/lib/openscreen/"
# Asset resolution: when app.isPackaged is false, the main process resolves
# assets at <appPath>/public/assets/. Mirror the electron-builder
# extraResources layout so wallpapers load correctly.
mkdir -p "$out/lib/openscreen/public/assets"
cp -r public/wallpapers "$out/lib/openscreen/public/assets/wallpapers"
# Wrap system electron with the app directory
mkdir -p "$out/bin"
makeWrapper "${electron}/bin/electron" "$out/bin/openscreen" \
--add-flags "$out/lib/openscreen" \
--set ELECTRON_IS_DEV 0
# Install icons to hicolor theme
for size in 16 24 32 48 64 128 256 512 1024; do
icon="icons/icons/png/''${size}x''${size}.png"
if [ -f "$icon" ]; then
install -Dm644 "$icon" \
"$out/share/icons/hicolor/''${size}x''${size}/apps/openscreen.png"
fi
done
runHook postInstall
'';
nativeBuildInputs = [
makeWrapper
copyDesktopItems
];
desktopItems = [
(makeDesktopItem {
name = "openscreen";
desktopName = "OpenScreen";
genericName = "Screen Recorder";
exec = "openscreen %U";
icon = "openscreen";
comment = "Desktop screen recorder with built-in editor";
categories = [
"AudioVideo"
"Video"
"Recorder"
];
startupWMClass = "Openscreen";
terminal = false;
})
];
meta = {
description = "Desktop screen recorder with built-in editor";
homepage = "https://github.com/siddharthvaddem/openscreen";
license = lib.licenses.mit;
mainProgram = "openscreen";
platforms = lib.platforms.linux;
};
}
+1
View File
@@ -11,6 +11,7 @@ import path from "node:path";
const LOCALES_DIR = path.resolve("src/i18n/locales");
const BASE_LOCALE = "en";
const COMPARE_LOCALES = ["zh-CN", "zh-TW", "es", "tr", "ko-KR"];
function getKeys(obj, prefix = "") {
const keys = [];
@@ -1,15 +1,27 @@
import { type CSSProperties, type PointerEvent, useRef, useState } from "react";
import { type CSSProperties, type PointerEvent, useEffect, useRef, useState } from "react";
import { Rnd } from "react-rnd";
import {
getBlurOverlayColor,
getMosaicGridOverlayColor,
getNormalizedMosaicBlockSize,
} from "@/lib/blurEffects";
import { cn } from "@/lib/utils";
import { getArrowComponent } from "./ArrowSvgs";
import {
type AnnotationRegion,
type BlurData,
DEFAULT_BLUR_BLOCK_SIZE,
DEFAULT_BLUR_DATA,
DEFAULT_BLUR_INTENSITY,
} from "./types";
const FREEHAND_POINT_THRESHOLD = 1;
type PreviewCanvasSource = {
width: number;
height: number;
clientWidth?: number;
clientHeight?: number;
};
function buildBlurPolygonClipPath(points: Array<{ x: number; y: number }>) {
if (points.length < 3) return undefined;
@@ -36,6 +48,8 @@ interface AnnotationOverlayProps {
onClick: (id: string) => void;
zIndex: number;
isSelectedBoost: boolean; // Boost z-index when selected for easy editing
previewSourceCanvas?: PreviewCanvasSource | null;
previewFrameVersion?: number;
}
export function AnnotationOverlay({
@@ -50,11 +64,13 @@ export function AnnotationOverlay({
onClick,
zIndex,
isSelectedBoost,
previewSourceCanvas,
previewFrameVersion,
}: AnnotationOverlayProps) {
const x = (annotation.position.x / 100) * containerWidth;
const y = (annotation.position.y / 100) * containerHeight;
const width = (annotation.size.width / 100) * containerWidth;
const height = (annotation.size.height / 100) * containerHeight;
const committedX = (annotation.position.x / 100) * containerWidth;
const committedY = (annotation.position.y / 100) * containerHeight;
const committedWidth = (annotation.size.width / 100) * containerWidth;
const committedHeight = (annotation.size.height / 100) * containerHeight;
const blurShape = annotation.type === "blur" ? (annotation.blurData?.shape ?? "rectangle") : null;
const isSelectedFreehandBlur = isSelected && blurShape === "freehand";
const isDraggingRef = useRef(false);
@@ -65,6 +81,108 @@ export function AnnotationOverlay({
[],
);
const [livePointerPoint, setLivePointerPoint] = useState<{ x: number; y: number } | null>(null);
const mosaicCanvasRef = useRef<HTMLCanvasElement | null>(null);
const blurType = annotation.type === "blur" ? (annotation.blurData?.type ?? "blur") : "blur";
const blurOverlayColor =
annotation.type === "blur" ? getBlurOverlayColor(annotation.blurData) : "";
const mosaicGridOverlayColor =
annotation.type === "blur" ? getMosaicGridOverlayColor(annotation.blurData) : "";
const [liveRect, setLiveRect] = useState({
x: committedX,
y: committedY,
width: committedWidth,
height: committedHeight,
});
useEffect(() => {
setLiveRect({
x: committedX,
y: committedY,
width: committedWidth,
height: committedHeight,
});
}, [committedHeight, committedWidth, committedX, committedY]);
const { x, y, width, height } = liveRect;
useEffect(() => {
if (annotation.type !== "blur" || blurType !== "mosaic") {
return;
}
void previewFrameVersion;
const canvas = mosaicCanvasRef.current;
const sourceCanvas = previewSourceCanvas;
if (!canvas || !sourceCanvas) {
return;
}
const sourceWidth = sourceCanvas.width;
const sourceHeight = sourceCanvas.height;
const sourceClientWidth = sourceCanvas.clientWidth || containerWidth || sourceWidth;
const sourceClientHeight = sourceCanvas.clientHeight || containerHeight || sourceHeight;
if (
sourceWidth <= 0 ||
sourceHeight <= 0 ||
sourceClientWidth <= 0 ||
sourceClientHeight <= 0
) {
return;
}
const drawWidth = Math.max(1, Math.round(width));
const drawHeight = Math.max(1, Math.round(height));
if (drawWidth <= 0 || drawHeight <= 0) {
return;
}
canvas.width = drawWidth;
canvas.height = drawHeight;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) {
return;
}
const scaleX = sourceWidth / sourceClientWidth;
const scaleY = sourceHeight / sourceClientHeight;
const sourceX = Math.max(0, Math.floor(x * scaleX));
const sourceY = Math.max(0, Math.floor(y * scaleY));
const sourceSampleWidth = Math.max(1, Math.ceil(drawWidth * scaleX));
const sourceSampleHeight = Math.max(1, Math.ceil(drawHeight * scaleY));
const clampedSampleWidth = Math.max(1, Math.min(sourceSampleWidth, sourceWidth - sourceX));
const clampedSampleHeight = Math.max(1, Math.min(sourceSampleHeight, sourceHeight - sourceY));
const blockSize = getNormalizedMosaicBlockSize(annotation.blurData);
const downscaledWidth = Math.max(1, Math.round(drawWidth / blockSize));
const downscaledHeight = Math.max(1, Math.round(drawHeight / blockSize));
canvas.width = downscaledWidth;
canvas.height = downscaledHeight;
context.clearRect(0, 0, downscaledWidth, downscaledHeight);
context.imageSmoothingEnabled = true;
context.drawImage(
sourceCanvas as CanvasImageSource,
sourceX,
sourceY,
clampedSampleWidth,
clampedSampleHeight,
0,
0,
downscaledWidth,
downscaledHeight,
);
}, [
annotation,
blurType,
containerHeight,
containerWidth,
height,
previewFrameVersion,
previewSourceCanvas,
width,
x,
y,
]);
const renderArrow = () => {
const direction = annotation.figureData?.arrowDirection || "right";
@@ -240,6 +358,10 @@ export function AnnotationOverlay({
1,
Math.round(annotation.blurData?.intensity ?? DEFAULT_BLUR_INTENSITY),
);
const blockSize = Math.max(
1,
Math.round(annotation.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE),
);
const activeFreehandPoints =
shape === "freehand"
? isFreehandDrawing
@@ -292,12 +414,43 @@ export function AnnotationOverlay({
className="absolute inset-0"
style={{
...shapeMaskStyle,
backdropFilter: `blur(${blurIntensity}px)`,
WebkitBackdropFilter: `blur(${blurIntensity}px)`,
backgroundColor: "rgba(255, 255, 255, 0.02)",
backdropFilter: blurType === "mosaic" ? "none" : `blur(${blurIntensity}px)`,
WebkitBackdropFilter: blurType === "mosaic" ? "none" : `blur(${blurIntensity}px)`,
backgroundColor: blurOverlayColor,
opacity: shouldShowFreehandBlurFill ? 1 : 0,
}}
/>
{blurType === "mosaic" && shouldShowFreehandBlurFill && (
<canvas
ref={mosaicCanvasRef}
className="absolute inset-0 w-full h-full"
style={{
...shapeMaskStyle,
imageRendering: "pixelated",
}}
/>
)}
{blurType === "mosaic" && shouldShowFreehandBlurFill && (
<div
className="absolute inset-0 pointer-events-none"
style={{
...shapeMaskStyle,
backgroundColor: blurOverlayColor,
}}
/>
)}
{blurType === "mosaic" && (
<div
className="absolute inset-0 pointer-events-none"
style={{
...shapeMaskStyle,
backgroundImage: `linear-gradient(${mosaicGridOverlayColor} 1px, transparent 1px), linear-gradient(90deg, ${mosaicGridOverlayColor} 1px, transparent 1px)`,
backgroundSize: `${blockSize}px ${blockSize}px`,
mixBlendMode: "screen",
opacity: 0.35,
}}
/>
)}
{isSelected && shape !== "freehand" && (
<div
className="absolute inset-0 pointer-events-none border-2 border-[#34B27B]/80"
@@ -354,7 +507,19 @@ export function AnnotationOverlay({
onDragStart={() => {
isDraggingRef.current = true;
}}
onDrag={(_e, d) => {
setLiveRect((prev) => ({
...prev,
x: d.x,
y: d.y,
}));
}}
onDragStop={(_e, d) => {
setLiveRect((prev) => ({
...prev,
x: d.x,
y: d.y,
}));
const xPercent = (d.x / containerWidth) * 100;
const yPercent = (d.y / containerHeight) * 100;
onPositionChange(annotation.id, { x: xPercent, y: yPercent });
@@ -364,7 +529,21 @@ export function AnnotationOverlay({
isDraggingRef.current = false;
}, 100);
}}
onResize={(_e, _direction, ref, _delta, position) => {
setLiveRect({
x: position.x,
y: position.y,
width: ref.offsetWidth,
height: ref.offsetHeight,
});
}}
onResizeStop={(_e, _direction, ref, _delta, position) => {
setLiveRect({
x: position.x,
y: position.y,
width: ref.offsetWidth,
height: ref.offsetHeight,
});
const xPercent = (position.x / containerWidth) * 100;
const yPercent = (position.y / containerHeight) * 100;
const widthPercent = (ref.offsetWidth / containerWidth) * 100;
@@ -1,14 +1,26 @@
import { Info, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { useScopedT } from "@/contexts/I18nContext";
import { getBlurOverlayColor } from "@/lib/blurEffects";
import { cn } from "@/lib/utils";
import {
type AnnotationRegion,
type BlurColor,
type BlurData,
type BlurShape,
DEFAULT_BLUR_BLOCK_SIZE,
DEFAULT_BLUR_DATA,
MAX_BLUR_BLOCK_SIZE,
MAX_BLUR_INTENSITY,
MIN_BLUR_BLOCK_SIZE,
MIN_BLUR_INTENSITY,
} from "./types";
@@ -31,6 +43,10 @@ export function BlurSettingsPanel({
{ value: "rectangle", labelKey: "blurShapeRectangle" },
{ value: "oval", labelKey: "blurShapeOval" },
];
const blurColorOptions: Array<{ value: BlurColor; labelKey: string }> = [
{ value: "white", labelKey: "blurColorWhite" },
{ value: "black", labelKey: "blurColorBlack" },
];
return (
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl p-4 flex flex-col shadow-xl h-full overflow-y-auto custom-scrollbar">
@@ -91,27 +107,116 @@ export function BlurSettingsPanel({
})}
</div>
<div className="mt-4">
<label className="text-xs font-medium text-slate-300 mb-2 block">
{t("annotation.blurType")}
</label>
<Select
value={blurRegion.blurData?.type ?? DEFAULT_BLUR_DATA.type}
onValueChange={(value) => {
const nextBlurData: BlurData = {
...DEFAULT_BLUR_DATA,
...blurRegion.blurData,
type: value === "mosaic" ? "mosaic" : "blur",
};
onBlurDataChange(nextBlurData);
requestAnimationFrame(() => {
onBlurDataCommit?.();
});
}}
>
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200">
<SelectItem value="blur">{t("annotation.blurTypeBlur")}</SelectItem>
<SelectItem value="mosaic">{t("annotation.blurTypeMosaic")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="mt-4">
<label className="text-xs font-medium text-slate-300 mb-2 block">
{t("annotation.blurColor")}
</label>
<div className="grid grid-cols-2 gap-2">
{blurColorOptions.map((option) => {
const activeColor = blurRegion.blurData?.color ?? DEFAULT_BLUR_DATA.color;
const isActive = activeColor === option.value;
return (
<button
key={option.value}
onClick={() => {
const nextBlurData: BlurData = {
...DEFAULT_BLUR_DATA,
...blurRegion.blurData,
color: option.value,
};
onBlurDataChange(nextBlurData);
requestAnimationFrame(() => {
onBlurDataCommit?.();
});
}}
className={cn(
"h-10 rounded-lg border flex items-center gap-2 px-3 transition-all",
isActive
? "bg-[#34B27B] border-[#34B27B]"
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20",
)}
>
<div
className="w-4 h-4 rounded-full border border-white/20"
style={{
backgroundColor: getBlurOverlayColor({
...DEFAULT_BLUR_DATA,
...blurRegion.blurData,
color: option.value,
}),
}}
/>
<span className="text-xs text-slate-200">
{t(`annotation.${option.labelKey}`)}
</span>
</button>
);
})}
</div>
</div>
<div className="mt-4 p-3 rounded-lg bg-white/5 border border-white/10">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-slate-300">
{t("annotation.blurIntensity")}
{blurRegion.blurData?.type === "mosaic"
? t("annotation.mosaicBlockSize")
: t("annotation.blurIntensity")}
</span>
<span className="text-[10px] text-slate-400 font-mono">
{Math.round(blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity)}px
{Math.round(
blurRegion.blurData?.type === "mosaic"
? (blurRegion.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE)
: (blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity),
)}
px
</span>
</div>
<Slider
value={[blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity]}
value={[
blurRegion.blurData?.type === "mosaic"
? (blurRegion.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE)
: (blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity),
]}
onValueChange={(values) => {
onBlurDataChange({
...DEFAULT_BLUR_DATA,
...blurRegion.blurData,
intensity: values[0],
...(blurRegion.blurData?.type === "mosaic"
? { blockSize: values[0] }
: { intensity: values[0] }),
});
}}
onValueCommit={() => onBlurDataCommit?.()}
min={MIN_BLUR_INTENSITY}
max={MAX_BLUR_INTENSITY}
min={blurRegion.blurData?.type === "mosaic" ? MIN_BLUR_BLOCK_SIZE : MIN_BLUR_INTENSITY}
max={blurRegion.blurData?.type === "mosaic" ? MAX_BLUR_BLOCK_SIZE : MAX_BLUR_INTENSITY}
step={1}
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
/>
+47 -1
View File
@@ -225,6 +225,9 @@ interface SettingsPanelProps {
onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void;
webcamMaskShape?: import("./types").WebcamMaskShape;
onWebcamMaskShapeChange?: (shape: import("./types").WebcamMaskShape) => void;
selectedZoomInDuration?: number;
selectedZoomOutDuration?: number;
onZoomDurationChange?: (zoomIn: number, zoomOut: number) => void;
webcamSizePreset?: WebcamSizePreset;
onWebcamSizePresetChange?: (size: WebcamSizePreset) => void;
onWebcamSizePresetCommit?: () => void;
@@ -241,6 +244,13 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [
{ depth: 6, label: "5×" },
];
const ZOOM_SPEED_OPTIONS = [
{ label: "Instant", zoomIn: 0, zoomOut: 0 },
{ label: "Fast", zoomIn: 500, zoomOut: 350 },
{ label: "Smooth", zoomIn: 1522, zoomOut: 1015 },
{ label: "Lazy", zoomIn: 3000, zoomOut: 2000 },
];
export function SettingsPanel({
selected,
onWallpaperChange,
@@ -306,6 +316,9 @@ export function SettingsPanel({
onWebcamLayoutPresetChange,
webcamMaskShape = "rectangle",
onWebcamMaskShapeChange,
selectedZoomInDuration,
selectedZoomOutDuration,
onZoomDurationChange,
webcamSizePreset = DEFAULT_WEBCAM_SIZE_PRESET,
onWebcamSizePresetChange,
onWebcamSizePresetCommit,
@@ -648,6 +661,39 @@ export function SettingsPanel({
)}
</div>
)}
{zoomEnabled && (
<div className="mt-3">
<span className="text-sm font-medium text-slate-200 mb-2 block">
{t("zoom.speed.title") || "Zoom Speed"}
</span>
<div className="grid grid-cols-4 gap-1.5">
{ZOOM_SPEED_OPTIONS.map((opt) => {
const isActive =
selectedZoomInDuration !== undefined &&
selectedZoomOutDuration !== undefined &&
Math.round(selectedZoomInDuration) === Math.round(opt.zoomIn) &&
Math.round(selectedZoomOutDuration) === Math.round(opt.zoomOut);
return (
<Button
key={opt.label}
type="button"
onClick={() => onZoomDurationChange?.(opt.zoomIn, opt.zoomOut)}
className={cn(
"h-auto w-full rounded-lg border px-1 py-2 text-center shadow-sm transition-all",
"duration-200 ease-out cursor-pointer",
isActive
? "border-[#34B27B] bg-[#34B27B] text-white shadow-[#34B27B]/20"
: "border-white/5 bg-white/5 text-slate-400 hover:bg-white/10 hover:border-white/10 hover:text-slate-200",
)}
>
<span className="text-[10px] font-semibold">{opt.label}</span>
</Button>
);
})}
</div>
</div>
)}
{zoomEnabled && (
<Button
onClick={handleDeleteClick}
@@ -1026,7 +1072,7 @@ export function SettingsPanel({
</AccordionTrigger>
<AccordionContent className="pb-3">
<Tabs defaultValue="image" className="w-full">
<TabsList className="mb-2 bg-white/5 border border-white/5 p-0.5 w-full grid grid-cols-3 h-7 rounded-lg">
<TabsList className="mb-2 bg-white/5 border border-white/5 p-0.5 w-full grid grid-cols-3 rounded-lg">
<TabsTrigger
value="image"
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 text-[10px] py-1 rounded-md transition-all"
@@ -74,6 +74,7 @@ import {
type ZoomRegion,
} from "./types";
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./videoPlayback/constants";
export default function VideoEditor() {
const {
@@ -956,6 +957,19 @@ export default function VideoEditor() {
[pushState],
);
const handleZoomDurationChange = useCallback(
(id: string, zoomIn: number, zoomOut: number) => {
pushState((prev) => ({
zoomRegions: prev.zoomRegions.map((region) =>
region.id === id
? { ...region, zoomInDurationMs: zoomIn, zoomOutDurationMs: zoomOut }
: region,
),
}));
},
[pushState],
);
const handleAnnotationSpanChange = useCallback(
(id: string, span: Span) => {
pushState((prev) => ({
@@ -1853,6 +1867,7 @@ export default function VideoEditor() {
onZoomAdded={handleZoomAdded}
onZoomSuggested={handleZoomSuggested}
onZoomSpanChange={handleZoomSpanChange}
onZoomDurationChange={handleZoomDurationChange}
onZoomDelete={handleZoomDelete}
selectedZoomId={selectedZoomId}
onSelectZoom={handleSelectZoom}
@@ -1994,6 +2009,21 @@ export default function VideoEditor() {
onSpeedDelete={handleSpeedDelete}
unsavedExport={unsavedExport}
onSaveUnsavedExport={handleSaveUnsavedExport}
selectedZoomInDuration={
selectedZoomId
? (zoomRegions.find((z) => z.id === selectedZoomId)?.zoomInDurationMs ??
Math.round(ZOOM_IN_TRANSITION_WINDOW_MS))
: undefined
}
selectedZoomOutDuration={
selectedZoomId
? (zoomRegions.find((z) => z.id === selectedZoomId)?.zoomOutDurationMs ??
Math.round(TRANSITION_WINDOW_MS))
: undefined
}
onZoomDurationChange={(zoomIn, zoomOut) =>
selectedZoomId && handleZoomDurationChange(selectedZoomId, zoomIn, zoomOut)
}
/>
</div>
</div>
+14 -3
View File
@@ -1348,7 +1348,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
if (annotation.id === selectedAnnotationId) return true;
const timeMs = Math.round(currentTime * 1000);
return timeMs >= annotation.startMs && timeMs <= annotation.endMs;
return timeMs >= annotation.startMs && timeMs < annotation.endMs;
});
const filteredBlurRegions = (blurRegions || []).filter((blurRegion) => {
@@ -1358,7 +1358,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
if (blurRegion.id === selectedBlurId) return true;
const timeMs = Math.round(currentTime * 1000);
return timeMs >= blurRegion.startMs && timeMs <= blurRegion.endMs;
return timeMs >= blurRegion.startMs && timeMs < blurRegion.endMs;
});
const sorted = [
@@ -1371,6 +1371,15 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
region: blurRegion,
})),
].sort((a, b) => a.region.zIndex - b.region.zIndex);
const previewSnapshotCanvas = (() => {
const app = appRef.current;
if (!app?.renderer?.extract) return null;
try {
return app.renderer.extract.canvas(app.stage);
} catch {
return null;
}
})();
// Handle click-through cycling: when clicking same annotation, cycle to next
const handleAnnotationClick = (clickedId: string) => {
@@ -1404,7 +1413,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
<AnnotationOverlay
key={
item.kind === "blur"
? `${item.region.id}-${overlaySize.width}-${overlaySize.height}-${item.region.blurData?.shape ?? "rectangle"}-${(item.region.blurData?.freehandPoints ?? []).map((p) => `${Math.round(p.x)}_${Math.round(p.y)}`).join("-")}`
? `${item.region.id}-${overlaySize.width}-${overlaySize.height}-${item.region.blurData?.type ?? "blur"}-${item.region.blurData?.shape ?? "rectangle"}-${item.region.blurData?.color ?? "white"}-${Math.round(item.region.blurData?.blockSize ?? 0)}-${Math.round(item.region.blurData?.intensity ?? 0)}-${(item.region.blurData?.freehandPoints ?? []).map((p) => `${Math.round(p.x)}_${Math.round(p.y)}`).join("-")}`
: `${item.region.id}-${overlaySize.width}-${overlaySize.height}`
}
annotation={item.region}
@@ -1438,6 +1447,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
? item.region.id === selectedBlurId
: item.region.id === selectedAnnotationId
}
previewSourceCanvas={previewSnapshotCanvas}
previewFrameVersion={Math.round(currentTime * 1000)}
/>
));
})()}
@@ -68,6 +68,75 @@ describe("projectPersistence media compatibility", () => {
).toBe("rectangle");
});
it("normalizes blur region type and mosaic block size safely", () => {
const editor = normalizeProjectEditor({
annotationRegions: [
{
id: "annotation-1",
startMs: 0,
endMs: 500,
type: "blur",
content: "",
position: { x: 10, y: 10 },
size: { width: 20, height: 20 },
style: {
color: "#fff",
backgroundColor: "transparent",
fontSize: 32,
fontFamily: "Inter",
fontWeight: "bold",
fontStyle: "normal",
textDecoration: "none",
textAlign: "center",
},
zIndex: 1,
blurData: {
type: "mosaic",
shape: "rectangle",
color: "black",
intensity: 999,
blockSize: 999,
},
},
{
id: "annotation-2",
startMs: 0,
endMs: 500,
type: "blur",
content: "",
position: { x: 10, y: 10 },
size: { width: 20, height: 20 },
style: {
color: "#fff",
backgroundColor: "transparent",
fontSize: 32,
fontFamily: "Inter",
fontWeight: "bold",
fontStyle: "normal",
textDecoration: "none",
textAlign: "center",
},
zIndex: 2,
blurData: {
type: "invalid" as never,
shape: "rectangle",
color: "invalid" as never,
intensity: 10,
blockSize: 0,
},
},
],
});
expect(editor.annotationRegions[0].blurData?.type).toBe("mosaic");
expect(editor.annotationRegions[0].blurData?.color).toBe("black");
expect(editor.annotationRegions[0].blurData?.intensity).toBe(40);
expect(editor.annotationRegions[0].blurData?.blockSize).toBe(48);
expect(editor.annotationRegions[1].blurData?.type).toBe("blur");
expect(editor.annotationRegions[1].blurData?.color).toBe("white");
expect(editor.annotationRegions[1].blurData?.blockSize).toBe(4);
});
it("accepts the dual frame webcam layout preset", () => {
expect(normalizeProjectEditor({ webcamLayoutPreset: "dual-frame" }).webcamLayoutPreset).toBe(
"dual-frame",
@@ -1,3 +1,4 @@
import { normalizeBlurColor, normalizeBlurType } from "@/lib/blurEffects";
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
import type { ProjectMedia } from "@/lib/recordingSession";
import { normalizeProjectMedia } from "@/lib/recordingSession";
@@ -9,6 +10,7 @@ import {
DEFAULT_ANNOTATION_POSITION,
DEFAULT_ANNOTATION_SIZE,
DEFAULT_ANNOTATION_STYLE,
DEFAULT_BLUR_BLOCK_SIZE,
DEFAULT_BLUR_DATA,
DEFAULT_BLUR_FREEHAND_POINTS,
DEFAULT_BLUR_INTENSITY,
@@ -20,8 +22,10 @@ import {
DEFAULT_WEBCAM_POSITION,
DEFAULT_WEBCAM_SIZE_PRESET,
DEFAULT_ZOOM_DEPTH,
MAX_BLUR_BLOCK_SIZE,
MAX_BLUR_INTENSITY,
MAX_PLAYBACK_SPEED,
MIN_BLUR_BLOCK_SIZE,
MIN_BLUR_INTENSITY,
MIN_PLAYBACK_SPEED,
type SpeedRegion,
@@ -305,6 +309,8 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
VALID_BLUR_SHAPES.has(region.blurData.shape)
? region.blurData.shape
: DEFAULT_BLUR_DATA.shape;
const blurType = normalizeBlurType(region.blurData?.type);
const blurColor = normalizeBlurColor(region.blurData?.color);
return {
id: region.id,
@@ -365,10 +371,15 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
? {
...DEFAULT_BLUR_DATA,
...region.blurData,
type: blurType,
shape: blurShape,
color: blurColor,
intensity: isFiniteNumber(region.blurData.intensity)
? clamp(region.blurData.intensity, MIN_BLUR_INTENSITY, MAX_BLUR_INTENSITY)
: DEFAULT_BLUR_INTENSITY,
blockSize: isFiniteNumber(region.blurData.blockSize)
? clamp(region.blurData.blockSize, MIN_BLUR_BLOCK_SIZE, MAX_BLUR_BLOCK_SIZE)
: DEFAULT_BLUR_BLOCK_SIZE,
freehandPoints: Array.isArray(region.blurData.freehandPoints)
? region.blurData.freehandPoints
.filter(
+115 -1
View File
@@ -1,8 +1,13 @@
import type { Span } from "dnd-timeline";
import { useItem } from "dnd-timeline";
import { useItem, useTimelineContext } from "dnd-timeline";
import { Gauge, MessageSquare, Scissors, ZoomIn } from "lucide-react";
import { useMemo } from "react";
import { cn } from "@/lib/utils";
import {
DEFAULT_ZOOM_IN_MS,
DEFAULT_ZOOM_OUT_MS,
getDurations,
} from "../videoPlayback/zoomRegionUtils";
import glassStyles from "./ItemGlass.module.css";
interface ItemProps {
@@ -13,7 +18,10 @@ interface ItemProps {
isSelected?: boolean;
onSelect?: () => void;
zoomDepth?: number;
zoomInDurationMs?: number;
zoomOutDurationMs?: number;
speedValue?: number;
onZoomDurationChange?: (id: string, zoomIn: number, zoomOut: number) => void;
variant?: "zoom" | "trim" | "annotation" | "speed" | "blur";
}
@@ -44,10 +52,14 @@ export default function Item({
isSelected = false,
onSelect,
zoomDepth = 1,
zoomInDurationMs,
zoomOutDurationMs,
speedValue,
variant = "zoom",
children,
onZoomDurationChange,
}: ItemProps) {
const { pixelsToValue } = useTimelineContext();
const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({
id,
span,
@@ -79,6 +91,16 @@ export default function Item({
const MIN_ITEM_PX = 6;
const safeItemStyle = { ...itemStyle, minWidth: MIN_ITEM_PX };
const { zoomIn, zoomOut } = useMemo(() => {
if (!isZoom) return { zoomIn: 0, zoomOut: 0 };
return getDurations({
startMs: span.start,
endMs: span.end,
zoomInDurationMs,
zoomOutDurationMs,
});
}, [isZoom, span.start, span.end, zoomInDurationMs, zoomOutDurationMs]);
return (
<div
ref={setNodeRef}
@@ -101,6 +123,98 @@ export default function Item({
onSelect?.();
}}
>
{isZoom && (
<>
{/* Transition In Marker */}
<div
className="absolute top-0 bottom-0 left-0 bg-white/10 border-r border-white/20 pointer-events-none"
style={{
width: `${(zoomIn / (span.end - span.start)) * 100}%`,
}}
/>
{/* Draggable handle for Transition In */}
<div
className="absolute top-0 bottom-0 w-2 cursor-col-resize z-20 group-hover:bg-white/5 transition-colors"
style={{
left: `${(zoomIn / (span.end - span.start)) * 100}%`,
transform: "translateX(-50%)",
}}
onPointerDown={(e) => {
e.stopPropagation();
e.preventDefault();
const target = e.currentTarget;
target.setPointerCapture(e.pointerId);
const startX = e.clientX;
const initialZoomIn = zoomInDurationMs ?? DEFAULT_ZOOM_IN_MS;
const initialZoomOut = zoomOutDurationMs ?? DEFAULT_ZOOM_OUT_MS;
const onPointerMove = (moveEvent: PointerEvent) => {
const deltaPx = moveEvent.clientX - startX;
const deltaMs = pixelsToValue(deltaPx);
const newDuration = Math.max(
0,
Math.min(initialZoomIn + deltaMs, span.end - span.start - initialZoomOut),
);
onZoomDurationChange?.(id, newDuration, initialZoomOut);
};
const onPointerUp = () => {
target.releasePointerCapture(e.pointerId);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
};
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
}}
/>
{/* Transition Out Marker */}
<div
className="absolute top-0 bottom-0 right-0 bg-white/10 border-l border-white/20 pointer-events-none"
style={{
width: `${(zoomOut / (span.end - span.start)) * 100}%`,
}}
/>
{/* Draggable handle for Transition Out */}
<div
className="absolute top-0 bottom-0 w-2 cursor-col-resize z-20 group-hover:bg-white/5 transition-colors"
style={{
right: `${(zoomOut / (span.end - span.start)) * 100}%`,
transform: "translateX(50%)",
}}
onPointerDown={(e) => {
e.stopPropagation();
e.preventDefault();
const target = e.currentTarget;
target.setPointerCapture(e.pointerId);
const startX = e.clientX;
const initialZoomIn = zoomInDurationMs ?? DEFAULT_ZOOM_IN_MS;
const initialZoomOut = zoomOutDurationMs ?? DEFAULT_ZOOM_OUT_MS;
const onPointerMove = (moveEvent: PointerEvent) => {
const deltaPx = startX - moveEvent.clientX; // Inverted because right-anchored
const deltaMs = pixelsToValue(deltaPx);
const newDuration = Math.max(
0,
Math.min(initialZoomOut + deltaMs, span.end - span.start - initialZoomIn),
);
onZoomDurationChange?.(id, initialZoomIn, newDuration);
};
const onPointerUp = () => {
target.releasePointerCapture(e.pointerId);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
};
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
}}
/>
</>
)}
<div
className={cn(glassStyles.zoomEndCap, glassStyles.left)}
style={{
@@ -59,6 +59,7 @@ interface TimelineEditorProps {
onZoomAdded: (span: Span) => void;
onZoomSuggested?: (span: Span, focus: ZoomFocus) => void;
onZoomSpanChange: (id: string, span: Span) => void;
onZoomDurationChange: (id: string, zoomIn: number, zoomOut: number) => void;
onZoomDelete: (id: string) => void;
selectedZoomId: string | null;
onSelectZoom: (id: string | null) => void;
@@ -103,6 +104,8 @@ interface TimelineRenderItem {
label: string;
zoomDepth?: number;
speedValue?: number;
zoomInDurationMs?: number;
zoomOutDurationMs?: number;
variant: "zoom" | "trim" | "annotation" | "speed" | "blur";
}
@@ -539,6 +542,7 @@ function Timeline({
selectedAnnotationId,
selectedBlurId,
selectedSpeedId,
onZoomDurationChange,
keyframes = [],
}: {
items: TimelineRenderItem[];
@@ -556,6 +560,7 @@ function Timeline({
selectedAnnotationId?: string | null;
selectedBlurId?: string | null;
selectedSpeedId?: string | null;
onZoomDurationChange: (id: string, zoomIn: number, zoomOut: number) => void;
keyframes?: { id: string; time: number }[];
}) {
const t = useScopedT("timeline");
@@ -682,6 +687,9 @@ function Timeline({
isSelected={item.id === selectedZoomId}
onSelect={() => onSelectZoom?.(item.id)}
zoomDepth={item.zoomDepth}
zoomInDurationMs={item.zoomInDurationMs}
zoomOutDurationMs={item.zoomOutDurationMs}
onZoomDurationChange={onZoomDurationChange}
variant="zoom"
>
{item.label}
@@ -770,6 +778,7 @@ export default function TimelineEditor({
onZoomAdded,
onZoomSuggested,
onZoomSpanChange,
onZoomDurationChange,
onZoomDelete,
selectedZoomId,
onSelectZoom,
@@ -1338,6 +1347,8 @@ export default function TimelineEditor({
span: { start: region.startMs, end: region.endMs },
label: t("labels.zoomItem", { index: String(index + 1) }),
zoomDepth: region.depth,
zoomInDurationMs: region.zoomInDurationMs,
zoomOutDurationMs: region.zoomOutDurationMs,
variant: "zoom",
}));
@@ -1594,6 +1605,7 @@ export default function TimelineEditor({
selectedAnnotationId={selectedAnnotationId}
selectedBlurId={selectedBlurId}
selectedSpeedId={selectedSpeedId}
onZoomDurationChange={onZoomDurationChange}
keyframes={keyframes}
/>
</TimelineWrapper>
+13
View File
@@ -33,6 +33,8 @@ export interface ZoomRegion {
depth: ZoomDepth;
focus: ZoomFocus;
focusMode?: ZoomFocusMode;
zoomInDurationMs?: number;
zoomOutDurationMs?: number;
}
export interface CursorTelemetryPoint {
@@ -66,14 +68,22 @@ export interface FigureData {
}
export type BlurShape = "rectangle" | "oval" | "freehand";
export type BlurType = "blur" | "mosaic";
export type BlurColor = "white" | "black";
export const MIN_BLUR_INTENSITY = 2;
export const MAX_BLUR_INTENSITY = 40;
export const DEFAULT_BLUR_INTENSITY = 12;
export const MIN_BLUR_BLOCK_SIZE = 4;
export const MAX_BLUR_BLOCK_SIZE = 48;
export const DEFAULT_BLUR_BLOCK_SIZE = 12;
export interface BlurData {
type: BlurType;
shape: BlurShape;
color: BlurColor;
intensity: number;
blockSize: number;
// Points are normalized (0-100) within the annotation bounds.
freehandPoints?: Array<{ x: number; y: number }>;
}
@@ -155,8 +165,11 @@ export const DEFAULT_BLUR_FREEHAND_POINTS: Array<{ x: number; y: number }> = [
];
export const DEFAULT_BLUR_DATA: BlurData = {
type: "blur",
shape: "rectangle",
color: "white",
intensity: DEFAULT_BLUR_INTENSITY,
blockSize: DEFAULT_BLUR_BLOCK_SIZE,
freehandPoints: DEFAULT_BLUR_FREEHAND_POINTS,
};
@@ -7,7 +7,6 @@ import { clamp01, cubicBezier, easeOutScreenStudio } from "./mathUtils";
const CHAINED_ZOOM_PAN_GAP_MS = 1500;
const CONNECTED_ZOOM_PAN_DURATION_MS = 1000;
const ZOOM_IN_OVERLAP_MS = 500;
type DominantRegionOptions = {
connectZooms?: boolean;
@@ -38,26 +37,49 @@ function easeConnectedPan(value: number) {
return cubicBezier(0.1, 0.0, 0.2, 1.0, value);
}
export function computeRegionStrength(region: ZoomRegion, timeMs: number) {
const zoomInEnd = region.startMs + ZOOM_IN_OVERLAP_MS;
const leadInStart = zoomInEnd - ZOOM_IN_TRANSITION_WINDOW_MS;
const leadOutEnd = region.endMs + TRANSITION_WINDOW_MS;
export const DEFAULT_ZOOM_OUT_MS = TRANSITION_WINDOW_MS;
export const DEFAULT_ZOOM_IN_MS = ZOOM_IN_TRANSITION_WINDOW_MS;
if (timeMs < leadInStart || timeMs > leadOutEnd) {
export function getDurations(region: {
startMs: number;
endMs: number;
zoomInDurationMs?: number;
zoomOutDurationMs?: number;
}) {
let zoomIn = region.zoomInDurationMs ?? DEFAULT_ZOOM_IN_MS;
let zoomOut = region.zoomOutDurationMs ?? DEFAULT_ZOOM_OUT_MS;
const duration = region.endMs - region.startMs;
if (zoomIn + zoomOut > duration) {
const scale = duration / (zoomIn + zoomOut);
zoomIn *= scale;
zoomOut *= scale;
}
return { zoomIn, zoomOut };
}
export function computeRegionStrength(region: ZoomRegion, timeMs: number) {
const { zoomIn, zoomOut } = getDurations(region);
if (timeMs < region.startMs || timeMs > region.endMs) {
return 0;
}
if (timeMs < zoomInEnd) {
const progress = (timeMs - leadInStart) / ZOOM_IN_TRANSITION_WINDOW_MS;
// Zooming in
if (timeMs < region.startMs + zoomIn) {
const progress = Math.max(0, Math.min(1, (timeMs - region.startMs) / zoomIn));
return easeOutScreenStudio(progress);
}
if (timeMs <= region.endMs) {
return 1;
// Zooming out
if (timeMs > region.endMs - zoomOut) {
const progress = Math.max(0, Math.min(1, (region.endMs - timeMs) / zoomOut));
return easeOutScreenStudio(progress);
}
const progress = clamp01((timeMs - region.endMs) / TRANSITION_WINDOW_MS);
return 1 - easeOutScreenStudio(progress);
// Full zoom
return 1;
}
function getLinearFocus(start: ZoomFocus, end: ZoomFocus, amount: number): ZoomFocus {
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config";
import enDialogs from "@/i18n/locales/en/dialogs.json";
import esDialogs from "@/i18n/locales/es/dialogs.json";
import frDialogs from "@/i18n/locales/fr/dialogs.json";
import koKRDialogs from "@/i18n/locales/ko-KR/dialogs.json";
import trDialogs from "@/i18n/locales/tr/dialogs.json";
import zhCNDialogs from "@/i18n/locales/zh-CN/dialogs.json";
const tutorialHelpKeys = [
"triggerLabel",
"title",
"description",
"explanationBefore",
"remove",
"explanationMiddle",
"covered",
"explanationAfter",
"visualExample",
"removed",
"kept",
"part1",
"part2",
"part3",
"finalVideo",
"step1Title",
"step1DescriptionBefore",
"step1DescriptionAfter",
"step2Title",
"step2Description",
] as const;
const keysThatMayBeEmpty = new Set<(typeof tutorialHelpKeys)[number]>(["step1DescriptionBefore"]);
const dialogsByLocale = {
en: enDialogs,
"zh-CN": zhCNDialogs,
es: esDialogs,
fr: frDialogs,
tr: trDialogs,
"ko-KR": koKRDialogs,
} satisfies Record<Locale, { tutorial: Record<string, unknown> }>;
describe("TutorialHelp translations", () => {
it("defines every tutorial help key for each supported locale", () => {
for (const locale of SUPPORTED_LOCALES) {
const tutorial = dialogsByLocale[locale].tutorial;
for (const key of tutorialHelpKeys) {
const message = tutorial[key];
const label = `${locale} dialogs.tutorial.${key}`;
expect(message, label).toEqual(expect.any(String));
if (!keysThatMayBeEmpty.has(key)) {
expect((message as string).trim().length, label).toBeGreaterThan(0);
}
}
}
});
});
+1
View File
@@ -1,4 +1,5 @@
export const DEFAULT_LOCALE = "en" as const;
export const SUPPORTED_LOCALES = ["en", "zh-CN", "zh-TW", "es", "fr", "tr", "ko-KR"] as const;
export const I18N_NAMESPACES = [
"common",
"dialogs",
+14
View File
@@ -8,6 +8,13 @@
"manual": "Manual",
"auto": "Auto",
"autoDescription": "Camera follows the recorded cursor position"
},
"speed": {
"title": "Zoom Speed",
"instant": "Instant",
"fast": "Fast",
"smooth": "Smooth",
"lazy": "Lazy"
}
},
"speed": {
@@ -119,8 +126,15 @@
"arrowDirection": "Arrow Direction",
"strokeWidth": "Stroke Width: {{width}}px",
"arrowColor": "Arrow Color",
"blurType": "Blur Type",
"blurTypeBlur": "Blur",
"blurTypeMosaic": "Mosaic Blur",
"blurColor": "Blur Color",
"blurColorWhite": "White",
"blurColorBlack": "Black",
"blurShape": "Blur Shape",
"blurIntensity": "Blur Intensity",
"mosaicBlockSize": "Mosaic Block Size",
"blurShapeRectangle": "Rectangle",
"blurShapeOval": "Oval",
"blurShapeFreehand": "Freehand",
+14
View File
@@ -8,6 +8,13 @@
"manual": "Manual",
"auto": "Auto",
"autoDescription": "La cámara sigue la posición del cursor grabado"
},
"speed": {
"title": "Velocidad de zoom",
"instant": "Instantáneo",
"fast": "Rápido",
"smooth": "Suave",
"lazy": "Lento"
}
},
"speed": {
@@ -119,8 +126,15 @@
"arrowDirection": "Dirección de la flecha",
"strokeWidth": "Grosor del trazo: {{width}}px",
"arrowColor": "Color de la flecha",
"blurType": "Tipo de desenfoque",
"blurTypeBlur": "Desenfoque",
"blurTypeMosaic": "Desenfoque mosaico",
"blurColor": "Color del desenfoque",
"blurColorWhite": "Blanco",
"blurColorBlack": "Negro",
"blurShape": "Forma del desenfoque",
"blurIntensity": "Intensidad del desenfoque",
"mosaicBlockSize": "Tamano del bloque mosaico",
"blurShapeRectangle": "Rectángulo",
"blurShapeOval": "Óvalo",
"blurShapeFreehand": "Mano alzada",
+7 -5
View File
@@ -27,10 +27,11 @@
"triggerLabel": "Comment fonctionne la coupe",
"title": "Comment fonctionne la coupe",
"description": "Comprendre comment supprimer les parties indésirables de votre vidéo.",
"explanation": "L'outil Coupe fonctionne en définissant les segments que vous souhaitez",
"explanationRemove": "supprimer",
"explanationCovered": "couvert",
"explanationEnd": "par un segment de coupe rouge sera coupé lors de l'export.",
"explanationBefore": "L'outil Coupe fonctionne en définissant les segments que vous souhaitez",
"remove": "supprimer",
"explanationMiddle": " — tout élément",
"covered": "couvert",
"explanationAfter": "par un segment de coupe rouge sera coupé lors de l'export.",
"visualExample": "Exemple visuel",
"removed": "SUPPRIMÉ",
"kept": "Conservé",
@@ -39,7 +40,8 @@
"part3": "Partie 3",
"finalVideo": "Vidéo finale",
"step1Title": "1. Ajouter une coupe",
"step1Description": "Appuyez sur T ou cliquez sur l'icône ciseaux pour marquer une section à supprimer.",
"step1DescriptionBefore": "Appuyez sur ",
"step1DescriptionAfter": " ou cliquez sur l'icône ciseaux pour marquer une section à supprimer.",
"step2Title": "2. Ajuster",
"step2Description": "Faites glisser les bords de la région rouge pour couvrir exactement ce que vous souhaitez couper."
},
+7
View File
@@ -115,8 +115,15 @@
"arrowDirection": "Direction de la flèche",
"strokeWidth": "Épaisseur du trait : {{width}}px",
"arrowColor": "Couleur de la flèche",
"blurType": "Type de flou",
"blurTypeBlur": "Flou",
"blurTypeMosaic": "Flou mosaique",
"blurColor": "Couleur du flou",
"blurColorWhite": "Blanc",
"blurColorBlack": "Noir",
"blurShape": "Forme du flou",
"blurIntensity": "Intensité du flou",
"mosaicBlockSize": "Taille des blocs de mosaique",
"blurShapeRectangle": "Rectangle",
"blurShapeOval": "Ovale",
"blurShapeFreehand": "Main levée",
+7 -5
View File
@@ -27,10 +27,11 @@
"triggerLabel": "Kırpma nasıl çalışır",
"title": "Kırpma Nasıl Çalışır",
"description": "Videonuzun istenmeyen bölümlerini nasıl keseceğinizi anlayın.",
"explanation": "Kırpma aracı, kaldırmak istediğiniz bölümleri tanımlayarak çalışır.",
"explanationRemove": "kaldırmak",
"explanationCovered": "kaplanan",
"explanationEnd": "kırmızı kırpma bölgesi ile işaretlenen kısımlar dışa aktarımda kesilecektir.",
"explanationBefore": "Kırpma aracı, istediğiniz bölümleri",
"remove": "kaldırmak",
"explanationMiddle": " için kullanılır; kırmızı kırpma bölgesiyle",
"covered": "kaplanan",
"explanationAfter": "her şey dışa aktarımda kesilecektir.",
"visualExample": "Görsel Örnek",
"removed": "KALDIRILDI",
"kept": "Korundu",
@@ -39,7 +40,8 @@
"part3": "Bölüm 3",
"finalVideo": "Son Video",
"step1Title": "1. Kırpma Ekle",
"step1Description": "Kaldırılacak bölümü işaretlemek için T tuşuna basın veya makas simgesine tıklayın.",
"step1DescriptionBefore": "Kaldırılacak bölümü işaretlemek için ",
"step1DescriptionAfter": " tuşuna basın veya makas simgesine tıklayın.",
"step2Title": "2. Ayarla",
"step2Description": "Kesmek istediğiniz kısmı tam olarak kaplamak için kırmızı bölgenin kenarlarını sürükleyin."
},
+2 -2
View File
@@ -23,7 +23,7 @@
"exitFullscreen": "退出全屏"
},
"locale": {
"name": "中文",
"short": "中"
"name": "简体中文",
"short": "中"
}
}
+7
View File
@@ -8,6 +8,13 @@
"manual": "手动",
"auto": "自动",
"autoDescription": "摄像头跟随录制时的光标位置"
},
"speed": {
"title": "缩放速度",
"instant": "即时",
"fast": "快速",
"smooth": "平滑",
"lazy": "缓慢"
}
},
"speed": {
+29
View File
@@ -0,0 +1,29 @@
{
"actions": {
"cancel": "取消",
"save": "儲存",
"delete": "刪除",
"close": "關閉",
"share": "分享",
"done": "完成",
"open": "開啟",
"upload": "上傳",
"export": "匯出",
"file": "檔案",
"edit": "編輯",
"view": "檢視",
"window": "視窗",
"quit": "退出",
"stopRecording": "停止錄製"
},
"playback": {
"play": "播放",
"pause": "暫停",
"fullscreen": "全螢幕",
"exitFullscreen": "退出全螢幕"
},
"locale": {
"name": "繁體中文",
"short": "繁中"
}
}
+70
View File
@@ -0,0 +1,70 @@
{
"export": {
"complete": "匯出完成",
"yourFormatReady": "您的 {{format}} 已準備就緒",
"showInFolder": "在資料夾中顯示",
"finalizingVideo": "正在完成影片匯出...",
"compilingGifProgress": "正在編譯 GIF... {{progress}}%",
"compilingGifWait": "正在編譯 GIF... 這可能需要一些時間",
"takeMoment": "這可能需要一點時間...",
"failed": "匯出失敗",
"tryAgain": "請重試",
"finalizingVideoTitle": "正在完成影片",
"compilingGif": "正在編譯 GIF",
"exportingFormat": "正在匯出 {{format}}",
"compiling": "編譯中",
"renderingFrames": "渲染影格",
"processing": "處理中...",
"finalizing": "正在完成...",
"compilingStatus": "編譯中...",
"status": "狀態",
"format": "格式",
"frames": "影格",
"cancelExport": "取消匯出",
"savedSuccessfully": "{{format}} 儲存成功!"
},
"tutorial": {
"triggerLabel": "剪輯功能說明",
"title": "剪輯功能說明",
"description": "了解如何剪掉影片中不需要的部分。",
"explanationBefore": "剪輯工具透過定義您要",
"remove": "移除",
"explanationMiddle": "——任何被",
"covered": "覆蓋",
"explanationAfter": "的紅色剪輯區域部分將在匯出時被剪掉。",
"visualExample": "示例演示",
"removed": "已移除",
"kept": "保留",
"part1": "第 1 部分",
"part2": "第 2 部分",
"part3": "第 3 部分",
"finalVideo": "最終影片",
"step1Title": "1. 添加剪輯",
"step1DescriptionBefore": "按",
"step1DescriptionAfter": "鍵或點擊剪刀圖示來標記要移除的片段。",
"step2Title": "2. 調整",
"step2Description": "拖動紅色區域的邊緣,精確覆蓋您要剪掉的部分。"
},
"unsavedChanges": {
"title": "未儲存的變更",
"message": "您有未儲存的變更。",
"detail": "是否在關閉前儲存專案?",
"saveAndClose": "儲存並關閉",
"discardAndClose": "捨棄並關閉",
"loadProject": "載入專案…",
"saveProject": "儲存專案…",
"saveProjectAs": "專案另存新檔…"
},
"fileDialogs": {
"saveGif": "儲存匯出的 GIF",
"saveVideo": "儲存匯出的影片",
"selectVideo": "選擇影片檔案",
"saveProject": "儲存 OpenScreen 專案",
"openProject": "開啟 OpenScreen 專案",
"gifImage": "GIF 圖片",
"mp4Video": "MP4 影片",
"videoFiles": "影片檔案",
"openscreenProject": "OpenScreen 專案",
"allFiles": "所有檔案"
}
}
+41
View File
@@ -0,0 +1,41 @@
{
"newRecording": {
"title": "返回錄影",
"description": "目前工作階段已儲存。",
"cancel": "取消",
"confirm": "確認"
},
"errors": {
"noVideoLoaded": "未載入影片",
"videoNotReady": "影片未就緒",
"unableToDetermineSourcePath": "無法確定來源影片路徑",
"failedToSaveGif": "儲存 GIF 失敗",
"gifExportFailed": "GIF 匯出失敗",
"failedToSaveVideo": "儲存影片失敗",
"exportFailed": "匯出失敗",
"exportFailedWithError": "匯出失敗:{{error}}",
"failedToSaveExport": "儲存匯出檔案失敗",
"failedToSaveExportedVideo": "儲存匯出的影片失敗",
"failedToRevealInFolder": "在資料夾中顯示時出錯:{{error}}"
},
"export": {
"canceled": "匯出已取消",
"exportedSuccessfully": "{{format}} 匯出成功"
},
"project": {
"saveCanceled": "專案儲存已取消",
"failedToSave": "儲存專案失敗",
"savedTo": "專案已儲存至 {{path}}",
"failedToLoad": "載入專案失敗",
"invalidFormat": "無效的專案檔案格式",
"loadedFrom": "專案已從 {{path}} 載入"
},
"recording": {
"failedCameraAccess": "請求攝影機權限失敗。",
"cameraBlocked": "攝影機權限已被封鎖。請在系統設定中啟用以使用攝影機。",
"systemAudioUnavailable": "系統音訊不可用。將在無系統音訊的情況下錄製。",
"microphoneDenied": "麥克風權限被拒絕。錄製將繼續,但不包含音訊。",
"cameraDenied": "攝影機權限被拒絕。錄製將繼續,但不包含攝影機畫面。",
"permissionDenied": "錄影權限被拒絕。請允許螢幕錄製。"
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"tooltips": {
"hideHUD": "隱藏控制面板",
"closeApp": "關閉應用程式",
"restartRecording": "重新開始錄製",
"cancelRecording": "取消錄製",
"pauseRecording": "暫停錄製",
"resumeRecording": "繼續錄製",
"openVideoFile": "開啟影片檔案",
"openProject": "開啟專案"
},
"audio": {
"enableSystemAudio": "啟用系統音訊",
"disableSystemAudio": "停用系統音訊",
"enableMicrophone": "啟用麥克風",
"disableMicrophone": "停用麥克風",
"defaultMicrophone": "預設麥克風"
},
"webcam": {
"enableWebcam": "啟用攝影機",
"disableWebcam": "停用攝影機",
"defaultCamera": "預設攝影機",
"searching": "正在搜尋...",
"noneFound": "未找到攝影機",
"unavailable": "攝影機不可用"
},
"sourceSelector": {
"loading": "正在載入來源...",
"screens": "螢幕 ({{count}})",
"windows": "視窗 ({{count}})",
"defaultSourceName": "螢幕"
},
"recording": {
"selectSource": "請選擇要錄製的來源"
},
"language": "語言"
}
+176
View File
@@ -0,0 +1,176 @@
{
"zoom": {
"level": "縮放級別",
"selectRegion": "選擇要調整的縮放區域",
"deleteZoom": "刪除縮放",
"focusMode": {
"title": "對焦模式",
"manual": "手動",
"auto": "自動",
"autoDescription": "攝影機跟隨錄製時的游標位置"
},
"speed": {
"title": "縮放速度",
"instant": "即時",
"fast": "快速",
"smooth": "平滑",
"lazy": "緩慢"
}
},
"speed": {
"playbackSpeed": "播放速度",
"selectRegion": "選擇要調整的速度區域",
"deleteRegion": "刪除速度區域",
"customPlaybackSpeed": "自訂播放速度",
"maxSpeedError": "速度不能超過 16×"
},
"trim": {
"deleteRegion": "刪除剪輯區域"
},
"layout": {
"title": "版面配置",
"preset": "預設",
"selectPreset": "選擇預設",
"pictureInPicture": "子母畫面",
"verticalStack": "垂直堆疊",
"dualFrame": "雙畫框",
"webcamShape": "攝影機形狀",
"webcamSize": "攝影機大小"
},
"effects": {
"title": "影片效果",
"blurBg": "模糊背景",
"motionBlur": "動態模糊",
"off": "關",
"shadow": "陰影",
"roundness": "圓角",
"padding": "內邊距"
},
"background": {
"title": "背景",
"image": "圖片",
"color": "顏色",
"gradient": "漸層",
"uploadCustom": "上傳自訂",
"gradientLabel": "漸層 {{index}}"
},
"crop": {
"title": "裁剪",
"cropVideo": "裁剪影片",
"dragInstruction": "拖動每一側來調整裁剪區域",
"ratio": "比例",
"free": "自由",
"done": "完成",
"lockAspectRatio": "鎖定長寬比",
"unlockAspectRatio": "解鎖長寬比"
},
"exportFormat": {
"mp4": "MP4",
"gif": "GIF",
"mp4Video": "MP4 影片",
"mp4Description": "高品質影片檔案",
"gifAnimation": "GIF 動畫",
"gifDescription": "可分享的動態圖片"
},
"exportQuality": {
"title": "匯出品質",
"low": "低",
"medium": "中",
"high": "高"
},
"gifSettings": {
"frameRate": "GIF 影格率",
"size": "GIF 尺寸",
"loop": "循環 GIF"
},
"project": {
"save": "儲存專案",
"load": "載入專案"
},
"export": {
"videoButton": "匯出影片",
"gifButton": "匯出 GIF",
"chooseSaveLocation": "選擇儲存位置"
},
"links": {
"reportBug": "回報錯誤",
"starOnGithub": "在 GitHub 上加星"
},
"imageUpload": {
"invalidFileType": "無效的檔案類型",
"jpgOnly": "請上傳 JPG 或 JPEG 格式的圖片檔案。",
"uploadSuccess": "自訂圖片上傳成功!",
"failedToUpload": "上傳圖片失敗",
"errorReading": "讀取檔案時出錯。"
},
"annotation": {
"title": "標註設定",
"active": "啟用",
"typeText": "文字",
"typeImage": "圖片",
"typeArrow": "箭頭",
"typeBlur": "模糊",
"textContent": "文字內容",
"textPlaceholder": "輸入您的文字...",
"fontStyle": "字體樣式",
"selectStyle": "選擇樣式",
"size": "大小",
"customFonts": "自訂字體",
"textColor": "文字顏色",
"background": "背景",
"none": "無",
"color": "顏色",
"clearBackground": "清除背景",
"uploadImage": "上傳圖片",
"supportedFormats": "支援的格式:JPG、PNG、GIF、WebP",
"arrowDirection": "箭頭方向",
"strokeWidth": "描邊寬度:{{width}}px",
"arrowColor": "箭頭顏色",
"blurShape": "模糊形狀",
"blurIntensity": "模糊強度",
"blurShapeRectangle": "矩形",
"blurShapeOval": "橢圓",
"blurShapeFreehand": "自由手繪",
"deleteAnnotation": "刪除標註",
"shortcutsAndTips": "快捷鍵與提示",
"tipMovePlayhead": "將播放頭移動到重疊的標註區域並選擇一個項目。",
"tipTabCycle": "使用 Tab 鍵在重疊項目之間循環切換。",
"tipShiftTabCycle": "使用 Shift+Tab 反向循環切換。",
"invalidImageType": "無效的檔案類型",
"imageFormatsOnly": "請上傳 JPG、PNG、GIF 或 WebP 格式的圖片檔案。",
"imageUploadSuccess": "圖片上傳成功!",
"failedImageUpload": "上傳圖片失敗"
},
"fontStyles": {
"classic": "經典",
"editor": "編輯器",
"strong": "粗體",
"typewriter": "打字機",
"deco": "裝飾",
"simple": "簡約",
"modern": "現代",
"clean": "簡潔"
},
"customFont": {
"dialogTitle": "新增 Google 字體",
"urlLabel": "Google Fonts 匯入 URL",
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
"urlHelp": "從 Google Fonts 取得:選擇字體 → 點擊 \"Get font\" → 複製 @import URL",
"nameLabel": "顯示名稱",
"namePlaceholder": "我的自訂字體",
"nameHelp": "這是字體在字體選擇器中顯示的名稱",
"addButton": "新增字體",
"addingButton": "新增中...",
"errorEmptyUrl": "請輸入 Google Fonts 匯入 URL",
"errorInvalidUrl": "請輸入有效的 Google Fonts URL",
"errorEmptyName": "請輸入字體名稱",
"errorExtractFailed": "無法從 URL 中提取字體系列",
"successMessage": "字體 \"{{fontName}}\" 新增成功",
"failedToAdd": "新增字體失敗",
"errorTimeout": "字體載入時間過長。請檢查 URL 並重試。",
"errorLoadFailed": "無法載入該字體。請確認 Google Fonts URL 是否正確。"
},
"language": {
"title": "語言"
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"title": "鍵盤快捷鍵",
"customize": "自訂",
"configurable": "可設定",
"fixed": "固定",
"pressKey": "請按下按鍵…",
"clickToChange": "點擊以變更",
"pressEscToCancel": "按 Esc 取消",
"helpText": "點擊一個快捷鍵,然後按下新的組合鍵。按 Esc 取消。",
"resetToDefaults": "還原預設設定",
"alreadyUsedBy": "已被 \"{{action}}\" 使用",
"swap": "交換",
"reservedShortcut": "此快捷鍵已保留給 \"{{label}}\",無法重新指定。",
"savedToast": "鍵盤快捷鍵已儲存",
"resetToast": "已還原預設快捷鍵 — 點擊儲存以套用",
"actions": {
"addZoom": "新增縮放",
"addTrim": "新增剪輯",
"addSpeed": "新增速度",
"addAnnotation": "新增標註",
"addBlur": "新增模糊",
"addKeyframe": "新增關鍵影格",
"deleteSelected": "刪除所選",
"playPause": "播放 / 暫停"
},
"fixedActions": {
"undo": "復原",
"redo": "重做",
"cycleAnnotationsForward": "向前切換標註",
"cycleAnnotationsBackward": "向後切換標註",
"deleteSelectedAlt": "刪除所選(替代)",
"panTimeline": "平移時間軸",
"zoomTimeline": "縮放時間軸",
"frameBack": "上一影格",
"frameForward": "下一影格"
}
}
+53
View File
@@ -0,0 +1,53 @@
{
"buttons": {
"addZoom": "新增縮放 (Z)",
"suggestZooms": "根據游標建議縮放",
"addTrim": "新增剪輯 (T)",
"addAnnotation": "新增標註 (A)",
"addSpeed": "新增速度 (S)",
"addBlur": "新增模糊 (B)"
},
"hints": {
"pressZoom": "按 Z 新增縮放",
"pressTrim": "按 T 新增剪輯",
"pressAnnotation": "按 A 新增標註",
"pressSpeed": "按 S 新增速度",
"pressBlur": "按 B 新增模糊區域"
},
"labels": {
"pan": "平移",
"zoom": "縮放",
"zoomItem": "縮放 {{index}}",
"trimItem": "剪輯 {{index}}",
"speedItem": "速度 {{index}}",
"annotationItem": "標註",
"imageItem": "圖片",
"emptyText": "空文字",
"blurItem": "模糊 {{index}}"
},
"emptyState": {
"noVideo": "未載入影片",
"dragAndDrop": "拖放影片以開始編輯"
},
"errors": {
"cannotPlaceZoom": "無法在此處放置縮放",
"zoomExistsAtLocation": "此位置已存在縮放或沒有足夠的空間。",
"zoomSuggestionUnavailable": "縮放建議處理器不可用",
"noCursorTelemetry": "無可用的游標遙測資料",
"noCursorTelemetryDescription": "請先錄製一段螢幕錄影以產生基於游標的建議。",
"noUsableTelemetry": "無可用的游標遙測資料",
"noUsableTelemetryDescription": "錄製內容沒有包含足夠的游標移動資料。",
"noDwellMoments": "未找到明確的游標停留時刻",
"noDwellMomentsDescription": "請嘗試在重要操作上進行較慢游標停留的錄製。",
"noAutoZoomSlots": "無可用的自動縮放位置",
"noAutoZoomSlotsDescription": "偵測到的停留點與現有縮放區域重疊。",
"cannotPlaceTrim": "無法在此處放置剪輯",
"trimExistsAtLocation": "此位置已存在剪輯或沒有足夠的空間。",
"cannotPlaceSpeed": "無法在此處放置速度",
"speedExistsAtLocation": "此位置已存在速度區域或沒有足夠的空間。"
},
"success": {
"addedZoomSuggestions": "已新增 {{count}} 個基於游標的縮放建議",
"addedZoomSuggestionsPlural": "已新增 {{count}} 個基於游標的縮放建議"
}
}
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import { applyMosaicToImageData, getBlurOverlayColor, normalizeBlurColor } from "./blurEffects";
function createTestImageData(width: number, height: number) {
const data = new Uint8ClampedArray(width * height * 4);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const offset = (y * width + x) * 4;
data[offset] = x * 20 + y;
data[offset + 1] = y * 20 + x;
data[offset + 2] = (x + y) * 10;
data[offset + 3] = 255;
}
}
return {
data,
width,
height,
} as ImageData;
}
describe("applyMosaicToImageData", () => {
it("collapses each block to a single representative color", () => {
const imageData = createTestImageData(4, 4);
const original = new Uint8ClampedArray(imageData.data);
applyMosaicToImageData(imageData, 2);
const topLeft = Array.from(imageData.data.slice(0, 4));
const topRightOffset = (1 * 4 + 1) * 4;
const topRight = Array.from(imageData.data.slice(topRightOffset, topRightOffset + 4));
expect(topLeft).toEqual(topRight);
expect(Array.from(original.slice(0, 4))).not.toEqual(topLeft);
});
it("reduces unique pixel colors, making the transform information-lossy", () => {
const imageData = createTestImageData(8, 8);
const before = new Set<string>();
const after = new Set<string>();
for (let i = 0; i < imageData.data.length; i += 4) {
before.add(
`${imageData.data[i]}-${imageData.data[i + 1]}-${imageData.data[i + 2]}-${imageData.data[i + 3]}`,
);
}
applyMosaicToImageData(imageData, 4);
for (let i = 0; i < imageData.data.length; i += 4) {
after.add(
`${imageData.data[i]}-${imageData.data[i + 1]}-${imageData.data[i + 2]}-${imageData.data[i + 3]}`,
);
}
expect(after.size).toBeLessThan(before.size);
expect(after.size).toBe(4);
});
});
describe("blur color helpers", () => {
it("normalizes invalid blur colors to white", () => {
expect(normalizeBlurColor("black")).toBe("black");
expect(normalizeBlurColor("invalid")).toBe("white");
});
it("returns a dark overlay when black blur color is selected", () => {
expect(
getBlurOverlayColor({
type: "blur",
shape: "rectangle",
color: "black",
intensity: 12,
blockSize: 12,
}),
).toBe("rgba(0, 0, 0, 0.18)");
});
});
+113
View File
@@ -0,0 +1,113 @@
import {
type BlurColor,
type BlurData,
type BlurType,
DEFAULT_BLUR_BLOCK_SIZE,
DEFAULT_BLUR_INTENSITY,
MAX_BLUR_BLOCK_SIZE,
MAX_BLUR_INTENSITY,
MIN_BLUR_BLOCK_SIZE,
MIN_BLUR_INTENSITY,
} from "@/components/video-editor/types";
function clamp(value: number, min: number, max: number) {
if (!Number.isFinite(value)) return min;
return Math.min(max, Math.max(min, value));
}
export function normalizeBlurType(value: unknown): BlurType {
return value === "mosaic" ? "mosaic" : "blur";
}
export function normalizeBlurColor(value: unknown): BlurColor {
return value === "black" ? "black" : "white";
}
export function getNormalizedBlurIntensity(blurData?: BlurData | null): number {
return clamp(
blurData?.intensity ?? DEFAULT_BLUR_INTENSITY,
MIN_BLUR_INTENSITY,
MAX_BLUR_INTENSITY,
);
}
export function getNormalizedMosaicBlockSize(blurData?: BlurData | null, scaleFactor = 1): number {
const rawBlockSize = clamp(
blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE,
MIN_BLUR_BLOCK_SIZE,
MAX_BLUR_BLOCK_SIZE,
);
return Math.max(1, Math.round(rawBlockSize * Math.max(scaleFactor, 0.01)));
}
export function getBlurOverlayColor(blurData?: BlurData | null): string {
const blurColor = normalizeBlurColor(blurData?.color);
const blurType = normalizeBlurType(blurData?.type);
if (blurColor === "black") {
return blurType === "mosaic" ? "rgba(0, 0, 0, 0.72)" : "rgba(0, 0, 0, 0.56)";
}
return blurType === "mosaic" ? "rgba(255, 255, 255, 0.06)" : "rgba(255, 255, 255, 0.02)";
}
export function getMosaicGridOverlayColor(blurData?: BlurData | null): string {
return normalizeBlurColor(blurData?.color) === "black"
? "rgba(255,255,255,0.05)"
: "rgba(255,255,255,0.04)";
}
export function applyMosaicToImageData(imageData: ImageData, blockSize: number): ImageData {
const width = imageData.width;
const height = imageData.height;
const data = imageData.data;
const normalizedBlockSize = Math.max(1, Math.floor(blockSize));
if (width <= 0 || height <= 0 || normalizedBlockSize <= 1) {
return imageData;
}
for (let blockY = 0; blockY < height; blockY += normalizedBlockSize) {
for (let blockX = 0; blockX < width; blockX += normalizedBlockSize) {
const blockWidth = Math.min(normalizedBlockSize, width - blockX);
const blockHeight = Math.min(normalizedBlockSize, height - blockY);
const pixelCount = blockWidth * blockHeight;
if (pixelCount <= 0) {
continue;
}
let redTotal = 0;
let greenTotal = 0;
let blueTotal = 0;
let alphaTotal = 0;
for (let y = blockY; y < blockY + blockHeight; y++) {
for (let x = blockX; x < blockX + blockWidth; x++) {
const offset = (y * width + x) * 4;
redTotal += data[offset];
greenTotal += data[offset + 1];
blueTotal += data[offset + 2];
alphaTotal += data[offset + 3];
}
}
const averageRed = Math.round(redTotal / pixelCount);
const averageGreen = Math.round(greenTotal / pixelCount);
const averageBlue = Math.round(blueTotal / pixelCount);
const averageAlpha = Math.round(alphaTotal / pixelCount);
for (let y = blockY; y < blockY + blockHeight; y++) {
for (let x = blockX; x < blockX + blockWidth; x++) {
const offset = (y * width + x) * 4;
data[offset] = averageRed;
data[offset + 1] = averageGreen;
data[offset + 2] = averageBlue;
data[offset + 3] = averageAlpha;
}
}
}
}
return imageData;
}
+27 -18
View File
@@ -1,10 +1,11 @@
import { type AnnotationRegion, type ArrowDirection } from "@/components/video-editor/types";
import {
type AnnotationRegion,
type ArrowDirection,
DEFAULT_BLUR_INTENSITY,
MAX_BLUR_INTENSITY,
MIN_BLUR_INTENSITY,
} from "@/components/video-editor/types";
applyMosaicToImageData,
getBlurOverlayColor,
getNormalizedBlurIntensity,
getNormalizedMosaicBlockSize,
normalizeBlurType,
} from "@/lib/blurEffects";
let blurScratchCanvas: HTMLCanvasElement | null = null;
let blurScratchCtx: CanvasRenderingContext2D | null = null;
@@ -151,15 +152,16 @@ function renderBlur(
scaleFactor: number,
) {
const canvas = ctx.canvas;
const configuredIntensity = annotation.blurData?.intensity ?? DEFAULT_BLUR_INTENSITY;
const blurType = normalizeBlurType(annotation.blurData?.type);
const blurRadius = Math.max(
1,
Math.round(clamp(configuredIntensity, MIN_BLUR_INTENSITY, MAX_BLUR_INTENSITY) * scaleFactor),
Math.round(getNormalizedBlurIntensity(annotation.blurData) * scaleFactor),
);
// Sample pixels around the target shape too; without this padding, small blur regions
// lose intensity because the filter has no neighboring pixels to blend with.
const samplePadding = Math.max(2, Math.ceil(blurRadius * 2));
const samplePadding =
blurType === "mosaic"
? Math.max(0, Math.ceil(getNormalizedMosaicBlockSize(annotation.blurData, scaleFactor)))
: Math.max(2, Math.ceil(blurRadius * 2));
const sx = Math.max(0, Math.floor(x) - samplePadding);
const sy = Math.max(0, Math.floor(y) - samplePadding);
const ex = Math.min(canvas.width, Math.ceil(x + width) + samplePadding);
@@ -179,19 +181,26 @@ function renderBlur(
blurScratchCtx.clearRect(0, 0, sw, sh);
blurScratchCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);
if (blurType === "mosaic") {
const imageData = blurScratchCtx.getImageData(0, 0, sw, sh);
applyMosaicToImageData(
imageData,
getNormalizedMosaicBlockSize(annotation.blurData, scaleFactor),
);
blurScratchCtx.putImageData(imageData, 0, 0);
}
ctx.save();
drawBlurPath(ctx, annotation, x, y, width, height);
ctx.clip();
ctx.filter = `blur(${blurRadius}px)`;
ctx.filter = blurType === "mosaic" ? "none" : `blur(${blurRadius}px)`;
ctx.drawImage(blurScratchCanvas, sx, sy);
ctx.filter = "none";
ctx.fillStyle = getBlurOverlayColor(annotation.blurData);
ctx.fillRect(sx, sy, sw, sh);
ctx.restore();
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function renderText(
ctx: CanvasRenderingContext2D,
annotation: AnnotationRegion,
@@ -364,7 +373,7 @@ export async function renderAnnotations(
): Promise<void> {
// Filter active annotations at current time
const activeAnnotations = annotations.filter(
(ann) => currentTimeMs >= ann.startMs && currentTimeMs <= ann.endMs,
(ann) => currentTimeMs >= ann.startMs && currentTimeMs < ann.endMs,
);
// Sort by z-index (lower first, so higher z-index draws on top)