Merge branch 'main' into main
@@ -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
|
||||
|
||||
|
||||
@@ -12,28 +12,22 @@ ipcMain.on("hud-overlay-hide", () => {
|
||||
hudOverlayWindow.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: n } = r, c = 500, w = 100, y = Math.floor(n.x + (n.width - c) / 2), h = Math.floor(n.y + n.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$2, "preload.mjs"),
|
||||
nodeIntegration: false,
|
||||
@@ -41,36 +35,26 @@ function createHudOverlayWindow() {
|
||||
backgroundThrottling: false
|
||||
}
|
||||
});
|
||||
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: {
|
||||
@@ -81,32 +65,24 @@ function createEditorWindow() {
|
||||
backgroundThrottling: false
|
||||
}
|
||||
});
|
||||
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: n } = 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((n - 420) / 2),
|
||||
frame: !1,
|
||||
resizable: !1,
|
||||
alwaysOnTop: !0,
|
||||
transparent: !0,
|
||||
backgroundColor: "#00000000",
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname$2, "preload.mjs"),
|
||||
@@ -114,169 +90,117 @@ function createSourceSelectorWindow() {
|
||||
contextIsolation: true
|
||||
}
|
||||
});
|
||||
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, n, c, w, y) {
|
||||
i.handle("get-sources", async (e, s) => (await V.getSources(s)).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
|
||||
}))), i.handle("select-source", (e, s) => {
|
||||
v = s;
|
||||
const a = w();
|
||||
return a && a.close(), v;
|
||||
}), i.handle("get-selected-source", () => v), i.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) => {
|
||||
n();
|
||||
}), i.handle("switch-to-editor", () => {
|
||||
const e = c();
|
||||
e && e.close(), r();
|
||||
}), i.handle("store-recorded-video", async (e, s, a) => {
|
||||
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, a);
|
||||
return await P.writeFile(t, Buffer.from(s)), 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 () => {
|
||||
}), i.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 s = (await P.readdir(p)).filter((j) => j.endsWith(".webm"));
|
||||
if (s.length === 0)
|
||||
return { success: !1, message: "No recorded video found" };
|
||||
const a = s.sort().reverse()[0];
|
||||
return { success: !0, path: o.join(p, a) };
|
||||
} 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) => {
|
||||
}), i.handle("set-recording-state", (e, s) => {
|
||||
y && y(s, (v || { name: "Screen" }).name);
|
||||
}), i.handle("open-external-url", async (e, s) => {
|
||||
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(s), { success: !0 };
|
||||
} catch (a) {
|
||||
return console.error("Failed to open URL:", a), { success: !1, error: String(a) };
|
||||
}
|
||||
});
|
||||
ipcMain.handle("get-asset-base-path", () => {
|
||||
}), i.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) => {
|
||||
}), i.handle("save-exported-video", async (e, s, a) => {
|
||||
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"), a),
|
||||
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(s)), {
|
||||
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 () => {
|
||||
}), i.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)
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -300,11 +224,9 @@ const __dirname$1 = path.dirname(fileURLToPath(import.meta.url));
|
||||
const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings");
|
||||
async function ensureRecordingsDir() {
|
||||
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$1, "..");
|
||||
@@ -319,82 +241,53 @@ let selectedSourceName = "";
|
||||
function createWindow() {
|
||||
mainWindow = createHudOverlayWindow();
|
||||
}
|
||||
function createTray() {
|
||||
const iconPath = path.join(process.env.VITE_PUBLIC || RENDERER_DIST, "rec-button.png");
|
||||
let icon = nativeImage.createFromPath(iconPath);
|
||||
icon = icon.resize({ width: 24, height: 24, quality: "best" });
|
||||
tray = new Tray(icon);
|
||||
updateTrayMenu();
|
||||
function q() {
|
||||
const r = o.join(process.env.VITE_PUBLIC || D, "rec-button.png");
|
||||
let n = W.createFromPath(r);
|
||||
n = n.resize({ width: 24, height: 24, quality: "best" }), u = new k(n), 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}`);
|
||||
], n = L.buildFromTemplate(r);
|
||||
u.setContextMenu(n), 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,
|
||||
(n, c) => {
|
||||
x = c, n ? (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
|
||||
};
|
||||
|
||||
@@ -104,6 +104,7 @@ export function registerIpcHandlers(
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
ipcMain.handle('open-external-url', async (_, url: string) => {
|
||||
try {
|
||||
await shell.openExternal(url)
|
||||
|
||||
@@ -12,23 +12,31 @@
|
||||
"@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",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dnd-timeline": "^2.2.0",
|
||||
"emoji-picker-react": "^4.16.1",
|
||||
"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 +2587,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 +2758,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 +2911,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 +3101,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",
|
||||
@@ -6291,6 +6456,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 +7085,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"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 +7191,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 +7639,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 +9538,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 +10674,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 +10768,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 +10803,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 +10835,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 +10908,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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "openscreen",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -16,23 +16,31 @@
|
||||
"@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",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dnd-timeline": "^2.2.0",
|
||||
"emoji-picker-react": "^4.16.1",
|
||||
"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",
|
||||
@@ -62,4 +70,4 @@
|
||||
"vite-plugin-electron-renderer": "^0.14.5"
|
||||
},
|
||||
"main": "dist-electron/main.js"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 460 KiB After Width: | Height: | Size: 776 KiB |
|
Before Width: | Height: | Size: 549 KiB After Width: | Height: | Size: 534 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 627 KiB |
|
Before Width: | Height: | Size: 581 KiB After Width: | Height: | Size: 460 KiB |
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,477 @@
|
||||
import { useState, 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 Colorful from '@uiw/react-color-colorful';
|
||||
import { hsvaToHex, hexToHsva } from '@uiw/color-convert';
|
||||
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 [textColorHsva, setTextColorHsva] = useState(hexToHsva(annotation.style.color));
|
||||
const [bgColorHsva, setBgColorHsva] = useState(hexToHsva(annotation.style.backgroundColor || '#00000000'));
|
||||
const [figureColorHsva, setFigureColorHsva] = useState(
|
||||
hexToHsva(annotation.figureData?.color || '#34B27B')
|
||||
);
|
||||
|
||||
|
||||
|
||||
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-auto p-0 border-none bg-transparent shadow-xl">
|
||||
<div className="p-2 bg-[#1a1a1c] border border-white/10 rounded-xl">
|
||||
<Colorful
|
||||
color={textColorHsva}
|
||||
disableAlpha={true}
|
||||
onChange={(color) => {
|
||||
setTextColorHsva(color.hsva);
|
||||
onStyleChange({ color: hsvaToHex(color.hsva) });
|
||||
}}
|
||||
style={{ width: '100%', borderRadius: '8px' }}
|
||||
/>
|
||||
</div>
|
||||
</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-auto p-0 border-none bg-transparent shadow-xl">
|
||||
<div className="p-2 bg-[#1a1a1c] border border-white/10 rounded-xl">
|
||||
<Colorful
|
||||
color={bgColorHsva}
|
||||
onChange={(color) => {
|
||||
setBgColorHsva(color.hsva);
|
||||
onStyleChange({ backgroundColor: hsvaToHex(color.hsva) });
|
||||
}}
|
||||
style={{ width: '100%', 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' });
|
||||
setBgColorHsva({ h: 0, s: 0, v: 0, a: 0 });
|
||||
}}
|
||||
>
|
||||
Clear Background
|
||||
</Button>
|
||||
</div>
|
||||
</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-auto p-0 border-none bg-transparent shadow-xl">
|
||||
<div className="p-2 bg-[#1a1a1c] border border-white/10 rounded-xl">
|
||||
<Colorful
|
||||
color={figureColorHsva}
|
||||
disableAlpha={true}
|
||||
onChange={(color) => {
|
||||
setFigureColorHsva(color.hsva);
|
||||
const newFigureData: FigureData = {
|
||||
...annotation.figureData!,
|
||||
color: hsvaToHex(color.hsva),
|
||||
};
|
||||
onFigureDataChange?.(newFigureData);
|
||||
}}
|
||||
style={{ width: '100%', borderRadius: '8px' }}
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type AspectRatio, formatAspectRatioForCSS } from "@/utils/aspectRatioUtils";
|
||||
import { type AspectRatio } from "@/utils/aspectRatioUtils";
|
||||
|
||||
interface CropRegion {
|
||||
x: number; // 0-1 normalized
|
||||
@@ -18,7 +18,7 @@ interface CropControlProps {
|
||||
|
||||
type DragHandle = 'top' | 'right' | 'bottom' | 'left' | null;
|
||||
|
||||
export function CropControl({ videoElement, cropRegion, onCropChange, aspectRatio }: CropControlProps) {
|
||||
export function CropControl({ videoElement, cropRegion, onCropChange }: CropControlProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState<DragHandle>(null);
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -33,6 +33,10 @@ 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>
|
||||
@@ -53,6 +57,10 @@ export function KeyboardShortcutsHelp() {
|
||||
<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">{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>
|
||||
</div>
|
||||
|
||||
@@ -11,10 +11,12 @@ import { hsvaToHex } from '@uiw/color-convert';
|
||||
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 = 18;
|
||||
const WALLPAPER_RELATIVE = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `wallpapers/wallpaper${i + 1}.jpg`);
|
||||
@@ -66,7 +68,16 @@ interface SettingsPanelProps {
|
||||
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;
|
||||
@@ -80,7 +91,38 @@ 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, motionBlurEnabled = true, onMotionBlurChange, borderRadius = 0, onBorderRadiusChange, padding = 50, onPaddingChange, cropRegion, onCropChange, aspectRatio, videoElement, onExport }: SettingsPanelProps) {
|
||||
export function SettingsPanel({
|
||||
selected,
|
||||
onWallpaperChange,
|
||||
selectedZoomDepth,
|
||||
onZoomDepthChange,
|
||||
selectedZoomId,
|
||||
onZoomDelete,
|
||||
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);
|
||||
@@ -156,6 +198,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">
|
||||
@@ -232,10 +293,10 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="mb-4">
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
{/* Drop Shadow Slider */}
|
||||
<div className="p-3 rounded-xl bg-white/5 border border-white/5 space-y-2">
|
||||
<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>
|
||||
@@ -250,7 +311,7 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
|
||||
/>
|
||||
</div>
|
||||
{/* Corner Roundness Slider */}
|
||||
<div className="p-3 rounded-xl bg-white/5 border border-white/5 space-y-2">
|
||||
<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>
|
||||
@@ -265,7 +326,7 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
|
||||
/>
|
||||
</div>
|
||||
{/* Padding Slider */}
|
||||
<div className="p-3 rounded-xl bg-white/5 border border-white/5 space-y-2">
|
||||
<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>
|
||||
@@ -282,18 +343,15 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<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 && (
|
||||
@@ -344,7 +402,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"
|
||||
@@ -422,7 +480,7 @@ 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}
|
||||
@@ -436,7 +494,7 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
|
||||
</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
|
||||
@@ -458,7 +516,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"
|
||||
)}
|
||||
>
|
||||
Medium
|
||||
</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"
|
||||
)}
|
||||
>
|
||||
Good
|
||||
</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"
|
||||
)}
|
||||
>
|
||||
Source
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
|
||||
@@ -16,13 +16,19 @@ 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";
|
||||
|
||||
const WALLPAPER_COUNT = 18;
|
||||
@@ -46,15 +52,20 @@ export default function VideoEditor() {
|
||||
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
|
||||
@@ -118,7 +129,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) => {
|
||||
@@ -130,10 +152,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) => {
|
||||
@@ -143,14 +165,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
|
||||
@@ -165,7 +186,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
|
||||
@@ -208,7 +228,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);
|
||||
@@ -216,13 +235,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);
|
||||
@@ -235,6 +410,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');
|
||||
@@ -269,58 +450,91 @@ export default function VideoEditor() {
|
||||
const sourceWidth = video.videoWidth || 1920;
|
||||
const sourceHeight = video.videoHeight || 1080;
|
||||
|
||||
let exportWidth: number = sourceWidth;
|
||||
let exportHeight: number = sourceHeight;
|
||||
let exportWidth: number;
|
||||
let exportHeight: number;
|
||||
let bitrate: number;
|
||||
|
||||
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 (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;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
exportWidth = baseWidth;
|
||||
exportHeight = Math.floor((baseWidth / 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 {
|
||||
// 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;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate visually lossless bitrate matching screen recording optimization
|
||||
const totalPixels = exportWidth * exportHeight;
|
||||
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;
|
||||
}
|
||||
// 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,
|
||||
@@ -339,6 +553,9 @@ export default function VideoEditor() {
|
||||
borderRadius,
|
||||
padding,
|
||||
cropRegion,
|
||||
annotationRegions,
|
||||
previewWidth,
|
||||
previewHeight,
|
||||
onProgress: (progress: ExportProgress) => {
|
||||
setExportProgress(progress);
|
||||
},
|
||||
@@ -379,7 +596,7 @@ export default function VideoEditor() {
|
||||
setIsExporting(false);
|
||||
exporterRef.current = null;
|
||||
}
|
||||
}, [videoPath, wallpaper, zoomRegions, trimRegions, shadowIntensity, showBlur, motionBlurEnabled, borderRadius, padding, cropRegion, isPlaying, aspectRatio]);
|
||||
}, [videoPath, wallpaper, zoomRegions, trimRegions, shadowIntensity, showBlur, motionBlurEnabled, borderRadius, padding, cropRegion, annotationRegions, isPlaying, aspectRatio, exportQuality]);
|
||||
|
||||
const handleCancelExport = useCallback(() => {
|
||||
if (exporterRef.current) {
|
||||
@@ -433,6 +650,7 @@ export default function VideoEditor() {
|
||||
videoPath={videoPath || ''}
|
||||
onDurationChange={setDuration}
|
||||
onTimeUpdate={setCurrentTime}
|
||||
currentTime={currentTime}
|
||||
onPlayStateChange={setIsPlaying}
|
||||
onError={setError}
|
||||
wallpaper={wallpaper}
|
||||
@@ -449,6 +667,11 @@ export default function VideoEditor() {
|
||||
padding={padding}
|
||||
cropRegion={cropRegion}
|
||||
trimRegions={trimRegions}
|
||||
annotationRegions={annotationRegions}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
onSelectAnnotation={handleSelectAnnotation}
|
||||
onAnnotationPositionChange={handleAnnotationPositionChange}
|
||||
onAnnotationSizeChange={handleAnnotationSizeChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -490,6 +713,12 @@ export default function VideoEditor() {
|
||||
onTrimDelete={handleTrimDelete}
|
||||
selectedTrimId={selectedTrimId}
|
||||
onSelectTrim={handleSelectTrim}
|
||||
annotationRegions={annotationRegions}
|
||||
onAnnotationAdded={handleAnnotationAdded}
|
||||
onAnnotationSpanChange={handleAnnotationSpanChange}
|
||||
onAnnotationDelete={handleAnnotationDelete}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
onSelectAnnotation={handleSelectAnnotation}
|
||||
aspectRatio={aspectRatio}
|
||||
onAspectRatioChange={setAspectRatio}
|
||||
/>
|
||||
@@ -520,7 +749,16 @@ export default function VideoEditor() {
|
||||
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>
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -12,11 +12,13 @@ import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/la
|
||||
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;
|
||||
@@ -34,6 +36,11 @@ interface VideoPlaybackProps {
|
||||
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 {
|
||||
@@ -41,6 +48,7 @@ export interface VideoPlaybackRef {
|
||||
app: Application | null;
|
||||
videoSprite: Sprite | null;
|
||||
videoContainer: Container | null;
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
play: () => Promise<void>;
|
||||
pause: () => void;
|
||||
}
|
||||
@@ -49,6 +57,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
videoPath,
|
||||
onDurationChange,
|
||||
onTimeUpdate,
|
||||
currentTime,
|
||||
onPlayStateChange,
|
||||
onError,
|
||||
wallpaper,
|
||||
@@ -66,6 +75,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
cropRegion,
|
||||
trimRegions = [],
|
||||
aspectRatio,
|
||||
annotationRegions = [],
|
||||
selectedAnnotationId,
|
||||
onSelectAnnotation,
|
||||
onAnnotationPositionChange,
|
||||
onAnnotationSizeChange,
|
||||
}, ref) => {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -98,6 +112,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
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);
|
||||
@@ -196,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;
|
||||
@@ -480,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]);
|
||||
|
||||
|
||||
@@ -691,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);
|
||||
@@ -744,6 +774,15 @@ 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 || ''})` }
|
||||
@@ -784,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
|
||||
|
||||
@@ -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
|
||||
@@ -32,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,
|
||||
@@ -41,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
|
||||
@@ -85,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, ChevronDown, Check } 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,7 +9,7 @@ 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,
|
||||
@@ -22,6 +22,7 @@ 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;
|
||||
|
||||
@@ -35,13 +36,18 @@ 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;
|
||||
}
|
||||
@@ -60,7 +66,7 @@ interface TimelineRenderItem {
|
||||
span: Span;
|
||||
label: string;
|
||||
zoomDepth?: number;
|
||||
variant: 'zoom' | 'trim';
|
||||
variant: 'zoom' | 'trim' | 'annotation';
|
||||
}
|
||||
|
||||
const SCALE_CANDIDATES = [
|
||||
@@ -150,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;
|
||||
@@ -172,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>
|
||||
@@ -319,8 +367,10 @@ function Timeline({
|
||||
onSeek,
|
||||
onSelectZoom,
|
||||
onSelectTrim,
|
||||
onSelectAnnotation,
|
||||
selectedZoomId,
|
||||
selectedTrimId,
|
||||
selectedAnnotationId,
|
||||
}: {
|
||||
items: TimelineRenderItem[];
|
||||
videoDurationMs: number;
|
||||
@@ -329,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;
|
||||
@@ -341,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;
|
||||
@@ -352,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) => (
|
||||
@@ -400,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>
|
||||
);
|
||||
}
|
||||
@@ -420,6 +501,12 @@ export default function TimelineEditor({
|
||||
onTrimDelete,
|
||||
selectedTrimId,
|
||||
onSelectTrim,
|
||||
annotationRegions = [],
|
||||
onAnnotationAdded,
|
||||
onAnnotationSpanChange,
|
||||
onAnnotationDelete,
|
||||
selectedAnnotationId,
|
||||
onSelectAnnotation,
|
||||
aspectRatio,
|
||||
onAspectRatioChange,
|
||||
}: TimelineEditorProps) {
|
||||
@@ -476,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]);
|
||||
@@ -508,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)[]) => {
|
||||
@@ -535,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) {
|
||||
@@ -598,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;
|
||||
}
|
||||
@@ -615,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();
|
||||
@@ -622,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) {
|
||||
@@ -658,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
|
||||
@@ -667,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 (
|
||||
@@ -706,6 +872,15 @@ 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>
|
||||
@@ -771,8 +946,10 @@ export default function TimelineEditor({
|
||||
onSeek={onSeek}
|
||||
onSelectZoom={onSelectZoom}
|
||||
onSelectTrim={onSelectTrim}
|
||||
onSelectAnnotation={onSelectAnnotation}
|
||||
selectedZoomId={selectedZoomId}
|
||||
selectedTrimId={selectedTrimId}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
/>
|
||||
</TimelineWrapper>
|
||||
</div>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -20,6 +21,9 @@ interface FrameRenderConfig {
|
||||
cropRegion: CropRegion;
|
||||
videoWidth: number;
|
||||
videoHeight: number;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
previewWidth?: number;
|
||||
previewHeight?: number;
|
||||
}
|
||||
|
||||
interface AnimationState {
|
||||
@@ -297,6 +301,27 @@ 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 {
|
||||
@@ -482,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) {
|
||||
|
||||
@@ -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
|
||||
@@ -24,3 +24,5 @@ export interface VideoFrameData {
|
||||
timestamp: number; // in microseconds
|
||||
duration: number; // in microseconds
|
||||
}
|
||||
|
||||
export type ExportQuality = 'medium' | 'good' | 'source';
|
||||
|
||||
@@ -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;
|
||||
@@ -17,6 +17,9 @@ interface VideoExporterConfig extends ExportConfig {
|
||||
padding?: number;
|
||||
videoPadding?: number;
|
||||
cropRegion: CropRegion;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
previewWidth?: number;
|
||||
previewHeight?: number;
|
||||
onProgress?: (progress: ExportProgress) => void;
|
||||
}
|
||||
|
||||
@@ -53,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;
|
||||
}
|
||||
|
||||
@@ -94,6 +97,9 @@ export class VideoExporter {
|
||||
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();
|
||||
|
||||
@@ -126,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);
|
||||
@@ -134,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 => {
|
||||
@@ -187,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({
|
||||
@@ -247,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) {
|
||||
@@ -262,7 +269,7 @@ export class VideoExporter {
|
||||
matrix: 'rgb',
|
||||
fullRange: true,
|
||||
};
|
||||
|
||||
|
||||
const metadata: EncodedVideoChunkMetadata = {
|
||||
decoderConfig: {
|
||||
codec: this.config.codec || 'avc1.640033',
|
||||
@@ -272,7 +279,7 @@ export class VideoExporter {
|
||||
colorSpace,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
await this.muxer!.addVideoChunk(chunk, metadata);
|
||||
} else {
|
||||
await this.muxer!.addVideoChunk(chunk, meta);
|
||||
@@ -281,7 +288,7 @@ export class VideoExporter {
|
||||
console.error('Muxing error:', error);
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
this.muxingPromises.push(muxingPromise);
|
||||
this.encodeQueue--;
|
||||
},
|
||||
@@ -307,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');
|
||||
|
||||