Compare commits

..
39 Commits
Author SHA1 Message Date
Siddharth 6d2e1edb5b fix build errors 2026-01-20 20:05:14 -08:00
Sid 08f58b3539 Merge pull request #101 from Al-Farhan/bug/source-selector-tabs-content-height
fix(ui): increase height for source selector tabs content
2026-01-16 19:20:45 -08:00
Farhan Shaikh 1586dbe65e fix(ui): increase height for source selector tabs content 2026-01-13 16:42:19 +05:30
Sid 3a63617c55 Merge pull request #84 from solnikhil/export
feat(export): add GIF exporting, Frame Rate & Output Size selection, and loop animation
2026-01-11 10:27:18 -08:00
Nikhil Solanki e8d2c19b7d Merge branch 'main' into export 2026-01-11 21:52:29 +05:30
Nikhil Solanki 23ede0fcfa Update GIF export options to remove 10 FPS and small size
Removed 10 FPS from valid GIF frame rates and the 'small' size preset from GIF export options. Updated UI grid layouts and tests to reflect these changes for consistency.
2026-01-11 21:50:42 +05:30
Sid 78cfb64f98 Merge pull request #97 from twinkalp10/bug/fix-bg-image-selection-height
fix(ui): set minimum height for image selection tab in settings panel
2026-01-10 12:05:56 -08:00
Twinkal P 3124342309 fix(ui): set minimum height for image selection tab in settings panel 2026-01-10 17:33:11 +00:00
Sid a9bd2ac820 Merge pull request #90 from gerrywastaken/claude/openscreen-linux-boot-performance-8Xq9v
fix(linux): Greatly AppImage boot time from ~20s to ~2s
2026-01-01 12:05:13 -08:00
Claude e190915c48 fix(linux): reduce AppImage boot time from ~50s to near-instant
Change compression from "maximum" to "normal" for electron-builder.

The "maximum" compression setting causes gzip/xz compression in the
squashfs filesystem, which has extremely poor random access performance
(~35 MB/s). This results in 50+ second boot times on Linux AppImage
releases due to FUSE overhead during Electron's many small file reads
at startup.

With "normal" compression, the AppImage uses faster decompression
algorithms, dramatically improving startup time while only marginally
increasing package size.

