Compare commits

...
39 Commits
Author SHA1 Message Date
Siddharth d6d1a3eca6 build fix 2025-12-05 23:31:36 -07:00
Siddharth c5aa622898 hex based inputs for brand consitency 2025-12-05 23:22:30 -07:00
Siddharth 1345c8109c rename export res 2025-12-05 23:04:58 -07:00
Siddharth d91ed78fc2 delete trim ux improvement 2025-12-05 22:32:26 -07:00
Siddharth 5d7b817586 fix default wallpaper missing from export in build 2025-12-05 22:22:17 -07:00
Sid fecb9a9b22 Merge pull request #28 from ilGianfri/main
Add platform-aware keyboard shortcut formatting
2025-12-04 16:46:28 -08:00
Sid cbdef41667 Merge branch 'main' into main 2025-12-04 16:46:16 -08:00
Alessandro Spisso f34bd19183 feat: implement platform-aware keyboard shortcuts and add IPC handler for platform detection 2025-12-04 23:53:25 +01:00
Siddharth 7a7db0b277 revert exporter 2025-12-04 10:22:20 -07:00
Siddharth 3a4ec9c470 update assets 2025-12-02 23:25:40 -07:00
Siddharth d2a62b137d cleanup settings and readme 2025-12-02 21:49:38 -07:00
Siddharth c9e9d1d1bd missing shortcut 2025-12-02 18:50:53 -07:00
Siddharth cce88b3dab build errors and version update 2025-12-02 18:31:31 -07:00
Siddharth 4018741648 settings update 2025-12-02 18:11:00 -07:00
Siddharth ed3cdab64e export quality options 2025-12-02 17:41:30 -07:00
Siddharth 4ffa9c6ecb reduce seek bottleneck 2025-12-02 16:32:35 -07:00
Siddharth 899e55d257 update usescreenrecorder 2025-12-01 22:16:38 -07:00
Siddharth 977be1e3b1 draggable playhead and pause/play shortcut 2025-12-01 15:36:03 -07:00
Siddharth 262745a97f final annotation preview and export 2025-12-01 11:20:05 -07:00
Siddharth 6ac712eaac final annotation settings 2025-11-30 21:14:55 -07:00
Siddharth 79e40cef68 improved annotation experience 2025-11-30 19:19:08 -07:00
Siddharth c847953a52 allow multiple annotation conflicts, and cycle using Tab 2025-11-30 18:39:56 -07:00
Alessandro Spisso 391938049b Add platform-aware keyboard shortcut formatting
Introduces a new utility (platformUtils.ts) to format keyboard shortcuts based on the user's platform (macOS or others). Updates KeyboardShortcutsHelp and TimelineEditor to use the new formatShortcut function for displaying shortcuts, ensuring correct symbols are shown for modifier keys.
2025-12-01 00:19:34 +01:00
Siddharth 71ba4e4cea rm dead code 2025-11-30 15:35:03 -07:00
Siddharth ec3b9b46a1 annotations in preview 2025-11-30 14:47:22 -07:00
Sid 2ad5899417 Merge pull request #23 from suenyiyang/feat/disable-source-selection-when-recording
feat: disable source selection and project selection when recording
2025-11-29 19:34:41 -08:00
Yiyang Suen 1f08d3ca26 feat: disable source selection and project selection when recording 2025-11-30 10:19:12 +08:00
Sid bae17c0d1b Merge pull request #20 from siddharthvaddem/aspect-ratio
Aspect ratio
2025-11-29 10:42:48 -08:00
Siddharth 0c89e3e01a export aspect ratio 2025-11-29 11:38:09 -07:00
Siddharth d2ee511466 preview aspect ratio 2025-11-28 23:54:58 -07:00
Siddharth 4c725dfceb settings cleanup 2025-11-28 21:55:42 -07:00
Siddharth 71e2b51f5b padding video control 2025-11-28 21:46:05 -07:00
Siddharth c9321240d8 enable custom border radius 2025-11-28 19:15:56 -07:00
Siddharth 159f770da8 missing item depth level 2025-11-28 18:48:11 -07:00
Siddharth 443e4b0581 motion blur export stale closure 2025-11-28 18:04:38 -07:00
Siddharth 65bc21f153 fix the bug i just introduced lol 2025-11-28 17:41:30 -07:00
Siddharth 59807662d8 aspect ratio bug 2025-11-28 17:14:55 -07:00
Siddharth 8f31bde518 motion-blue switch 2025-11-28 16:08:11 -07:00
Sid 6fb9a24834 Merge pull request #14 from siddharthvaddem/feature/trim
Feature/trim
2025-11-27 22:31:33 -08:00
47 changed files with 3470 additions and 8735 deletions
+4 -1
View File
@@ -34,7 +34,10 @@ OpenScreen is 100% free for personal and commercial use. Use it, modify it, dist
- Customize the duration and position of zooms however you please
- Crop video recordings to hide parts
- Choose between wallpapers, solid colors, gradients or your own picture for your background
- Motion blur and exponential easing for smoother pan and zoom effects
- Motion blur for smoother pan and zoom effects
- Add annotations (text, arrows, images)
- Trim sections of the clip
- Export in different aspect ratios and resolutions
## macOS Installation instructions
+181 -309
View File
@@ -1,397 +1,269 @@
import { ipcMain, screen, BrowserWindow, desktopCapturer, shell, app, dialog, nativeImage, Tray, Menu } from "electron";
import { fileURLToPath } from "node:url";
import path from "node:path";
import fs from "node:fs/promises";
const __dirname$1 = path.dirname(fileURLToPath(import.meta.url));
const APP_ROOT = path.join(__dirname$1, "..");
const VITE_DEV_SERVER_URL$1 = process.env["VITE_DEV_SERVER_URL"];
const RENDERER_DIST$1 = path.join(APP_ROOT, "dist");
let hudOverlayWindow = null;
ipcMain.on("hud-overlay-hide", () => {
if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) {
hudOverlayWindow.minimize();
}
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 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();
});
function createHudOverlayWindow() {
const primaryDisplay = screen.getPrimaryDisplay();
const { workArea } = primaryDisplay;
const windowWidth = 500;
const windowHeight = 100;
const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2);
const y = Math.floor(workArea.y + workArea.height - windowHeight - 5);
const win = new BrowserWindow({
width: windowWidth,
height: windowHeight,
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({
width: c,
height: w,
minWidth: 500,
maxWidth: 500,
minHeight: 100,
maxHeight: 100,
x,
y,
frame: false,
transparent: true,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
hasShadow: false,
x: y,
y: h,
frame: !1,
transparent: !0,
resizable: !1,
alwaysOnTop: !0,
skipTaskbar: !0,
hasShadow: !1,
webPreferences: {
preload: path.join(__dirname$1, "preload.mjs"),
nodeIntegration: false,
contextIsolation: true,
backgroundThrottling: false
preload: o.join(_, "preload.mjs"),
nodeIntegration: !1,
contextIsolation: !0,
backgroundThrottling: !1
}
});
win.webContents.on("did-finish-load", () => {
win == null ? void 0 : win.webContents.send("main-process-message", (/* @__PURE__ */ new Date()).toLocaleString());
});
hudOverlayWindow = win;
win.on("closed", () => {
if (hudOverlayWindow === win) {
hudOverlayWindow = null;
}
});
if (VITE_DEV_SERVER_URL$1) {
win.loadURL(VITE_DEV_SERVER_URL$1 + "?windowType=hud-overlay");
} else {
win.loadFile(path.join(RENDERER_DIST$1, "index.html"), {
query: { windowType: "hud-overlay" }
});
}
return win;
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"), {
query: { windowType: "hud-overlay" }
}), e;
}
function createEditorWindow() {
const win = new BrowserWindow({
function M() {
const r = new R({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 12, y: 12 },
transparent: false,
resizable: true,
alwaysOnTop: false,
skipTaskbar: false,
transparent: !1,
resizable: !0,
alwaysOnTop: !1,
skipTaskbar: !1,
title: "OpenScreen",
backgroundColor: "#000000",
webPreferences: {
preload: path.join(__dirname$1, "preload.mjs"),
nodeIntegration: false,
contextIsolation: true,
webSecurity: false,
backgroundThrottling: false
preload: o.join(_, "preload.mjs"),
nodeIntegration: !1,
contextIsolation: !0,
webSecurity: !1,
backgroundThrottling: !1
}
});
win.maximize();
win.webContents.on("did-finish-load", () => {
win == null ? void 0 : win.webContents.send("main-process-message", (/* @__PURE__ */ new Date()).toLocaleString());
});
if (VITE_DEV_SERVER_URL$1) {
win.loadURL(VITE_DEV_SERVER_URL$1 + "?windowType=editor");
} else {
win.loadFile(path.join(RENDERER_DIST$1, "index.html"), {
query: { windowType: "editor" }
});
}
return win;
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"), {
query: { windowType: "editor" }
}), r;
}
function createSourceSelectorWindow() {
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
const win = new BrowserWindow({
function A() {
const { width: r, height: s } = b.getPrimaryDisplay().workAreaSize, c = new R({
width: 620,
height: 420,
minHeight: 350,
maxHeight: 500,
x: Math.round((width - 620) / 2),
y: Math.round((height - 420) / 2),
frame: false,
resizable: false,
alwaysOnTop: true,
transparent: true,
x: Math.round((r - 620) / 2),
y: Math.round((s - 420) / 2),
frame: !1,
resizable: !1,
alwaysOnTop: !0,
transparent: !0,
backgroundColor: "#00000000",
webPreferences: {
preload: path.join(__dirname$1, "preload.mjs"),
nodeIntegration: false,
contextIsolation: true
preload: o.join(_, "preload.mjs"),
nodeIntegration: !1,
contextIsolation: !0
}
});
if (VITE_DEV_SERVER_URL$1) {
win.loadURL(VITE_DEV_SERVER_URL$1 + "?windowType=source-selector");
} else {
win.loadFile(path.join(RENDERER_DIST$1, "index.html"), {
query: { windowType: "source-selector" }
});
}
return win;
return m ? c.loadURL(m + "?windowType=source-selector") : c.loadFile(o.join(T, "index.html"), {
query: { windowType: "source-selector" }
}), c;
}
let selectedSource = null;
function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, getMainWindow, getSourceSelectorWindow, onRecordingStateChange) {
ipcMain.handle("get-sources", async (_, opts) => {
const sources = await desktopCapturer.getSources(opts);
return sources.map((source) => ({
id: source.id,
name: source.name,
display_id: source.display_id,
thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null,
appIcon: source.appIcon ? source.appIcon.toDataURL() : null
}));
});
ipcMain.handle("select-source", (_, source) => {
selectedSource = source;
const sourceSelectorWin = getSourceSelectorWindow();
if (sourceSelectorWin) {
sourceSelectorWin.close();
}
return selectedSource;
});
ipcMain.handle("get-selected-source", () => {
return selectedSource;
});
ipcMain.handle("open-source-selector", () => {
const sourceSelectorWin = getSourceSelectorWindow();
if (sourceSelectorWin) {
sourceSelectorWin.focus();
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();
if (e) {
e.focus();
return;
}
createSourceSelectorWindow2();
});
ipcMain.handle("switch-to-editor", () => {
const mainWin = getMainWindow();
if (mainWin) {
mainWin.close();
}
createEditorWindow2();
});
ipcMain.handle("store-recorded-video", async (_, videoData, fileName) => {
s();
}), n.handle("switch-to-editor", () => {
const e = c();
e && e.close(), r();
}), n.handle("store-recorded-video", async (e, a, i) => {
try {
const videoPath = path.join(RECORDINGS_DIR, fileName);
await fs.writeFile(videoPath, Buffer.from(videoData));
currentVideoPath = videoPath;
return {
success: true,
path: videoPath,
const t = o.join(p, i);
return await P.writeFile(t, Buffer.from(a)), h = t, {
success: !0,
path: t,
message: "Video stored successfully"
};
} catch (error) {
console.error("Failed to store video:", error);
return {
success: false,
} catch (t) {
return console.error("Failed to store video:", t), {
success: !1,
message: "Failed to store video",
error: String(error)
error: String(t)
};
}
});
ipcMain.handle("get-recorded-video-path", async () => {
}), n.handle("get-recorded-video-path", async () => {
try {
const files = await fs.readdir(RECORDINGS_DIR);
const videoFiles = files.filter((file) => file.endsWith(".webm"));
if (videoFiles.length === 0) {
return { success: false, message: "No recorded video found" };
}
const latestVideo = videoFiles.sort().reverse()[0];
const videoPath = path.join(RECORDINGS_DIR, latestVideo);
return { success: true, path: videoPath };
} catch (error) {
console.error("Failed to get video path:", error);
return { success: false, message: "Failed to get video path", error: String(error) };
const a = (await P.readdir(p)).filter((j) => j.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) };
} catch (e) {
return console.error("Failed to get video path:", e), { success: !1, message: "Failed to get video path", error: String(e) };
}
});
ipcMain.handle("set-recording-state", (_, recording) => {
const source = selectedSource || { name: "Screen" };
if (onRecordingStateChange) {
onRecordingStateChange(recording, source.name);
}
});
ipcMain.handle("open-external-url", async (_, url) => {
}), n.handle("set-recording-state", (e, a) => {
y && y(a, (v || { name: "Screen" }).name);
}), n.handle("open-external-url", async (e, a) => {
try {
await shell.openExternal(url);
return { success: true };
} catch (error) {
console.error("Failed to open URL:", error);
return { success: false, error: String(error) };
return await O.openExternal(a), { success: !0 };
} catch (i) {
return console.error("Failed to open URL:", i), { success: !1, error: String(i) };
}
});
ipcMain.handle("get-asset-base-path", () => {
}), n.handle("get-asset-base-path", () => {
try {
if (app.isPackaged) {
return path.join(process.resourcesPath, "assets");
}
return path.join(app.getAppPath(), "public", "assets");
} catch (err) {
console.error("Failed to resolve asset base path:", err);
return null;
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;
}
});
ipcMain.handle("save-exported-video", async (_, videoData, fileName) => {
}), n.handle("save-exported-video", async (e, a, i) => {
try {
const result = await dialog.showSaveDialog({
const t = await S.showSaveDialog({
title: "Save Exported Video",
defaultPath: path.join(app.getPath("downloads"), fileName),
defaultPath: o.join(d.getPath("downloads"), i),
filters: [
{ name: "MP4 Video", extensions: ["mp4"] }
],
properties: ["createDirectory", "showOverwriteConfirmation"]
});
if (result.canceled || !result.filePath) {
return {
success: false,
cancelled: true,
message: "Export cancelled"
};
}
await fs.writeFile(result.filePath, Buffer.from(videoData));
return {
success: true,
path: result.filePath,
return t.canceled || !t.filePath ? {
success: !1,
cancelled: !0,
message: "Export cancelled"
} : (await P.writeFile(t.filePath, Buffer.from(a)), {
success: !0,
path: t.filePath,
message: "Video exported successfully"
};
} catch (error) {
console.error("Failed to save exported video:", error);
return {
success: false,
});
} catch (t) {
return console.error("Failed to save exported video:", t), {
success: !1,
message: "Failed to save exported video",
error: String(error)
error: String(t)
};
}
});
ipcMain.handle("open-video-file-picker", async () => {
}), n.handle("open-video-file-picker", async () => {
try {
const result = await dialog.showOpenDialog({
const e = await S.showOpenDialog({
title: "Select Video File",
defaultPath: RECORDINGS_DIR,
defaultPath: p,
filters: [
{ name: "Video Files", extensions: ["webm", "mp4", "mov", "avi", "mkv"] },
{ name: "All Files", extensions: ["*"] }
],
properties: ["openFile"]
});
if (result.canceled || result.filePaths.length === 0) {
return { success: false, cancelled: true };
}
return {
success: true,
path: result.filePaths[0]
return e.canceled || e.filePaths.length === 0 ? { success: !1, cancelled: !0 } : {
success: !0,
path: e.filePaths[0]
};
} catch (error) {
console.error("Failed to open file picker:", error);
return {
success: false,
} catch (e) {
return console.error("Failed to open file picker:", e), {
success: !1,
message: "Failed to open file picker",
error: String(error)
error: String(e)
};
}
});
let currentVideoPath = null;
ipcMain.handle("set-current-video-path", (_, path2) => {
currentVideoPath = path2;
return { success: true };
});
ipcMain.handle("get-current-video-path", () => {
return currentVideoPath ? { success: true, path: currentVideoPath } : { success: false };
});
ipcMain.handle("clear-current-video-path", () => {
currentVideoPath = null;
return { success: true };
});
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);
}
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings");
async function ensureRecordingsDir() {
const z = o.dirname(E(import.meta.url)), p = o.join(d.getPath("userData"), "recordings");
async function N() {
try {
await fs.mkdir(RECORDINGS_DIR, { recursive: true });
console.log("RECORDINGS_DIR:", RECORDINGS_DIR);
console.log("User Data Path:", app.getPath("userData"));
} catch (error) {
console.error("Failed to create recordings directory:", error);
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);
}
}
process.env.APP_ROOT = path.join(__dirname, "..");
const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"];
const MAIN_DIST = path.join(process.env.APP_ROOT, "dist-electron");
const RENDERER_DIST = path.join(process.env.APP_ROOT, "dist");
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, "public") : RENDERER_DIST;
let mainWindow = null;
let sourceSelectorWindow = null;
let tray = null;
let selectedSourceName = "";
function createWindow() {
mainWindow = createHudOverlayWindow();
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();
}
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();
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 updateTrayMenu() {
if (!tray) return;
const menuTemplate = [
function F() {
if (!u) return;
const r = [
{
label: "Stop Recording",
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("stop-recording-from-tray");
}
l && !l.isDestroyed() && l.webContents.send("stop-recording-from-tray");
}
}
];
const contextMenu = Menu.buildFromTemplate(menuTemplate);
tray.setContextMenu(contextMenu);
tray.setToolTip(`Recording: ${selectedSourceName}`);
], s = L.buildFromTemplate(r);
u.setContextMenu(s), u.setToolTip(`Recording: ${x}`);
}
function createEditorWindowWrapper() {
if (mainWindow) {
mainWindow.close();
mainWindow = null;
}
mainWindow = createEditorWindow();
function $() {
l && (l.close(), l = null), l = M();
}
function createSourceSelectorWindowWrapper() {
sourceSelectorWindow = createSourceSelectorWindow();
sourceSelectorWindow.on("closed", () => {
sourceSelectorWindow = null;
});
return sourceSelectorWindow;
function G() {
return g = A(), g.on("closed", () => {
g = null;
}), g;
}
app.on("window-all-closed", () => {
d.on("window-all-closed", () => {
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
d.on("activate", () => {
R.getAllWindows().length === 0 && I();
});
app.whenReady().then(async () => {
const { ipcMain: ipcMain2 } = await import("electron");
ipcMain2.on("hud-overlay-close", () => {
if (process.platform === "darwin") {
app.quit();
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,
() => g,
(s, c) => {
x = c, s ? (u || q(), F()) : (u && (u.destroy(), u = null), l && l.restore());
}
});
await ensureRecordingsDir();
registerIpcHandlers(
createEditorWindowWrapper,
createSourceSelectorWindowWrapper,
() => mainWindow,
() => sourceSelectorWindow,
(recording, sourceName) => {
selectedSourceName = sourceName;
if (recording) {
if (!tray) createTray();
updateTrayMenu();
} else {
if (tray) {
tray.destroy();
tray = null;
}
if (mainWindow) mainWindow.restore();
}
}
);
createWindow();
), I();
});
export {
MAIN_DIST,
RECORDINGS_DIR,
RENDERER_DIST,
VITE_DEV_SERVER_URL
Y as MAIN_DIST,
p as RECORDINGS_DIR,
D as RENDERER_DIST,
B as VITE_DEV_SERVER_URL
};
+1 -60
View File
@@ -1,60 +1 @@
"use strict";
const electron = require("electron");
electron.contextBridge.exposeInMainWorld("electronAPI", {
hudOverlayHide: () => {
electron.ipcRenderer.send("hud-overlay-hide");
},
hudOverlayClose: () => {
electron.ipcRenderer.send("hud-overlay-close");
},
getAssetBasePath: async () => {
return await electron.ipcRenderer.invoke("get-asset-base-path");
},
getSources: async (opts) => {
return await electron.ipcRenderer.invoke("get-sources", opts);
},
switchToEditor: () => {
return electron.ipcRenderer.invoke("switch-to-editor");
},
openSourceSelector: () => {
return electron.ipcRenderer.invoke("open-source-selector");
},
selectSource: (source) => {
return electron.ipcRenderer.invoke("select-source", source);
},
getSelectedSource: () => {
return electron.ipcRenderer.invoke("get-selected-source");
},
storeRecordedVideo: (videoData, fileName) => {
return electron.ipcRenderer.invoke("store-recorded-video", videoData, fileName);
},
getRecordedVideoPath: () => {
return electron.ipcRenderer.invoke("get-recorded-video-path");
},
setRecordingState: (recording) => {
return electron.ipcRenderer.invoke("set-recording-state", recording);
},
onStopRecordingFromTray: (callback) => {
const listener = () => callback();
electron.ipcRenderer.on("stop-recording-from-tray", listener);
return () => electron.ipcRenderer.removeListener("stop-recording-from-tray", listener);
},
openExternalUrl: (url) => {
return electron.ipcRenderer.invoke("open-external-url", url);
},
saveExportedVideo: (videoData, fileName) => {
return electron.ipcRenderer.invoke("save-exported-video", videoData, fileName);
},
openVideoFilePicker: () => {
return electron.ipcRenderer.invoke("open-video-file-picker");
},
setCurrentVideoPath: (path) => {
return electron.ipcRenderer.invoke("set-current-video-path", path);
},
getCurrentVideoPath: () => {
return electron.ipcRenderer.invoke("get-current-video-path");
},
clearCurrentVideoPath: () => {
return electron.ipcRenderer.invoke("clear-current-video-path");
}
});
"use strict";const e=require("electron");e.contextBridge.exposeInMainWorld("electronAPI",{hudOverlayHide:()=>{e.ipcRenderer.send("hud-overlay-hide")},hudOverlayClose:()=>{e.ipcRenderer.send("hud-overlay-close")},getAssetBasePath:async()=>await e.ipcRenderer.invoke("get-asset-base-path"),getSources:async r=>await e.ipcRenderer.invoke("get-sources",r),switchToEditor:()=>e.ipcRenderer.invoke("switch-to-editor"),openSourceSelector:()=>e.ipcRenderer.invoke("open-source-selector"),selectSource:r=>e.ipcRenderer.invoke("select-source",r),getSelectedSource:()=>e.ipcRenderer.invoke("get-selected-source"),storeRecordedVideo:(r,t)=>e.ipcRenderer.invoke("store-recorded-video",r,t),getRecordedVideoPath:()=>e.ipcRenderer.invoke("get-recorded-video-path"),setRecordingState:r=>e.ipcRenderer.invoke("set-recording-state",r),onStopRecordingFromTray:r=>{const t=()=>r();return e.ipcRenderer.on("stop-recording-from-tray",t),()=>e.ipcRenderer.removeListener("stop-recording-from-tray",t)},openExternalUrl:r=>e.ipcRenderer.invoke("open-external-url",r),saveExportedVideo:(r,t)=>e.ipcRenderer.invoke("save-exported-video",r,t),openVideoFilePicker:()=>e.ipcRenderer.invoke("open-video-file-picker"),setCurrentVideoPath:r=>e.ipcRenderer.invoke("set-current-video-path",r),getCurrentVideoPath:()=>e.ipcRenderer.invoke("get-current-video-path"),clearCurrentVideoPath:()=>e.ipcRenderer.invoke("clear-current-video-path"),getPlatform:()=>e.ipcRenderer.invoke("get-platform")});
+1
View File
@@ -39,6 +39,7 @@ interface Window {
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>
getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>
clearCurrentVideoPath: () => Promise<{ success: boolean }>
getPlatform: () => Promise<string>
hudOverlayHide: () => void;
hudOverlayClose: () => void;
}
+5
View File
@@ -104,6 +104,7 @@ export function registerIpcHandlers(
}
})
ipcMain.handle('open-external-url', async (_, url: string) => {
try {
await shell.openExternal(url)
@@ -207,4 +208,8 @@ export function registerIpcHandlers(
currentVideoPath = null;
return { success: true };
});
ipcMain.handle('get-platform', () => {
return process.platform;
});
}
+3
View File
@@ -60,4 +60,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
clearCurrentVideoPath: () => {
return ipcRenderer.invoke('clear-current-video-path')
},
getPlatform: () => {
return ipcRenderer.invoke('get-platform')
},
})
+357 -105
View File
@@ -1,34 +1,43 @@
{
"name": "openscreen",
"version": "0.0.0",
"version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openscreen",
"version": "0.0.0",
"version": "1.0.1",
"dependencies": {
"@fix-webm-duration/fix": "^1.0.1",
"@pixi/filter-drop-shadow": "^5.2.0",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@uiw/color-convert": "^2.9.2",
"@uiw/react-color-colorful": "^2.9.0",
"@uiw/react-color-block": "^2.9.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dnd-timeline": "^2.2.0",
"emoji-picker-react": "^4.16.1",
"fix-webm-duration": "^1.0.6",
"gsap": "^3.13.0",
"lucide-react": "^0.545.0",
"mediabunny": "^1.25.1",
"motion": "^12.23.24",
"mp4box": "^2.2.0",
"pixi.js": "^8.14.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.5.0",
"react-resizable-panels": "^3.0.6",
"react-rnd": "^10.5.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
@@ -2579,6 +2588,43 @@
}
}
},
"node_modules/@radix-ui/react-popover": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
"integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
@@ -2713,6 +2759,49 @@
}
}
},
"node_modules/@radix-ui/react-select": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
"integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-visually-hidden": "1.2.3",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz",
@@ -2823,6 +2912,60 @@
}
}
},
"node_modules/@radix-ui/react-toggle": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz",
"integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toggle-group": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz",
"integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-toggle": "1.1.10",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
@@ -2959,6 +3102,29 @@
}
}
},
"node_modules/@radix-ui/react-visually-hidden": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
@@ -3750,14 +3916,15 @@
"@babel/runtime": ">=7.19.0"
}
},
"node_modules/@uiw/react-color-alpha": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@uiw/react-color-alpha/-/react-color-alpha-2.9.0.tgz",
"integrity": "sha512-tP9zs2KUPzIGvdtTBTNlDEdwHWcNJ3Ju/nnpPTsKERRJF2vzucb3v18JzihX0ACiOXU9+JVqgpAVgvoZXMeITA==",
"node_modules/@uiw/react-color-block": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/@uiw/react-color-block/-/react-color-block-2.9.2.tgz",
"integrity": "sha512-0EIZTELA5pnxyMlBOFo3hrpy73db+Qeq6E+QptNfD/8izor8OvY1Uquj2VqD6gDz+iVHMELIoKxpaQ8sZR7NOg==",
"license": "MIT",
"dependencies": {
"@uiw/color-convert": "2.9.0",
"@uiw/react-drag-event-interactive": "2.9.0"
"@uiw/color-convert": "2.9.2",
"@uiw/react-color-editable-input": "2.9.2",
"@uiw/react-color-swatch": "2.9.2"
},
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
@@ -3768,58 +3935,27 @@
"react-dom": ">=16.9.0"
}
},
"node_modules/@uiw/react-color-alpha/node_modules/@uiw/color-convert": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@uiw/color-convert/-/color-convert-2.9.0.tgz",
"integrity": "sha512-tFm6iac8iN3VL9IIAXKAVpBX6ZQH7ucCvb+EgZNM5y8prRWlk53cERnP4RycE3wnYb/HUKW0lrngoygQnEWAHg==",
"node_modules/@uiw/react-color-editable-input": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/@uiw/react-color-editable-input/-/react-color-editable-input-2.9.2.tgz",
"integrity": "sha512-DY7pu12+LDRn6cxDMvsy1/quaPTxicAPz/kfODV7KBf8+Hq4rFWeJ4KS6m22IKIbQxrBQgmQG0WFJLaPeY7cPw==",
"license": "MIT",
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@babel/runtime": ">=7.19.0"
}
},
"node_modules/@uiw/react-color-colorful": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@uiw/react-color-colorful/-/react-color-colorful-2.9.0.tgz",
"integrity": "sha512-qw7zhgX9J4aydK0hKKLroXMYp0tw3ASNuZM1fQNwXXIpOkEW4zO+PHTkzUBpbII68wGS8tAxsZessTWNpAVi0w==",
"license": "MIT",
"dependencies": {
"@uiw/color-convert": "2.9.0",
"@uiw/react-color-alpha": "2.9.0",
"@uiw/react-color-hue": "2.9.0",
"@uiw/react-color-saturation": "2.9.0"
},
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@babel/runtime": ">=7.19.0",
"react": ">=16.9.0",
"react-dom": ">=16.9.0"
}
},
"node_modules/@uiw/react-color-colorful/node_modules/@uiw/color-convert": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@uiw/color-convert/-/color-convert-2.9.0.tgz",
"integrity": "sha512-tFm6iac8iN3VL9IIAXKAVpBX6ZQH7ucCvb+EgZNM5y8prRWlk53cERnP4RycE3wnYb/HUKW0lrngoygQnEWAHg==",
"license": "MIT",
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@babel/runtime": ">=7.19.0"
}
},
"node_modules/@uiw/react-color-hue": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@uiw/react-color-hue/-/react-color-hue-2.9.0.tgz",
"integrity": "sha512-2Ix027ref3ppiVq+OQkbyKr6O8fLTH+n0wEW4xhHfa3kV8mBa2bEu8jAjrkSwO6uElaE6OJcuqYe2llyKRlIFg==",
"node_modules/@uiw/react-color-swatch": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/@uiw/react-color-swatch/-/react-color-swatch-2.9.2.tgz",
"integrity": "sha512-6zBy+E9NzZR672M2wPsbbNRqKy9Wi9jOuuxxyzov1CEZp+pPX7UwMlCX6RUhKdO0PzTSPCVQmbz5bplu5vsW0w==",
"license": "MIT",
"dependencies": {
"@uiw/color-convert": "2.9.0",
"@uiw/react-color-alpha": "2.9.0"
"@uiw/color-convert": "2.9.2"
},
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
@@ -3830,62 +3966,6 @@
"react-dom": ">=16.9.0"
}
},
"node_modules/@uiw/react-color-hue/node_modules/@uiw/color-convert": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@uiw/color-convert/-/color-convert-2.9.0.tgz",
"integrity": "sha512-tFm6iac8iN3VL9IIAXKAVpBX6ZQH7ucCvb+EgZNM5y8prRWlk53cERnP4RycE3wnYb/HUKW0lrngoygQnEWAHg==",
"license": "MIT",
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@babel/runtime": ">=7.19.0"
}
},
"node_modules/@uiw/react-color-saturation": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@uiw/react-color-saturation/-/react-color-saturation-2.9.0.tgz",
"integrity": "sha512-tBARqsW0ap+gwByGKSid1nYaGJd6dw2MTMwCABF9rebPQHtYbN/YgBjl9dNnp6XDGd+VBhH4RbpM38WV7J1F4g==",
"license": "MIT",
"dependencies": {
"@uiw/color-convert": "2.9.0",
"@uiw/react-drag-event-interactive": "2.9.0"
},
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@babel/runtime": ">=7.19.0",
"react": ">=16.9.0",
"react-dom": ">=16.9.0"
}
},
"node_modules/@uiw/react-color-saturation/node_modules/@uiw/color-convert": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@uiw/color-convert/-/color-convert-2.9.0.tgz",
"integrity": "sha512-tFm6iac8iN3VL9IIAXKAVpBX6ZQH7ucCvb+EgZNM5y8prRWlk53cERnP4RycE3wnYb/HUKW0lrngoygQnEWAHg==",
"license": "MIT",
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@babel/runtime": ">=7.19.0"
}
},
"node_modules/@uiw/react-drag-event-interactive": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@uiw/react-drag-event-interactive/-/react-drag-event-interactive-2.9.0.tgz",
"integrity": "sha512-oBqrClsSuYA3IjkSHp4uHOBLgPkR98KeKeheqCRltF8w/a1CPaFCl43pyKyxwXaTV6qM6cb3VkT3JRCHMNqTzA==",
"license": "MIT",
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@babel/runtime": ">=7.19.0",
"react": ">=16.9.0",
"react-dom": ">=16.9.0"
}
},
"node_modules/@ungap/structured-clone": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
@@ -6291,6 +6371,21 @@
"dev": true,
"license": "ISC"
},
"node_modules/emoji-picker-react": {
"version": "4.16.1",
"resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.16.1.tgz",
"integrity": "sha512-MrPX0tOCfRL3uYI4of/2GRZ7S6qS7YlacKiF78uFH84/C62vcuHE2DZyv5b4ZJMk0e06es1jjB4e31Bb+YSM8w==",
"license": "MIT",
"dependencies": {
"flairup": "1.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">=16"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -6905,6 +7000,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/fix-webm-duration": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/fix-webm-duration/-/fix-webm-duration-1.0.6.tgz",
"integrity": "sha512-zVAqi4gE+8ywxJuAyV/rlJVX6CMtvyapEbQx6jyoeX9TMjdqAlt/FdG5d7rXSSkDVzTvS0H7CtwzHcH/vh4FPA==",
"license": "MIT"
},
"node_modules/flairup": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/flairup/-/flairup-1.0.0.tgz",
"integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==",
"license": "MIT"
},
"node_modules/flat-cache": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
@@ -7005,6 +7112,33 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/framer-motion": {
"version": "12.23.24",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz",
"integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.23.23",
"motion-utils": "^12.23.6",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@@ -7426,6 +7560,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/gsap": {
"version": "3.13.0",
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.13.0.tgz",
"integrity": "sha512-QL7MJ2WMjm1PHWsoFrAQH/J8wUeqZvMtHO58qdekHpCfhvhSL4gSiz6vJf5EeMP0LOn3ZCprL2ki/gjED8ghVw==",
"license": "Standard 'no charge' license: https://gsap.com/standard-license."
},
"node_modules/har-schema": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz",
@@ -9319,6 +9459,47 @@
"node": ">=10"
}
},
"node_modules/motion": {
"version": "12.23.24",
"resolved": "https://registry.npmjs.org/motion/-/motion-12.23.24.tgz",
"integrity": "sha512-Rc5E7oe2YZ72N//S3QXGzbnXgqNrTESv8KKxABR20q2FLch9gHLo0JLyYo2hZ238bZ9Gx6cWhj9VO0IgwbMjCw==",
"license": "MIT",
"dependencies": {
"framer-motion": "^12.23.24",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion-dom": {
"version": "12.23.23",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.23.6"
}
},
"node_modules/motion-utils": {
"version": "12.23.6",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
"license": "MIT"
},
"node_modules/mp4box": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/mp4box/-/mp4box-2.2.0.tgz",
@@ -10414,6 +10595,17 @@
"node": ">=10"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/psl": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
@@ -10497,6 +10689,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/re-resizable": {
"version": "6.11.2",
"resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.11.2.tgz",
"integrity": "sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A==",
"license": "MIT",
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -10522,6 +10724,29 @@
"react": "^18.3.1"
}
},
"node_modules/react-draggable": {
"version": "4.4.6",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.6.tgz",
"integrity": "sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==",
"license": "MIT",
"dependencies": {
"clsx": "^1.1.1",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-draggable/node_modules/clsx": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
@@ -10531,6 +10756,12 @@
"react": "*"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -10598,6 +10829,27 @@
"react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/react-rnd": {
"version": "10.5.2",
"resolved": "https://registry.npmjs.org/react-rnd/-/react-rnd-10.5.2.tgz",
"integrity": "sha512-0Tm4x7k7pfHf2snewJA8x7Nwgt3LV+58MVEWOVsFjk51eYruFEa6Wy7BNdxt4/lH0wIRsu7Gm3KjSXY2w7YaNw==",
"license": "MIT",
"dependencies": {
"re-resizable": "6.11.2",
"react-draggable": "4.4.6",
"tslib": "2.6.2"
},
"peerDependencies": {
"react": ">=16.3.0",
"react-dom": ">=16.3.0"
}
},
"node_modules/react-rnd/node_modules/tslib": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
"license": "0BSD"
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
+11 -2
View File
@@ -1,7 +1,7 @@
{
"name": "openscreen",
"private": true,
"version": "0.0.0",
"version": "1.0.1",
"type": "module",
"scripts": {
"dev": "vite",
@@ -16,23 +16,32 @@
"@pixi/filter-drop-shadow": "^5.2.0",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@uiw/color-convert": "^2.9.2",
"@uiw/react-color-colorful": "^2.9.0",
"@uiw/react-color-block": "^2.9.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dnd-timeline": "^2.2.0",
"emoji-picker-react": "^4.16.1",
"fix-webm-duration": "^1.0.6",
"gsap": "^3.13.0",
"lucide-react": "^0.545.0",
"mediabunny": "^1.25.1",
"motion": "^12.23.24",
"mp4box": "^2.2.0",
"pixi.js": "^8.14.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.5.0",
"react-resizable-panels": "^3.0.6",
"react-rnd": "^10.5.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
-8053
View File
File diff suppressed because it is too large Load Diff
-5
View File
@@ -1,5 +0,0 @@
onlyBuiltDependencies:
- electron
- esbuild
- lzma-native
- phantomjs-prebuilt
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 460 KiB

After

Width:  |  Height:  |  Size: 776 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 549 KiB

After

Width:  |  Height:  |  Size: 534 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 627 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 581 KiB

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 890 KiB

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

+2
View File
@@ -118,6 +118,7 @@ export function LaunchWindow() {
size="sm"
className={`gap-1 text-white bg-transparent hover:bg-transparent px-0 flex-1 text-left text-xs ${styles.electronNoDrag}`}
onClick={openSourceSelector}
disabled={recording}
>
<MdMonitor size={14} className="text-white" />
{truncateText(selectedSource, 6)}
@@ -154,6 +155,7 @@ export function LaunchWindow() {
size="sm"
onClick={openVideoFile}
className={`gap-1 text-white bg-transparent hover:bg-transparent px-0 flex-1 text-right text-xs ${styles.electronNoDrag} ${styles.folderButton}`}
disabled={recording}
>
<FaFolderMinus size={14} className="text-white" />
<span className={styles.folderText}>Open</span>
+48
View File
@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
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",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
+160
View File
@@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
+61
View File
@@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
})
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
))
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
})
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
export { ToggleGroup, ToggleGroupItem }
+45
View File
@@ -0,0 +1,45 @@
"use client"
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-3",
sm: "h-9 px-2.5",
lg: "h-11 px-5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
))
Toggle.displayName = TogglePrimitive.Root.displayName
export { Toggle, toggleVariants }
@@ -0,0 +1,218 @@
import { useRef } from "react";
import { Rnd } from "react-rnd";
import type { AnnotationRegion } from "./types";
import { cn } from "@/lib/utils";
import { getArrowComponent } from "./ArrowSvgs";
interface AnnotationOverlayProps {
annotation: AnnotationRegion;
isSelected: boolean;
containerWidth: number;
containerHeight: number;
onPositionChange: (id: string, position: { x: number; y: number }) => void;
onSizeChange: (id: string, size: { width: number; height: number }) => void;
onClick: (id: string) => void;
zIndex: number;
isSelectedBoost: boolean; // Boost z-index when selected for easy editing
}
export function AnnotationOverlay({
annotation,
isSelected,
containerWidth,
containerHeight,
onPositionChange,
onSizeChange,
onClick,
zIndex,
isSelectedBoost,
}: AnnotationOverlayProps) {
const x = (annotation.position.x / 100) * containerWidth;
const y = (annotation.position.y / 100) * containerHeight;
const width = (annotation.size.width / 100) * containerWidth;
const height = (annotation.size.height / 100) * containerHeight;
const isDraggingRef = useRef(false);
const renderArrow = () => {
const direction = annotation.figureData?.arrowDirection || 'right';
const color = annotation.figureData?.color || '#34B27B';
const strokeWidth = annotation.figureData?.strokeWidth || 4;
const ArrowComponent = getArrowComponent(direction);
return <ArrowComponent color={color} strokeWidth={strokeWidth} />;
};
const renderContent = () => {
switch (annotation.type) {
case 'text':
return (
<div
className="w-full h-full flex items-center p-2 overflow-hidden"
style={{
justifyContent: annotation.style.textAlign === 'left' ? 'flex-start' :
annotation.style.textAlign === 'right' ? 'flex-end' : 'center',
alignItems: 'center',
}}
>
<span
style={{
color: annotation.style.color,
backgroundColor: annotation.style.backgroundColor,
fontSize: `${annotation.style.fontSize}px`,
fontFamily: annotation.style.fontFamily,
fontWeight: annotation.style.fontWeight,
fontStyle: annotation.style.fontStyle,
textDecoration: annotation.style.textDecoration,
textAlign: annotation.style.textAlign,
wordBreak: 'break-word',
whiteSpace: 'pre-wrap',
boxDecorationBreak: 'clone',
WebkitBoxDecorationBreak: 'clone',
padding: '0.1em 0.2em',
borderRadius: '4px',
lineHeight: '1.4',
}}
>
{annotation.content}
</span>
</div>
);
case 'image':
if (annotation.content && annotation.content.startsWith('data:image')) {
return (
<img
src={annotation.content}
alt="Annotation"
className="w-full h-full object-contain"
draggable={false}
/>
);
}
return (
<div className="w-full h-full flex items-center justify-center text-slate-400 text-sm">
No image
</div>
);
case 'figure':
if (!annotation.figureData) {
return (
<div className="w-full h-full flex items-center justify-center text-slate-400 text-sm">
No arrow data
</div>
);
}
return (
<div className="w-full h-full flex items-center justify-center p-2">
{renderArrow()}
</div>
);
default:
return null;
}
};
return (
<Rnd
position={{ x, y }}
size={{ width, height }}
onDragStart={() => {
isDraggingRef.current = true;
}}
onDragStop={(_e, d) => {
const xPercent = (d.x / containerWidth) * 100;
const yPercent = (d.y / containerHeight) * 100;
onPositionChange(annotation.id, { x: xPercent, y: yPercent });
// Reset dragging flag after a short delay to prevent click event
setTimeout(() => {
isDraggingRef.current = false;
}, 100);
}}
onResizeStop={(_e, _direction, ref, _delta, position) => {
const xPercent = (position.x / containerWidth) * 100;
const yPercent = (position.y / containerHeight) * 100;
const widthPercent = (ref.offsetWidth / containerWidth) * 100;
const heightPercent = (ref.offsetHeight / containerHeight) * 100;
onPositionChange(annotation.id, { x: xPercent, y: yPercent });
onSizeChange(annotation.id, { width: widthPercent, height: heightPercent });
}}
onClick={() => {
if (isDraggingRef.current) return;
onClick(annotation.id);
}}
bounds="parent"
className={cn(
"cursor-move transition-all",
isSelected && "ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent"
)}
style={{
zIndex: isSelectedBoost ? zIndex + 1000 : zIndex, // Boost selected annotation to ensure it's on top
pointerEvents: isSelected ? 'auto' : 'none',
border: isSelected ? '2px solid rgba(52, 178, 123, 0.8)' : 'none',
backgroundColor: isSelected ? 'rgba(52, 178, 123, 0.1)' : 'transparent',
boxShadow: isSelected ? '0 0 0 1px rgba(52, 178, 123, 0.35)' : 'none',
}}
enableResizing={isSelected}
disableDragging={!isSelected}
resizeHandleStyles={{
topLeft: {
width: '12px',
height: '12px',
backgroundColor: isSelected ? 'white' : 'transparent',
border: isSelected ? '2px solid #34B27B' : 'none',
borderRadius: '50%',
left: '-6px',
top: '-6px',
cursor: 'nwse-resize',
},
topRight: {
width: '12px',
height: '12px',
backgroundColor: isSelected ? 'white' : 'transparent',
border: isSelected ? '2px solid #34B27B' : 'none',
borderRadius: '50%',
right: '-6px',
top: '-6px',
cursor: 'nesw-resize',
},
bottomLeft: {
width: '12px',
height: '12px',
backgroundColor: isSelected ? 'white' : 'transparent',
border: isSelected ? '2px solid #34B27B' : 'none',
borderRadius: '50%',
left: '-6px',
bottom: '-6px',
cursor: 'nesw-resize',
},
bottomRight: {
width: '12px',
height: '12px',
backgroundColor: isSelected ? 'white' : 'transparent',
border: isSelected ? '2px solid #34B27B' : 'none',
borderRadius: '50%',
right: '-6px',
bottom: '-6px',
cursor: 'nwse-resize',
},
}}
>
<div
className={cn(
"w-full h-full rounded-lg",
annotation.type === 'text' && "bg-transparent",
annotation.type === 'image' && "bg-transparent",
annotation.type === 'figure' && "bg-transparent",
isSelected && "shadow-lg"
)}
>
{renderContent()}
</div>
</Rnd>
);
}
@@ -0,0 +1,486 @@
import {useRef } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Trash2, Type, Image as ImageIcon, Upload, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, ChevronDown, Info } from "lucide-react";
import { toast } from "sonner";
import Block from '@uiw/react-color-block';
import type { AnnotationRegion, AnnotationType, ArrowDirection, FigureData } from "./types";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Slider } from "@/components/ui/slider";
import { cn } from "@/lib/utils";
import { getArrowComponent } from "./ArrowSvgs";
interface AnnotationSettingsPanelProps {
annotation: AnnotationRegion;
onContentChange: (content: string) => void;
onTypeChange: (type: AnnotationType) => void;
onStyleChange: (style: Partial<AnnotationRegion['style']>) => void;
onFigureDataChange?: (figureData: FigureData) => void;
onDelete: () => void;
}
const FONT_FAMILIES = [
{ value: 'system-ui, -apple-system, sans-serif', label: 'Classic' },
{ value: 'Georgia, serif', label: 'Editor' },
{ value: 'Impact, Arial Black, sans-serif', label: 'Strong' },
{ value: 'Courier New, monospace', label: 'Typewriter' },
{ value: 'Brush Script MT, cursive', label: 'Deco' },
{ value: 'Arial, sans-serif', label: 'Simple' },
{ value: 'Verdana, sans-serif', label: 'Modern' },
{ value: 'Trebuchet MS, sans-serif', label: 'Clean' },
];
const FONT_SIZES = [12, 14, 16, 18, 20, 24, 28, 32, 36, 40, 48, 56, 64, 72, 80, 96, 128];
export function AnnotationSettingsPanel({
annotation,
onContentChange,
onTypeChange,
onStyleChange,
onFigureDataChange,
onDelete,
}: AnnotationSettingsPanelProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const colorPalette = [
'#FF0000', // Red
'#FFD700', // Yellow/Gold
'#00FF00', // Green
'#FFFFFF', // White
'#0000FF', // Blue
'#FF6B00', // Orange
'#9B59B6', // Purple
'#E91E63', // Pink
'#00BCD4', // Cyan
'#FF5722', // Deep Orange
'#8BC34A', // Light Green
'#FFC107', // Amber
'#34B27B', // Brand Green
'#000000', // Black
'#607D8B', // Blue Grey
'#795548', // Brown
];
const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files || files.length === 0) return;
const file = files[0];
// Validate file type
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
if (!validTypes.includes(file.type)) {
toast.error('Invalid file type', {
description: 'Please upload a JPG, PNG, GIF, or WebP image file.',
});
event.target.value = '';
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target?.result as string;
if (dataUrl) {
onContentChange(dataUrl);
toast.success('Image uploaded successfully!');
}
};
reader.onerror = () => {
toast.error('Failed to upload image', {
description: 'There was an error reading the file.',
});
};
reader.readAsDataURL(file);
event.target.value = '';
};
return (
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl p-4 flex flex-col shadow-xl h-full overflow-y-auto custom-scrollbar">
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<span className="text-sm font-medium text-slate-200">Annotation Settings</span>
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-1 rounded-full">
Active
</span>
</div>
{/* Type Selector */}
<Tabs value={annotation.type} onValueChange={(value) => onTypeChange(value as AnnotationType)} className="mb-6">
<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="text" className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2">
<Type className="w-4 h-4" />
Text
</TabsTrigger>
<TabsTrigger value="image" className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2">
<ImageIcon className="w-4 h-4" />
Image
</TabsTrigger>
<TabsTrigger value="figure" className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2">
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M4 12h16m0 0l-6-6m6 6l-6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Arrow
</TabsTrigger>
</TabsList>
{/* Text Content */}
<TabsContent value="text" className="mt-0 space-y-4">
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">Text Content</label>
<textarea
value={annotation.textContent || annotation.content}
onChange={(e) => onContentChange(e.target.value)}
placeholder="Enter your text..."
rows={5}
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-slate-200 text-sm placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-[#34B27B] focus:border-transparent resize-none"
/>
</div>
{/* Styling Controls */}
<div className="space-y-4">
{/* Font Family & Size */}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">Font Style</label>
<Select
value={annotation.style.fontFamily}
onValueChange={(value) => onStyleChange({ fontFamily: value })}
>
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 h-9 text-xs">
<SelectValue placeholder="Select style" />
</SelectTrigger>
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200">
{FONT_FAMILIES.map((font) => (
<SelectItem key={font.value} value={font.value} style={{ fontFamily: font.value }}>
{font.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">Size</label>
<Select
value={annotation.style.fontSize.toString()}
onValueChange={(value) => onStyleChange({ fontSize: parseInt(value) })}
>
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 h-9 text-xs">
<SelectValue placeholder="Size" />
</SelectTrigger>
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200 max-h-[200px]">
{FONT_SIZES.map((size) => (
<SelectItem key={size} value={size.toString()}>
{size}px
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Formatting Toggles */}
<div className="flex items-center justify-between gap-2">
<ToggleGroup type="multiple" className="justify-start bg-white/5 p-1 rounded-lg border border-white/5">
<ToggleGroupItem
value="bold"
aria-label="Toggle bold"
data-state={annotation.style.fontWeight === 'bold' ? 'on' : 'off'}
onClick={() => onStyleChange({ fontWeight: annotation.style.fontWeight === 'bold' ? 'normal' : 'bold' })}
className="h-8 w-8 data-[state=on]:bg-[#34B27B] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
>
<Bold className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem
value="italic"
aria-label="Toggle italic"
data-state={annotation.style.fontStyle === 'italic' ? 'on' : 'off'}
onClick={() => onStyleChange({ fontStyle: annotation.style.fontStyle === 'italic' ? 'normal' : 'italic' })}
className="h-8 w-8 data-[state=on]:bg-[#34B27B] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
>
<Italic className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem
value="underline"
aria-label="Toggle underline"
data-state={annotation.style.textDecoration === 'underline' ? 'on' : 'off'}
onClick={() => onStyleChange({ textDecoration: annotation.style.textDecoration === 'underline' ? 'none' : 'underline' })}
className="h-8 w-8 data-[state=on]:bg-[#34B27B] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
>
<Underline className="h-4 w-4" />
</ToggleGroupItem>
</ToggleGroup>
<ToggleGroup type="single" value={annotation.style.textAlign} className="justify-start bg-white/5 p-1 rounded-lg border border-white/5">
<ToggleGroupItem
value="left"
aria-label="Align left"
onClick={() => onStyleChange({ textAlign: 'left' })}
className="h-8 w-8 data-[state=on]:bg-[#34B27B] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
>
<AlignLeft className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem
value="center"
aria-label="Align center"
onClick={() => onStyleChange({ textAlign: 'center' })}
className="h-8 w-8 data-[state=on]:bg-[#34B27B] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
>
<AlignCenter className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem
value="right"
aria-label="Align right"
onClick={() => onStyleChange({ textAlign: 'right' })}
className="h-8 w-8 data-[state=on]:bg-[#34B27B] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
>
<AlignRight className="h-4 w-4" />
</ToggleGroupItem>
</ToggleGroup>
</div>
{/* Colors */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">Text Color</label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full h-9 justify-start gap-2 bg-white/5 border-white/10 hover:bg-white/10 px-2"
>
<div
className="w-4 h-4 rounded-full border border-white/20"
style={{ backgroundColor: annotation.style.color }}
/>
<span className="text-xs text-slate-300 truncate flex-1 text-left">
{annotation.style.color}
</span>
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl">
<Block
color={annotation.style.color}
colors={colorPalette}
onChange={(color) => {
onStyleChange({ color: color.hex });
}}
style={{
borderRadius: '8px',
}}
/>
</PopoverContent>
</Popover>
</div>
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">Background</label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full h-9 justify-start gap-2 bg-white/5 border-white/10 hover:bg-white/10 px-2"
>
<div
className="w-4 h-4 rounded-full border border-white/20 relative overflow-hidden"
>
<div className="absolute inset-0 checkerboard-bg opacity-50" />
<div
className="absolute inset-0"
style={{ backgroundColor: annotation.style.backgroundColor }}
/>
</div>
<span className="text-xs text-slate-300 truncate flex-1 text-left">
{annotation.style.backgroundColor === 'transparent' ? 'None' : 'Color'}
</span>
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl">
<Block
color={annotation.style.backgroundColor === 'transparent' ? '#000000' : annotation.style.backgroundColor}
colors={colorPalette}
onChange={(color) => {
onStyleChange({ backgroundColor: color.hex });
}}
style={{
borderRadius: '8px',
}}
/>
<Button
variant="ghost"
size="sm"
className="w-full mt-2 text-xs h-7 hover:bg-white/5 text-slate-400"
onClick={() => {
onStyleChange({ backgroundColor: 'transparent' });
}}
>
Clear Background
</Button>
</PopoverContent>
</Popover>
</div>
</div>
</div>
</TabsContent>
{/* Image Upload */}
<TabsContent value="image" className="mt-0 space-y-4">
<input
type="file"
ref={fileInputRef}
onChange={handleImageUpload}
accept=".jpg,.jpeg,.png,.gif,.webp,image/*"
className="hidden"
/>
<Button
onClick={() => fileInputRef.current?.click()}
variant="outline"
className="w-full gap-2 bg-white/5 text-slate-200 border-white/10 hover:bg-[#34B27B] hover:text-white hover:border-[#34B27B] transition-all py-8"
>
<Upload className="w-5 h-5" />
Upload Image
</Button>
{annotation.content && annotation.content.startsWith('data:image') && (
<div className="rounded-lg border border-white/10 overflow-hidden bg-white/5 p-2">
<img
src={annotation.content}
alt="Uploaded annotation"
className="w-full h-auto rounded-md"
/>
</div>
)}
<p className="text-xs text-slate-500 text-center leading-relaxed">
Supported formats: JPG, PNG, GIF, WebP
</p>
</TabsContent>
<TabsContent value="figure" className="mt-0 space-y-4">
<div>
<label className="text-xs font-medium text-slate-200 mb-3 block">Arrow Direction</label>
<div className="grid grid-cols-4 gap-2">
{([
'up', 'down', 'left', 'right',
'up-right', 'up-left', 'down-right', 'down-left',
] as ArrowDirection[]).map((direction) => {
const ArrowComponent = getArrowComponent(direction);
return (
<button
key={direction}
onClick={() => {
const newFigureData: FigureData = {
...annotation.figureData!,
arrowDirection: direction,
};
onFigureDataChange?.(newFigureData);
}}
className={cn(
"h-16 rounded-lg border flex items-center justify-center transition-all p-2",
annotation.figureData?.arrowDirection === direction
? "bg-[#34B27B] border-[#34B27B]"
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20"
)}
>
<ArrowComponent
color={annotation.figureData?.arrowDirection === direction ? "#ffffff" : "#94a3b8"}
strokeWidth={3}
/>
</button>
);
})}
</div>
</div>
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">
Stroke Width: {annotation.figureData?.strokeWidth || 4}px
</label>
<Slider
value={[annotation.figureData?.strokeWidth || 4]}
onValueChange={([value]) => {
const newFigureData: FigureData = {
...annotation.figureData!,
strokeWidth: value,
};
onFigureDataChange?.(newFigureData);
}}
min={1}
max={6}
step={1}
className="w-full"
/>
</div>
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">Arrow Color</label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full h-10 justify-start gap-2 bg-white/5 border-white/10 hover:bg-white/10"
>
<div
className="w-5 h-5 rounded-full border border-white/20"
style={{ backgroundColor: annotation.figureData?.color || '#34B27B' }}
/>
<span className="text-xs text-slate-300 truncate flex-1 text-left">
{annotation.figureData?.color || '#34B27B'}
</span>
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl">
<Block
color={annotation.figureData?.color || '#34B27B'}
colors={colorPalette}
onChange={(color) => {
const newFigureData: FigureData = {
...annotation.figureData!,
color: color.hex,
};
onFigureDataChange?.(newFigureData);
}}
style={{
borderRadius: '8px',
}}
/>
</PopoverContent>
</Popover>
</div>
</TabsContent>
</Tabs>
<Button
onClick={onDelete}
variant="destructive"
size="sm"
className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all mt-4"
>
<Trash2 className="w-4 h-4" />
Delete Annotation
</Button>
<div className="mt-6 p-3 bg-white/5 rounded-lg border border-white/5">
<div className="flex items-center gap-2 mb-2 text-slate-300">
<Info className="w-3.5 h-3.5" />
<span className="text-xs font-medium">Shortcuts & Tips</span>
</div>
<ul className="text-[10px] text-slate-400 space-y-1.5 list-disc pl-3 leading-relaxed">
<li>Move playhead to overlapping annotation section and select an item.</li>
<li>Use <kbd className="px-1 py-0.5 bg-white/10 rounded text-slate-300 font-mono">Tab</kbd> to cycle through overlapping items.</li>
<li>Use <kbd className="px-1 py-0.5 bg-white/10 rounded text-slate-300 font-mono">Shift+Tab</kbd> to cycle backwards.</li>
</ul>
</div>
</div>
</div>
);
}
+194
View File
@@ -0,0 +1,194 @@
import type { ArrowDirection } from './types';
interface ArrowSvgProps {
color: string;
strokeWidth: number;
className?: string;
}
/**
* Inline SVG arrow components for 8 directions.
* These match the visual style of the previous icon-based arrows but use
* pure SVG paths for easy replication in export.
*/
export function ArrowUp({ color, strokeWidth, className }: ArrowSvgProps) {
return (
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
<defs>
<filter id="arrow-shadow">
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
</filter>
</defs>
<path
d="M 50 20 L 50 80 M 50 20 L 35 35 M 50 20 L 65 35"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
filter="url(#arrow-shadow)"
/>
</svg>
);
}
export function ArrowDown({ color, strokeWidth, className }: ArrowSvgProps) {
return (
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
<defs>
<filter id="arrow-shadow">
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
</filter>
</defs>
<path
d="M 50 20 L 50 80 M 50 80 L 35 65 M 50 80 L 65 65"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
filter="url(#arrow-shadow)"
/>
</svg>
);
}
export function ArrowLeft({ color, strokeWidth, className }: ArrowSvgProps) {
return (
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
<defs>
<filter id="arrow-shadow">
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
</filter>
</defs>
<path
d="M 80 50 L 20 50 M 20 50 L 35 35 M 20 50 L 35 65"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
filter="url(#arrow-shadow)"
/>
</svg>
);
}
export function ArrowRight({ color, strokeWidth, className }: ArrowSvgProps) {
return (
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
<defs>
<filter id="arrow-shadow">
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
</filter>
</defs>
<path
d="M 20 50 L 80 50 M 80 50 L 65 35 M 80 50 L 65 65"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
filter="url(#arrow-shadow)"
/>
</svg>
);
}
export function ArrowUpRight({ color, strokeWidth, className }: ArrowSvgProps) {
return (
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
<defs>
<filter id="arrow-shadow">
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
</filter>
</defs>
<path
d="M 25 75 L 75 25 M 75 25 L 60 30 M 75 25 L 70 40"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
filter="url(#arrow-shadow)"
/>
</svg>
);
}
export function ArrowUpLeft({ color, strokeWidth, className }: ArrowSvgProps) {
return (
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
<defs>
<filter id="arrow-shadow">
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
</filter>
</defs>
<path
d="M 75 75 L 25 25 M 25 25 L 40 30 M 25 25 L 30 40"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
filter="url(#arrow-shadow)"
/>
</svg>
);
}
export function ArrowDownRight({ color, strokeWidth, className }: ArrowSvgProps) {
return (
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
<defs>
<filter id="arrow-shadow">
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
</filter>
</defs>
<path
d="M 25 25 L 75 75 M 75 75 L 70 60 M 75 75 L 60 70"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
filter="url(#arrow-shadow)"
/>
</svg>
);
}
export function ArrowDownLeft({ color, strokeWidth, className }: ArrowSvgProps) {
return (
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
<defs>
<filter id="arrow-shadow">
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
</filter>
</defs>
<path
d="M 75 25 L 25 75 M 25 75 L 30 60 M 25 75 L 40 70"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
filter="url(#arrow-shadow)"
/>
</svg>
);
}
export function getArrowComponent(direction: ArrowDirection) {
switch (direction) {
case 'up': return ArrowUp;
case 'down': return ArrowDown;
case 'left': return ArrowLeft;
case 'right': return ArrowRight;
case 'up-right': return ArrowUpRight;
case 'up-left': return ArrowUpLeft;
case 'down-right': return ArrowDownRight;
case 'down-left': return ArrowDownLeft;
}
}
+13 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { type AspectRatio } from "@/utils/aspectRatioUtils";
interface CropRegion {
x: number; // 0-1 normalized
@@ -12,6 +13,7 @@ interface CropControlProps {
videoElement: HTMLVideoElement | null;
cropRegion: CropRegion;
onCropChange: (region: CropRegion) => void;
aspectRatio: AspectRatio;
}
type DragHandle = 'top' | 'right' | 'bottom' | 'left' | null;
@@ -114,12 +116,22 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
const cropPixelY = cropRegion.y * 100;
const cropPixelWidth = cropRegion.width * 100;
const cropPixelHeight = cropRegion.height * 100;
const videoAspectRatio = videoElement ? videoElement.videoWidth / videoElement.videoHeight : 16/9;
const isVideoPortrait = videoAspectRatio < 1;
const maxContainerWidth = isVideoPortrait ? '40vw' : '75vw';
const maxContainerHeight = '75vh';
return (
<div className="w-full p-8">
<div
ref={containerRef}
className="relative w-full aspect-video bg-black rounded-lg overflow-visible cursor-default select-none shadow-2xl"
className="relative w-full bg-black rounded-lg overflow-visible cursor-default select-none shadow-2xl"
style={{
aspectRatio: videoAspectRatio,
maxWidth: maxContainerWidth,
maxHeight: maxContainerHeight,
margin: '0 auto',
}}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
+1 -7
View File
@@ -114,13 +114,7 @@ export function ExportDialog({
</div>
</div>
<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">Current Frame</div>
<div className="text-slate-200 font-mono text-lg font-medium">
{progress.currentFrame} <span className="text-slate-500 text-sm">/ {progress.totalFrames}</span>
</div>
</div>
<div className="grid grid-cols-1 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]">
@@ -1,6 +1,28 @@
import { HelpCircle } from "lucide-react";
import { useState, useEffect } from "react";
import { formatShortcut } from "@/utils/platformUtils";
export function KeyboardShortcutsHelp() {
const [shortcuts, setShortcuts] = useState({
delete: 'Ctrl + D',
pan: 'Shift + Ctrl + Scroll',
zoom: 'Ctrl + Scroll'
});
useEffect(() => {
Promise.all([
formatShortcut(['mod', 'D']),
formatShortcut(['shift', 'mod', 'Scroll']),
formatShortcut(['mod', 'Scroll'])
]).then(([deleteKey, panKey, zoomKey]) => {
setShortcuts({
delete: deleteKey,
pan: panKey,
zoom: zoomKey
});
});
}, []);
return (
<div className="relative group">
<HelpCircle className="w-4 h-4 text-slate-500 hover:text-[#34B27B] transition-colors cursor-help" />
@@ -11,21 +33,33 @@ export function KeyboardShortcutsHelp() {
<span className="text-slate-400">Add Zoom</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">Z</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Add Annotation</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">A</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Add Keyframe</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">F</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Add Trim</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">T</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Delete Selected</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">⌘ + D</kbd>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{shortcuts.delete}</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Pan Timeline</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">⇧ + ⌘ + Scroll</kbd>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{shortcuts.pan}</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Zoom Timeline</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">⌘ + Scroll</kbd>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{shortcuts.zoom}</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Pause/Play</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">Space</kbd>
</div>
</div>
</div>
+218 -35
View File
@@ -6,16 +6,18 @@ import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import Colorful from '@uiw/react-color-colorful';
import { hsvaToHex } from '@uiw/color-convert';
import Block from '@uiw/react-color-block';
import { Trash2, Download, Crop, X, Bug, Upload } from "lucide-react";
import { GiHearts } from "react-icons/gi";
import { toast } from "sonner";
import type { ZoomDepth, CropRegion } from "./types";
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";
const WALLPAPER_COUNT = 20;
const WALLPAPER_COUNT = 18;
const WALLPAPER_RELATIVE = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `wallpapers/wallpaper${i + 1}.jpg`);
const GRADIENTS = [
"linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(253,129,82,1) 54.8%, rgba(249,202,86,1) 86.3% )",
@@ -51,14 +53,32 @@ interface SettingsPanelProps {
onZoomDepthChange?: (depth: ZoomDepth) => void;
selectedZoomId?: string | null;
onZoomDelete?: (id: string) => void;
selectedTrimId?: string | null;
onTrimDelete?: (id: string) => void;
shadowIntensity?: number;
onShadowChange?: (intensity: number) => void;
showBlur?: boolean;
onBlurChange?: (showBlur: boolean) => void;
motionBlurEnabled?: boolean;
onMotionBlurChange?: (enabled: boolean) => void;
borderRadius?: number;
onBorderRadiusChange?: (radius: number) => void;
padding?: number;
onPaddingChange?: (padding: number) => void;
cropRegion?: CropRegion;
onCropChange?: (region: CropRegion) => void;
aspectRatio: AspectRatio;
videoElement?: HTMLVideoElement | null;
exportQuality?: ExportQuality;
onExportQualityChange?: (quality: ExportQuality) => void;
onExport?: () => void;
selectedAnnotationId?: string | null;
annotationRegions?: AnnotationRegion[];
onAnnotationContentChange?: (id: string, content: string) => void;
onAnnotationTypeChange?: (id: string, type: AnnotationType) => void;
onAnnotationStyleChange?: (id: string, style: Partial<AnnotationRegion['style']>) => void;
onAnnotationFigureDataChange?: (id: string, figureData: any) => void;
onAnnotationDelete?: (id: string) => void;
}
export default SettingsPanel;
@@ -72,7 +92,40 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [
{ depth: 6, label: "5×" },
];
export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth, onZoomDepthChange, selectedZoomId, onZoomDelete, shadowIntensity = 0, onShadowChange, showBlur, onBlurChange, cropRegion, onCropChange, videoElement, onExport }: SettingsPanelProps) {
export function SettingsPanel({
selected,
onWallpaperChange,
selectedZoomDepth,
onZoomDepthChange,
selectedZoomId,
onZoomDelete,
selectedTrimId,
onTrimDelete,
shadowIntensity = 0,
onShadowChange,
showBlur,
onBlurChange,
motionBlurEnabled = true,
onMotionBlurChange,
borderRadius = 0,
onBorderRadiusChange,
padding = 50,
onPaddingChange,
cropRegion,
onCropChange,
aspectRatio,
videoElement,
exportQuality = 'good',
onExportQualityChange,
onExport,
selectedAnnotationId,
annotationRegions = [],
onAnnotationContentChange,
onAnnotationTypeChange,
onAnnotationStyleChange,
onAnnotationFigureDataChange,
onAnnotationDelete,
}: SettingsPanelProps) {
const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]);
const [customImages, setCustomImages] = useState<string[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -89,11 +142,18 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
})()
return () => { mounted = false }
}, [])
const [hsva, setHsva] = useState({ h: 0, s: 0, v: 68, a: 1 });
const colorPalette = [
'#FF0000', '#FFD700', '#00FF00', '#FFFFFF', '#0000FF', '#FF6B00',
'#9B59B6', '#E91E63', '#00BCD4', '#FF5722', '#8BC34A', '#FFC107',
'#34B27B', '#000000', '#607D8B', '#795548',
];
const [selectedColor, setSelectedColor] = useState('#ADADAD');
const [gradient, setGradient] = useState<string>(GRADIENTS[0]);
const [showCropDropdown, setShowCropDropdown] = useState(false);
const zoomEnabled = Boolean(selectedZoomDepth);
const trimEnabled = Boolean(selectedTrimId);
const handleDeleteClick = () => {
if (selectedZoomId && onZoomDelete) {
@@ -101,6 +161,12 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
}
};
const handleTrimDeleteClick = () => {
if (selectedTrimId && onTrimDelete) {
onTrimDelete(selectedTrimId);
}
};
const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files || files.length === 0) return;
@@ -148,6 +214,25 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
}
};
// Find selected annotation
const selectedAnnotation = selectedAnnotationId
? annotationRegions.find(a => a.id === selectedAnnotationId)
: null;
// If an annotation is selected, show annotation settings instead
if (selectedAnnotation && onAnnotationContentChange && onAnnotationTypeChange && onAnnotationStyleChange && onAnnotationDelete) {
return (
<AnnotationSettingsPanel
annotation={selectedAnnotation}
onContentChange={(content) => onAnnotationContentChange(selectedAnnotation.id, content)}
onTypeChange={(type) => onAnnotationTypeChange(selectedAnnotation.id, type)}
onStyleChange={(style) => onAnnotationStyleChange(selectedAnnotation.id, style)}
onFigureDataChange={onAnnotationFigureDataChange ? (figureData) => onAnnotationFigureDataChange(selectedAnnotation.id, figureData) : undefined}
onDelete={() => onAnnotationDelete(selectedAnnotation.id)}
/>
);
}
return (
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl p-4 flex flex-col shadow-xl h-full overflow-y-auto custom-scrollbar">
<div className="mb-6">
@@ -201,21 +286,30 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
)}
</div>
{/* Trim Delete Section */}
<div className="mb-6">
{trimEnabled && (
<Button
onClick={handleTrimDeleteClick}
variant="destructive"
size="sm"
className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all"
>
<Trash2 className="w-4 h-4" />
Delete Trim Region
</Button>
)}
</div>
<div className="mb-6">
<div className="grid grid-cols-2 gap-3">
{/* Drop Shadow Slider */}
<div className="p-3 rounded-xl bg-white/5 border border-white/5 space-y-2">
<div className="flex items-center justify-between">
<div className="text-xs font-medium text-slate-200">Shadow</div>
<span className="text-[10px] text-slate-400 font-mono">{Math.round(shadowIntensity * 100)}%</span>
</div>
<Slider
value={[shadowIntensity]}
onValueChange={(values) => onShadowChange?.(values[0])}
min={0}
max={1}
step={0.01}
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B]"
{/* Motion Blur Switch */}
<div className="flex items-center justify-between p-3 rounded-xl bg-white/5 border border-white/5">
<div className="text-xs font-medium text-slate-200">Motion Blur</div>
<Switch
checked={motionBlurEnabled}
onCheckedChange={onMotionBlurChange}
className="data-[state=checked]:bg-[#34B27B]"
/>
</div>
{/* Blur Background Switch */}
@@ -230,18 +324,65 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
</div>
</div>
<div className="mb-6">
<div className="mb-4">
<div className="grid grid-cols-2 gap-2.5">
{/* Drop Shadow Slider */}
<div className="p-2.5 rounded-xl bg-white/5 border border-white/5 space-y-1.5">
<div className="flex items-center justify-between">
<div className="text-xs font-medium text-slate-200">Shadow</div>
<span className="text-[10px] text-slate-400 font-mono">{Math.round(shadowIntensity * 100)}%</span>
</div>
<Slider
value={[shadowIntensity]}
onValueChange={(values) => onShadowChange?.(values[0])}
min={0}
max={1}
step={0.01}
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B]"
/>
</div>
{/* Corner Roundness Slider */}
<div className="p-2.5 rounded-xl bg-white/5 border border-white/5 space-y-1.5">
<div className="flex items-center justify-between">
<div className="text-xs font-medium text-slate-200">Roundness</div>
<span className="text-[10px] text-slate-400 font-mono">{borderRadius}px</span>
</div>
<Slider
value={[borderRadius]}
onValueChange={(values) => onBorderRadiusChange?.(values[0])}
min={0}
max={16}
step={0.5}
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B]"
/>
</div>
{/* Padding Slider */}
<div className="p-2.5 rounded-xl bg-white/5 border border-white/5 space-y-1.5">
<div className="flex items-center justify-between">
<div className="text-xs font-medium text-slate-200">Padding</div>
<span className="text-[10px] text-slate-400 font-mono">{padding}%</span>
</div>
<Slider
value={[padding]}
onValueChange={(values) => onPaddingChange?.(values[0])}
min={0}
max={100}
step={1}
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B]"
/>
</div>
</div>
</div>
<div className="mb-4">
<Button
onClick={() => setShowCropDropdown(!showCropDropdown)}
variant="outline"
className="w-full gap-2 bg-white/5 text-slate-200 border-white/10 hover:bg-white/10 hover:border-white/20 hover:text-white h-11 transition-all"
className="w-full gap-2 bg-white/5 text-slate-200 border-white/10 hover:bg-white/10 hover:border-white/20 hover:text-white h-9 transition-all"
>
<Crop className="w-4 h-4" />
Crop Video
</Button>
<p className="text-[10px] text-slate-500 text-center mt-3 px-4 leading-relaxed">
If the preview looks weirdly positioned or doesn't load, try force reloading the app a few times till it works.
</p>
</div>
{showCropDropdown && cropRegion && onCropChange && (
@@ -250,7 +391,7 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 animate-in fade-in duration-200"
onClick={() => setShowCropDropdown(false)}
/>
<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-5xl animate-in zoom-in-95 duration-200">
<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-5xl max-h-[90vh] overflow-auto animate-in zoom-in-95 duration-200">
<div className="flex items-center justify-between mb-6">
<div>
<span className="text-xl font-bold text-slate-200">Crop Video</span>
@@ -269,6 +410,7 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
videoElement={videoElement || null}
cropRegion={cropRegion}
onCropChange={onCropChange}
aspectRatio={aspectRatio}
/>
<div className="mt-6 flex justify-end">
<Button
@@ -291,7 +433,7 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
</TabsList>
<div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar pr-2">
<TabsContent value="image" className="mt-0 space-y-3">
<TabsContent value="image" className="mt-0 space-y-3 px-2">
{/* Upload Button */}
<input
type="file"
@@ -369,21 +511,24 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
</div>
</TabsContent>
<TabsContent value="color" className="mt-0">
<TabsContent value="color" className="mt-0 px-2">
<div className="p-1">
<Colorful
color={hsva}
disableAlpha={true}
<Block
color={selectedColor}
colors={colorPalette}
onChange={(color) => {
setHsva(color.hsva);
onWallpaperChange(hsvaToHex(color.hsva));
setSelectedColor(color.hex);
onWallpaperChange(color.hex);
}}
style={{
width: '100%',
borderRadius: '12px',
}}
style={{ width: '100%', borderRadius: '12px' }}
/>
</div>
</TabsContent>
<TabsContent value="gradient" className="mt-0">
<TabsContent value="gradient" className="mt-0 px-2">
<div className="grid grid-cols-6 gap-2.5">
{GRADIENTS.map((g, idx) => (
<div
@@ -405,7 +550,45 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
</div>
</Tabs>
<div className="mt-6 pt-6 border-t border-white/5">
<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>
</div>
<Button
type="button"
size="lg"
+363 -39
View File
@@ -16,15 +16,23 @@ import {
DEFAULT_ZOOM_DEPTH,
clampFocusToDepth,
DEFAULT_CROP_REGION,
DEFAULT_ANNOTATION_POSITION,
DEFAULT_ANNOTATION_SIZE,
DEFAULT_ANNOTATION_STYLE,
DEFAULT_FIGURE_DATA,
type ZoomDepth,
type ZoomFocus,
type ZoomRegion,
type TrimRegion,
type AnnotationRegion,
type CropRegion,
type FigureData,
} from "./types";
import { VideoExporter, type ExportProgress } from "@/lib/exporter";
import { VideoExporter, type ExportProgress, type ExportQuality } from "@/lib/exporter";
import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils";
import { getAssetPath } from "@/lib/assetPath";
const WALLPAPER_COUNT = 20;
const WALLPAPER_COUNT = 18;
const WALLPAPER_PATHS = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `/wallpapers/wallpaper${i + 1}.jpg`);
export default function VideoEditor() {
@@ -37,19 +45,28 @@ export default function VideoEditor() {
const [wallpaper, setWallpaper] = useState<string>(WALLPAPER_PATHS[0]);
const [shadowIntensity, setShadowIntensity] = useState(0);
const [showBlur, setShowBlur] = useState(false);
const [motionBlurEnabled, setMotionBlurEnabled] = useState(true);
const [borderRadius, setBorderRadius] = useState(0);
const [padding, setPadding] = useState(50);
const [cropRegion, setCropRegion] = useState<CropRegion>(DEFAULT_CROP_REGION);
const [zoomRegions, setZoomRegions] = useState<ZoomRegion[]>([]);
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
const [trimRegions, setTrimRegions] = useState<TrimRegion[]>([]);
const [selectedTrimId, setSelectedTrimId] = useState<string | null>(null);
const [annotationRegions, setAnnotationRegions] = useState<AnnotationRegion[]>([]);
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
const [isExporting, setIsExporting] = useState(false);
const [exportProgress, setExportProgress] = useState<ExportProgress | null>(null);
const [exportError, setExportError] = useState<string | null>(null);
const [showExportDialog, setShowExportDialog] = useState(false);
const [aspectRatio, setAspectRatio] = useState<AspectRatio>('16:9');
const [exportQuality, setExportQuality] = useState<ExportQuality>('good');
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
const nextZoomIdRef = useRef(1);
const nextTrimIdRef = useRef(1);
const nextAnnotationIdRef = useRef(1);
const nextAnnotationZIndexRef = useRef(1); // Track z-index for stacking order
const exporterRef = useRef<VideoExporter | null>(null);
// Helper to convert file path to proper file:// URL
@@ -88,6 +105,23 @@ export default function VideoEditor() {
loadVideo();
}, []);
// Initialize default wallpaper with resolved asset path
useEffect(() => {
let mounted = true;
(async () => {
try {
const resolvedPath = await getAssetPath('wallpapers/wallpaper1.jpg');
if (mounted) {
setWallpaper(resolvedPath);
}
} catch (err) {
// If resolution fails, keep the fallback
console.warn('Failed to resolve default wallpaper path:', err);
}
})();
return () => { mounted = false };
}, []);
function togglePlayPause() {
const playback = videoPlaybackRef.current;
const video = playback?.video;
@@ -113,7 +147,18 @@ export default function VideoEditor() {
const handleSelectTrim = useCallback((id: string | null) => {
setSelectedTrimId(id);
if (id) setSelectedZoomId(null);
if (id) {
setSelectedZoomId(null);
setSelectedAnnotationId(null);
}
}, []);
const handleSelectAnnotation = useCallback((id: string | null) => {
setSelectedAnnotationId(id);
if (id) {
setSelectedZoomId(null);
setSelectedTrimId(null);
}
}, []);
const handleZoomAdded = useCallback((span: Span) => {
@@ -125,10 +170,10 @@ export default function VideoEditor() {
depth: DEFAULT_ZOOM_DEPTH,
focus: { cx: 0.5, cy: 0.5 },
};
console.log('Zoom region added:', newRegion);
setZoomRegions((prev) => [...prev, newRegion]);
setSelectedZoomId(id);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
}, []);
const handleTrimAdded = useCallback((span: Span) => {
@@ -138,14 +183,13 @@ export default function VideoEditor() {
startMs: Math.round(span.start),
endMs: Math.round(span.end),
};
console.log('Trim region added:', newRegion);
setTrimRegions((prev) => [...prev, newRegion]);
setSelectedTrimId(id);
setSelectedZoomId(null);
setSelectedAnnotationId(null);
}, []);
const handleZoomSpanChange = useCallback((id: string, span: Span) => {
console.log('Zoom span changed:', { id, start: Math.round(span.start), end: Math.round(span.end) });
setZoomRegions((prev) =>
prev.map((region) =>
region.id === id
@@ -160,7 +204,6 @@ export default function VideoEditor() {
}, []);
const handleTrimSpanChange = useCallback((id: string, span: Span) => {
console.log('Trim span changed:', { id, start: Math.round(span.start), end: Math.round(span.end) });
setTrimRegions((prev) =>
prev.map((region) =>
region.id === id
@@ -203,7 +246,6 @@ export default function VideoEditor() {
}, [selectedZoomId]);
const handleZoomDelete = useCallback((id: string) => {
console.log('Zoom region deleted:', id);
setZoomRegions((prev) => prev.filter((region) => region.id !== id));
if (selectedZoomId === id) {
setSelectedZoomId(null);
@@ -211,13 +253,169 @@ export default function VideoEditor() {
}, [selectedZoomId]);
const handleTrimDelete = useCallback((id: string) => {
console.log('Trim region deleted:', id);
setTrimRegions((prev) => prev.filter((region) => region.id !== id));
if (selectedTrimId === id) {
setSelectedTrimId(null);
}
}, [selectedTrimId]);
const handleAnnotationAdded = useCallback((span: Span) => {
const id = `annotation-${nextAnnotationIdRef.current++}`;
const zIndex = nextAnnotationZIndexRef.current++; // Assign z-index based on creation order
const newRegion: AnnotationRegion = {
id,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
type: 'text',
content: 'Enter text...',
position: { ...DEFAULT_ANNOTATION_POSITION },
size: { ...DEFAULT_ANNOTATION_SIZE },
style: { ...DEFAULT_ANNOTATION_STYLE },
zIndex,
};
setAnnotationRegions((prev) => [...prev, newRegion]);
setSelectedAnnotationId(id);
setSelectedZoomId(null);
setSelectedTrimId(null);
}, []);
const handleAnnotationSpanChange = useCallback((id: string, span: Span) => {
setAnnotationRegions((prev) =>
prev.map((region) =>
region.id === id
? {
...region,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
}
: region,
),
);
}, []);
const handleAnnotationDelete = useCallback((id: string) => {
setAnnotationRegions((prev) => prev.filter((region) => region.id !== id));
if (selectedAnnotationId === id) {
setSelectedAnnotationId(null);
}
}, [selectedAnnotationId]);
const handleAnnotationContentChange = useCallback((id: string, content: string) => {
setAnnotationRegions((prev) => {
const updated = prev.map((region) => {
if (region.id !== id) return region;
// Store content in type-specific fields
if (region.type === 'text') {
return { ...region, content, textContent: content };
} else if (region.type === 'image') {
return { ...region, content, imageContent: content };
} else {
return { ...region, content };
}
});
return updated;
});
}, []);;
const handleAnnotationTypeChange = useCallback((id: string, type: AnnotationRegion['type']) => {
setAnnotationRegions((prev) => {
const updated = prev.map((region) => {
if (region.id !== id) return region;
const updatedRegion = { ...region, type };
// Restore content from type-specific storage
if (type === 'text') {
updatedRegion.content = region.textContent || 'Enter text...';
} else if (type === 'image') {
updatedRegion.content = region.imageContent || '';
} else if (type === 'figure') {
updatedRegion.content = '';
if (!region.figureData) {
updatedRegion.figureData = { ...DEFAULT_FIGURE_DATA };
}
}
return updatedRegion;
});
return updated;
});
}, []);
const handleAnnotationStyleChange = useCallback((id: string, style: Partial<AnnotationRegion['style']>) => {
setAnnotationRegions((prev) =>
prev.map((region) =>
region.id === id
? { ...region, style: { ...region.style, ...style } }
: region,
),
);
}, []);
const handleAnnotationFigureDataChange = useCallback((id: string, figureData: FigureData) => {
setAnnotationRegions((prev) =>
prev.map((region) =>
region.id === id
? { ...region, figureData }
: region,
),
);
}, []);
const handleAnnotationPositionChange = useCallback((id: string, position: { x: number; y: number }) => {
setAnnotationRegions((prev) =>
prev.map((region) =>
region.id === id
? { ...region, position }
: region,
),
);
}, []);
const handleAnnotationSizeChange = useCallback((id: string, size: { width: number; height: number }) => {
setAnnotationRegions((prev) =>
prev.map((region) =>
region.id === id
? { ...region, size }
: region,
),
);
}, []);
// Global Tab prevention
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Tab') {
// Allow tab only in inputs/textareas
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}
e.preventDefault();
}
if (e.key === ' ' || e.code === 'Space') {
// Allow space only in inputs/textareas
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}
e.preventDefault();
const playback = videoPlaybackRef.current;
if (playback?.video) {
if (playback.video.paused) {
playback.play().catch(console.error);
} else {
playback.pause();
}
}
}
};
window.addEventListener('keydown', handleKeyDown, { capture: true });
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
}, []);
useEffect(() => {
if (selectedZoomId && !zoomRegions.some((region) => region.id === selectedZoomId)) {
setSelectedZoomId(null);
@@ -230,6 +428,12 @@ export default function VideoEditor() {
}
}, [selectedTrimId, trimRegions]);
useEffect(() => {
if (selectedAnnotationId && !annotationRegions.some((region) => region.id === selectedAnnotationId)) {
setSelectedAnnotationId(null);
}
}, [selectedAnnotationId, annotationRegions]);
const handleExport = useCallback(async () => {
if (!videoPath) {
toast.error('No video loaded');
@@ -260,22 +464,100 @@ export default function VideoEditor() {
return;
}
const width = video.videoWidth || 1920;
const height = video.videoHeight || 1080;
const aspectRatioValue = getAspectRatioValue(aspectRatio);
const sourceWidth = video.videoWidth || 1920;
const sourceHeight = video.videoHeight || 1080;
let exportWidth: number;
let exportHeight: number;
let bitrate: number;
// Calculate visually lossless bitrate matching screen recording optimization
const totalPixels = width * height;
let bitrate = 30_000_000;
if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) {
bitrate = 50_000_000;
} else if (totalPixels > 2560 * 1440) {
bitrate = 80_000_000;
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;
const exporter = new VideoExporter({
videoUrl: videoPath,
width,
height,
width: exportWidth,
height: exportHeight,
frameRate: 60,
bitrate,
codec: 'avc1.640033',
@@ -285,7 +567,13 @@ export default function VideoEditor() {
showShadow: shadowIntensity > 0,
shadowIntensity,
showBlur,
motionBlurEnabled,
borderRadius,
padding,
cropRegion,
annotationRegions,
previewWidth,
previewHeight,
onProgress: (progress: ExportProgress) => {
setExportProgress(progress);
},
@@ -326,7 +614,7 @@ export default function VideoEditor() {
setIsExporting(false);
exporterRef.current = null;
}
}, [videoPath, wallpaper, zoomRegions, trimRegions, shadowIntensity, showBlur, cropRegion, isPlaying]);
}, [videoPath, wallpaper, zoomRegions, trimRegions, shadowIntensity, showBlur, motionBlurEnabled, borderRadius, padding, cropRegion, annotationRegions, isPlaying, aspectRatio, exportQuality]);
const handleCancelExport = useCallback(() => {
if (exporterRef.current) {
@@ -373,12 +661,14 @@ export default function VideoEditor() {
<div className="w-full h-full flex flex-col items-center justify-center bg-black/40 rounded-2xl border border-white/5 shadow-2xl overflow-hidden">
{/* Video preview */}
<div className="w-full flex justify-center items-center" style={{ flex: '1 1 auto', margin: '6px 0 0' }}>
<div className="relative" style={{ width: 'auto', height: '100%', aspectRatio: '16/9', maxWidth: '100%', margin: '0 auto', boxSizing: 'border-box' }}>
<div className="relative" style={{ width: 'auto', height: '100%', aspectRatio: getAspectRatioValue(aspectRatio), maxWidth: '100%', margin: '0 auto', boxSizing: 'border-box' }}>
<VideoPlayback
aspectRatio={aspectRatio}
ref={videoPlaybackRef}
videoPath={videoPath || ''}
onDurationChange={setDuration}
onTimeUpdate={setCurrentTime}
currentTime={currentTime}
onPlayStateChange={setIsPlaying}
onError={setError}
wallpaper={wallpaper}
@@ -390,8 +680,16 @@ export default function VideoEditor() {
showShadow={shadowIntensity > 0}
shadowIntensity={shadowIntensity}
showBlur={showBlur}
motionBlurEnabled={motionBlurEnabled}
borderRadius={borderRadius}
padding={padding}
cropRegion={cropRegion}
trimRegions={trimRegions}
annotationRegions={annotationRegions}
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
onAnnotationPositionChange={handleAnnotationPositionChange}
onAnnotationSizeChange={handleAnnotationSizeChange}
/>
</div>
</div>
@@ -418,43 +716,69 @@ export default function VideoEditor() {
<Panel defaultSize={30} minSize={20}>
<div className="h-full bg-[#09090b] rounded-2xl border border-white/5 shadow-lg overflow-hidden flex flex-col">
<TimelineEditor
videoDuration={duration}
currentTime={currentTime}
onSeek={handleSeek}
zoomRegions={zoomRegions}
onZoomAdded={handleZoomAdded}
onZoomSpanChange={handleZoomSpanChange}
onZoomDelete={handleZoomDelete}
selectedZoomId={selectedZoomId}
onSelectZoom={handleSelectZoom}
trimRegions={trimRegions}
onTrimAdded={handleTrimAdded}
onTrimSpanChange={handleTrimSpanChange}
onTrimDelete={handleTrimDelete}
selectedTrimId={selectedTrimId}
onSelectTrim={handleSelectTrim}
/>
videoDuration={duration}
currentTime={currentTime}
onSeek={handleSeek}
zoomRegions={zoomRegions}
onZoomAdded={handleZoomAdded}
onZoomSpanChange={handleZoomSpanChange}
onZoomDelete={handleZoomDelete}
selectedZoomId={selectedZoomId}
onSelectZoom={handleSelectZoom}
trimRegions={trimRegions}
onTrimAdded={handleTrimAdded}
onTrimSpanChange={handleTrimSpanChange}
onTrimDelete={handleTrimDelete}
selectedTrimId={selectedTrimId}
onSelectTrim={handleSelectTrim}
annotationRegions={annotationRegions}
onAnnotationAdded={handleAnnotationAdded}
onAnnotationSpanChange={handleAnnotationSpanChange}
onAnnotationDelete={handleAnnotationDelete}
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
aspectRatio={aspectRatio}
onAspectRatioChange={setAspectRatio}
/>
</div>
</Panel>
</PanelGroup>
</div>
{/* Right section: settings panel */}
<SettingsPanel
<SettingsPanel
selected={wallpaper}
onWallpaperChange={setWallpaper}
selectedZoomDepth={selectedZoomId ? zoomRegions.find(z => z.id === selectedZoomId)?.depth : null}
onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)}
selectedZoomId={selectedZoomId}
onZoomDelete={handleZoomDelete}
selectedTrimId={selectedTrimId}
onTrimDelete={handleTrimDelete}
shadowIntensity={shadowIntensity}
onShadowChange={setShadowIntensity}
showBlur={showBlur}
onBlurChange={setShowBlur}
motionBlurEnabled={motionBlurEnabled}
onMotionBlurChange={setMotionBlurEnabled}
borderRadius={borderRadius}
onBorderRadiusChange={setBorderRadius}
padding={padding}
onPaddingChange={setPadding}
cropRegion={cropRegion}
onCropChange={setCropRegion}
aspectRatio={aspectRatio}
videoElement={videoPlaybackRef.current?.video || null}
exportQuality={exportQuality}
onExportQualityChange={setExportQuality}
onExport={handleExport}
selectedAnnotationId={selectedAnnotationId}
annotationRegions={annotationRegions}
onAnnotationContentChange={handleAnnotationContentChange}
onAnnotationTypeChange={handleAnnotationTypeChange}
onAnnotationStyleChange={handleAnnotationStyleChange}
onAnnotationFigureDataChange={handleAnnotationFigureDataChange}
onAnnotationDelete={handleAnnotationDelete}
/>
</div>
+117 -16
View File
@@ -2,7 +2,7 @@ import type React from "react";
import { useEffect, useRef, useImperativeHandle, forwardRef, useState, useMemo, useCallback } from "react";
import { getAssetPath } from "@/lib/assetPath";
import { Application, Container, Sprite, Graphics, BlurFilter, Texture, VideoSource } from 'pixi.js';
import { ZOOM_DEPTH_SCALES, type ZoomRegion, type ZoomFocus, type ZoomDepth, type TrimRegion } from "./types";
import { ZOOM_DEPTH_SCALES, type ZoomRegion, type ZoomFocus, type ZoomDepth, type TrimRegion, type AnnotationRegion } from "./types";
import { DEFAULT_FOCUS, SMOOTHING_FACTOR, MIN_DELTA } from "./videoPlayback/constants";
import { clamp01 } from "./videoPlayback/mathUtils";
import { findDominantRegion } from "./videoPlayback/zoomRegionUtils";
@@ -11,11 +11,14 @@ import { updateOverlayIndicator } from "./videoPlayback/overlayUtils";
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
import { applyZoomTransform } from "./videoPlayback/zoomTransform";
import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers";
import { type AspectRatio, formatAspectRatioForCSS } from "@/utils/aspectRatioUtils";
import { AnnotationOverlay } from "./AnnotationOverlay";
interface VideoPlaybackProps {
videoPath: string;
onDurationChange: (duration: number) => void;
onTimeUpdate: (time: number) => void;
currentTime: number;
onPlayStateChange: (playing: boolean) => void;
onError: (error: string) => void;
wallpaper?: string;
@@ -27,8 +30,17 @@ interface VideoPlaybackProps {
showShadow?: boolean;
shadowIntensity?: number;
showBlur?: boolean;
motionBlurEnabled?: boolean;
borderRadius?: number;
padding?: number;
cropRegion?: import('./types').CropRegion;
trimRegions?: TrimRegion[];
aspectRatio: AspectRatio;
annotationRegions?: AnnotationRegion[];
selectedAnnotationId?: string | null;
onSelectAnnotation?: (id: string | null) => void;
onAnnotationPositionChange?: (id: string, position: { x: number; y: number }) => void;
onAnnotationSizeChange?: (id: string, size: { width: number; height: number }) => void;
}
export interface VideoPlaybackRef {
@@ -36,6 +48,7 @@ export interface VideoPlaybackRef {
app: Application | null;
videoSprite: Sprite | null;
videoContainer: Container | null;
containerRef: React.RefObject<HTMLDivElement>;
play: () => Promise<void>;
pause: () => void;
}
@@ -44,6 +57,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
videoPath,
onDurationChange,
onTimeUpdate,
currentTime,
onPlayStateChange,
onError,
wallpaper,
@@ -55,8 +69,17 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
showShadow,
shadowIntensity = 0,
showBlur,
motionBlurEnabled = true,
borderRadius = 0,
padding = 50,
cropRegion,
trimRegions = [],
aspectRatio,
annotationRegions = [],
selectedAnnotationId,
onSelectAnnotation,
onAnnotationPositionChange,
onAnnotationSizeChange,
}, ref) => {
const videoRef = useRef<HTMLVideoElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
@@ -88,6 +111,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
const lockedVideoDimensionsRef = useRef<{ width: number; height: number } | null>(null);
const layoutVideoContentRef = useRef<(() => void) | null>(null);
const trimRegionsRef = useRef<TrimRegion[]>([]);
const motionBlurEnabledRef = useRef(motionBlurEnabled);
const videoReadyRafRef = useRef<number | null>(null);
const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
return clampFocusToStageUtil(focus, depth, stageSizeRef.current);
@@ -147,6 +172,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
videoElement,
cropRegion,
lockedVideoDimensions: lockedVideoDimensionsRef.current,
borderRadius,
padding,
});
if (result) {
@@ -168,7 +195,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
updateOverlayForRegion(activeRegion);
}
}, [updateOverlayForRegion, cropRegion]);
}, [updateOverlayForRegion, cropRegion, borderRadius, padding]);
useEffect(() => {
layoutVideoContentRef.current = layoutVideoContent;
@@ -184,15 +211,13 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
app: appRef.current,
videoSprite: videoSpriteRef.current,
videoContainer: videoContainerRef.current,
containerRef,
play: async () => {
const video = videoRef.current;
if (!video) {
allowPlaybackRef.current = false;
return;
}
allowPlaybackRef.current = true;
const vid = videoRef.current;
if (!vid) return;
try {
await video.play();
allowPlaybackRef.current = true;
await vid.play();
} catch (error) {
allowPlaybackRef.current = false;
throw error;
@@ -294,6 +319,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
trimRegionsRef.current = trimRegions;
}, [trimRegions]);
useEffect(() => {
motionBlurEnabledRef.current = motionBlurEnabled;
}, [motionBlurEnabled]);
useEffect(() => {
if (!pixiReady || !videoReady) return;
@@ -351,6 +380,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
focusY: DEFAULT_FOCUS.cy,
motionIntensity: 0,
isPlaying: false,
motionBlurEnabled: motionBlurEnabledRef.current,
});
requestAnimationFrame(() => {
@@ -463,6 +493,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
video.pause();
video.currentTime = 0;
allowPlaybackRef.current = false;
lockedVideoDimensionsRef.current = null;
setVideoReady(false);
if (videoReadyRafRef.current) {
cancelAnimationFrame(videoReadyRafRef.current);
videoReadyRafRef.current = null;
}
}, [videoPath]);
@@ -585,6 +621,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
focusY: state.focusY,
motionIntensity,
isPlaying: isPlayingRef.current,
motionBlurEnabled: motionBlurEnabledRef.current,
});
};
@@ -673,13 +710,24 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
video.pause();
allowPlaybackRef.current = false;
currentTimeRef.current = 0;
// hacky fix: To ensure video is fully ready for PixiJS
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (videoReadyRafRef.current) {
cancelAnimationFrame(videoReadyRafRef.current);
videoReadyRafRef.current = null;
}
const waitForRenderableFrame = () => {
const hasDimensions = video.videoWidth > 0 && video.videoHeight > 0;
const hasData = video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA;
if (hasDimensions && hasData) {
videoReadyRafRef.current = null;
setVideoReady(true);
});
});
return;
}
videoReadyRafRef.current = requestAnimationFrame(waitForRenderableFrame);
};
videoReadyRafRef.current = requestAnimationFrame(waitForRenderableFrame);
};
const [resolvedWallpaper, setResolvedWallpaper] = useState<string | null>(null);
@@ -726,13 +774,22 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
return () => { mounted = false }
}, [wallpaper])
useEffect(() => {
return () => {
if (videoReadyRafRef.current) {
cancelAnimationFrame(videoReadyRafRef.current);
videoReadyRafRef.current = null;
}
};
}, [])
const isImageUrl = Boolean(resolvedWallpaper && (resolvedWallpaper.startsWith('file://') || resolvedWallpaper.startsWith('http') || resolvedWallpaper.startsWith('/') || resolvedWallpaper.startsWith('data:')))
const backgroundStyle = isImageUrl
? { backgroundImage: `url(${resolvedWallpaper || ''})` }
: { background: resolvedWallpaper || '' };
return (
<div className="relative aspect-video rounded-sm overflow-hidden" style={{ width: '100%' }}>
<div className="relative rounded-sm overflow-hidden" style={{ width: '100%', aspectRatio: formatAspectRatioForCSS(aspectRatio) }}>
{/* Background layer - always render as DOM element with blur */}
<div
className="absolute inset-0 bg-cover bg-center"
@@ -766,6 +823,50 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
className="absolute rounded-md border border-[#34B27B]/80 bg-[#34B27B]/20 shadow-[0_0_0_1px_rgba(52,178,123,0.35)]"
style={{ display: 'none', pointerEvents: 'none' }}
/>
{(() => {
const filtered = (annotationRegions || []).filter((annotation) => {
if (typeof annotation.startMs !== 'number' || typeof annotation.endMs !== 'number') return false;
if (annotation.id === selectedAnnotationId) return true;
const timeMs = Math.round(currentTime * 1000);
return timeMs >= annotation.startMs && timeMs <= annotation.endMs;
});
// Sort by z-index (lowest to highest) so higher z-index renders on top
const sorted = [...filtered].sort((a, b) => a.zIndex - b.zIndex);
// Handle click-through cycling: when clicking same annotation, cycle to next
const handleAnnotationClick = (clickedId: string) => {
if (!onSelectAnnotation) return;
// If clicking on already selected annotation and there are multiple overlapping
if (clickedId === selectedAnnotationId && sorted.length > 1) {
// Find current index and cycle to next
const currentIndex = sorted.findIndex(a => a.id === clickedId);
const nextIndex = (currentIndex + 1) % sorted.length;
onSelectAnnotation(sorted[nextIndex].id);
} else {
// First click or clicking different annotation
onSelectAnnotation(clickedId);
}
};
return sorted.map((annotation) => (
<AnnotationOverlay
key={annotation.id}
annotation={annotation}
isSelected={annotation.id === selectedAnnotationId}
containerWidth={overlayRef.current?.clientWidth || 800}
containerHeight={overlayRef.current?.clientHeight || 600}
onPositionChange={(id, position) => onAnnotationPositionChange?.(id, position)}
onSizeChange={(id, size) => onAnnotationSizeChange?.(id, size)}
onClick={handleAnnotationClick}
zIndex={annotation.zIndex}
isSelectedBoost={annotation.id === selectedAnnotationId}
/>
));
})()}
</div>
)}
<video
+26 -6
View File
@@ -1,7 +1,7 @@
import { useItem } from "dnd-timeline";
import type { Span } from "dnd-timeline";
import { cn } from "@/lib/utils";
import { ZoomIn, Scissors } from "lucide-react";
import { ZoomIn, Scissors, MessageSquare } from "lucide-react";
import glassStyles from "./ItemGlass.module.css";
interface ItemProps {
@@ -12,7 +12,7 @@ interface ItemProps {
isSelected?: boolean;
onSelect?: () => void;
zoomDepth?: number;
variant?: 'zoom' | 'trim';
variant?: 'zoom' | 'trim' | 'annotation';
}
// Map zoom depth to multiplier labels
@@ -22,6 +22,7 @@ const ZOOM_LABELS: Record<number, string> = {
3: "1.8×",
4: "2.2×",
5: "3.5×",
6: "5×",
};
export default function Item({
@@ -31,7 +32,8 @@ export default function Item({
isSelected = false,
onSelect,
zoomDepth = 1,
variant = 'zoom'
variant = 'zoom',
children
}: ItemProps) {
const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({
id,
@@ -40,8 +42,19 @@ export default function Item({
});
const isZoom = variant === 'zoom';
const glassClass = isZoom ? glassStyles.glassGreen : glassStyles.glassRed;
const endCapColor = isZoom ? '#21916A' : '#ef4444';
const isTrim = variant === 'trim';
const glassClass = isZoom
? glassStyles.glassGreen
: isTrim
? glassStyles.glassRed
: glassStyles.glassYellow;
const endCapColor = isZoom
? '#21916A'
: isTrim
? '#ef4444'
: '#B4A046';
return (
<div
@@ -84,13 +97,20 @@ export default function Item({
{ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
</span>
</>
) : (
) : isTrim ? (
<>
<Scissors className="w-3.5 h-3.5" />
<span className="text-[11px] font-semibold tracking-tight">
Trim
</span>
</>
) : (
<>
<MessageSquare className="w-3.5 h-3.5" />
<span className="text-[11px] font-semibold tracking-tight">
{children}
</span>
</>
)}
</div>
</div>
@@ -50,6 +50,32 @@
z-index: 10;
}
.glassYellow {
position: relative;
border-radius: 8px;
-corner-smoothing: antialiased;
background: rgba(180, 160, 70, 0.15);
border: 1px solid rgba(180, 160, 70, 0.3);
box-shadow: 0 2px 12px 0 rgba(180, 160, 70, 0.1) inset;
margin: 2px 0;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.glassYellow:hover {
background: rgba(180, 160, 70, 0.25);
border-color: rgba(180, 160, 70, 0.5);
box-shadow: 0 4px 20px 0 rgba(180, 160, 70, 0.2) inset;
}
.glassYellow.selected {
background: rgba(180, 160, 70, 0.35);
border-color: #B4A046;
box-shadow: 0 0 0 1px #B4A046, 0 4px 20px 0 rgba(180, 160, 70, 0.3) inset;
z-index: 10;
}
.zoomEndCap {
position: absolute;
top: 0;
@@ -62,7 +88,11 @@
}
.glassGreen:hover .zoomEndCap,
.glassGreen.selected .zoomEndCap {
.glassGreen.selected .zoomEndCap,
.glassRed:hover .zoomEndCap,
.glassRed.selected .zoomEndCap,
.glassYellow:hover .zoomEndCap,
.glassYellow.selected .zoomEndCap {
opacity: 1;
}
@@ -72,9 +102,10 @@
border-top-left-radius: 7px;
border-bottom-left-radius: 7px;
}
.zoomEndCap.right {
right: 0;
cursor: ew-resize;
border-top-right-radius: 7px;
border-bottom-right-radius: 7px;
}
}
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTimelineContext } from "dnd-timeline";
import { Button } from "@/components/ui/button";
import { Plus, Scissors, ZoomIn } from "lucide-react";
import { Plus, Scissors, ZoomIn, MessageSquare, ChevronDown, Check } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import TimelineWrapper from "./TimelineWrapper";
@@ -9,11 +9,20 @@ import Row from "./Row";
import Item from "./Item";
import KeyframeMarkers from "./KeyframeMarkers";
import type { Range, Span } from "dnd-timeline";
import type { ZoomRegion, TrimRegion } from "../types";
import type { ZoomRegion, TrimRegion, AnnotationRegion } from "../types";
import { v4 as uuidv4 } from 'uuid';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { type AspectRatio, getAspectRatioLabel } from "@/utils/aspectRatioUtils";
import { formatShortcut } from "@/utils/platformUtils";
const ZOOM_ROW_ID = "row-zoom";
const TRIM_ROW_ID = "row-trim";
const ANNOTATION_ROW_ID = "row-annotation";
const FALLBACK_RANGE_MS = 1000;
const TARGET_MARKER_COUNT = 12;
@@ -27,13 +36,20 @@ interface TimelineEditorProps {
onZoomDelete: (id: string) => void;
selectedZoomId: string | null;
onSelectZoom: (id: string | null) => void;
// Trim props
trimRegions?: TrimRegion[];
onTrimAdded?: (span: Span) => void;
onTrimSpanChange?: (id: string, span: Span) => void;
onTrimDelete?: (id: string) => void;
selectedTrimId?: string | null;
onSelectTrim?: (id: string | null) => void;
annotationRegions?: AnnotationRegion[];
onAnnotationAdded?: (span: Span) => void;
onAnnotationSpanChange?: (id: string, span: Span) => void;
onAnnotationDelete?: (id: string) => void;
selectedAnnotationId?: string | null;
onSelectAnnotation?: (id: string | null) => void;
aspectRatio: AspectRatio;
onAspectRatioChange: (aspectRatio: AspectRatio) => void;
}
interface TimelineScaleConfig {
@@ -50,7 +66,7 @@ interface TimelineRenderItem {
span: Span;
label: string;
zoomDepth?: number;
variant: 'zoom' | 'trim';
variant: 'zoom' | 'trim' | 'annotation';
}
const SCALE_CANDIDATES = [
@@ -140,13 +156,50 @@ function formatTimeLabel(milliseconds: number, intervalMs: number) {
function PlaybackCursor({
currentTimeMs,
videoDurationMs
videoDurationMs,
onSeek,
timelineRef,
}: {
currentTimeMs: number;
videoDurationMs: number;
onSeek?: (time: number) => void;
timelineRef: React.RefObject<HTMLDivElement>;
}) {
const { sidebarWidth, direction, range, valueToPixels } = useTimelineContext();
const { sidebarWidth, direction, range, valueToPixels, pixelsToValue } = useTimelineContext();
const sideProperty = direction === "rtl" ? "right" : "left";
const [isDragging, setIsDragging] = useState(false);
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
if (!timelineRef.current || !onSeek) return;
const rect = timelineRef.current.getBoundingClientRect();
const clickX = e.clientX - rect.left - sidebarWidth;
// Allow dragging outside to 0 or max, but clamp the value
const relativeMs = pixelsToValue(clickX);
const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
onSeek(absoluteMs / 1000);
};
const handleMouseUp = () => {
setIsDragging(false);
document.body.style.cursor = '';
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
document.body.style.cursor = 'ew-resize';
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = '';
};
}, [isDragging, onSeek, timelineRef, sidebarWidth, range.start, videoDurationMs, pixelsToValue]);
if (videoDurationMs <= 0 || currentTimeMs < 0) {
return null;
@@ -162,22 +215,27 @@ function PlaybackCursor({
return (
<div
className="absolute top-0 bottom-0 pointer-events-none z-50"
className="absolute top-0 bottom-0 z-50 group/cursor"
style={{
[sideProperty === "right" ? "marginRight" : "marginLeft"]: `${sidebarWidth - 1}px`,
pointerEvents: 'none', // Allow clicks to pass through to timeline, but we'll enable pointer events on the handle
}}
>
<div
className="absolute top-0 bottom-0 w-[2px] bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.5)]"
className="absolute top-0 bottom-0 w-[2px] bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.5)] cursor-ew-resize pointer-events-auto hover:shadow-[0_0_15px_rgba(52,178,123,0.7)] transition-shadow"
style={{
[sideProperty]: `${offset}px`,
}}
onMouseDown={(e) => {
e.stopPropagation(); // Prevent timeline click
setIsDragging(true);
}}
>
<div
className="absolute -top-1 left-1/2 -translate-x-1/2"
style={{ width: '12px', height: '12px' }}
className="absolute -top-1 left-1/2 -translate-x-1/2 hover:scale-125 transition-transform"
style={{ width: '16px', height: '16px' }}
>
<div className="w-full h-full bg-[#34B27B] rotate-45 rounded-sm shadow-lg border border-white/20" />
<div className="w-3 h-3 mx-auto mt-[2px] bg-[#34B27B] rotate-45 rounded-sm shadow-lg border border-white/20" />
</div>
</div>
</div>
@@ -309,8 +367,10 @@ function Timeline({
onSeek,
onSelectZoom,
onSelectTrim,
onSelectAnnotation,
selectedZoomId,
selectedTrimId,
selectedAnnotationId,
}: {
items: TimelineRenderItem[];
videoDurationMs: number;
@@ -319,10 +379,18 @@ function Timeline({
onSeek?: (time: number) => void;
onSelectZoom?: (id: string | null) => void;
onSelectTrim?: (id: string | null) => void;
onSelectAnnotation?: (id: string | null) => void;
selectedZoomId: string | null;
selectedTrimId?: string | null;
selectedAnnotationId?: string | null;
}) {
const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext();
const localTimelineRef = useRef<HTMLDivElement | null>(null);
const setRefs = useCallback((node: HTMLDivElement | null) => {
setTimelineRef(node);
localTimelineRef.current = node;
}, [setTimelineRef]);
const handleTimelineClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!onSeek || videoDurationMs <= 0) return;
@@ -331,6 +399,7 @@ function Timeline({
// This is handled by event propagation - items stop propagation
onSelectZoom?.(null);
onSelectTrim?.(null);
onSelectAnnotation?.(null);
const rect = e.currentTarget.getBoundingClientRect();
const clickX = e.clientX - rect.left - sidebarWidth;
@@ -342,21 +411,27 @@ function Timeline({
const timeInSeconds = absoluteMs / 1000;
onSeek(timeInSeconds);
}, [onSeek, onSelectZoom, onSelectTrim, videoDurationMs, sidebarWidth, range.start, pixelsToValue]);
}, [onSeek, onSelectZoom, onSelectTrim, onSelectAnnotation, videoDurationMs, sidebarWidth, range.start, pixelsToValue]);
const zoomItems = items.filter(item => item.rowId === ZOOM_ROW_ID);
const trimItems = items.filter(item => item.rowId === TRIM_ROW_ID);
const annotationItems = items.filter(item => item.rowId === ANNOTATION_ROW_ID);
return (
<div
ref={setTimelineRef}
ref={setRefs}
style={style}
className="select-none bg-[#09090b] min-h-[140px] relative cursor-pointer group"
onClick={handleTimelineClick}
>
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff03_1px,transparent_1px)] bg-[length:20px_100%] pointer-events-none" />
<TimelineAxis intervalMs={intervalMs} videoDurationMs={videoDurationMs} currentTimeMs={currentTimeMs} />
<PlaybackCursor currentTimeMs={currentTimeMs} videoDurationMs={videoDurationMs} />
<PlaybackCursor
currentTimeMs={currentTimeMs}
videoDurationMs={videoDurationMs}
onSeek={onSeek}
timelineRef={localTimelineRef}
/>
<Row id={ZOOM_ROW_ID}>
{zoomItems.map((item) => (
@@ -390,6 +465,22 @@ function Timeline({
</Item>
))}
</Row>
<Row id={ANNOTATION_ROW_ID}>
{annotationItems.map((item) => (
<Item
id={item.id}
key={item.id}
rowId={item.rowId}
span={item.span}
isSelected={item.id === selectedAnnotationId}
onSelect={() => onSelectAnnotation?.(item.id)}
variant="annotation"
>
{item.label}
</Item>
))}
</Row>
</div>
);
}
@@ -410,6 +501,14 @@ export default function TimelineEditor({
onTrimDelete,
selectedTrimId,
onSelectTrim,
annotationRegions = [],
onAnnotationAdded,
onAnnotationSpanChange,
onAnnotationDelete,
selectedAnnotationId,
onSelectAnnotation,
aspectRatio,
onAspectRatioChange,
}: TimelineEditorProps) {
const totalMs = useMemo(() => Math.max(0, Math.round(videoDuration * 1000)), [videoDuration]);
const currentTimeMs = useMemo(() => Math.round(currentTime * 1000), [currentTime]);
@@ -422,6 +521,18 @@ export default function TimelineEditor({
const [range, setRange] = useState<Range>(() => createInitialRange(totalMs));
const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]);
const [selectedKeyframeId, setSelectedKeyframeId] = useState<string | null>(null);
const [shortcuts, setShortcuts] = useState({
pan: 'Shift + Ctrl + Scroll',
zoom: 'Ctrl + Scroll'
});
useEffect(() => {
formatShortcut(['shift', 'mod', 'Scroll']).then(pan => {
formatShortcut(['mod', 'Scroll']).then(zoom => {
setShortcuts({ pan, zoom });
});
});
}, []);
// Add keyframe at current playhead position
const addKeyframe = useCallback(() => {
@@ -452,6 +563,12 @@ export default function TimelineEditor({
onSelectTrim(null);
}, [selectedTrimId, onTrimDelete, onSelectTrim]);
const deleteSelectedAnnotation = useCallback(() => {
if (!selectedAnnotationId || !onAnnotationDelete || !onSelectAnnotation) return;
onAnnotationDelete(selectedAnnotationId);
onSelectAnnotation(null);
}, [selectedAnnotationId, onAnnotationDelete, onSelectAnnotation]);
useEffect(() => {
setRange(createInitialRange(totalMs));
}, [totalMs]);
@@ -484,12 +601,17 @@ export default function TimelineEditor({
onTrimSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd });
}
});
}, [zoomRegions, trimRegions, totalMs, safeMinDurationMs, onZoomSpanChange, onTrimSpanChange]);
}, [zoomRegions, trimRegions, annotationRegions, totalMs, safeMinDurationMs, onZoomSpanChange, onTrimSpanChange, onAnnotationSpanChange]);
const hasOverlap = useCallback((newSpan: Span, excludeId?: string): boolean => {
// Determine which row the item belongs to
const isZoomItem = zoomRegions.some(r => r.id === excludeId);
const isTrimItem = trimRegions.some(r => r.id === excludeId);
const isAnnotationItem = annotationRegions.some(r => r.id === excludeId);
if (isAnnotationItem) {
return false;
}
// Helper to check overlap against a specific set of regions
const checkOverlap = (regions: (ZoomRegion | TrimRegion)[]) => {
@@ -511,8 +633,9 @@ export default function TimelineEditor({
if (isTrimItem) {
return checkOverlap(trimRegions);
}
return false;
}, [zoomRegions, trimRegions]);
}, [zoomRegions, trimRegions, annotationRegions]);
const handleAddZoom = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0) {
@@ -574,10 +697,25 @@ export default function TimelineEditor({
onTrimAdded({ start: startPos, end: startPos + actualDuration });
}, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded]);
// Listen for F key to add keyframe, Z key to add zoom, T key to add trim, Ctrl+D to remove selected keyframe or zoom item
const handleAddAnnotation = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) {
return;
}
const defaultDuration = Math.min(1000, totalMs);
if (defaultDuration <= 0) {
return;
}
// Multiple annotations can exist at the same timestamp
const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
const endPos = Math.min(startPos + defaultDuration, totalMs);
onAnnotationAdded({ start: startPos, end: endPos });
}, [videoDuration, totalMs, currentTimeMs, onAnnotationAdded]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore if typing in an input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}
@@ -591,6 +729,32 @@ export default function TimelineEditor({
if (e.key === 't' || e.key === 'T') {
handleAddTrim();
}
if (e.key === 'a' || e.key === 'A') {
handleAddAnnotation();
}
// Tab: Cycle through overlapping annotations at current time
if (e.key === 'Tab' && annotationRegions.length > 0) {
const currentTimeMs = Math.round(currentTime * 1000);
const overlapping = annotationRegions
.filter(a => currentTimeMs >= a.startMs && currentTimeMs <= a.endMs)
.sort((a, b) => a.zIndex - b.zIndex); // Sort by z-index
if (overlapping.length > 0) {
e.preventDefault();
if (!selectedAnnotationId || !overlapping.some(a => a.id === selectedAnnotationId)) {
onSelectAnnotation?.(overlapping[0].id);
} else {
// Cycle to next annotation
const currentIndex = overlapping.findIndex(a => a.id === selectedAnnotationId);
const nextIndex = e.shiftKey
? (currentIndex - 1 + overlapping.length) % overlapping.length // Shift+Tab = backward
: (currentIndex + 1) % overlapping.length; // Tab = forward
onSelectAnnotation?.(overlapping[nextIndex].id);
}
}
}
if ((e.key === 'd' || e.key === 'D') && (e.ctrlKey || e.metaKey)) {
if (selectedKeyframeId) {
deleteSelectedKeyframe();
@@ -598,12 +762,14 @@ export default function TimelineEditor({
deleteSelectedZoom();
} else if (selectedTrimId) {
deleteSelectedTrim();
} else if (selectedAnnotationId) {
deleteSelectedAnnotation();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [addKeyframe, handleAddZoom, handleAddTrim, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedTrim, selectedKeyframeId, selectedZoomId, selectedTrimId]);
}, [addKeyframe, handleAddZoom, handleAddTrim, handleAddAnnotation, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedTrim, deleteSelectedAnnotation, selectedKeyframeId, selectedZoomId, selectedTrimId, selectedAnnotationId, annotationRegions, currentTime, onSelectAnnotation]);
const clampedRange = useMemo<Range>(() => {
if (totalMs === 0) {
@@ -634,8 +800,30 @@ export default function TimelineEditor({
variant: 'trim',
}));
return [...zooms, ...trims];
}, [zoomRegions, trimRegions]);
const annotations: TimelineRenderItem[] = annotationRegions.map((region) => {
let label: string;
if (region.type === 'text') {
// Show text preview
const preview = region.content.trim() || 'Empty text';
label = preview.length > 20 ? `${preview.substring(0, 20)}...` : preview;
} else if (region.type === 'image') {
label = 'Image';
} else {
label = 'Annotation';
}
return {
id: region.id,
rowId: ANNOTATION_ROW_ID,
span: { start: region.startMs, end: region.endMs },
label,
variant: 'annotation',
};
});
return [...zooms, ...trims, ...annotations];
}, [zoomRegions, trimRegions, annotationRegions]);
const handleItemSpanChange = useCallback((id: string, span: Span) => {
// Check if it's a zoom or trim item
@@ -643,8 +831,10 @@ export default function TimelineEditor({
onZoomSpanChange(id, span);
} else if (trimRegions.some(r => r.id === id)) {
onTrimSpanChange?.(id, span);
} else if (annotationRegions.some(r => r.id === id)) {
onAnnotationSpanChange?.(id, span);
}
}, [zoomRegions, trimRegions, onZoomSpanChange, onTrimSpanChange]);
}, [zoomRegions, trimRegions, annotationRegions, onZoomSpanChange, onTrimSpanChange, onAnnotationSpanChange]);
if (!videoDuration || videoDuration === 0) {
return (
@@ -682,15 +872,50 @@ export default function TimelineEditor({
>
<Scissors className="w-4 h-4" />
</Button>
<Button
onClick={handleAddAnnotation}
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-[#B4A046] hover:bg-[#B4A046]/10 transition-all"
title="Add Annotation (A)"
>
<MessageSquare className="w-4 h-4" />
</Button>
</div>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger 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"
>
<span className="font-medium">{getAspectRatioLabel(aspectRatio)}</span>
<ChevronDown className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-[#1a1a1a] border-white/10">
{(['16:9', '9:16', '1:1', '4:3', '4:5'] as AspectRatio[]).map((ratio) => (
<DropdownMenuItem
key={ratio}
onClick={() => onAspectRatioChange(ratio)}
className="text-slate-300 hover:text-white hover:bg-white/10 cursor-pointer flex items-center justify-between gap-3"
>
<span>{getAspectRatioLabel(ratio)}</span>
{aspectRatio === ratio && <Check className="w-3 h-3 text-[#34B27B]" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex-1" />
<div className="flex items-center gap-4 text-[10px] text-slate-500 font-medium">
<span className="flex items-center gap-1.5">
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">⇧ + ⌘ + Scroll</kbd>
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">{shortcuts.pan}</kbd>
<span>Pan</span>
</span>
<span className="flex items-center gap-1.5">
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">⌘ + Scroll</kbd>
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">{shortcuts.zoom}</kbd>
<span>Zoom</span>
</span>
</div>
@@ -721,8 +946,10 @@ export default function TimelineEditor({
onSeek={onSeek}
onSelectZoom={onSelectZoom}
onSelectTrim={onSelectTrim}
onSelectAnnotation={onSelectAnnotation}
selectedZoomId={selectedZoomId}
selectedTrimId={selectedTrimId}
selectedAnnotationId={selectedAnnotationId}
/>
</TimelineWrapper>
</div>
+79 -4
View File
@@ -19,11 +19,86 @@ export interface TrimRegion {
endMs: number;
}
export type AnnotationType = 'text' | 'image' | 'figure';
export type ArrowDirection = 'up' | 'down' | 'left' | 'right' | 'up-right' | 'up-left' | 'down-right' | 'down-left';
export interface FigureData {
arrowDirection: ArrowDirection;
color: string;
strokeWidth: number;
}
export interface AnnotationPosition {
x: number;
y: number;
}
export interface AnnotationSize {
width: number;
height: number;
}
export interface AnnotationTextStyle {
color: string;
backgroundColor: string;
fontSize: number; // pixels
fontFamily: string;
fontWeight: 'normal' | 'bold';
fontStyle: 'normal' | 'italic';
textDecoration: 'none' | 'underline';
textAlign: 'left' | 'center' | 'right';
}
export interface AnnotationRegion {
id: string;
startMs: number;
endMs: number;
type: AnnotationType;
content: string; // Legacy - still used for current type
textContent?: string; // Separate storage for text
imageContent?: string; // Separate storage for image data URL
position: AnnotationPosition;
size: AnnotationSize;
style: AnnotationTextStyle;
zIndex: number;
figureData?: FigureData;
}
export const DEFAULT_ANNOTATION_POSITION: AnnotationPosition = {
x: 50,
y: 50,
};
export const DEFAULT_ANNOTATION_SIZE: AnnotationSize = {
width: 30,
height: 20,
};
export const DEFAULT_ANNOTATION_STYLE: AnnotationTextStyle = {
color: '#ffffff',
backgroundColor: 'transparent',
fontSize: 32,
fontFamily: 'Inter',
fontWeight: 'bold',
fontStyle: 'normal',
textDecoration: 'none',
textAlign: 'center',
};
export const DEFAULT_FIGURE_DATA: FigureData = {
arrowDirection: 'right',
color: '#34B27B',
strokeWidth: 4,
};
export interface CropRegion {
x: number; // 0-1 normalized
y: number; // 0-1 normalized
width: number; // 0-1 normalized
height: number; // 0-1 normalized
x: number;
y: number;
width: number;
height: number;
}
export const DEFAULT_CROP_REGION: CropRegion = {
@@ -1,5 +1,4 @@
import { Application, Sprite, Graphics } from 'pixi.js';
import { VIEWPORT_SCALE } from "./constants";
import type { CropRegion } from '../types';
interface LayoutParams {
@@ -10,6 +9,8 @@ interface LayoutParams {
videoElement: HTMLVideoElement;
cropRegion?: CropRegion;
lockedVideoDimensions?: { width: number; height: number } | null;
borderRadius?: number;
padding?: number;
}
interface LayoutResult {
@@ -22,8 +23,7 @@ interface LayoutResult {
}
export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
const { container, app, videoSprite, maskGraphics, videoElement, cropRegion, lockedVideoDimensions } = params;
const { container, app, videoSprite, maskGraphics, videoElement, cropRegion, lockedVideoDimensions, borderRadius = 0, padding = 0 } = params;
const videoWidth = lockedVideoDimensions?.width || videoElement.videoWidth;
const videoHeight = lockedVideoDimensions?.height || videoElement.videoHeight;
@@ -56,8 +56,10 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
const cropEndY = cropStartY + croppedVideoHeight;
// Calculate scale to fit the cropped area in the viewport
const maxDisplayWidth = width * VIEWPORT_SCALE;
const maxDisplayHeight = height * VIEWPORT_SCALE;
// Padding is a percentage (0-100), where 50 matches the original VIEWPORT_SCALE of 0.8
const paddingScale = 1.0 - (padding / 100) * 0.4;
const maxDisplayWidth = width * paddingScale;
const maxDisplayHeight = height * paddingScale;
const scale = Math.min(
maxDisplayWidth / croppedVideoWidth,
@@ -92,9 +94,10 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
// Create a mask that only shows the cropped region (centered in container)
const maskX = centerOffsetX;
const maskY = centerOffsetY;
const radius = Math.min(croppedDisplayWidth, croppedDisplayHeight) * 0.02;
// Apply border radius
maskGraphics.clear();
maskGraphics.roundRect(maskX, maskY, croppedDisplayWidth, croppedDisplayHeight, radius);
maskGraphics.roundRect(maskX, maskY, croppedDisplayWidth, croppedDisplayHeight, borderRadius);
maskGraphics.fill({ color: 0xffffff });
return {
@@ -10,6 +10,7 @@ interface TransformParams {
focusY: number;
motionIntensity: number;
isPlaying: boolean;
motionBlurEnabled?: boolean;
}
export function applyZoomTransform({
@@ -22,6 +23,7 @@ export function applyZoomTransform({
focusY,
motionIntensity,
isPlaying,
motionBlurEnabled = true,
}: TransformParams) {
if (
stageSize.width <= 0 ||
@@ -52,7 +54,7 @@ export function applyZoomTransform({
cameraContainer.position.set(cameraX, cameraY);
if (blurFilter) {
const shouldBlur = isPlaying && motionIntensity > 0.0005;
const shouldBlur = motionBlurEnabled && isPlaying && motionIntensity > 0.0005;
const motionBlur = shouldBlur ? Math.min(6, motionIntensity * 120) : 0;
blurFilter.blur = motionBlur;
}
+66 -23
View File
@@ -13,6 +13,38 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const chunks = useRef<Blob[]>([]);
const startTime = useRef<number>(0);
// Target visually lossless 4K @ 60fps; fall back gracefully when hardware cannot keep up
const TARGET_FRAME_RATE = 60;
const TARGET_WIDTH = 3840;
const TARGET_HEIGHT = 2160;
const FOUR_K_PIXELS = TARGET_WIDTH * TARGET_HEIGHT;
const selectMimeType = () => {
const preferred = [
"video/webm;codecs=av1",
"video/webm;codecs=h264",
"video/webm;codecs=vp9",
"video/webm;codecs=vp8",
"video/webm"
];
return preferred.find(type => MediaRecorder.isTypeSupported(type)) ?? "video/webm";
};
const computeBitrate = (width: number, height: number) => {
const pixels = width * height;
const highFrameRateBoost = TARGET_FRAME_RATE >= 60 ? 1.7 : 1;
if (pixels >= FOUR_K_PIXELS) {
return Math.round(45_000_000 * highFrameRateBoost);
}
if (pixels >= 2560 * 1440) {
return Math.round(28_000_000 * highFrameRateBoost);
}
return Math.round(18_000_000 * highFrameRateBoost);
};
const stopRecording = useRef(() => {
if (mediaRecorder.current?.state === "recording") {
if (stream.current) {
@@ -55,14 +87,16 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
return;
}
// Capture screen at source resolution without constraints
const mediaStream = await (navigator.mediaDevices as any).getUserMedia({
audio: false,
video: {
mandatory: {
chromeMediaSource: "desktop",
chromeMediaSourceId: selectedSource.id,
frameRate: { ideal: 60, max: 60 }
maxWidth: TARGET_WIDTH,
maxHeight: TARGET_HEIGHT,
maxFrameRate: TARGET_FRAME_RATE,
minFrameRate: 30,
},
},
});
@@ -71,31 +105,36 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
throw new Error("Media stream is not available.");
}
const videoTrack = stream.current.getVideoTracks()[0];
let { width = 1920, height = 1080 } = videoTrack.getSettings();
try {
await videoTrack.applyConstraints({
frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE },
width: { ideal: TARGET_WIDTH, max: TARGET_WIDTH },
height: { ideal: TARGET_HEIGHT, max: TARGET_HEIGHT },
});
} catch (error) {
console.warn("Unable to lock 4K/60fps constraints, using best available track settings.", error);
}
let { width = 1920, height = 1080, frameRate = TARGET_FRAME_RATE } = videoTrack.getSettings();
// Ensure dimensions are divisible by 2 for VP9/AV1 codec compatibility
width = Math.floor(width / 2) * 2;
height = Math.floor(height / 2) * 2;
console.log(`Recording at ${width}x${height}`);
const videoBitsPerSecond = computeBitrate(width, height);
const mimeType = selectMimeType();
console.log(
`Recording at ${width}x${height} @ ${frameRate ?? TARGET_FRAME_RATE}fps using ${mimeType} / ${Math.round(
videoBitsPerSecond / 1_000_000
)} Mbps`
);
const totalPixels = width * height;
// Use visually lossless bitrates optimized for quality and file size balance
let bitrate = 30_000_000;
if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) {
bitrate = 50_000_000;
} else if (totalPixels > 2560 * 1440) {
bitrate = 80_000_000;
}
chunks.current = [];
// Prefer AV1 codec for better compression, fallback to VP9 then VP8
const supportedCodecs = [
'video/webm;codecs=av1',
'video/webm;codecs=vp9',
'video/webm;codecs=vp8'
];
const mimeType = supportedCodecs.find(codec => MediaRecorder.isTypeSupported(codec)) || 'video/webm;codecs=vp9';
const recorder = new MediaRecorder(stream.current, { mimeType, videoBitsPerSecond: bitrate });
const recorder = new MediaRecorder(stream.current, {
mimeType,
videoBitsPerSecond,
});
mediaRecorder.current = recorder;
recorder.ondataavailable = e => {
if (e.data && e.data.size > 0) chunks.current.push(e.data);
@@ -104,7 +143,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
stream.current = null;
if (chunks.current.length === 0) return;
const duration = Date.now() - startTime.current;
const buggyBlob = new Blob(chunks.current, { type: mimeType });
const recordedChunks = chunks.current;
const buggyBlob = new Blob(recordedChunks, { type: mimeType });
// Clear chunks early to free memory immediately after blob creation
chunks.current = [];
const timestamp = Date.now();
@@ -119,14 +159,17 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
return;
}
if (videoResult.path) {
await window.electronAPI.setCurrentVideoPath(videoResult.path);
}
await window.electronAPI.switchToEditor();
} catch (error) {
console.error('Error saving recording:', error);
}
};
recorder.onerror = () => setRecording(false);
// Use larger timeslice to reduce recording overhead and improve smoothness
recorder.start(5000);
recorder.start(1000);
startTime.current = Date.now();
setRecording(true);
window.electronAPI?.setRecordingState(true);
+315
View File
@@ -0,0 +1,315 @@
import type { AnnotationRegion, ArrowDirection } from '@/components/video-editor/types';
// SVG path data for each arrow direction
const ARROW_PATHS: Record<ArrowDirection, string[]> = {
'up': [
'M 50 20 L 50 80',
'M 50 20 L 35 35',
'M 50 20 L 65 35',
],
'down': [
'M 50 20 L 50 80',
'M 50 80 L 35 65',
'M 50 80 L 65 65',
],
'left': [
'M 80 50 L 20 50',
'M 20 50 L 35 35',
'M 20 50 L 35 65',
],
'right': [
'M 20 50 L 80 50',
'M 80 50 L 65 35',
'M 80 50 L 65 65',
],
'up-right': [
'M 25 75 L 75 25',
'M 75 25 L 60 30',
'M 75 25 L 70 40',
],
'up-left': [
'M 75 75 L 25 25',
'M 25 25 L 40 30',
'M 25 25 L 30 40',
],
'down-right': [
'M 25 25 L 75 75',
'M 75 75 L 70 60',
'M 75 75 L 60 70',
],
'down-left': [
'M 75 25 L 25 75',
'M 25 75 L 30 60',
'M 25 75 L 40 70',
],
};
function parseSvgPath(pathString: string, scaleX: number, scaleY: number): Array<{ cmd: string; args: number[] }> {
const commands: Array<{ cmd: string; args: number[] }> = [];
const parts = pathString.trim().split(/\s+/);
let i = 0;
while (i < parts.length) {
const cmd = parts[i];
if (cmd === 'M' || cmd === 'L') {
const x = parseFloat(parts[i + 1]) * scaleX;
const y = parseFloat(parts[i + 2]) * scaleY;
commands.push({ cmd, args: [x, y] });
i += 3;
} else {
i++;
}
}
return commands;
}
function renderArrow(
ctx: CanvasRenderingContext2D,
direction: ArrowDirection,
color: string,
strokeWidth: number,
x: number,
y: number,
width: number,
height: number,
_scaleFactor: number
) {
const paths = ARROW_PATHS[direction];
if (!paths) return;
ctx.save();
ctx.translate(x, y);
const padding = 8 * _scaleFactor;
const availableWidth = Math.max(0, width - padding * 2);
const availableHeight = Math.max(0, height - padding * 2);
const scale = Math.min(availableWidth / 100, availableHeight / 100);
const offsetX = padding + (availableWidth - 100 * scale) / 2;
const offsetY = padding + (availableHeight - 100 * scale) / 2;
// Apply centering offset
ctx.translate(offsetX, offsetY);
// Apply shadow filter
ctx.shadowColor = 'rgba(0, 0, 0, 0.3)';
ctx.shadowBlur = 8 * scale;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 4 * scale;
ctx.strokeStyle = color;
ctx.lineWidth = strokeWidth * scale;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// Draw all paths as a single shape to avoid overlapping shadows/strokes
ctx.beginPath();
for (const pathString of paths) {
const commands = parseSvgPath(pathString, scale, scale);
for (const { cmd, args } of commands) {
if (cmd === 'M') {
ctx.moveTo(args[0], args[1]);
} else if (cmd === 'L') {
ctx.lineTo(args[0], args[1]);
}
}
}
ctx.stroke();
ctx.restore();
}
function renderText(
ctx: CanvasRenderingContext2D,
annotation: AnnotationRegion,
x: number,
y: number,
width: number,
height: number,
scaleFactor: number
) {
const style = annotation.style;
ctx.save();
const fontWeight = style.fontWeight === 'bold' ? 'bold' : 'normal';
const fontStyle = style.fontStyle === 'italic' ? 'italic' : 'normal';
const scaledFontSize = style.fontSize * scaleFactor;
ctx.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${style.fontFamily}`;
ctx.textBaseline = 'middle';
const containerPadding = 8 * scaleFactor;
let textX = x;
let textY = y + height / 2;
if (style.textAlign === 'center') {
textX = x + width / 2;
ctx.textAlign = 'center';
} else if (style.textAlign === 'right') {
textX = x + width - containerPadding;
ctx.textAlign = 'right';
} else {
textX = x + containerPadding;
ctx.textAlign = 'left';
}
const lines = annotation.content.split('\n');
const lineHeight = scaledFontSize * 1.4;
const startY = textY - ((lines.length - 1) * lineHeight) / 2;
lines.forEach((line, index) => {
const currentY = startY + index * lineHeight;
if (style.backgroundColor && style.backgroundColor !== 'transparent') {
const metrics = ctx.measureText(line);
const verticalPadding = scaledFontSize * 0.1;
const horizontalPadding = scaledFontSize * 0.2;
const borderRadius = 4 * scaleFactor;
let bgX = textX - horizontalPadding;
const bgWidth = metrics.width + horizontalPadding * 2;
const contentHeight = scaledFontSize * 1.4;
const bgHeight = contentHeight + verticalPadding * 2;
const bgY = currentY - bgHeight / 2;
if (style.textAlign === 'center') {
bgX = textX - bgWidth / 2;
} else if (style.textAlign === 'right') {
bgX = textX - bgWidth;
}
ctx.fillStyle = style.backgroundColor;
ctx.beginPath();
ctx.roundRect(bgX, bgY, bgWidth, bgHeight, borderRadius);
ctx.fill();
}
ctx.fillStyle = style.color;
ctx.fillText(line, textX, currentY);
if (style.textDecoration === 'underline') {
const metrics = ctx.measureText(line);
let underlineX = textX;
const underlineY = currentY + scaledFontSize * 0.15;
if (style.textAlign === 'center') {
underlineX = textX - metrics.width / 2;
} else if (style.textAlign === 'right') {
underlineX = textX - metrics.width;
}
ctx.strokeStyle = style.color;
ctx.lineWidth = Math.max(1, scaledFontSize / 16);
ctx.beginPath();
ctx.moveTo(underlineX, underlineY);
ctx.lineTo(underlineX + metrics.width, underlineY);
ctx.stroke();
}
});
ctx.restore();
}
async function renderImage(
ctx: CanvasRenderingContext2D,
annotation: AnnotationRegion,
x: number,
y: number,
width: number,
height: number
): Promise<void> {
if (!annotation.content || !annotation.content.startsWith('data:image')) {
return;
}
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
// Preserve aspect ratio - contain the image within the bounds
const imgAspect = img.width / img.height;
const boxAspect = width / height;
let drawWidth = width;
let drawHeight = height;
let drawX = x;
let drawY = y;
if (imgAspect > boxAspect) {
drawHeight = width / imgAspect;
drawY = y + (height - drawHeight) / 2;
} else {
drawWidth = height * imgAspect;
drawX = x + (width - drawWidth) / 2;
}
ctx.drawImage(img, drawX, drawY, drawWidth, drawHeight);
resolve();
};
img.onerror = () => {
console.error('[AnnotationRenderer] Failed to load image annotation');
resolve();
};
img.src = annotation.content;
});
}
export async function renderAnnotations(
ctx: CanvasRenderingContext2D,
annotations: AnnotationRegion[],
canvasWidth: number,
canvasHeight: number,
currentTimeMs: number,
scaleFactor: number = 1.0
): Promise<void> {
// Filter active annotations at current time
const activeAnnotations = annotations.filter(
(ann) => currentTimeMs >= ann.startMs && currentTimeMs <= ann.endMs
);
// Sort by z-index (lower first, so higher z-index draws on top)
const sortedAnnotations = [...activeAnnotations].sort((a, b) => a.zIndex - b.zIndex);
for (const annotation of sortedAnnotations) {
const x = (annotation.position.x / 100) * canvasWidth;
const y = (annotation.position.y / 100) * canvasHeight;
const width = (annotation.size.width / 100) * canvasWidth;
const height = (annotation.size.height / 100) * canvasHeight;
switch (annotation.type) {
case 'text':
renderText(ctx, annotation, x, y, width, height, scaleFactor);
break;
case 'image':
await renderImage(ctx, annotation, x, y, width, height);
break;
case 'figure':
if (annotation.figureData) {
renderArrow(
ctx,
annotation.figureData.arrowDirection,
annotation.figureData.color,
annotation.figureData.strokeWidth,
x,
y,
width,
height,
scaleFactor
);
}
break;
}
}
}
+38 -14
View File
@@ -1,10 +1,11 @@
import { Application, Container, Sprite, Graphics, BlurFilter, Texture } from 'pixi.js';
import type { ZoomRegion, CropRegion } from '@/components/video-editor/types';
import type { ZoomRegion, CropRegion, AnnotationRegion } from '@/components/video-editor/types';
import { ZOOM_DEPTH_SCALES } from '@/components/video-editor/types';
import { findDominantRegion } from '@/components/video-editor/videoPlayback/zoomRegionUtils';
import { applyZoomTransform } from '@/components/video-editor/videoPlayback/zoomTransform';
import { DEFAULT_FOCUS, SMOOTHING_FACTOR, MIN_DELTA, VIEWPORT_SCALE } from '@/components/video-editor/videoPlayback/constants';
import { DEFAULT_FOCUS, SMOOTHING_FACTOR, MIN_DELTA } from '@/components/video-editor/videoPlayback/constants';
import { clampFocusToStage as clampFocusToStageUtil } from '@/components/video-editor/videoPlayback/focusUtils';
import { renderAnnotations } from './annotationRenderer';
interface FrameRenderConfig {
width: number;
@@ -14,9 +15,15 @@ interface FrameRenderConfig {
showShadow: boolean;
shadowIntensity: number;
showBlur: boolean;
motionBlurEnabled?: boolean;
borderRadius?: number;
padding?: number;
cropRegion: CropRegion;
videoWidth: number;
videoHeight: number;
annotationRegions?: AnnotationRegion[];
previewWidth?: number;
previewHeight?: number;
}
interface AnimationState {
@@ -286,6 +293,7 @@ export class FrameRenderer {
focusY: this.animationState.focusY,
motionIntensity: maxMotionIntensity,
isPlaying: true,
motionBlurEnabled: this.config.motionBlurEnabled ?? true,
});
// Render the PixiJS stage to its canvas (video only, transparent background)
@@ -293,13 +301,34 @@ export class FrameRenderer {
// Composite with shadows to final output canvas
this.compositeWithShadows();
// Render annotations on top if present
if (this.config.annotationRegions && this.config.annotationRegions.length > 0 && this.compositeCtx) {
// Calculate scale factor based on export vs preview dimensions
const previewWidth = this.config.previewWidth || 1920;
const previewHeight = this.config.previewHeight || 1080;
const scaleX = this.config.width / previewWidth;
const scaleY = this.config.height / previewHeight;
const scaleFactor = (scaleX + scaleY) / 2;
await renderAnnotations(
this.compositeCtx,
this.config.annotationRegions,
this.config.width,
this.config.height,
timeMs,
scaleFactor
);
}
}
private updateLayout(): void {
if (!this.app || !this.videoSprite || !this.maskGraphics || !this.videoContainer) return;
const { width, height } = this.config;
const { cropRegion } = this.config;
const { cropRegion, borderRadius = 0, padding = 0 } = this.config;
const videoWidth = this.config.videoWidth;
const videoHeight = this.config.videoHeight;
@@ -311,10 +340,12 @@ export class FrameRenderer {
const croppedVideoWidth = videoWidth * (cropEndX - cropStartX);
const croppedVideoHeight = videoHeight * (cropEndY - cropStartY);
// Calculate scale to fit in viewport
const viewportWidth = width * VIEWPORT_SCALE;
const viewportHeight = height * VIEWPORT_SCALE;
// Padding is a percentage (0-100), where 50% ~ 0.8 scale
const paddingScale = 1.0 - (padding / 100) * 0.4;
const viewportWidth = width * paddingScale;
const viewportHeight = height * paddingScale;
const scale = Math.min(viewportWidth / croppedVideoWidth, viewportHeight / croppedVideoHeight);
// Position video sprite
@@ -335,9 +366,8 @@ export class FrameRenderer {
this.videoContainer.y = centerOffsetY;
// Update mask
const radius = Math.min(croppedDisplayWidth, croppedDisplayHeight) * 0.02;
this.maskGraphics.clear();
this.maskGraphics.roundRect(0, 0, croppedDisplayWidth, croppedDisplayHeight, radius);
this.maskGraphics.roundRect(0, 0, croppedDisplayWidth, croppedDisplayHeight, borderRadius);
this.maskGraphics.fill({ color: 0xffffff });
// Cache layout info
@@ -477,12 +507,6 @@ export class FrameRenderer {
return this.compositeCanvas;
}
updateConfig(config: Partial<FrameRenderConfig>): void {
this.config = { ...this.config, ...config };
if (config.wallpaper) {
this.setupBackground();
}
}
destroy(): void {
if (this.videoSprite) {
+1 -3
View File
@@ -2,7 +2,5 @@ export { VideoExporter } from './videoExporter';
export { VideoFileDecoder } from './videoDecoder';
export { FrameRenderer } from './frameRenderer';
export { VideoMuxer } from './muxer';
export type { ExportConfig, ExportProgress, ExportResult, VideoFrameData } from './types';
export type { ExportConfig, ExportProgress, ExportResult, VideoFrameData, ExportQuality } from './types';
// Ref: https://pietrasiak.com/fast-video-rendering-and-encoding-using-web-apis
+2
View File
@@ -24,3 +24,5 @@ export interface VideoFrameData {
timestamp: number; // in microseconds
duration: number; // in microseconds
}
export type ExportQuality = 'medium' | 'good' | 'source';
+28 -14
View File
@@ -2,7 +2,7 @@ import type { ExportConfig, ExportProgress, ExportResult } from './types';
import { VideoFileDecoder } from './videoDecoder';
import { FrameRenderer } from './frameRenderer';
import { VideoMuxer } from './muxer';
import type { ZoomRegion, CropRegion, TrimRegion } from '@/components/video-editor/types';
import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion } from '@/components/video-editor/types';
interface VideoExporterConfig extends ExportConfig {
videoUrl: string;
@@ -12,7 +12,14 @@ interface VideoExporterConfig extends ExportConfig {
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;
}
@@ -49,20 +56,20 @@ export class VideoExporter {
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;
}
@@ -84,9 +91,15 @@ export class VideoExporter {
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();
@@ -119,7 +132,7 @@ export class VideoExporter {
while (frameIndex < totalFrames && !this.cancelled) {
const i = frameIndex;
const timestamp = i * frameDuration;
// Map effective time to source time (accounting for trim regions)
const effectiveTimeMs = (i * timeStep) * 1000;
const sourceTimeMs = this.mapEffectiveToSourceTime(effectiveTimeMs);
@@ -127,7 +140,7 @@ export class VideoExporter {
// Seek if needed or wait for first frame to be ready
const needsSeek = Math.abs(videoElement.currentTime - videoTime) > 0.001;
if (needsSeek) {
// Attach listener BEFORE setting currentTime to avoid race condition
const seekedPromise = new Promise<void>(resolve => {
@@ -180,10 +193,11 @@ export class VideoExporter {
} else {
console.warn(`[Frame ${i}] Encoder not ready! State: ${this.encoder?.state}`);
}
exportFrame.close();
frameIndex++;
// Update progress
if (this.config.onProgress) {
this.config.onProgress({
@@ -240,11 +254,11 @@ export class VideoExporter {
if (meta?.decoderConfig?.colorSpace && !this.videoColorSpace) {
this.videoColorSpace = meta.decoderConfig.colorSpace;
}
// Stream chunk to muxer immediately (parallel processing)
const isFirstChunk = this.chunkCount === 0;
this.chunkCount++;
const muxingPromise = (async () => {
try {
if (isFirstChunk && this.videoDescription) {
@@ -255,7 +269,7 @@ export class VideoExporter {
matrix: 'rgb',
fullRange: true,
};
const metadata: EncodedVideoChunkMetadata = {
decoderConfig: {
codec: this.config.codec || 'avc1.640033',
@@ -265,7 +279,7 @@ export class VideoExporter {
colorSpace,
},
};
await this.muxer!.addVideoChunk(chunk, metadata);
} else {
await this.muxer!.addVideoChunk(chunk, meta);
@@ -274,7 +288,7 @@ export class VideoExporter {
console.error('Muxing error:', error);
}
})();
this.muxingPromises.push(muxingPromise);
this.encodeQueue--;
},
@@ -300,7 +314,7 @@ export class VideoExporter {
// Check hardware support first
const hardwareSupport = await VideoEncoder.isConfigSupported(encoderConfig);
if (hardwareSupport.supported) {
// Use hardware encoding
console.log('[VideoExporter] Using hardware acceleration');
+31
View File
@@ -0,0 +1,31 @@
export type AspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '4:5';
export function getAspectRatioValue(aspectRatio: AspectRatio): number {
switch (aspectRatio) {
case '16:9': return 16 / 9;
case '9:16': return 9 / 16;
case '1:1': return 1;
case '4:3': return 4 / 3;
case '4:5': return 4 / 5;
}
}
export function getAspectRatioDimensions(
aspectRatio: AspectRatio,
baseWidth: number
): { width: number; height: number } {
const ratio = getAspectRatioValue(aspectRatio);
return {
width: baseWidth,
height: baseWidth / ratio,
};
}
export function getAspectRatioLabel(aspectRatio: AspectRatio): string {
return aspectRatio;
}
export function formatAspectRatioForCSS(aspectRatio: AspectRatio): string {
return aspectRatio.replace(':', '/');
}
+60
View File
@@ -0,0 +1,60 @@
let cachedPlatform: string | null = null;
/**
* Gets the current platform from Electron
*/
const getPlatform = async (): Promise<string> => {
if (cachedPlatform) return cachedPlatform;
try {
const platform = await window.electronAPI.getPlatform();
cachedPlatform = platform;
return platform;
} catch (error) {
console.warn('Failed to get platform from Electron, falling back to navigator:', error);
// Fallback for development/testing
let fallbackPlatform = 'win32';
if (typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.platform)) {
fallbackPlatform = 'darwin';
}
cachedPlatform = fallbackPlatform;
return fallbackPlatform;
}
};
/**
* Detects if the current platform is macOS
*/
export const isMac = async (): Promise<boolean> => {
const platform = await getPlatform();
return platform === 'darwin';
};
/**
* Gets the modifier key symbol based on the platform
*/
export const getModifierKey = async (): Promise<string> => {
return (await isMac()) ? '⌘' : 'Ctrl';
};
/**
* Gets the shift key symbol based on the platform
*/
export const getShiftKey = async (): Promise<string> => {
return (await isMac()) ? '⇧' : 'Shift';
};
/**
* Formats a keyboard shortcut for display based on the platform
* @param keys Array of key combinations (e.g., ['mod', 'D'] or ['shift', 'mod', 'Scroll'])
*/
export const formatShortcut = async (keys: string[]): Promise<string> => {
const isMacPlatform = await isMac();
return keys
.map(key => {
if (key.toLowerCase() === 'mod') return isMacPlatform ? '⌘' : 'Ctrl';
if (key.toLowerCase() === 'shift') return isMacPlatform ? '⇧' : 'Shift';
return key;
})
.join(' + ');
};