Refs: electron-userland/electron-builder#6317
Refs: electron-userland/electron-builder#7483
2026-01-01 01:52:44 +00:00
Sid 171a02aef4 Merge pull request #72 from ateendra24/fix-issue-34
feat: add tutorial help component for video trimming guidance
2025-12-28 19:03:35 -08:00
Nikhil Solanki 085ebad38f Add custom hidden scrollbar styles and clean up code
Introduced CSS classes to hide scrollbars while maintaining scrollability across browsers. Also removed unnecessary blank lines in frameRenderer.ts for code cleanliness.
2025-12-25 16:12:34 +05:30
Nikhil Solanki f3e12629c2 Adjust layout and sizing of SettingsPanel tabs
Updated the Tabs and TabsList components in SettingsPanel to use fixed min and max heights and improved flex properties for better layout consistency and scrolling behavior.
2025-12-25 15:09:26 +05:30
Nikhil Solanki f00d381f94 fixed swloppy gitignore 2025-12-25 14:48:24 +05:30
Nikhil Solanki fb92b0b6d9 / 2025-12-25 02:05:38 +05:30
Nikhil Solanki 8ca2b8362a Update .gitignore 2025-12-25 02:02:08 +05:30
Nikhil Solanki 134f392553 Update .gitignore 2025-12-25 02:01:20 +05:30
Nikhil Solanki c7e81c6b7f Update .gitignore 2025-12-25 02:00:27 +05:30
Nikhil Solanki f58b8b2897 Mega gitignore 2025-12-25 02:00:06 +05:30
Nikhil Solanki 6e6ecba172 Add GIF export feature to video editor
Implements GIF export alongside MP4, including new export types, a GIF exporter module, UI components for format selection and GIF options, and integration into the export dialog and video editor. Adds property-based and unit tests for GIF export correctness, updates dependencies to include gif.js and related types, and refines Electron save dialog to support GIF files.
2025-12-25 01:50:02 +05:30
AP Solanki 175bb36eda feat: add tutorial help component for video trimming guidance 2025-12-18 10:38:07 +05:30
Sid 2ca99136ba Update README.md 2025-12-17 00:41:24 -07:00
Siddharth b7485865f3 fix:Border radius appears smaller in export compared to preview 2025-12-16 13:52:53 -07:00
Sid 7db2fa4e01 Merge pull request #65 from LauZzL/feature/system-tray-icon
feat(electron): implement dynamic tray icon and menu updates
2025-12-16 12:28:25 -08:00
LauZzL 81b59cad7c feat(electron): implement dynamic tray icon and menu updates
- Show "Stop Recording" menu & recording icon when recording
- Show "Open/Quit" menu & default icon when not recording
2025-12-16 21:28:18 +08:00
Sid 7e0ce53df0 Update package.json 2025-12-14 10:47:47 -07:00
Sid d57140b031 Merge pull request #59 from kamikazebr/feat/linux-support
feat: add Linux support
2025-12-14 09:33:11 -08:00
Felipe Novaes F Rocha 5dd85abaee chore: remove pnpm lock files from tracking 2025-12-14 14:29:23 -03:00
Felipe Novaes F Rocha 78fbd30b15 ci: add Linux build to workflow 2025-12-13 20:25:15 -03:00
Felipe Novaes F Rocha ebb1d29375 feat: add Linux support 2025-12-13 20:07:41 -03:00
Siddharth 250fc5d221 disable user-select 2025-12-10 21:30:25 -07:00
Sid 58b5ea0f9b Merge pull request #50 from suenyiyang/feat/optimize-clamped-content
feat: add content-clamp component to show full text when truncated
2025-12-09 13:00:38 -08:00
Yiyang Suen 4bc8a1e970 feat: remove content clamp popover content box shadow 2025-12-09 09:31:35 +08:00
Yiyang Suen 16752a7ae8 feat: add content-clamp component to show clamped text 2025-12-09 09:22:30 +08:00
Sid 5f4d20b26d Merge pull request #46 from LauZzL/fix/windows-close-button-not-work
fix(electron): remove platform check for hud overlay close event
2025-12-07 10:31:44 -08:00
LauZzL 8cbdcf2d7a fix(electron): remove platform check for hud overlay close event
This check causes the close button to stop working on Windows.
2025-12-07 17:51:19 +08:00
Siddharth f1f507e6e9 replace 2025-12-06 11:44:57 -07:00
Sid a7fb7670a7 - 2025-12-06 11:29:42 -07:00
Sid eccccf583b - 2025-12-06 11:23:34 -07:00
32 changed files with 3267 additions and 661 deletions
-15
View File
@@ -1,15 +0,0 @@
# These are supported funding model platforms
github: siddharthvaddem
patreon: # Replace with a single Patreon username
open_collective: # Replace with a sissgle Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: siddharthvaddem
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+29
View File
@@ -68,3 +68,32 @@ jobs:
path: release/**/*.dmg
retention-days: 30
build-linux:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '22'
- name: Install dependencies
run: npm ci
- name: Install app dependencies
run: npx electron-builder install-app-deps
- name: Build Linux app
run: npm run build:linux
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload Linux build
uses: actions/upload-artifact@v4
with:
name: linux-installer
path: release/**/*.AppImage
retention-days: 30
+1 -1
View File
@@ -23,5 +23,5 @@ dist-ssr
*.sln
*.sw?
release/**
*.kiro/
# npx electron-builder --mac --win
+27 -3
View File
@@ -1,13 +1,18 @@
<p align="center">
<img src="openscreen.png" alt="OpenScreen Logo" width="64" />
<img src="openscreen.png" alt="OpenScreen Logo" width="64" />
<br />
<br />
<a href="https://deepwiki.com/siddharthvaddem/openscreen">
<img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki" />
</a>
</p>
# <p align="center">OpenScreen</p>
<p align="center"><strong>OpenScreen is your free, open-source alternative to Screen Studio (sort of).</strong></p>
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, making beautiful product demos and walkthroughs, here's a free-to-use app for you. OpenScreen does not offer all Screen Studio features, but covers the basics well!
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!
@@ -39,10 +44,12 @@ OpenScreen is 100% free for personal and commercial use. Use it, modify it, dist
- Trim sections of the clip
- Export in different aspect ratios and resolutions
## macOS Installation instructions
## Installation
Download the latest installer for your platform from the [GitHub Releases](https://github.com/siddharthvaddem/openscreen/releases) page.
### macOS
If you encounter issues with macOS Gatekeeper blocking the app (since it does not come with a developer certificate), you can bypass this by running the following command in your terminal after installation:
```bash
@@ -51,6 +58,17 @@ xattr -rd com.apple.quarantine /Applications/Openscreen.app
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.
### Linux
Download the `.AppImage` file from the releases page. Make it executable and run:
```bash
chmod +x Openscreen-Linux-*.AppImage
./Openscreen-Linux-*.AppImage
```
You may need to grant screen recording permissions depending on your desktop environment.
## Built with
- Electron
- React
@@ -64,7 +82,13 @@ After running this command, proceed to **System Preferences > Security & Privacy
_I'm new to open source, idk what I'm doing lol. If something is wrong please raise an issue 🙏_
## Contributing
Contributions are welcome! If you’d like to help out or see what’s currently being worked on, take a look at the open issues and the [project roadmap](https://github.com/users/siddharthvaddem/projects/3) to understand the current direction of the project and find ways to contribute.
## License
This project is licensed under the [MIT License](./LICENSE). By using this software, you agree that the authors are not liable for any issues, damages, or claims arising from its use.
+138 -119
View File
@@ -1,22 +1,22 @@
import { ipcMain as n, screen as b, BrowserWindow as R, desktopCapturer as V, shell as O, app as d, dialog as S, nativeImage as W, Tray as k, Menu as L } from "electron";
import { fileURLToPath as E } from "node:url";
import { ipcMain as s, screen as F, BrowserWindow as R, desktopCapturer as L, shell as C, app as d, dialog as E, nativeImage as U, Tray as M, Menu as A } from "electron";
import { fileURLToPath as j } from "node:url";
import o from "node:path";
import P from "node:fs/promises";
const _ = o.dirname(E(import.meta.url)), U = o.join(_, ".."), m = process.env.VITE_DEV_SERVER_URL, T = o.join(U, "dist");
let f = null;
n.on("hud-overlay-hide", () => {
f && !f.isDestroyed() && f.minimize();
const _ = o.dirname(j(import.meta.url)), z = o.join(_, ".."), w = process.env.VITE_DEV_SERVER_URL, S = o.join(z, "dist");
let m = null;
s.on("hud-overlay-hide", () => {
m && !m.isDestroyed() && m.minimize();
});
function C() {
const r = b.getPrimaryDisplay(), { workArea: s } = r, c = 500, w = 100, y = Math.floor(s.x + (s.width - c) / 2), h = Math.floor(s.y + s.height - w - 5), e = new R({
function H() {
const n = F.getPrimaryDisplay(), { workArea: t } = n, c = 500, u = 100, y = Math.floor(t.x + (t.width - c) / 2), p = Math.floor(t.y + t.height - u - 5), e = new R({
width: c,
height: w,
height: u,
minWidth: 500,
maxWidth: 500,
minHeight: 100,
maxHeight: 100,
x: y,
y: h,
y: p,
frame: !1,
transparent: !0,
resizable: !1,
@@ -32,20 +32,22 @@ function C() {
});
return e.webContents.on("did-finish-load", () => {
e == null || e.webContents.send("main-process-message", (/* @__PURE__ */ new Date()).toLocaleString());
}), f = e, e.on("closed", () => {
f === e && (f = null);
}), m ? e.loadURL(m + "?windowType=hud-overlay") : e.loadFile(o.join(T, "index.html"), {
}), m = e, e.on("closed", () => {
m === e && (m = null);
}), w ? e.loadURL(w + "?windowType=hud-overlay") : e.loadFile(o.join(S, "index.html"), {
query: { windowType: "hud-overlay" }
}), e;
}
function M() {
const r = new R({
function q() {
const n = process.platform === "darwin", t = new R({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 12, y: 12 },
...n && {
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 12, y: 12 }
},
transparent: !1,
resizable: !0,
alwaysOnTop: !1,
@@ -60,20 +62,20 @@ function M() {
backgroundThrottling: !1
}
});
return r.maximize(), r.webContents.on("did-finish-load", () => {
r == null || r.webContents.send("main-process-message", (/* @__PURE__ */ new Date()).toLocaleString());
}), m ? r.loadURL(m + "?windowType=editor") : r.loadFile(o.join(T, "index.html"), {
return t.maximize(), t.webContents.on("did-finish-load", () => {
t == null || t.webContents.send("main-process-message", (/* @__PURE__ */ new Date()).toLocaleString());
}), w ? t.loadURL(w + "?windowType=editor") : t.loadFile(o.join(S, "index.html"), {
query: { windowType: "editor" }
}), r;
}), t;
}
function A() {
const { width: r, height: s } = b.getPrimaryDisplay().workAreaSize, c = new R({
function B() {
const { width: n, height: t } = F.getPrimaryDisplay().workAreaSize, c = new R({
width: 620,
height: 420,
minHeight: 350,
maxHeight: 500,
x: Math.round((r - 620) / 2),
y: Math.round((s - 420) / 2),
x: Math.round((n - 620) / 2),
y: Math.round((t - 420) / 2),
frame: !1,
resizable: !1,
alwaysOnTop: !0,
@@ -85,102 +87,100 @@ function A() {
contextIsolation: !0
}
});
return m ? c.loadURL(m + "?windowType=source-selector") : c.loadFile(o.join(T, "index.html"), {
return w ? c.loadURL(w + "?windowType=source-selector") : c.loadFile(o.join(S, "index.html"), {
query: { windowType: "source-selector" }
}), c;
}
let v = null;
function H(r, s, c, w, y) {
n.handle("get-sources", async (e, a) => (await V.getSources(a)).map((t) => ({
id: t.id,
name: t.name,
display_id: t.display_id,
thumbnail: t.thumbnail ? t.thumbnail.toDataURL() : null,
appIcon: t.appIcon ? t.appIcon.toDataURL() : null
}))), n.handle("select-source", (e, a) => {
v = a;
const i = w();
return i && i.close(), v;
}), n.handle("get-selected-source", () => v), n.handle("open-source-selector", () => {
const e = w();
let T = null;
function N(n, t, c, u, y) {
s.handle("get-sources", async (e, a) => (await L.getSources(a)).map((r) => ({
id: r.id,
name: r.name,
display_id: r.display_id,
thumbnail: r.thumbnail ? r.thumbnail.toDataURL() : null,
appIcon: r.appIcon ? r.appIcon.toDataURL() : null
}))), s.handle("select-source", (e, a) => {
T = a;
const l = u();
return l && l.close(), T;
}), s.handle("get-selected-source", () => T), s.handle("open-source-selector", () => {
const e = u();
if (e) {
e.focus();
return;
}
s();
}), n.handle("switch-to-editor", () => {
t();
}), s.handle("switch-to-editor", () => {
const e = c();
e && e.close(), r();
}), n.handle("store-recorded-video", async (e, a, i) => {
e && e.close(), n();
}), s.handle("store-recorded-video", async (e, a, l) => {
try {
const t = o.join(p, i);
return await P.writeFile(t, Buffer.from(a)), h = t, {
const r = o.join(h, l);
return await P.writeFile(r, Buffer.from(a)), p = r, {
success: !0,
path: t,
path: r,
message: "Video stored successfully"
};
} catch (t) {
return console.error("Failed to store video:", t), {
} catch (r) {
return console.error("Failed to store video:", r), {
success: !1,
message: "Failed to store video",
error: String(t)
error: String(r)
};
}
}), n.handle("get-recorded-video-path", async () => {
}), s.handle("get-recorded-video-path", async () => {
try {
const a = (await P.readdir(p)).filter((j) => j.endsWith(".webm"));
const a = (await P.readdir(h)).filter((I) => I.endsWith(".webm"));
if (a.length === 0)
return { success: !1, message: "No recorded video found" };
const i = a.sort().reverse()[0];
return { success: !0, path: o.join(p, i) };
const l = a.sort().reverse()[0];
return { success: !0, path: o.join(h, l) };
} catch (e) {
return console.error("Failed to get video path:", e), { success: !1, message: "Failed to get video path", error: String(e) };
}
}), n.handle("set-recording-state", (e, a) => {
y && y(a, (v || { name: "Screen" }).name);
}), n.handle("open-external-url", async (e, a) => {
}), s.handle("set-recording-state", (e, a) => {
y && y(a, (T || { name: "Screen" }).name);
}), s.handle("open-external-url", async (e, a) => {
try {
return await O.openExternal(a), { success: !0 };
} catch (i) {
return console.error("Failed to open URL:", i), { success: !1, error: String(i) };
return await C.openExternal(a), { success: !0 };
} catch (l) {
return console.error("Failed to open URL:", l), { success: !1, error: String(l) };
}
}), n.handle("get-asset-base-path", () => {
}), s.handle("get-asset-base-path", () => {
try {
return d.isPackaged ? o.join(process.resourcesPath, "assets") : o.join(d.getAppPath(), "public", "assets");
} catch (e) {
return console.error("Failed to resolve asset base path:", e), null;
}
}), n.handle("save-exported-video", async (e, a, i) => {
}), s.handle("save-exported-video", async (e, a, l) => {
try {
const t = await S.showSaveDialog({
title: "Save Exported Video",
defaultPath: o.join(d.getPath("downloads"), i),
filters: [
{ name: "MP4 Video", extensions: ["mp4"] }
],
const r = l.toLowerCase().endsWith(".gif"), I = r ? [{ name: "GIF Image", extensions: ["gif"] }] : [{ name: "MP4 Video", extensions: ["mp4"] }], v = await E.showSaveDialog({
title: r ? "Save Exported GIF" : "Save Exported Video",
defaultPath: o.join(d.getPath("downloads"), l),
filters: I,
properties: ["createDirectory", "showOverwriteConfirmation"]
});
return t.canceled || !t.filePath ? {
return v.canceled || !v.filePath ? {
success: !1,
cancelled: !0,
message: "Export cancelled"
} : (await P.writeFile(t.filePath, Buffer.from(a)), {
} : (await P.writeFile(v.filePath, Buffer.from(a)), {
success: !0,
path: t.filePath,
path: v.filePath,
message: "Video exported successfully"
});
} catch (t) {
return console.error("Failed to save exported video:", t), {
} catch (r) {
return console.error("Failed to save exported video:", r), {
success: !1,
message: "Failed to save exported video",
error: String(t)
error: String(r)
};
}
}), n.handle("open-video-file-picker", async () => {
}), s.handle("open-video-file-picker", async () => {
try {
const e = await S.showOpenDialog({
const e = await E.showOpenDialog({
title: "Select Video File",
defaultPath: p,
defaultPath: h,
filters: [
{ name: "Video Files", extensions: ["webm", "mp4", "mov", "avi", "mkv"] },
{ name: "All Files", extensions: ["*"] }
@@ -199,71 +199,90 @@ function H(r, s, c, w, y) {
};
}
});
let h = null;
n.handle("set-current-video-path", (e, a) => (h = a, { success: !0 })), n.handle("get-current-video-path", () => h ? { success: !0, path: h } : { success: !1 }), n.handle("clear-current-video-path", () => (h = null, { success: !0 })), n.handle("get-platform", () => process.platform);
let p = null;
s.handle("set-current-video-path", (e, a) => (p = a, { success: !0 })), s.handle("get-current-video-path", () => p ? { success: !0, path: p } : { success: !1 }), s.handle("clear-current-video-path", () => (p = null, { success: !0 })), s.handle("get-platform", () => process.platform);
}
const z = o.dirname(E(import.meta.url)), p = o.join(d.getPath("userData"), "recordings");
async function N() {
const G = o.dirname(j(import.meta.url)), h = o.join(d.getPath("userData"), "recordings");
async function $() {
try {
await P.mkdir(p, { recursive: !0 }), console.log("RECORDINGS_DIR:", p), console.log("User Data Path:", d.getPath("userData"));
} catch (r) {
console.error("Failed to create recordings directory:", r);
await P.mkdir(h, { recursive: !0 }), console.log("RECORDINGS_DIR:", h), console.log("User Data Path:", d.getPath("userData"));
} catch (n) {
console.error("Failed to create recordings directory:", n);
}
}
process.env.APP_ROOT = o.join(z, "..");
const B = process.env.VITE_DEV_SERVER_URL, Y = o.join(process.env.APP_ROOT, "dist-electron"), D = o.join(process.env.APP_ROOT, "dist");
process.env.VITE_PUBLIC = B ? o.join(process.env.APP_ROOT, "public") : D;
let l = null, g = null, u = null, x = "";
function I() {
l = C();
process.env.APP_ROOT = o.join(G, "..");
const Q = process.env.VITE_DEV_SERVER_URL, re = o.join(process.env.APP_ROOT, "dist-electron"), O = o.join(process.env.APP_ROOT, "dist");
process.env.VITE_PUBLIC = Q ? o.join(process.env.APP_ROOT, "public") : O;
let i = null, g = null, f = null, V = "";
const W = k("openscreen.png"), J = k("rec-button.png");
function b() {
i = H();
}
function q() {
const r = o.join(process.env.VITE_PUBLIC || D, "rec-button.png");
let s = W.createFromPath(r);
s = s.resize({ width: 24, height: 24, quality: "best" }), u = new k(s), F();
function D() {
f = new M(W);
}
function F() {
if (!u) return;
const r = [
function k(n) {
return U.createFromPath(o.join(process.env.VITE_PUBLIC || O, n)).resize({
width: 24,
height: 24,
quality: "best"
});
}
function x(n = !1) {
if (!f) return;
const t = n ? J : W, c = n ? `Recording: ${V}` : "OpenScreen", u = n ? [
{
label: "Stop Recording",
click: () => {
l && !l.isDestroyed() && l.webContents.send("stop-recording-from-tray");
i && !i.isDestroyed() && i.webContents.send("stop-recording-from-tray");
}
}
], s = L.buildFromTemplate(r);
u.setContextMenu(s), u.setToolTip(`Recording: ${x}`);
] : [
{
label: "Open",
click: () => {
i && !i.isDestroyed() ? i.isMinimized() && i.restore() : b();
}
},
{
label: "Quit",
click: () => {
d.quit();
}
}
];
f.setImage(t), f.setToolTip(c), f.setContextMenu(A.buildFromTemplate(u));
}
function $() {
l && (l.close(), l = null), l = M();
function K() {
i && (i.close(), i = null), i = q();
}
function G() {
return g = A(), g.on("closed", () => {
function X() {
return g = B(), g.on("closed", () => {
g = null;
}), g;
}
d.on("window-all-closed", () => {
});
d.on("activate", () => {
R.getAllWindows().length === 0 && I();
R.getAllWindows().length === 0 && b();
});
d.whenReady().then(async () => {
const { ipcMain: r } = await import("electron");
r.on("hud-overlay-close", () => {
process.platform === "darwin" && d.quit();
}), await N(), H(
$,
G,
() => l,
const { ipcMain: n } = await import("electron");
n.on("hud-overlay-close", () => {
d.quit();
}), D(), x(), await $(), N(
K,
X,
() => i,
() => g,
(s, c) => {
x = c, s ? (u || q(), F()) : (u && (u.destroy(), u = null), l && l.restore());
(t, c) => {
V = c, f || D(), x(t), t || i && i.restore();
}
), I();
), b();
});
export {
Y as MAIN_DIST,
p as RECORDINGS_DIR,
D as RENDERER_DIST,
B as VITE_DEV_SERVER_URL
re as MAIN_DIST,
h as RECORDINGS_DIR,
O as RENDERER_DIST,
Q as VITE_DEV_SERVER_URL
};
+4 -2
View File
@@ -6,7 +6,7 @@
"productName": "Openscreen",
"npmRebuild": true,
"buildDependenciesFromSource": true,
"compression": "maximum",
"compression": "normal",
"directories": {
"output": "release/${version}"
},
@@ -41,7 +41,9 @@
"target": [
"AppImage"
],
"artifactName": "${productName}-Linux-${version}.${ext}"
"icon": "icons/icons/png",
"artifactName": "${productName}-Linux-${version}.${ext}",
"category": "AudioVideo"
},
"win": {
"target": [
+10 -5
View File
@@ -130,12 +130,16 @@ export function registerIpcHandlers(
ipcMain.handle('save-exported-video', async (_, videoData: ArrayBuffer, fileName: string) => {
try {
// Determine file type from extension
const isGif = fileName.toLowerCase().endsWith('.gif');
const filters = isGif
? [{ name: 'GIF Image', extensions: ['gif'] }]
: [{ name: 'MP4 Video', extensions: ['mp4'] }];
const result = await dialog.showSaveDialog({
title: 'Save Exported Video',
title: isGif ? 'Save Exported GIF' : 'Save Exported Video',
defaultPath: path.join(app.getPath('downloads'), fileName),
filters: [
{ name: 'MP4 Video', extensions: ['mp4'] }
],
filters,
properties: ['createDirectory', 'showOverwriteConfirmation']
});
@@ -146,8 +150,9 @@ export function registerIpcHandlers(
message: 'Export cancelled'
};
}
await fs.writeFile(result.filePath, Buffer.from(videoData));
return {
success: true,
path: result.filePath,
+55 -30
View File
@@ -45,33 +45,63 @@ let sourceSelectorWindow: BrowserWindow | null = null
let tray: Tray | null = null
let selectedSourceName = ''
// Tray Icons
const defaultTrayIcon = getTrayIcon('openscreen.png');
const recordingTrayIcon = getTrayIcon('rec-button.png');
function createWindow() {
mainWindow = createHudOverlayWindow()
}
function createTray() {
const iconPath = path.join(process.env.VITE_PUBLIC || RENDERER_DIST, 'rec-button.png');
let icon = nativeImage.createFromPath(iconPath);
icon = icon.resize({ width: 24, height: 24, quality: 'best' });
tray = new Tray(icon);
updateTrayMenu();
tray = new Tray(defaultTrayIcon);
}
function updateTrayMenu() {
function getTrayIcon(filename: string) {
return nativeImage.createFromPath(path.join(process.env.VITE_PUBLIC || RENDERER_DIST, filename)).resize({
width: 24,
height: 24,
quality: 'best'
});
}
function updateTrayMenu(recording: boolean = false) {
if (!tray) return;
const menuTemplate = [
{
label: 'Stop Recording',
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('stop-recording-from-tray');
}
}
}
];
const contextMenu = Menu.buildFromTemplate(menuTemplate);
tray.setContextMenu(contextMenu);
tray.setToolTip(`Recording: ${selectedSourceName}`);
const trayIcon = recording ? recordingTrayIcon : defaultTrayIcon;
const trayToolTip = recording ? `Recording: ${selectedSourceName}` : "OpenScreen";
const menuTemplate = recording
? [
{
label: "Stop Recording",
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("stop-recording-from-tray");
}
},
},
]
: [
{
label: "Open",
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.isMinimized() && mainWindow.restore();
} else {
createWindow();
}
},
},
{
label: "Quit",
click: () => {
app.quit();
},
},
];
tray.setImage(trayIcon);
tray.setToolTip(trayToolTip);
tray.setContextMenu(Menu.buildFromTemplate(menuTemplate));
}
function createEditorWindowWrapper() {
@@ -111,10 +141,10 @@ app.whenReady().then(async () => {
// Listen for HUD overlay quit event (macOS only)
const { ipcMain } = await import('electron');
ipcMain.on('hud-overlay-close', () => {
if (process.platform === 'darwin') {
app.quit();
}
app.quit();
});
createTray()
updateTrayMenu()
// Ensure recordings directory exists
await ensureRecordingsDir()
@@ -125,14 +155,9 @@ app.whenReady().then(async () => {
() => sourceSelectorWindow,
(recording: boolean, sourceName: string) => {
selectedSourceName = sourceName
if (recording) {
if (!tray) createTray();
updateTrayMenu();
} else {
if (tray) {
tray.destroy();
tray = null;
}
if (!tray) createTray();
updateTrayMenu(recording);
if (!recording) {
if (mainWindow) mainWindow.restore();
}
}
+6 -2
View File
@@ -77,13 +77,17 @@ export function createHudOverlayWindow(): BrowserWindow {
}
export function createEditorWindow(): BrowserWindow {
const isMac = process.platform === 'darwin';
const win = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
titleBarStyle: 'hiddenInset',
trafficLightPosition: { x: 12, y: 12 },
...(isMac && {
titleBarStyle: 'hiddenInset',
trafficLightPosition: { x: 12, y: 12 },
}),
transparent: false,
resizable: true,
alwaysOnTop: false,
+1102 -278
View File
File diff suppressed because it is too large Load Diff
+12 -4
View File
@@ -1,7 +1,7 @@
{
"name": "openscreen",
"private": true,
"version": "1.0.1",
"version": "1.0.2",
"type": "module",
"scripts": {
"dev": "vite",
@@ -9,7 +9,10 @@
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"build:mac": "tsc && vite build && electron-builder --mac",
"build:win": "tsc && vite build && electron-builder --win"
"build:win": "tsc && vite build && electron-builder --win",
"build:linux": "tsc && vite build && electron-builder --linux",
"test": "vitest --run",
"test:watch": "vitest"
},
"dependencies": {
"@fix-webm-duration/fix": "^1.0.1",
@@ -24,6 +27,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@types/gif.js": "^0.2.5",
"@uiw/color-convert": "^2.9.2",
"@uiw/react-color-block": "^2.9.2",
"class-variance-authority": "^0.7.1",
@@ -31,6 +35,7 @@
"dnd-timeline": "^2.2.0",
"emoji-picker-react": "^4.16.1",
"fix-webm-duration": "^1.0.6",
"gif.js": "^0.2.0",
"gsap": "^3.13.0",
"lucide-react": "^0.545.0",
"mediabunny": "^1.25.1",
@@ -48,6 +53,7 @@
"uuid": "^13.0.0"
},
"devDependencies": {
"@types/node": "^25.0.3",
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
"@types/uuid": "^10.0.0",
@@ -55,20 +61,22 @@
"@typescript-eslint/parser": "^7.1.1",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.21",
"electron": "^30.0.1",
"electron": "^39.2.7",
"electron-builder": "^24.13.3",
"electron-icon-builder": "^2.0.1",
"electron-rebuild": "^3.2.9",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"fast-check": "^4.5.2",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.18",
"terser": "^5.44.1",
"typescript": "^5.2.2",
"vite": "^5.1.6",
"vite-plugin-electron": "^0.28.6",
"vite-plugin-electron-renderer": "^0.14.5"
"vite-plugin-electron-renderer": "^0.14.5",
"vitest": "^4.0.16"
},
"main": "dist-electron/main.js"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 853 KiB

+3 -7
View File
@@ -8,6 +8,7 @@ import { MdMonitor } from "react-icons/md";
import { RxDragHandleDots2 } from "react-icons/rx";
import { FaFolderMinus } from "react-icons/fa6";
import { FiMinus, FiX } from "react-icons/fi";
import { ContentClamp } from "../ui/content-clamp";
export function LaunchWindow() {
const { recording, toggleRecording } = useScreenRecorder();
@@ -61,11 +62,6 @@ export function LaunchWindow() {
return () => clearInterval(interval);
}, []);
const truncateText = (text: string, maxLength: number = 6) => {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + "...";
};
const openSourceSelector = () => {
if (window.electronAPI) {
window.electronAPI.openSourceSelector();
@@ -121,7 +117,7 @@ export function LaunchWindow() {
disabled={recording}
>
<MdMonitor size={14} className="text-white" />
{truncateText(selectedSource, 6)}
<ContentClamp truncateLength={6}>{selectedSource}</ContentClamp>
</Button>
<div className="w-px h-6 bg-white/30" />
@@ -131,7 +127,7 @@ export function LaunchWindow() {
size="sm"
onClick={hasSelectedSource ? toggleRecording : openSourceSelector}
disabled={!hasSelectedSource && !recording}
className={`gap-1 bg-transparent hover:bg-transparent px-0 flex-1 text-center text-xs ${styles.electronNoDrag}`}
className={`gap-1 text-white bg-transparent hover:bg-transparent px-0 flex-1 text-center text-xs ${styles.electronNoDrag}`}
>
{recording ? (
<>
+1 -1
View File
@@ -75,7 +75,7 @@ export function SourceSelector() {
<TabsTrigger value="screens" className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-zinc-200 rounded-full text-xs py-1">Screens</TabsTrigger>
<TabsTrigger value="windows" className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-zinc-200 rounded-full text-xs py-1">Windows</TabsTrigger>
</TabsList>
<div className="h-60 flex flex-col justify-stretch">
<div className="h-72 flex flex-col justify-stretch">
<TabsContent value="screens" className="h-full">
<div className="grid grid-cols-2 gap-2 h-full overflow-y-auto pr-1 relative">
{screenSources.map(source => (
+86
View File
@@ -0,0 +1,86 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Popover, PopoverArrow, PopoverContent, PopoverTrigger } from "./popover"
interface ContentClampProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode
truncateLength?: number
}
function ContentClamp({
children,
className,
truncateLength = 50,
...props
}: ContentClampProps) {
const text = typeof children === "string" ? children : String(children ?? "")
const isTruncated = text.length > truncateLength
const [open, setOpen] = React.useState(false)
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null)
const handleMouseEnter = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
setOpen(true)
}
const handleMouseLeave = () => {
timeoutRef.current = setTimeout(() => {
setOpen(false)
}, 100)
}
React.useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [])
if (!isTruncated) {
return (
<div className={cn("inline", className)} {...props}>
{children}
</div>
)
}
const truncatedText = text.slice(0, truncateLength) + "..."
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<span
className={className}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={(e) => e.preventDefault()}
{...props}
>
{truncatedText}
</span>
</PopoverTrigger>
<PopoverContent
className="w-auto max-w-sm rounded-lg border border-white bg-popover p-3 text-sm text-popover-foreground"
sideOffset={8}
animated={false}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onPointerDownOutside={(e) => e.preventDefault()}
onClick={(e) => e.stopPropagation()}
>
<PopoverArrow className="fill-white" />
{children}
</PopoverContent>
</Popover>
)
}
export { ContentClamp }
+21 -3
View File
@@ -21,8 +21,11 @@ function PopoverContent({
className,
align = "center",
sideOffset = 4,
animated = true,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
}: React.ComponentProps<typeof PopoverPrimitive.Content> & {
animated?: boolean
}) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
@@ -30,7 +33,9 @@ function PopoverContent({
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
"bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-md outline-hidden",
animated &&
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-popover-content-transform-origin)",
className
)}
{...props}
@@ -45,4 +50,17 @@ function PopoverAnchor({
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
function PopoverArrow({
className,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Arrow>) {
return (
<PopoverPrimitive.Arrow
data-slot="popover-arrow"
className={cn("fill-popover", className)}
{...props}
/>
)
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverArrow }
+91 -16
View File
@@ -10,6 +10,7 @@ interface ExportDialogProps {
isExporting: boolean;
error: string | null;
onCancel?: () => void;
exportFormat?: 'mp4' | 'gif';
}
export function ExportDialog({
@@ -19,6 +20,7 @@ export function ExportDialog({
isExporting,
error,
onCancel,
exportFormat = 'mp4',
}: ExportDialogProps) {
const [showSuccess, setShowSuccess] = useState(false);
@@ -35,6 +37,32 @@ export function ExportDialog({
if (!isOpen) return null;
const formatLabel = exportFormat === 'gif' ? 'GIF' : 'Video';
// Determine if we're in the compiling phase (frames done but still exporting)
const isCompiling = isExporting && progress && progress.percentage >= 100 && exportFormat === 'gif';
const isFinalizing = progress?.phase === 'finalizing';
const renderProgress = progress?.renderProgress;
// Get status message based on phase
const getStatusMessage = () => {
if (error) return 'Please try again';
if (isCompiling || isFinalizing) {
if (renderProgress !== undefined && renderProgress > 0) {
return `Compiling GIF... ${renderProgress}%`;
}
return 'Compiling GIF... This may take a while';
}
return 'This may take a moment...';
};
// Get title based on phase
const getTitle = () => {
if (error) return 'Export Failed';
if (isCompiling || isFinalizing) return 'Compiling GIF';
return `Exporting ${formatLabel}`;
};
return (
<>
<div
@@ -42,7 +70,7 @@ export function ExportDialog({
onClick={isExporting ? undefined : onClose}
/>
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-[60] bg-[#09090b] rounded-2xl shadow-2xl border border-white/10 p-8 w-[90vw] max-w-md animate-in zoom-in-95 duration-200">
<div className="flex items-center justify-between mb-8">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
{showSuccess ? (
<>
@@ -51,7 +79,7 @@ export function ExportDialog({
</div>
<div>
<span className="text-xl font-bold text-slate-200 block">Export Complete</span>
<span className="text-sm text-slate-400">Your video is ready</span>
<span className="text-sm text-slate-400">Your {formatLabel.toLowerCase()} is ready</span>
</div>
</>
) : (
@@ -67,10 +95,10 @@ export function ExportDialog({
)}
<div>
<span className="text-xl font-bold text-slate-200 block">
{error ? 'Export Failed' : isExporting ? 'Exporting Video' : 'Export Video'}
{getTitle()}
</span>
<span className="text-sm text-slate-400">
{error ? 'Please try again' : isExporting ? 'This may take a moment...' : 'Ready to start'}
{getStatusMessage()}
</span>
</div>
</>
@@ -103,23 +131,68 @@ export function ExportDialog({
<div className="space-y-6">
<div className="space-y-2">
<div className="flex justify-between text-xs font-medium text-slate-400 uppercase tracking-wider">
<span>Progress</span>
<span className="font-mono text-slate-200">{progress.percentage.toFixed(0)}%</span>
<span>{isCompiling || isFinalizing ? 'Compiling' : 'Rendering Frames'}</span>
<span className="font-mono text-slate-200">
{isCompiling || isFinalizing ? (
renderProgress !== undefined && renderProgress > 0 ? (
`${renderProgress}%`
) : (
<span className="flex items-center gap-2">
<Loader2 className="w-3 h-3 animate-spin" />
Processing...
</span>
)
) : (
`${progress.percentage.toFixed(0)}%`
)}
</span>
</div>
<div className="h-2 bg-white/5 rounded-full overflow-hidden border border-white/5">
<div
className="h-full bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.3)] transition-all duration-300 ease-out"
style={{ width: `${Math.min(progress.percentage, 100)}%` }}
/>
{isCompiling || isFinalizing ? (
// Show render progress if available, otherwise animated indeterminate bar
renderProgress !== undefined && renderProgress > 0 ? (
<div
className="h-full bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.3)] transition-all duration-300 ease-out"
style={{ width: `${renderProgress}%` }}
/>
) : (
<div className="h-full w-full relative overflow-hidden">
<div
className="absolute h-full w-1/3 bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.3)]"
style={{
animation: 'indeterminate 1.5s ease-in-out infinite',
}}
/>
<style>{`
@keyframes indeterminate {
0% { transform: translateX(-100%); }
100% { transform: translateX(400%); }
}
`}</style>
</div>
)
) : (
<div
className="h-full bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.3)] transition-all duration-300 ease-out"
style={{ width: `${Math.min(progress.percentage, 100)}%` }}
/>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4">
<div className="grid grid-cols-2 gap-4">
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">Status</div>
<div className="text-slate-200 font-medium text-sm flex items-center gap-2 h-[28px]">
<span className="w-2 h-2 rounded-full bg-[#34B27B] animate-pulse" />
Processing
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">
{isCompiling || isFinalizing ? 'Status' : 'Format'}
</div>
<div className="text-slate-200 font-medium text-sm">
{isCompiling || isFinalizing ? 'Compiling...' : formatLabel}
</div>
</div>
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">Frames</div>
<div className="text-slate-200 font-medium text-sm">
{progress.currentFrame} / {progress.totalFrames}
</div>
</div>
</div>
@@ -140,7 +213,9 @@ export function ExportDialog({
{showSuccess && (
<div className="text-center py-4 animate-in zoom-in-95">
<p className="text-lg text-slate-200 font-medium">Video saved successfully!</p>
<p className="text-lg text-slate-200 font-medium">
{formatLabel} saved successfully!
</p>
</div>
)}
</div>
@@ -0,0 +1,77 @@
import { Film, Image } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { ExportFormat } from '@/lib/exporter/types';
interface FormatSelectorProps {
selectedFormat: ExportFormat;
onFormatChange: (format: ExportFormat) => void;
disabled?: boolean;
}
interface FormatOption {
value: ExportFormat;
label: string;
description: string;
icon: React.ReactNode;
}
const formatOptions: FormatOption[] = [
{
value: 'mp4',
label: 'MP4 Video',
description: 'High quality video file',
icon: <Film className="w-5 h-5" />,
},
{
value: 'gif',
label: 'GIF Animation',
description: 'Animated image for sharing',
icon: <Image className="w-5 h-5" />,
},
];
export function FormatSelector({
selectedFormat,
onFormatChange,
disabled = false,
}: FormatSelectorProps) {
return (
<div className="grid grid-cols-2 gap-3">
{formatOptions.map((option) => {
const isSelected = selectedFormat === option.value;
return (
<button
key={option.value}
type="button"
disabled={disabled}
onClick={() => onFormatChange(option.value)}
className={cn(
'relative flex flex-col items-center gap-2 p-4 rounded-xl border transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-[#34B27B]/50 focus:ring-offset-2 focus:ring-offset-[#09090b]',
isSelected
? 'bg-[#34B27B]/10 border-[#34B27B]/50 text-white'
: 'bg-white/5 border-white/10 text-slate-400 hover:bg-white/10 hover:border-white/20 hover:text-slate-200',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
<div
className={cn(
'w-10 h-10 rounded-full flex items-center justify-center transition-colors',
isSelected ? 'bg-[#34B27B]/20 text-[#34B27B]' : 'bg-white/5'
)}
>
{option.icon}
</div>
<div className="text-center">
<div className="font-medium text-sm">{option.label}</div>
<div className="text-xs text-slate-500 mt-0.5">{option.description}</div>
</div>
{isSelected && (
<div className="absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34B27B]" />
)}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,110 @@
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS, type GifFrameRate, type GifSizePreset } from '@/lib/exporter/types';
interface GifOptionsPanelProps {
frameRate: GifFrameRate;
onFrameRateChange: (rate: GifFrameRate) => void;
loop: boolean;
onLoopChange: (loop: boolean) => void;
sizePreset: GifSizePreset;
onSizePresetChange: (preset: GifSizePreset) => void;
outputDimensions: { width: number; height: number };
disabled?: boolean;
}
export function GifOptionsPanel({
frameRate,
onFrameRateChange,
loop,
onLoopChange,
sizePreset,
onSizePresetChange,
outputDimensions,
disabled = false,
}: GifOptionsPanelProps) {
const sizePresetOptions = Object.entries(GIF_SIZE_PRESETS).map(([key, value]) => ({
value: key as GifSizePreset,
label: value.label,
}));
return (
<div className="space-y-4 animate-in slide-in-from-bottom-2 duration-200">
{/* Frame Rate */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">
Frame Rate
</label>
<Select
value={String(frameRate)}
onValueChange={(value) => onFrameRateChange(Number(value) as GifFrameRate)}
disabled={disabled}
>
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#1a1a1f] border-white/10 z-[100]">
{GIF_FRAME_RATES.map((rate) => (
<SelectItem
key={rate.value}
value={String(rate.value)}
className="text-slate-200 focus:bg-white/10 focus:text-white"
>
{rate.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Size Preset */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">
Output Size
</label>
<Select
value={sizePreset}
onValueChange={(value) => onSizePresetChange(value as GifSizePreset)}
disabled={disabled}
>
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#1a1a1f] border-white/10 z-[100]">
{sizePresetOptions.map((option) => (
<SelectItem
key={option.value}
value={option.value}
className="text-slate-200 focus:bg-white/10 focus:text-white"
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="text-xs text-slate-500">
Output: {outputDimensions.width} × {outputDimensions.height}px
</div>
</div>
{/* Loop Toggle */}
<div className="flex items-center justify-between py-2">
<div>
<label className="text-sm font-medium text-slate-200">Loop Animation</label>
<p className="text-xs text-slate-500">GIF will play continuously</p>
</div>
<Switch
checked={loop}
onCheckedChange={onLoopChange}
disabled={disabled}
/>
</div>
</div>
);
}
+160 -46
View File
@@ -7,15 +7,15 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import Block from '@uiw/react-color-block';
import { Trash2, Download, Crop, X, Bug, Upload } from "lucide-react";
import { GiHearts } from "react-icons/gi";
import { Trash2, Download, Crop, X, Bug, Upload, Star, Film, Image } from "lucide-react";
import { toast } from "sonner";
import type { ZoomDepth, CropRegion, AnnotationRegion, AnnotationType } from "./types";
import { CropControl } from "./CropControl";
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
import { type AspectRatio } from "@/utils/aspectRatioUtils";
import type { ExportQuality } from "@/lib/exporter";
import type { ExportQuality, ExportFormat, GifFrameRate, GifSizePreset } from "@/lib/exporter";
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter";
const WALLPAPER_COUNT = 18;
const WALLPAPER_RELATIVE = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `wallpapers/wallpaper${i + 1}.jpg`);
@@ -71,6 +71,16 @@ interface SettingsPanelProps {
videoElement?: HTMLVideoElement | null;
exportQuality?: ExportQuality;
onExportQualityChange?: (quality: ExportQuality) => void;
// Export format settings
exportFormat?: ExportFormat;
onExportFormatChange?: (format: ExportFormat) => void;
gifFrameRate?: GifFrameRate;
onGifFrameRateChange?: (rate: GifFrameRate) => void;
gifLoop?: boolean;
onGifLoopChange?: (loop: boolean) => void;
gifSizePreset?: GifSizePreset;
onGifSizePresetChange?: (preset: GifSizePreset) => void;
gifOutputDimensions?: { width: number; height: number };
onExport?: () => void;
selectedAnnotationId?: string | null;
annotationRegions?: AnnotationRegion[];
@@ -117,6 +127,15 @@ export function SettingsPanel({
videoElement,
exportQuality = 'good',
onExportQualityChange,
exportFormat = 'mp4',
onExportFormatChange,
gifFrameRate = 15,
onGifFrameRateChange,
gifLoop = true,
onGifLoopChange,
gifSizePreset = 'medium',
onGifSizePresetChange,
gifOutputDimensions = { width: 1280, height: 720 },
onExport,
selectedAnnotationId,
annotationRegions = [],
@@ -425,14 +444,14 @@ export function SettingsPanel({
</>
)}
<Tabs defaultValue="image" className="flex-1 flex flex-col min-h-0">
<Tabs defaultValue="image" className="flex-1 flex flex-col min-h-[200px]">
<TabsList className="mb-4 bg-white/5 border border-white/5 p-1 w-full grid grid-cols-3 h-auto rounded-xl">
<TabsTrigger value="image" className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all">Image</TabsTrigger>
<TabsTrigger value="color" className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all">Color</TabsTrigger>
<TabsTrigger value="gradient" className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all">Gradient</TabsTrigger>
</TabsList>
<div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar pr-2">
<div className="min-h-[220px] max-h-[300px] overflow-y-auto custom-scrollbar pr-2">
<TabsContent value="image" className="mt-0 space-y-3 px-2">
{/* Upload Button */}
<input
@@ -551,43 +570,138 @@ export function SettingsPanel({
</Tabs>
<div className="mt-4 pt-4 border-t border-white/5">
<div className="mb-2 text-xs font-medium text-slate-400">Export Quality</div>
{/* Export Quality Button Group */}
<div className="mb-2.5 bg-white/5 border border-white/5 p-1 w-full grid grid-cols-3 h-auto rounded-xl">
<button
onClick={() => onExportQualityChange?.('medium')}
className={cn(
"py-2 rounded-lg transition-all text-xs font-medium",
exportQuality === 'medium'
? "bg-white text-black"
: "text-slate-400 hover:text-slate-200"
)}
>
Low
</button>
<button
onClick={() => onExportQualityChange?.('good')}
className={cn(
"py-2 rounded-lg transition-all text-xs font-medium",
exportQuality === 'good'
? "bg-white text-black"
: "text-slate-400 hover:text-slate-200"
)}
>
Medium
</button>
<button
onClick={() => onExportQualityChange?.('source')}
className={cn(
"py-2 rounded-lg transition-all text-xs font-medium",
exportQuality === 'source'
? "bg-white text-black"
: "text-slate-400 hover:text-slate-200"
)}
>
High
</button>
{/* Format Selection */}
<div className="mb-4">
<div className="mb-2 text-xs font-medium text-slate-400 uppercase tracking-wider">Export Format</div>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => onExportFormatChange?.('mp4')}
className={cn(
"flex flex-col items-center gap-1.5 p-3 rounded-xl border transition-all",
exportFormat === 'mp4'
? "bg-[#34B27B]/10 border-[#34B27B]/50 text-white"
: "bg-white/5 border-white/10 text-slate-400 hover:bg-white/10 hover:text-slate-200"
)}
>
<Film className="w-5 h-5" />
<span className="text-xs font-medium">MP4</span>
</button>
<button
onClick={() => onExportFormatChange?.('gif')}
className={cn(
"flex flex-col items-center gap-1.5 p-3 rounded-xl border transition-all",
exportFormat === 'gif'
? "bg-[#34B27B]/10 border-[#34B27B]/50 text-white"
: "bg-white/5 border-white/10 text-slate-400 hover:bg-white/10 hover:text-slate-200"
)}
>
<Image className="w-5 h-5" />
<span className="text-xs font-medium">GIF</span>
</button>
</div>
</div>
{/* MP4 Quality Options */}
{exportFormat === 'mp4' && (
<>
<div className="mb-2 text-xs font-medium text-slate-400">Export Quality</div>
<div className="mb-4 bg-white/5 border border-white/5 p-1 w-full grid grid-cols-3 h-auto rounded-xl">
<button
onClick={() => onExportQualityChange?.('medium')}
className={cn(
"py-2 rounded-lg transition-all text-xs font-medium",
exportQuality === 'medium'
? "bg-white text-black"
: "text-slate-400 hover:text-slate-200"
)}
>
Low
</button>
<button
onClick={() => onExportQualityChange?.('good')}
className={cn(
"py-2 rounded-lg transition-all text-xs font-medium",
exportQuality === 'good'
? "bg-white text-black"
: "text-slate-400 hover:text-slate-200"
)}
>
Medium
</button>
<button
onClick={() => onExportQualityChange?.('source')}
className={cn(
"py-2 rounded-lg transition-all text-xs font-medium",
exportQuality === 'source'
? "bg-white text-black"
: "text-slate-400 hover:text-slate-200"
)}
>
High
</button>
</div>
</>
)}
{/* GIF Options */}
{exportFormat === 'gif' && (
<div className="mb-4 space-y-3">
{/* Frame Rate */}
<div>
<div className="mb-1.5 text-xs font-medium text-slate-400">Frame Rate</div>
<div className="bg-white/5 border border-white/5 p-1 w-full grid grid-cols-4 h-auto rounded-xl">
{GIF_FRAME_RATES.map((rate) => (
<button
key={rate.value}
onClick={() => onGifFrameRateChange?.(rate.value)}
className={cn(
"py-1.5 rounded-lg transition-all text-xs font-medium",
gifFrameRate === rate.value
? "bg-white text-black"
: "text-slate-400 hover:text-slate-200"
)}
>
{rate.value}
</button>
))}
</div>
</div>
{/* Size Preset */}
<div>
<div className="mb-1.5 text-xs font-medium text-slate-400">Output Size</div>
<div className="bg-white/5 border border-white/5 p-1 w-full grid grid-cols-3 h-auto rounded-xl">
{Object.entries(GIF_SIZE_PRESETS).map(([key, _preset]) => (
<button
key={key}
onClick={() => onGifSizePresetChange?.(key as GifSizePreset)}
className={cn(
"py-1.5 rounded-lg transition-all text-xs font-medium",
gifSizePreset === key
? "bg-white text-black"
: "text-slate-400 hover:text-slate-200"
)}
>
{key === 'original' ? 'Orig' : key.charAt(0).toUpperCase() + key.slice(1, 3)}
</button>
))}
</div>
<div className="mt-1 text-[10px] text-slate-500">
{gifOutputDimensions.width} × {gifOutputDimensions.height}px
</div>
</div>
{/* Loop Toggle */}
<div className="flex items-center justify-between py-1">
<span className="text-xs font-medium text-slate-200">Loop Animation</span>
<Switch
checked={gifLoop}
onCheckedChange={onGifLoopChange}
className="data-[state=checked]:bg-[#34B27B]"
/>
</div>
</div>
)}
<Button
type="button"
@@ -596,13 +710,13 @@ export function SettingsPanel({
className="w-full py-6 text-lg font-semibold flex items-center justify-center gap-3 bg-[#34B27B] text-white rounded-xl shadow-lg shadow-[#34B27B]/20 hover:bg-[#34B27B]/90 hover:scale-[1.02] active:scale-[0.98] transition-all duration-200"
>
<Download className="w-5 h-5" />
<span>Export Video</span>
<span>Export {exportFormat === 'gif' ? 'GIF' : 'Video'}</span>
</Button>
<div className="flex gap-2 mt-4">
<button
type="button"
onClick={() => {
window.electronAPI?.openExternalUrl('https://github.com/siddharthvaddem/openscreen/issues/new');
window.electronAPI?.openExternalUrl('https://github.com/siddharthvaddem/openscreen/issues/new/choose');
}}
className="flex-1 flex items-center justify-center gap-2 text-xs py-2"
>
@@ -612,12 +726,12 @@ export function SettingsPanel({
<button
type="button"
onClick={() => {
window.electronAPI?.openExternalUrl('https://buymeacoffee.com/siddharthvaddem');
window.electronAPI?.openExternalUrl('https://github.com/siddharthvaddem/openscreen');
}}
className="flex-1 flex items-center justify-center gap-2 text-xs"
>
<GiHearts className="w-3 h-3 text-red-500" />
<span>Support my work</span>
<Star className="w-3 h-3 text-yellow-400" />
<span>Star on GitHub</span>
</button>
</div>
</div>
@@ -0,0 +1,145 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { HelpCircle, Scissors, ArrowRight } from "lucide-react";
export function TutorialHelp() {
return (
<Dialog>
<DialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-slate-400 hover:text-slate-200 hover:bg-white/10 transition-all gap-1.5"
>
<HelpCircle className="w-3.5 h-3.5" />
<span className="font-medium">How trimming works</span>
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl bg-[#09090b] border-white/10 [&>button]:text-slate-400 [&>button:hover]:text-white">
<DialogHeader>
<DialogTitle className="text-xl font-semibold text-slate-200 flex items-center gap-2">
<Scissors className="w-5 h-5 text-[#ef4444]" /> How Trimming Works
</DialogTitle>
<DialogDescription className="text-slate-400">
Understanding how to cut out unwanted parts of your video.
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-8">
{/* Explanation */}
<div className="bg-white/5 rounded-lg p-4 border border-white/5">
<p className="text-slate-300 leading-relaxed">
The Trim tool works by defining the segments you want to
<span className="text-[#ef4444] font-bold"> remove</span>. Any part
of the timeline that is
<span className="text-[#ef4444] font-bold"> covered</span> by a red
trim segment will be cut out when you export.
</p>
</div>
{/* Visual Illustration */}
<div className="space-y-2">
<h3 className="text-sm font-medium text-slate-400 uppercase tracking-wider">
Visual Example
</h3>
<div className="relative h-24 bg-[#000] rounded-lg border border-white/10 flex items-center px-4 overflow-hidden select-none">
{/* Background track (Kept parts) */}
<div className="absolute inset-x-4 h-2 bg-slate-600 rounded-full overflow-hidden">
{/* Solid line representing video */}
</div>
{/* Removed Segment 1 */}
<div
className="absolute left-[20%] h-8 bg-[#ef4444]/20 border border-[#ef4444] rounded flex flex-col items-center justify-center z-10"
style={{ width: "20%" }}
>
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
REMOVED
</span>
</div>
{/* Removed Segment 2 */}
<div
className="absolute left-[65%] h-8 bg-[#ef4444]/20 border border-[#ef4444] rounded flex flex-col items-center justify-center z-10"
style={{ width: "15%" }}
>
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
REMOVED
</span>
</div>
{/* Labels for kept parts */}
<div className="absolute left-[5%] text-[10px] text-slate-400 font-medium">
Kept
</div>
<div className="absolute left-[50%] text-[10px] text-slate-400 font-medium">
Kept
</div>
<div className="absolute left-[90%] text-[10px] text-slate-400 font-medium">
Kept
</div>
</div>
<div className="flex justify-center mt-2">
<ArrowRight className="w-4 h-4 text-slate-600 rotate-90" />
</div>
{/* Result */}
<div className="relative h-12 bg-[#000] rounded-lg border border-white/10 flex items-center justify-center gap-1 px-4 select-none">
<div
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
style={{ width: "30%" }}
>
<span className="text-[10px] text-white font-medium">
Part 1
</span>
</div>
<div
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
style={{ width: "30%" }}
>
<span className="text-[10px] text-white font-medium">
Part 2
</span>
</div>
<div
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
style={{ width: "30%" }}
>
<span className="text-[10px] text-white font-medium">
Part 3
</span>
</div>
<span className="absolute right-4 text-xs text-slate-400">
Final Video
</span>
</div>
</div>
{/* Steps */}
<div className="grid grid-cols-2 gap-4">
<div className="p-3 rounded bg-white/5 border border-white/5">
<div className="text-[#ef4444] font-bold mb-1">
1. Add Trim
</div>
<p className="text-xs text-slate-400">
Press
<kbd className="bg-white/10 px-1 rounded text-slate-300">T</kbd>
or click the scissors icon to mark a section for removal.
</p>
</div>
<div className="p-3 rounded bg-white/5 border border-white/5">
<div className="text-[#ef4444] font-bold mb-1">
2. Adjust
</div>
<p className="text-xs text-slate-400">
Drag the edges of the red region to cover exactly what you want
to cut out.
</p>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+231 -124
View File
@@ -28,7 +28,7 @@ import {
type CropRegion,
type FigureData,
} from "./types";
import { VideoExporter, type ExportProgress, type ExportQuality } from "@/lib/exporter";
import { VideoExporter, GifExporter, type ExportProgress, type ExportQuality, type ExportSettings, type ExportFormat, type GifFrameRate, type GifSizePreset, GIF_SIZE_PRESETS, calculateOutputDimensions } from "@/lib/exporter";
import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils";
import { getAssetPath } from "@/lib/assetPath";
@@ -61,6 +61,10 @@ export default function VideoEditor() {
const [showExportDialog, setShowExportDialog] = useState(false);
const [aspectRatio, setAspectRatio] = useState<AspectRatio>('16:9');
const [exportQuality, setExportQuality] = useState<ExportQuality>('good');
const [exportFormat, setExportFormat] = useState<ExportFormat>('mp4');
const [gifFrameRate, setGifFrameRate] = useState<GifFrameRate>(15);
const [gifLoop, setGifLoop] = useState(true);
const [gifSizePreset, setGifSizePreset] = useState<GifSizePreset>('medium');
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
const nextZoomIdRef = useRef(1);
@@ -434,7 +438,7 @@ export default function VideoEditor() {
}
}, [selectedAnnotationId, annotationRegions]);
const handleExport = useCallback(async () => {
const handleOpenExportDialog = useCallback(() => {
if (!videoPath) {
toast.error('No video loaded');
return;
@@ -446,7 +450,42 @@ export default function VideoEditor() {
return;
}
// Build export settings from current state
const sourceWidth = video.videoWidth || 1920;
const sourceHeight = video.videoHeight || 1080;
const gifDimensions = calculateOutputDimensions(sourceWidth, sourceHeight, gifSizePreset, GIF_SIZE_PRESETS);
const settings: ExportSettings = {
format: exportFormat,
quality: exportFormat === 'mp4' ? exportQuality : undefined,
gifConfig: exportFormat === 'gif' ? {
frameRate: gifFrameRate,
loop: gifLoop,
sizePreset: gifSizePreset,
width: gifDimensions.width,
height: gifDimensions.height,
} : undefined,
};
setShowExportDialog(true);
setExportError(null);
// Start export immediately
handleExport(settings);
}, [videoPath, exportFormat, exportQuality, gifFrameRate, gifLoop, gifSizePreset]);
const handleExport = useCallback(async (settings: ExportSettings) => {
if (!videoPath) {
toast.error('No video loaded');
return;
}
const video = videoPlaybackRef.current?.video;
if (!video) {
toast.error('Video not ready');
return;
}
setIsExporting(true);
setExportProgress(null);
setExportError(null);
@@ -468,138 +507,187 @@ export default function VideoEditor() {
const sourceWidth = video.videoWidth || 1920;
const sourceHeight = video.videoHeight || 1080;
let exportWidth: number;
let exportHeight: number;
let bitrate: number;
if (exportQuality === 'source') {
// Use source resolution
exportWidth = sourceWidth;
exportHeight = sourceHeight;
if (aspectRatioValue === 1) {
// Square (1:1): use smaller dimension to avoid codec limits
const baseDimension = Math.floor(Math.min(sourceWidth, sourceHeight) / 2) * 2;
exportWidth = baseDimension;
exportHeight = baseDimension;
} else if (aspectRatioValue > 1) {
// Landscape: find largest even dimensions that exactly match aspect ratio
const baseWidth = Math.floor(sourceWidth / 2) * 2;
// Iterate down from baseWidth to find exact match
let found = false;
for (let w = baseWidth; w >= 100 && !found; w -= 2) {
const h = Math.round(w / aspectRatioValue);
if (h % 2 === 0 && Math.abs((w / h) - aspectRatioValue) < 0.0001) {
exportWidth = w;
exportHeight = h;
found = true;
}
}
if (!found) {
exportWidth = baseWidth;
exportHeight = Math.floor((baseWidth / aspectRatioValue) / 2) * 2;
}
} else {
// Portrait: find largest even dimensions that exactly match aspect ratio
const baseHeight = Math.floor(sourceHeight / 2) * 2;
// Iterate down from baseHeight to find exact match
let found = false;
for (let h = baseHeight; h >= 100 && !found; h -= 2) {
const w = Math.round(h * aspectRatioValue);
if (w % 2 === 0 && Math.abs((w / h) - aspectRatioValue) < 0.0001) {
exportWidth = w;
exportHeight = h;
found = true;
}
}
if (!found) {
exportHeight = baseHeight;
exportWidth = Math.floor((baseHeight * aspectRatioValue) / 2) * 2;
}
}
// Calculate visually lossless bitrate matching screen recording optimization
const totalPixels = exportWidth * exportHeight;
bitrate = 30_000_000;
if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) {
bitrate = 50_000_000;
} else if (totalPixels > 2560 * 1440) {
bitrate = 80_000_000;
}
} else {
// Use quality-based target resolution
const targetHeight = exportQuality === 'medium' ? 720 : 1080;
// Calculate dimensions maintaining aspect ratio
exportHeight = Math.floor(targetHeight / 2) * 2; // Ensure even
exportWidth = Math.floor((exportHeight * aspectRatioValue) / 2) * 2; // Ensure even
// Adjust bitrate for lower resolutions
const totalPixels = exportWidth * exportHeight;
if (totalPixels <= 1280 * 720) {
bitrate = 10_000_000; // 10 Mbps for 720p
} else if (totalPixels <= 1920 * 1080) {
bitrate = 20_000_000; // 20 Mbps for 1080p
} else {
bitrate = 30_000_000;
}
}
// Get preview CONTAINER dimensions for scaling
// Annotations render in HTML overlay matching container, not PixiJS canvas
const playbackRef = videoPlaybackRef.current;
const containerElement = playbackRef?.containerRef?.current;
const previewWidth = containerElement?.clientWidth || 1920;
const previewHeight = containerElement?.clientHeight || 1080;
if (settings.format === 'gif' && settings.gifConfig) {
// GIF Export
const gifExporter = new GifExporter({
videoUrl: videoPath,
width: settings.gifConfig.width,
height: settings.gifConfig.height,
frameRate: settings.gifConfig.frameRate,
loop: settings.gifConfig.loop,
sizePreset: settings.gifConfig.sizePreset,
wallpaper,
zoomRegions,
trimRegions,
showShadow: shadowIntensity > 0,
shadowIntensity,
showBlur,
motionBlurEnabled,
borderRadius,
padding,
videoPadding: padding,
cropRegion,
annotationRegions,
previewWidth,
previewHeight,
onProgress: (progress: ExportProgress) => {
setExportProgress(progress);
},
});
exporterRef.current = gifExporter as unknown as VideoExporter;
const result = await gifExporter.export();
const exporter = new VideoExporter({
videoUrl: videoPath,
width: exportWidth,
height: exportHeight,
frameRate: 60,
bitrate,
codec: 'avc1.640033',
wallpaper,
zoomRegions,
trimRegions,
showShadow: shadowIntensity > 0,
shadowIntensity,
showBlur,
motionBlurEnabled,
borderRadius,
padding,
cropRegion,
annotationRegions,
previewWidth,
previewHeight,
onProgress: (progress: ExportProgress) => {
setExportProgress(progress);
},
});
exporterRef.current = exporter;
const result = await exporter.export();
if (result.success && result.blob) {
const arrayBuffer = await result.blob.arrayBuffer();
const timestamp = Date.now();
const fileName = `export-${timestamp}.mp4`;
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
if (saveResult.cancelled) {
toast.info('Export cancelled');
} else if (saveResult.success) {
toast.success(`Video exported successfully to ${saveResult.path}`);
if (result.success && result.blob) {
const arrayBuffer = await result.blob.arrayBuffer();
const timestamp = Date.now();
const fileName = `export-${timestamp}.gif`;
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
if (saveResult.cancelled) {
toast.info('Export cancelled');
} else if (saveResult.success) {
toast.success(`GIF exported successfully to ${saveResult.path}`);
} else {
setExportError(saveResult.message || 'Failed to save GIF');
toast.error(saveResult.message || 'Failed to save GIF');
}
} else {
setExportError(saveResult.message || 'Failed to save video');
toast.error(saveResult.message || 'Failed to save video');
setExportError(result.error || 'GIF export failed');
toast.error(result.error || 'GIF export failed');
}
} else {
setExportError(result.error || 'Export failed');
toast.error(result.error || 'Export failed');
// MP4 Export
const quality = settings.quality || exportQuality;
let exportWidth: number;
let exportHeight: number;
let bitrate: number;
if (quality === 'source') {
// Use source resolution
exportWidth = sourceWidth;
exportHeight = sourceHeight;
if (aspectRatioValue === 1) {
// Square (1:1): use smaller dimension to avoid codec limits
const baseDimension = Math.floor(Math.min(sourceWidth, sourceHeight) / 2) * 2;
exportWidth = baseDimension;
exportHeight = baseDimension;
} else if (aspectRatioValue > 1) {
// Landscape: find largest even dimensions that exactly match aspect ratio
const baseWidth = Math.floor(sourceWidth / 2) * 2;
let found = false;
for (let w = baseWidth; w >= 100 && !found; w -= 2) {
const h = Math.round(w / aspectRatioValue);
if (h % 2 === 0 && Math.abs((w / h) - aspectRatioValue) < 0.0001) {
exportWidth = w;
exportHeight = h;
found = true;
}
}
if (!found) {
exportWidth = baseWidth;
exportHeight = Math.floor((baseWidth / aspectRatioValue) / 2) * 2;
}
} else {
// Portrait: find largest even dimensions that exactly match aspect ratio
const baseHeight = Math.floor(sourceHeight / 2) * 2;
let found = false;
for (let h = baseHeight; h >= 100 && !found; h -= 2) {
const w = Math.round(h * aspectRatioValue);
if (w % 2 === 0 && Math.abs((w / h) - aspectRatioValue) < 0.0001) {
exportWidth = w;
exportHeight = h;
found = true;
}
}
if (!found) {
exportHeight = baseHeight;
exportWidth = Math.floor((baseHeight * aspectRatioValue) / 2) * 2;
}
}
// Calculate visually lossless bitrate matching screen recording optimization
const totalPixels = exportWidth * exportHeight;
bitrate = 30_000_000;
if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) {
bitrate = 50_000_000;
} else if (totalPixels > 2560 * 1440) {
bitrate = 80_000_000;
}
} else {
// Use quality-based target resolution
const targetHeight = quality === 'medium' ? 720 : 1080;
// Calculate dimensions maintaining aspect ratio
exportHeight = Math.floor(targetHeight / 2) * 2;
exportWidth = Math.floor((exportHeight * aspectRatioValue) / 2) * 2;
// Adjust bitrate for lower resolutions
const totalPixels = exportWidth * exportHeight;
if (totalPixels <= 1280 * 720) {
bitrate = 10_000_000;
} else if (totalPixels <= 1920 * 1080) {
bitrate = 20_000_000;
} else {
bitrate = 30_000_000;
}
}
const exporter = new VideoExporter({
videoUrl: videoPath,
width: exportWidth,
height: exportHeight,
frameRate: 60,
bitrate,
codec: 'avc1.640033',
wallpaper,
zoomRegions,
trimRegions,
showShadow: shadowIntensity > 0,
shadowIntensity,
showBlur,
motionBlurEnabled,
borderRadius,
padding,
cropRegion,
annotationRegions,
previewWidth,
previewHeight,
onProgress: (progress: ExportProgress) => {
setExportProgress(progress);
},
});
exporterRef.current = exporter;
const result = await exporter.export();
if (result.success && result.blob) {
const arrayBuffer = await result.blob.arrayBuffer();
const timestamp = Date.now();
const fileName = `export-${timestamp}.mp4`;
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
if (saveResult.cancelled) {
toast.info('Export cancelled');
} else if (saveResult.success) {
toast.success(`Video exported successfully to ${saveResult.path}`);
} else {
setExportError(saveResult.message || 'Failed to save video');
toast.error(saveResult.message || 'Failed to save video');
}
} else {
setExportError(result.error || 'Export failed');
toast.error(result.error || 'Export failed');
}
}
if (wasPlaying) {
@@ -613,6 +701,10 @@ 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
setShowExportDialog(false);
setExportProgress(null);
}
}, [videoPath, wallpaper, zoomRegions, trimRegions, shadowIntensity, showBlur, motionBlurEnabled, borderRadius, padding, cropRegion, annotationRegions, isPlaying, aspectRatio, exportQuality]);
@@ -771,7 +863,21 @@ export default function VideoEditor() {
videoElement={videoPlaybackRef.current?.video || null}
exportQuality={exportQuality}
onExportQualityChange={setExportQuality}
onExport={handleExport}
exportFormat={exportFormat}
onExportFormatChange={setExportFormat}
gifFrameRate={gifFrameRate}
onGifFrameRateChange={setGifFrameRate}
gifLoop={gifLoop}
onGifLoopChange={setGifLoop}
gifSizePreset={gifSizePreset}
onGifSizePresetChange={setGifSizePreset}
gifOutputDimensions={calculateOutputDimensions(
videoPlaybackRef.current?.video?.videoWidth || 1920,
videoPlaybackRef.current?.video?.videoHeight || 1080,
gifSizePreset,
GIF_SIZE_PRESETS
)}
onExport={handleOpenExportDialog}
selectedAnnotationId={selectedAnnotationId}
annotationRegions={annotationRegions}
onAnnotationContentChange={handleAnnotationContentChange}
@@ -791,6 +897,7 @@ export default function VideoEditor() {
isExporting={isExporting}
error={exportError}
onCancel={handleCancelExport}
exportFormat={exportFormat}
/>
</div>
);
@@ -19,6 +19,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { type AspectRatio, getAspectRatioLabel } from "@/utils/aspectRatioUtils";
import { formatShortcut } from "@/utils/platformUtils";
import { TutorialHelp } from "../TutorialHelp";
const ZOOM_ROW_ID = "row-zoom";
const TRIM_ROW_ID = "row-trim";
@@ -907,6 +908,8 @@ export default function TimelineEditor({
))}
</DropdownMenuContent>
</DropdownMenu>
<div className="w-[1px] h-4 bg-white/10" />
<TutorialHelp />
</div>
<div className="flex-1" />
<div className="flex items-center gap-4 text-[10px] text-slate-500 font-medium">
+12
View File
@@ -65,6 +65,8 @@
}
body {
@apply bg-background text-foreground;
-webkit-user-select: none;
user-select: none;
}
}
@@ -75,6 +77,16 @@
transition: left 33ms linear, right 33ms linear;
}
/* Hidden scrollbar - still scrollable but invisible */
.custom-scrollbar {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.custom-scrollbar::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
/* Smooth playback scrubber */
input[type="range"] {
-webkit-appearance: none;
+7 -4
View File
@@ -311,8 +311,6 @@ export class FrameRenderer {
const scaleY = this.config.height / previewHeight;
const scaleFactor = (scaleX + scaleY) / 2;
await renderAnnotations(
this.compositeCtx,
this.config.annotationRegions,
@@ -365,9 +363,14 @@ export class FrameRenderer {
this.videoContainer.x = centerOffsetX;
this.videoContainer.y = centerOffsetY;
// Update mask
// scale border radius by export/preview canvas ratio
const previewWidth = this.config.previewWidth || 1920;
const previewHeight = this.config.previewHeight || 1080;
const canvasScaleFactor = Math.min(width / previewWidth, height / previewHeight);
const scaledBorderRadius = borderRadius * canvasScaleFactor;
this.maskGraphics.clear();
this.maskGraphics.roundRect(0, 0, croppedDisplayWidth, croppedDisplayHeight, borderRadius);
this.maskGraphics.roundRect(0, 0, croppedDisplayWidth, croppedDisplayHeight, scaledBorderRadius);
this.maskGraphics.fill({ color: 0xffffff });
// Cache layout info
+474
View File
@@ -0,0 +1,474 @@
import { describe, it, expect } from 'vitest';
import * as fc from 'fast-check';
import { calculateOutputDimensions } from './gifExporter';
import { GIF_SIZE_PRESETS, GifSizePreset } from './types';
/**
* Property 2: Loop Encoding Correctness
*
* *For any* GIF export configuration, when loop is enabled the output GIF SHALL
* have a loop count of 0 (infinite), and when loop is disabled the output GIF
* SHALL have a loop count of 1 (play once).
*
* **Validates: Requirements 3.2, 3.3**
*
* Feature: gif-export, Property 2: Loop Encoding Correctness
*/
describe('GIF Exporter', () => {
describe('Property 2: Loop Encoding Correctness', () => {
/**
* Test the loop configuration mapping logic.
* In gif.js: repeat=0 means infinite loop, repeat=1 means play once (no loop)
*/
it('should map loop=true to repeat=0 (infinite) and loop=false to repeat=1 (once)', () => {
fc.assert(
fc.property(
fc.boolean(),
(loopEnabled: boolean) => {
// This is the logic used in GifExporter constructor
const repeat = loopEnabled ? 0 : 1;
if (loopEnabled) {
// When loop is enabled, repeat should be 0 (infinite loop)
expect(repeat).toBe(0);
} else {
// When loop is disabled, repeat should be 1 (play once)
expect(repeat).toBe(1);
}
}
),
{ numRuns: 100 }
);
});
it('should always produce valid repeat values (0 or 1)', () => {
fc.assert(
fc.property(
fc.boolean(),
(loopEnabled: boolean) => {
const repeat = loopEnabled ? 0 : 1;
expect([0, 1]).toContain(repeat);
}
),
{ numRuns: 100 }
);
});
});
/**
* Property 4: Aspect Ratio Preservation
*
* *For any* source video with aspect ratio R and any size preset, the exported
* GIF SHALL have an aspect ratio within 0.01 of R.
*
* **Validates: Requirements 4.4**
*
* Feature: gif-export, Property 4: Aspect Ratio Preservation
*/
describe('Property 4: Aspect Ratio Preservation', () => {
const sizePresets: GifSizePreset[] = ['medium', 'large', 'original'];
it('should preserve aspect ratio within 0.01 tolerance for all size presets', () => {
fc.assert(
fc.property(
fc.integer({ min: 100, max: 4000 }), // sourceWidth
fc.integer({ min: 100, max: 4000 }), // sourceHeight
fc.constantFrom(...sizePresets),
(sourceWidth: number, sourceHeight: number, sizePreset: GifSizePreset) => {
const originalAspectRatio = sourceWidth / sourceHeight;
const { width, height } = calculateOutputDimensions(
sourceWidth,
sourceHeight,
sizePreset,
GIF_SIZE_PRESETS
);
const outputAspectRatio = width / height;
// Aspect ratio should be preserved within 0.01 tolerance
// (small deviation allowed due to rounding to even numbers)
expect(Math.abs(originalAspectRatio - outputAspectRatio)).toBeLessThan(0.02);
}
),
{ numRuns: 100 }
);
});
it('should return original dimensions when source is smaller than preset max height', () => {
fc.assert(
fc.property(
fc.integer({ min: 100, max: 400 }), // sourceWidth (small)
fc.integer({ min: 100, max: 400 }), // sourceHeight (small, less than 720p)
(sourceWidth: number, sourceHeight: number) => {
// For 'medium' preset with maxHeight 720, if source is smaller, use original
const { width, height } = calculateOutputDimensions(
sourceWidth,
sourceHeight,
'medium',
GIF_SIZE_PRESETS
);
expect(width).toBe(sourceWidth);
expect(height).toBe(sourceHeight);
}
),
{ numRuns: 100 }
);
});
it('should return original dimensions for "original" preset regardless of size', () => {
fc.assert(
fc.property(
fc.integer({ min: 100, max: 4000 }),
fc.integer({ min: 100, max: 4000 }),
(sourceWidth: number, sourceHeight: number) => {
const { width, height } = calculateOutputDimensions(
sourceWidth,
sourceHeight,
'original',
GIF_SIZE_PRESETS
);
expect(width).toBe(sourceWidth);
expect(height).toBe(sourceHeight);
}
),
{ numRuns: 100 }
);
});
it('should scale down to preset max height when source is larger', () => {
fc.assert(
fc.property(
fc.integer({ min: 1000, max: 4000 }), // sourceWidth (large)
fc.integer({ min: 800, max: 2000 }), // sourceHeight (larger than 720p)
(sourceWidth: number, sourceHeight: number) => {
// For 'medium' preset with maxHeight 720
const { width, height } = calculateOutputDimensions(
sourceWidth,
sourceHeight,
'medium',
GIF_SIZE_PRESETS
);
// Height should be at most 720 (or 722 due to even rounding)
expect(height).toBeLessThanOrEqual(722);
// Width should be scaled proportionally
expect(width).toBeLessThan(sourceWidth);
}
),
{ numRuns: 100 }
);
});
});
});
/**
* Property 3: Size Preset Resolution Mapping
*
* *For any* valid size preset and source video dimensions, the GIF_Exporter SHALL
* produce output with height matching the preset's max height (or source height if smaller),
* with width calculated to maintain aspect ratio.
*
* **Validates: Requirements 4.2**
*
* Feature: gif-export, Property 3: Size Preset Resolution Mapping
*/
describe('Property 3: Size Preset Resolution Mapping', () => {
it('should map size presets to correct max heights', () => {
fc.assert(
fc.property(
fc.integer({ min: 800, max: 4000 }), // sourceWidth (large enough to trigger scaling)
fc.integer({ min: 800, max: 2000 }), // sourceHeight (larger than all presets except original)
fc.constantFrom('medium', 'large') as fc.Arbitrary<GifSizePreset>,
(sourceWidth: number, sourceHeight: number, sizePreset: GifSizePreset) => {
const { height } = calculateOutputDimensions(
sourceWidth,
sourceHeight,
sizePreset,
GIF_SIZE_PRESETS
);
const expectedMaxHeight = GIF_SIZE_PRESETS[sizePreset].maxHeight;
// Height should be at or below the preset's max height
// (allowing +2 for even number rounding)
expect(height).toBeLessThanOrEqual(expectedMaxHeight + 2);
}
),
{ numRuns: 100 }
);
});
it('should use source dimensions when smaller than preset', () => {
fc.assert(
fc.property(
fc.integer({ min: 100, max: 400 }), // sourceWidth
fc.integer({ min: 100, max: 400 }), // sourceHeight (smaller than 720p 'medium' preset)
fc.constantFrom('medium', 'large', 'original') as fc.Arbitrary<GifSizePreset>,
(sourceWidth: number, sourceHeight: number, sizePreset: GifSizePreset) => {
const { width, height } = calculateOutputDimensions(
sourceWidth,
sourceHeight,
sizePreset,
GIF_SIZE_PRESETS
);
// When source is smaller than preset, use original dimensions
expect(width).toBe(sourceWidth);
expect(height).toBe(sourceHeight);
}
),
{ numRuns: 100 }
);
});
it('should produce even dimensions for encoder compatibility', () => {
fc.assert(
fc.property(
fc.integer({ min: 100, max: 4000 }),
fc.integer({ min: 100, max: 4000 }),
fc.constantFrom('medium', 'large', 'original') as fc.Arbitrary<GifSizePreset>,
(sourceWidth: number, sourceHeight: number, sizePreset: GifSizePreset) => {
const { width, height } = calculateOutputDimensions(
sourceWidth,
sourceHeight,
sizePreset,
GIF_SIZE_PRESETS
);
// When scaling occurs, dimensions should be even
// (original dimensions are passed through as-is)
if (sourceHeight > GIF_SIZE_PRESETS[sizePreset].maxHeight && sizePreset !== 'original') {
expect(width % 2).toBe(0);
expect(height % 2).toBe(0);
}
}
),
{ numRuns: 100 }
);
});
});
/**
* Property 6: Frame Count Consistency
*
* *For any* video with effective duration D (excluding trim regions) and frame rate F,
* the exported GIF SHALL contain approximately D × F frames (within ±1 frame tolerance).
*
* **Validates: Requirements 5.1**
*
* Feature: gif-export, Property 6: Frame Count Consistency
*/
describe('Property 6: Frame Count Consistency', () => {
// Helper function to calculate expected frame count
const calculateExpectedFrameCount = (durationSeconds: number, frameRate: number): number => {
return Math.ceil(durationSeconds * frameRate);
};
it('should calculate correct frame count for duration and frame rate', () => {
fc.assert(
fc.property(
fc.float({ min: 0.5, max: 60, noNaN: true }), // duration in seconds
fc.constantFrom(10, 15, 20, 25, 30), // valid frame rates
(duration: number, frameRate: number) => {
const expectedFrames = calculateExpectedFrameCount(duration, frameRate);
// Frame count should be positive
expect(expectedFrames).toBeGreaterThan(0);
// Frame count should be approximately duration * frameRate
const approximateFrames = duration * frameRate;
expect(Math.abs(expectedFrames - approximateFrames)).toBeLessThanOrEqual(1);
}
),
{ numRuns: 100 }
);
});
it('should produce more frames with higher frame rates', () => {
fc.assert(
fc.property(
fc.float({ min: 1, max: 30, noNaN: true }), // duration in seconds
(duration: number) => {
const frames10fps = calculateExpectedFrameCount(duration, 10);
const frames30fps = calculateExpectedFrameCount(duration, 30);
// 30fps should produce approximately 3x more frames than 10fps
expect(frames30fps).toBeGreaterThan(frames10fps);
expect(frames30fps / frames10fps).toBeCloseTo(3, 0);
}
),
{ numRuns: 100 }
);
});
it('should handle trim regions by reducing effective duration', () => {
fc.assert(
fc.property(
fc.float({ min: 5, max: 60, noNaN: true }), // total duration
fc.float({ min: 0.5, max: 2, noNaN: true }), // trim duration (smaller than total)
fc.constantFrom(10, 15, 20, 25, 30),
(totalDuration: number, trimDuration: number, frameRate: number) => {
const effectiveDuration = totalDuration - trimDuration;
const framesWithTrim = calculateExpectedFrameCount(effectiveDuration, frameRate);
const framesWithoutTrim = calculateExpectedFrameCount(totalDuration, frameRate);
// Trimmed video should have fewer frames
expect(framesWithTrim).toBeLessThan(framesWithoutTrim);
}
),
{ numRuns: 100 }
);
});
});
/**
* Property 5: Valid GIF Output (Configuration Validation)
*
* *For any* successful GIF export, the output blob SHALL be a valid GIF file.
* This test validates the GIF configuration parameters are correctly set up.
*
* **Validates: Requirements 5.3**
*
* Feature: gif-export, Property 5: Valid GIF Output
*
* Note: Full GIF encoding validation requires browser environment with video.
* This test validates configuration correctness.
*/
describe('Property 5: Valid GIF Output (Configuration)', () => {
it('should generate valid GIF configuration for all frame rates', () => {
fc.assert(
fc.property(
fc.constantFrom(10, 15, 20, 25, 30),
fc.integer({ min: 100, max: 1920 }),
fc.integer({ min: 100, max: 1080 }),
fc.boolean(),
(frameRate: number, width: number, height: number, loop: boolean) => {
// Validate frame delay calculation (gif.js uses milliseconds)
const frameDelay = Math.round(1000 / frameRate);
// Frame delay should be positive and reasonable
expect(frameDelay).toBeGreaterThan(0);
expect(frameDelay).toBeLessThanOrEqual(100); // 10fps = 100ms delay
// Loop configuration
const repeat = loop ? 0 : 1;
expect([0, 1]).toContain(repeat);
// Dimensions should be positive
expect(width).toBeGreaterThan(0);
expect(height).toBeGreaterThan(0);
}
),
{ numRuns: 100 }
);
});
it('should calculate correct frame delays for each frame rate', () => {
const expectedDelays: Record<number, number> = {
10: 100, // 1000ms / 10fps = 100ms
15: 67, // 1000ms / 15fps ≈ 67ms
20: 50, // 1000ms / 20fps = 50ms
25: 40, // 1000ms / 25fps = 40ms
30: 33, // 1000ms / 30fps ≈ 33ms
};
for (const [fps, expectedDelay] of Object.entries(expectedDelays)) {
const frameRate = Number(fps);
const actualDelay = Math.round(1000 / frameRate);
expect(actualDelay).toBe(expectedDelay);
}
});
});
/**
* Property 7: MP4 Export Regression
*
* *For any* valid MP4 export configuration that worked before this feature,
* the Video_Exporter SHALL continue to produce valid MP4 output.
*
* **Validates: Requirements 7.2**
*
* Feature: gif-export, Property 7: MP4 Export Regression
*
* Note: This test validates that MP4 export configuration remains unchanged.
*/
describe('Property 7: MP4 Export Regression', () => {
it('should maintain valid MP4 quality presets', () => {
const qualityPresets = ['medium', 'good', 'source'];
fc.assert(
fc.property(
fc.constantFrom(...qualityPresets),
(quality: string) => {
// Quality presets should be valid
expect(['medium', 'good', 'source']).toContain(quality);
}
),
{ numRuns: 100 }
);
});
it('should calculate valid MP4 export dimensions', () => {
fc.assert(
fc.property(
fc.integer({ min: 640, max: 3840 }), // sourceWidth
fc.integer({ min: 480, max: 2160 }), // sourceHeight
fc.constantFrom('medium', 'good', 'source'),
(sourceWidth: number, sourceHeight: number, quality: string) => {
let exportWidth: number;
let exportHeight: number;
const aspectRatio = sourceWidth / sourceHeight;
if (quality === 'source') {
// Source quality uses original dimensions (may be odd)
exportWidth = sourceWidth;
exportHeight = sourceHeight;
// Dimensions should be positive
expect(exportWidth).toBeGreaterThan(0);
expect(exportHeight).toBeGreaterThan(0);
} else {
const targetHeight = quality === 'medium' ? 720 : 1080;
exportHeight = Math.floor(targetHeight / 2) * 2;
exportWidth = Math.floor((exportHeight * aspectRatio) / 2) * 2;
// Dimensions should be positive and even for non-source quality
expect(exportWidth).toBeGreaterThan(0);
expect(exportHeight).toBeGreaterThan(0);
expect(exportWidth % 2).toBe(0);
expect(exportHeight % 2).toBe(0);
}
}
),
{ numRuns: 100 }
);
});
it('should maintain aspect ratio in MP4 export', () => {
fc.assert(
fc.property(
fc.integer({ min: 640, max: 3840 }),
fc.integer({ min: 480, max: 2160 }),
fc.constantFrom('medium', 'good'),
(sourceWidth: number, sourceHeight: number, quality: string) => {
const originalAspectRatio = sourceWidth / sourceHeight;
const targetHeight = quality === 'medium' ? 720 : 1080;
const exportHeight = Math.floor(targetHeight / 2) * 2;
const exportWidth = Math.floor((exportHeight * originalAspectRatio) / 2) * 2;
const exportAspectRatio = exportWidth / exportHeight;
// Aspect ratio should be preserved within tolerance (due to even rounding)
expect(Math.abs(originalAspectRatio - exportAspectRatio)).toBeLessThan(0.05);
}
),
{ numRuns: 100 }
);
});
});
+319
View File
@@ -0,0 +1,319 @@
import GIF from 'gif.js';
import type { ExportProgress, ExportResult, GifFrameRate, GifSizePreset, GIF_SIZE_PRESETS } from './types';
import { VideoFileDecoder } from './videoDecoder';
import { FrameRenderer } from './frameRenderer';
import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion } from '@/components/video-editor/types';
const GIF_WORKER_URL = new URL('gif.js/dist/gif.worker.js', import.meta.url).toString();
interface GifExporterConfig {
videoUrl: string;
width: number;
height: number;
frameRate: GifFrameRate;
loop: boolean;
sizePreset: GifSizePreset;
wallpaper: string;
zoomRegions: ZoomRegion[];
trimRegions?: TrimRegion[];
showShadow: boolean;
shadowIntensity: number;
showBlur: boolean;
motionBlurEnabled?: boolean;
borderRadius?: number;
padding?: number;
videoPadding?: number;
cropRegion: CropRegion;
annotationRegions?: AnnotationRegion[];
previewWidth?: number;
previewHeight?: number;
onProgress?: (progress: ExportProgress) => void;
}
/**
* Calculate output dimensions based on size preset and source dimensions while preserving aspect ratio.
* @param sourceWidth - Original video width
* @param sourceHeight - Original video height
* @param sizePreset - The size preset to use
* @param sizePresets - The size presets configuration
* @returns The calculated output dimensions
*/
export function calculateOutputDimensions(
sourceWidth: number,
sourceHeight: number,
sizePreset: GifSizePreset,
sizePresets: typeof GIF_SIZE_PRESETS
): { width: number; height: number } {
const preset = sizePresets[sizePreset];
const maxHeight = preset.maxHeight;
// If original is smaller than max height or preset is 'original', use source dimensions
if (sourceHeight <= maxHeight || sizePreset === 'original') {
return { width: sourceWidth, height: sourceHeight };
}
// Calculate scaled dimensions preserving aspect ratio
const aspectRatio = sourceWidth / sourceHeight;
const newHeight = maxHeight;
const newWidth = Math.round(newHeight * aspectRatio);
// Ensure dimensions are even (required for some encoders)
return {
width: newWidth % 2 === 0 ? newWidth : newWidth + 1,
height: newHeight % 2 === 0 ? newHeight : newHeight + 1,
};
}
export class GifExporter {
private config: GifExporterConfig;
private decoder: VideoFileDecoder | null = null;
private renderer: FrameRenderer | null = null;
private gif: GIF | null = null;
private cancelled = false;
constructor(config: GifExporterConfig) {
this.config = config;
}
/**
* Calculate the total duration excluding trim regions (in seconds)
*/
private getEffectiveDuration(totalDuration: number): number {
const trimRegions = this.config.trimRegions || [];
const totalTrimDuration = trimRegions.reduce((sum, region) => {
return sum + (region.endMs - region.startMs) / 1000;
}, 0);
return totalDuration - totalTrimDuration;
}
/**
* Map effective time (excluding trims) to source time (including trims)
*/
private mapEffectiveToSourceTime(effectiveTimeMs: number): number {
const trimRegions = this.config.trimRegions || [];
// Sort trim regions by start time
const sortedTrims = [...trimRegions].sort((a, b) => a.startMs - b.startMs);
let sourceTimeMs = effectiveTimeMs;
for (const trim of sortedTrims) {
// If the source time hasn't reached this trim region yet, we're done
if (sourceTimeMs < trim.startMs) {
break;
}
// Add the duration of this trim region to the source time
const trimDuration = trim.endMs - trim.startMs;
sourceTimeMs += trimDuration;
}
return sourceTimeMs;
}
async export(): Promise<ExportResult> {
try {
this.cleanup();
this.cancelled = false;
// Initialize decoder and load video
this.decoder = new VideoFileDecoder();
const videoInfo = await this.decoder.loadVideo(this.config.videoUrl);
// Initialize frame renderer
this.renderer = new FrameRenderer({
width: this.config.width,
height: this.config.height,
wallpaper: this.config.wallpaper,
zoomRegions: this.config.zoomRegions,
showShadow: this.config.showShadow,
shadowIntensity: this.config.shadowIntensity,
showBlur: this.config.showBlur,
motionBlurEnabled: this.config.motionBlurEnabled,
borderRadius: this.config.borderRadius,
padding: this.config.padding,
cropRegion: this.config.cropRegion,
videoWidth: videoInfo.width,
videoHeight: videoInfo.height,
annotationRegions: this.config.annotationRegions,
previewWidth: this.config.previewWidth,
previewHeight: this.config.previewHeight,
});
await this.renderer.initialize();
// Initialize GIF encoder
// Loop: 0 = infinite loop, 1 = play once (no loop)
const repeat = this.config.loop ? 0 : 1;
this.gif = new GIF({
workers: 4,
quality: 10,
width: this.config.width,
height: this.config.height,
workerScript: GIF_WORKER_URL,
repeat,
background: '#000000',
transparent: null,
dither: 'FloydSteinberg',
});
// Get the video element for frame extraction
const videoElement = this.decoder.getVideoElement();
if (!videoElement) {
throw new Error('Video element not available');
}
// Calculate effective duration and frame count (excluding trim regions)
const effectiveDuration = this.getEffectiveDuration(videoInfo.duration);
const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate);
// Calculate frame delay in milliseconds (gif.js uses ms)
const frameDelay = Math.round(1000 / this.config.frameRate);
console.log('[GifExporter] Original duration:', videoInfo.duration, 's');
console.log('[GifExporter] Effective duration:', effectiveDuration, 's');
console.log('[GifExporter] Total frames to export:', totalFrames);
console.log('[GifExporter] Frame rate:', this.config.frameRate, 'FPS');
console.log('[GifExporter] Frame delay:', frameDelay, 'ms');
console.log('[GifExporter] Loop:', this.config.loop ? 'infinite' : 'once');
// Process frames
const timeStep = 1 / this.config.frameRate;
let frameIndex = 0;
while (frameIndex < totalFrames && !this.cancelled) {
const i = frameIndex;
const timestamp = i * (1_000_000 / this.config.frameRate); // in microseconds
// Map effective time to source time (accounting for trim regions)
const effectiveTimeMs = (i * timeStep) * 1000;
const sourceTimeMs = this.mapEffectiveToSourceTime(effectiveTimeMs);
const videoTime = sourceTimeMs / 1000;
// Seek if needed
const needsSeek = Math.abs(videoElement.currentTime - videoTime) > 0.001;
if (needsSeek) {
const seekedPromise = new Promise<void>(resolve => {
videoElement.addEventListener('seeked', () => resolve(), { once: true });
});
videoElement.currentTime = videoTime;
await seekedPromise;
} else if (i === 0) {
// Only for the very first frame, wait for it to be ready
await new Promise<void>(resolve => {
videoElement.requestVideoFrameCallback(() => resolve());
});
}
// Create a VideoFrame from the video element
const videoFrame = new VideoFrame(videoElement, {
timestamp,
});
// Render the frame with all effects using source timestamp
const sourceTimestamp = sourceTimeMs * 1000; // Convert to microseconds
await this.renderer!.renderFrame(videoFrame, sourceTimestamp);
videoFrame.close();
// Get the rendered canvas and add to GIF
const canvas = this.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,
totalFrames,
percentage: (frameIndex / totalFrames) * 100,
estimatedTimeRemaining: 0,
});
}
}
if (this.cancelled) {
return { success: false, error: 'Export cancelled' };
}
// Update progress to show we're now in the finalizing phase
if (this.config.onProgress) {
this.config.onProgress({
currentFrame: totalFrames,
totalFrames,
percentage: 100,
estimatedTimeRemaining: 0,
phase: 'finalizing',
});
}
// Render the GIF
const blob = await new Promise<Blob>((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({
currentFrame: totalFrames,
totalFrames,
percentage: 100,
estimatedTimeRemaining: 0,
phase: 'finalizing',
renderProgress: Math.round(progress * 100),
});
}
});
// gif.js doesn't have a typed 'error' event, but we can catch errors in the try/catch
this.gif!.render();
});
return { success: true, blob };
} catch (error) {
console.error('GIF Export error:', error);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
} finally {
this.cleanup();
}
}
cancel(): void {
this.cancelled = true;
if (this.gif) {
this.gif.abort();
}
this.cleanup();
}
private cleanup(): void {
if (this.decoder) {
try {
this.decoder.destroy();
} catch (e) {
console.warn('Error destroying decoder:', e);
}
this.decoder = null;
}
if (this.renderer) {
try {
this.renderer.destroy();
} catch (e) {
console.warn('Error destroying renderer:', e);
}
this.renderer = null;
}
this.gif = null;
}
}
+19 -1
View File
@@ -2,5 +2,23 @@ export { VideoExporter } from './videoExporter';
export { VideoFileDecoder } from './videoDecoder';
export { FrameRenderer } from './frameRenderer';
export { VideoMuxer } from './muxer';
export type { ExportConfig, ExportProgress, ExportResult, VideoFrameData, ExportQuality } from './types';
export { GifExporter, calculateOutputDimensions } from './gifExporter';
export type {
ExportConfig,
ExportProgress,
ExportResult,
VideoFrameData,
ExportQuality,
ExportFormat,
GifFrameRate,
GifSizePreset,
GifExportConfig,
ExportSettings,
} from './types';
export {
GIF_SIZE_PRESETS,
GIF_FRAME_RATES,
VALID_GIF_FRAME_RATES,
isValidGifFrameRate
} from './types';
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest';
import * as fc from 'fast-check';
import {
isValidGifFrameRate,
VALID_GIF_FRAME_RATES,
GifFrameRate
} from './types';
/**
* Property 1: Valid Frame Rate Acceptance
*
* *For any* frame rate value, the GIF_Exporter SHALL accept it if and only if
* it is one of the valid presets (15, 20, 25, 30 FPS). Invalid frame rates
* should be rejected with an error.
*
* **Validates: Requirements 2.2**
*
* Feature: gif-export, Property 1: Valid Frame Rate Acceptance
*/
describe('GIF Export Types', () => {
describe('Property 1: Valid Frame Rate Acceptance', () => {
// Property test: Valid frame rates should be accepted
it('should accept all valid frame rates (15, 20, 25, 30)', () => {
fc.assert(
fc.property(
fc.constantFrom(...VALID_GIF_FRAME_RATES),
(frameRate: GifFrameRate) => {
expect(isValidGifFrameRate(frameRate)).toBe(true);
}
),
{ numRuns: 100 }
);
});
// Property test: Invalid frame rates should be rejected
it('should reject any frame rate not in the valid set', () => {
fc.assert(
fc.property(
fc.integer().filter(n => !VALID_GIF_FRAME_RATES.includes(n as GifFrameRate)),
(invalidFrameRate: number) => {
expect(isValidGifFrameRate(invalidFrameRate)).toBe(false);
}
),
{ numRuns: 100 }
);
});
// Property test: Frame rate validation is deterministic
it('should return consistent results for the same input', () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 60 }),
(frameRate: number) => {
const result1 = isValidGifFrameRate(frameRate);
const result2 = isValidGifFrameRate(frameRate);
expect(result1).toBe(result2);
}
),
{ numRuns: 100 }
);
});
});
});
+45
View File
@@ -11,6 +11,8 @@ export interface ExportProgress {
totalFrames: number;
percentage: number;
estimatedTimeRemaining: number; // in seconds
phase?: 'extracting' | 'finalizing'; // Phase of export
renderProgress?: number; // 0-100, progress of GIF rendering phase
}
export interface ExportResult {
@@ -26,3 +28,46 @@ export interface VideoFrameData {
}
export type ExportQuality = 'medium' | 'good' | 'source';
// GIF Export Types
export type ExportFormat = 'mp4' | 'gif';
export type GifFrameRate = 15 | 20 | 25 | 30;
export type GifSizePreset = 'medium' | 'large' | 'original';
export interface GifExportConfig {
frameRate: GifFrameRate;
loop: boolean;
sizePreset: GifSizePreset;
width: number;
height: number;
}
export interface ExportSettings {
format: ExportFormat;
// MP4 settings
quality?: ExportQuality;
// GIF settings
gifConfig?: GifExportConfig;
}
export const GIF_SIZE_PRESETS: Record<GifSizePreset, { maxHeight: number; label: string }> = {
medium: { maxHeight: 720, label: 'Medium (720p)' },
large: { maxHeight: 1080, label: 'Large (1080p)' },
original: { maxHeight: Infinity, label: 'Original' },
};
export const GIF_FRAME_RATES: { value: GifFrameRate; label: string }[] = [
{ value: 15, label: '15 FPS - Balanced' },
{ value: 20, label: '20 FPS - Smooth' },
{ value: 25, label: '25 FPS - Very smooth' },
{ value: 30, label: '30 FPS - Maximum' },
];
// Valid frame rates for validation
export const VALID_GIF_FRAME_RATES: readonly GifFrameRate[] = [15, 20, 25, 30] as const;
export function isValidGifFrameRate(rate: number): rate is GifFrameRate {
return VALID_GIF_FRAME_RATES.includes(rate as GifFrameRate);
}
+1
View File
@@ -25,5 +25,6 @@
"noFallthroughCasesInSwitch": true
},
"include": ["src", "electron"],
"exclude": ["**/*.test.ts", "**/*.test.tsx"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vitest/config'
import path from 'node:path'
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
})