Compare commits

...
53 Commits
Author SHA1 Message Date
Sid 7e0ce53df0 Update package.json 2025-12-14 10:47:47 -07:00
Sid d57140b031 Merge pull request #59 from kamikazebr/feat/linux-support
feat: add Linux support
2025-12-14 09:33:11 -08:00
Felipe Novaes F Rocha 5dd85abaee chore: remove pnpm lock files from tracking 2025-12-14 14:29:23 -03:00
Felipe Novaes F Rocha 78fbd30b15 ci: add Linux build to workflow 2025-12-13 20:25:15 -03:00
Felipe Novaes F Rocha ebb1d29375 feat: add Linux support 2025-12-13 20:07:41 -03:00
Siddharth 250fc5d221 disable user-select 2025-12-10 21:30:25 -07:00
Sid 58b5ea0f9b Merge pull request #50 from suenyiyang/feat/optimize-clamped-content
feat: add content-clamp component to show full text when truncated
2025-12-09 13:00:38 -08:00
Yiyang Suen 4bc8a1e970 feat: remove content clamp popover content box shadow 2025-12-09 09:31:35 +08:00
Yiyang Suen 16752a7ae8 feat: add content-clamp component to show clamped text 2025-12-09 09:22:30 +08:00
Sid 5f4d20b26d Merge pull request #46 from LauZzL/fix/windows-close-button-not-work
fix(electron): remove platform check for hud overlay close event
2025-12-07 10:31:44 -08:00
LauZzL 8cbdcf2d7a fix(electron): remove platform check for hud overlay close event
This check causes the close button to stop working on Windows.
2025-12-07 17:51:19 +08:00
Siddharth f1f507e6e9 replace 2025-12-06 11:44:57 -07:00
Sid a7fb7670a7 - 2025-12-06 11:29:42 -07:00
Sid eccccf583b - 2025-12-06 11:23:34 -07:00
Siddharth d6d1a3eca6 build fix 2025-12-05 23:31:36 -07:00
Siddharth c5aa622898 hex based inputs for brand consitency 2025-12-05 23:22:30 -07:00
Siddharth 1345c8109c rename export res 2025-12-05 23:04:58 -07:00
Siddharth d91ed78fc2 delete trim ux improvement 2025-12-05 22:32:26 -07:00
Siddharth 5d7b817586 fix default wallpaper missing from export in build 2025-12-05 22:22:17 -07:00
Sid fecb9a9b22 Merge pull request #28 from ilGianfri/main
Add platform-aware keyboard shortcut formatting
2025-12-04 16:46:28 -08:00
Sid cbdef41667 Merge branch 'main' into main 2025-12-04 16:46:16 -08:00
Alessandro Spisso f34bd19183 feat: implement platform-aware keyboard shortcuts and add IPC handler for platform detection 2025-12-04 23:53:25 +01:00
Siddharth 7a7db0b277 revert exporter 2025-12-04 10:22:20 -07:00
Siddharth 3a4ec9c470 update assets 2025-12-02 23:25:40 -07:00
Siddharth d2a62b137d cleanup settings and readme 2025-12-02 21:49:38 -07:00
Siddharth c9e9d1d1bd missing shortcut 2025-12-02 18:50:53 -07:00
Siddharth cce88b3dab build errors and version update 2025-12-02 18:31:31 -07:00
Siddharth 4018741648 settings update 2025-12-02 18:11:00 -07:00
Siddharth ed3cdab64e export quality options 2025-12-02 17:41:30 -07:00
Siddharth 4ffa9c6ecb reduce seek bottleneck 2025-12-02 16:32:35 -07:00
Siddharth 899e55d257 update usescreenrecorder 2025-12-01 22:16:38 -07:00
Siddharth 977be1e3b1 draggable playhead and pause/play shortcut 2025-12-01 15:36:03 -07:00
Siddharth 262745a97f final annotation preview and export 2025-12-01 11:20:05 -07:00
Siddharth 6ac712eaac final annotation settings 2025-11-30 21:14:55 -07:00
Siddharth 79e40cef68 improved annotation experience 2025-11-30 19:19:08 -07:00
Siddharth c847953a52 allow multiple annotation conflicts, and cycle using Tab 2025-11-30 18:39:56 -07:00
Alessandro Spisso 391938049b Add platform-aware keyboard shortcut formatting
Introduces a new utility (platformUtils.ts) to format keyboard shortcuts based on the user's platform (macOS or others). Updates KeyboardShortcutsHelp and TimelineEditor to use the new formatShortcut function for displaying shortcuts, ensuring correct symbols are shown for modifier keys.
2025-12-01 00:19:34 +01:00
Siddharth 71ba4e4cea rm dead code 2025-11-30 15:35:03 -07:00
Siddharth ec3b9b46a1 annotations in preview 2025-11-30 14:47:22 -07:00
Sid 2ad5899417 Merge pull request #23 from suenyiyang/feat/disable-source-selection-when-recording
feat: disable source selection and project selection when recording
2025-11-29 19:34:41 -08:00
Yiyang Suen 1f08d3ca26 feat: disable source selection and project selection when recording 2025-11-30 10:19:12 +08:00
Sid bae17c0d1b Merge pull request #20 from siddharthvaddem/aspect-ratio
Aspect ratio
2025-11-29 10:42:48 -08:00
Siddharth 0c89e3e01a export aspect ratio 2025-11-29 11:38:09 -07:00
Siddharth d2ee511466 preview aspect ratio 2025-11-28 23:54:58 -07:00
Siddharth 4c725dfceb settings cleanup 2025-11-28 21:55:42 -07:00
Siddharth 71e2b51f5b padding video control 2025-11-28 21:46:05 -07:00
Siddharth c9321240d8 enable custom border radius 2025-11-28 19:15:56 -07:00
Siddharth 159f770da8 missing item depth level 2025-11-28 18:48:11 -07:00
Siddharth 443e4b0581 motion blur export stale closure 2025-11-28 18:04:38 -07:00
Siddharth 65bc21f153 fix the bug i just introduced lol 2025-11-28 17:41:30 -07:00
Siddharth 59807662d8 aspect ratio bug 2025-11-28 17:14:55 -07:00
Siddharth 8f31bde518 motion-blue switch 2025-11-28 16:08:11 -07:00
Sid 6fb9a24834 Merge pull request #14 from siddharthvaddem/feature/trim
Feature/trim
2025-11-27 22:31:33 -08:00
54 changed files with 3463 additions and 8402 deletions
-15
View File
@@ -1,15 +0,0 @@
# These are supported funding model platforms
github: siddharthvaddem
patreon: # Replace with a single Patreon username
open_collective: # Replace with a sissgle Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: siddharthvaddem
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+29
View File
@@ -68,3 +68,32 @@ jobs:
path: release/**/*.dmg path: release/**/*.dmg
retention-days: 30 retention-days: 30
build-linux:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '22'
- name: Install dependencies
run: npm ci
- name: Install app dependencies
run: npx electron-builder install-app-deps
- name: Build Linux app
run: npm run build:linux
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload Linux build
uses: actions/upload-artifact@v4
with:
name: linux-installer
path: release/**/*.AppImage
retention-days: 30
+18 -2
View File
@@ -34,12 +34,17 @@ OpenScreen is 100% free for personal and commercial use. Use it, modify it, dist
- Customize the duration and position of zooms however you please - Customize the duration and position of zooms however you please
- Crop video recordings to hide parts - Crop video recordings to hide parts
- Choose between wallpapers, solid colors, gradients or your own picture for your background - 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 ## Installation
Download the latest installer for your platform from the [GitHub Releases](https://github.com/siddharthvaddem/openscreen/releases) page. Download the latest installer for your platform from the [GitHub Releases](https://github.com/siddharthvaddem/openscreen/releases) page.
### macOS
If you encounter issues with macOS Gatekeeper blocking the app (since it does not come with a developer certificate), you can bypass this by running the following command in your terminal after installation: If you encounter issues with macOS Gatekeeper blocking the app (since it does not come with a developer certificate), you can bypass this by running the following command in your terminal after installation:
```bash ```bash
@@ -48,6 +53,17 @@ xattr -rd com.apple.quarantine /Applications/Openscreen.app
After running this command, proceed to **System Preferences > Security & Privacy** to grant the necessary permissions for "screen recording" and "accessibility". Once permissions are granted, you can launch the app. After running this command, proceed to **System Preferences > Security & Privacy** to grant the necessary permissions for "screen recording" and "accessibility". Once permissions are granted, you can launch the app.
### Linux
Download the `.AppImage` file from the releases page. Make it executable and run:
```bash
chmod +x Openscreen-Linux-*.AppImage
./Openscreen-Linux-*.AppImage
```
You may need to grant screen recording permissions depending on your desktop environment.
## Built with ## Built with
- Electron - Electron
- React - React
+3
View File
@@ -292,6 +292,9 @@ function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, g
currentVideoPath = null; currentVideoPath = null;
return { success: true }; return { success: true };
}); });
ipcMain.handle("get-platform", () => {
return process.platform;
});
} }
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings"); const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings");
+3
View File
@@ -56,5 +56,8 @@ electron.contextBridge.exposeInMainWorld("electronAPI", {
}, },
clearCurrentVideoPath: () => { clearCurrentVideoPath: () => {
return electron.ipcRenderer.invoke("clear-current-video-path"); return electron.ipcRenderer.invoke("clear-current-video-path");
},
getPlatform: () => {
return electron.ipcRenderer.invoke("get-platform");
} }
}); });
+3 -1
View File
@@ -41,7 +41,9 @@
"target": [ "target": [
"AppImage" "AppImage"
], ],
"artifactName": "${productName}-Linux-${version}.${ext}" "icon": "icons/icons/png",
"artifactName": "${productName}-Linux-${version}.${ext}",
"category": "AudioVideo"
}, },
"win": { "win": {
"target": [ "target": [
+1
View File
@@ -39,6 +39,7 @@ interface Window {
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }> setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>
getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }> getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>
clearCurrentVideoPath: () => Promise<{ success: boolean }> clearCurrentVideoPath: () => Promise<{ success: boolean }>
getPlatform: () => Promise<string>
hudOverlayHide: () => void; hudOverlayHide: () => void;
hudOverlayClose: () => void; hudOverlayClose: () => void;
} }
+5
View File
@@ -104,6 +104,7 @@ export function registerIpcHandlers(
} }
}) })
ipcMain.handle('open-external-url', async (_, url: string) => { ipcMain.handle('open-external-url', async (_, url: string) => {
try { try {
await shell.openExternal(url) await shell.openExternal(url)
@@ -207,4 +208,8 @@ export function registerIpcHandlers(
currentVideoPath = null; currentVideoPath = null;
return { success: true }; return { success: true };
}); });
ipcMain.handle('get-platform', () => {
return process.platform;
});
} }
+1 -3
View File
@@ -111,9 +111,7 @@ app.whenReady().then(async () => {
// Listen for HUD overlay quit event (macOS only) // Listen for HUD overlay quit event (macOS only)
const { ipcMain } = await import('electron'); const { ipcMain } = await import('electron');
ipcMain.on('hud-overlay-close', () => { ipcMain.on('hud-overlay-close', () => {
if (process.platform === 'darwin') { app.quit();
app.quit();
}
}); });
// Ensure recordings directory exists // Ensure recordings directory exists
await ensureRecordingsDir() await ensureRecordingsDir()
+3
View File
@@ -60,4 +60,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
clearCurrentVideoPath: () => { clearCurrentVideoPath: () => {
return ipcRenderer.invoke('clear-current-video-path') return ipcRenderer.invoke('clear-current-video-path')
}, },
getPlatform: () => {
return ipcRenderer.invoke('get-platform')
},
}) })
+6 -2
View File
@@ -77,13 +77,17 @@ export function createHudOverlayWindow(): BrowserWindow {
} }
export function createEditorWindow(): BrowserWindow { export function createEditorWindow(): BrowserWindow {
const isMac = process.platform === 'darwin';
const win = new BrowserWindow({ const win = new BrowserWindow({
width: 1200, width: 1200,
height: 800, height: 800,
minWidth: 800, minWidth: 800,
minHeight: 600, minHeight: 600,
titleBarStyle: 'hiddenInset', ...(isMac && {
trafficLightPosition: { x: 12, y: 12 }, titleBarStyle: 'hiddenInset',
trafficLightPosition: { x: 12, y: 12 },
}),
transparent: false, transparent: false,
resizable: true, resizable: true,
alwaysOnTop: false, alwaysOnTop: false,
+357 -105
View File
@@ -1,34 +1,43 @@
{ {
"name": "openscreen", "name": "openscreen",
"version": "0.0.0", "version": "1.0.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "openscreen", "name": "openscreen",
"version": "0.0.0", "version": "1.0.1",
"dependencies": { "dependencies": {
"@fix-webm-duration/fix": "^1.0.1", "@fix-webm-duration/fix": "^1.0.1",
"@pixi/filter-drop-shadow": "^5.2.0", "@pixi/filter-drop-shadow": "^5.2.0",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@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-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@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/color-convert": "^2.9.2",
"@uiw/react-color-colorful": "^2.9.0", "@uiw/react-color-block": "^2.9.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dnd-timeline": "^2.2.0", "dnd-timeline": "^2.2.0",
"emoji-picker-react": "^4.16.1",
"fix-webm-duration": "^1.0.6",
"gsap": "^3.13.0",
"lucide-react": "^0.545.0", "lucide-react": "^0.545.0",
"mediabunny": "^1.25.1", "mediabunny": "^1.25.1",
"motion": "^12.23.24",
"mp4box": "^2.2.0", "mp4box": "^2.2.0",
"pixi.js": "^8.14.0", "pixi.js": "^8.14.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-resizable-panels": "^3.0.6", "react-resizable-panels": "^3.0.6",
"react-rnd": "^10.5.2",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
@@ -2579,6 +2588,43 @@
} }
} }
}, },
"node_modules/@radix-ui/react-popover": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
"integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": { "node_modules/@radix-ui/react-popper": {
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
@@ -2713,6 +2759,49 @@
} }
} }
}, },
"node_modules/@radix-ui/react-select": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
"integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-visually-hidden": "1.2.3",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider": { "node_modules/@radix-ui/react-slider": {
"version": "1.3.6", "version": "1.3.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz",
@@ -2823,6 +2912,60 @@
} }
} }
}, },
"node_modules/@radix-ui/react-toggle": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz",
"integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toggle-group": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz",
"integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-toggle": "1.1.10",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": { "node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
@@ -2959,6 +3102,29 @@
} }
} }
}, },
"node_modules/@radix-ui/react-visually-hidden": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/rect": { "node_modules/@radix-ui/rect": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
@@ -3750,14 +3916,15 @@
"@babel/runtime": ">=7.19.0" "@babel/runtime": ">=7.19.0"
} }
}, },
"node_modules/@uiw/react-color-alpha": { "node_modules/@uiw/react-color-block": {
"version": "2.9.0", "version": "2.9.2",
"resolved": "https://registry.npmjs.org/@uiw/react-color-alpha/-/react-color-alpha-2.9.0.tgz", "resolved": "https://registry.npmjs.org/@uiw/react-color-block/-/react-color-block-2.9.2.tgz",
"integrity": "sha512-tP9zs2KUPzIGvdtTBTNlDEdwHWcNJ3Ju/nnpPTsKERRJF2vzucb3v18JzihX0ACiOXU9+JVqgpAVgvoZXMeITA==", "integrity": "sha512-0EIZTELA5pnxyMlBOFo3hrpy73db+Qeq6E+QptNfD/8izor8OvY1Uquj2VqD6gDz+iVHMELIoKxpaQ8sZR7NOg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@uiw/color-convert": "2.9.0", "@uiw/color-convert": "2.9.2",
"@uiw/react-drag-event-interactive": "2.9.0" "@uiw/react-color-editable-input": "2.9.2",
"@uiw/react-color-swatch": "2.9.2"
}, },
"funding": { "funding": {
"url": "https://jaywcjlove.github.io/#/sponsor" "url": "https://jaywcjlove.github.io/#/sponsor"
@@ -3768,58 +3935,27 @@
"react-dom": ">=16.9.0" "react-dom": ">=16.9.0"
} }
}, },
"node_modules/@uiw/react-color-alpha/node_modules/@uiw/color-convert": { "node_modules/@uiw/react-color-editable-input": {
"version": "2.9.0", "version": "2.9.2",
"resolved": "https://registry.npmjs.org/@uiw/color-convert/-/color-convert-2.9.0.tgz", "resolved": "https://registry.npmjs.org/@uiw/react-color-editable-input/-/react-color-editable-input-2.9.2.tgz",
"integrity": "sha512-tFm6iac8iN3VL9IIAXKAVpBX6ZQH7ucCvb+EgZNM5y8prRWlk53cERnP4RycE3wnYb/HUKW0lrngoygQnEWAHg==", "integrity": "sha512-DY7pu12+LDRn6cxDMvsy1/quaPTxicAPz/kfODV7KBf8+Hq4rFWeJ4KS6m22IKIbQxrBQgmQG0WFJLaPeY7cPw==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://jaywcjlove.github.io/#/sponsor" "url": "https://jaywcjlove.github.io/#/sponsor"
}, },
"peerDependencies": {
"@babel/runtime": ">=7.19.0"
}
},
"node_modules/@uiw/react-color-colorful": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@uiw/react-color-colorful/-/react-color-colorful-2.9.0.tgz",
"integrity": "sha512-qw7zhgX9J4aydK0hKKLroXMYp0tw3ASNuZM1fQNwXXIpOkEW4zO+PHTkzUBpbII68wGS8tAxsZessTWNpAVi0w==",
"license": "MIT",
"dependencies": {
"@uiw/color-convert": "2.9.0",
"@uiw/react-color-alpha": "2.9.0",
"@uiw/react-color-hue": "2.9.0",
"@uiw/react-color-saturation": "2.9.0"
},
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": { "peerDependencies": {
"@babel/runtime": ">=7.19.0", "@babel/runtime": ">=7.19.0",
"react": ">=16.9.0", "react": ">=16.9.0",
"react-dom": ">=16.9.0" "react-dom": ">=16.9.0"
} }
}, },
"node_modules/@uiw/react-color-colorful/node_modules/@uiw/color-convert": { "node_modules/@uiw/react-color-swatch": {
"version": "2.9.0", "version": "2.9.2",
"resolved": "https://registry.npmjs.org/@uiw/color-convert/-/color-convert-2.9.0.tgz", "resolved": "https://registry.npmjs.org/@uiw/react-color-swatch/-/react-color-swatch-2.9.2.tgz",
"integrity": "sha512-tFm6iac8iN3VL9IIAXKAVpBX6ZQH7ucCvb+EgZNM5y8prRWlk53cERnP4RycE3wnYb/HUKW0lrngoygQnEWAHg==", "integrity": "sha512-6zBy+E9NzZR672M2wPsbbNRqKy9Wi9jOuuxxyzov1CEZp+pPX7UwMlCX6RUhKdO0PzTSPCVQmbz5bplu5vsW0w==",
"license": "MIT",
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@babel/runtime": ">=7.19.0"
}
},
"node_modules/@uiw/react-color-hue": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@uiw/react-color-hue/-/react-color-hue-2.9.0.tgz",
"integrity": "sha512-2Ix027ref3ppiVq+OQkbyKr6O8fLTH+n0wEW4xhHfa3kV8mBa2bEu8jAjrkSwO6uElaE6OJcuqYe2llyKRlIFg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@uiw/color-convert": "2.9.0", "@uiw/color-convert": "2.9.2"
"@uiw/react-color-alpha": "2.9.0"
}, },
"funding": { "funding": {
"url": "https://jaywcjlove.github.io/#/sponsor" "url": "https://jaywcjlove.github.io/#/sponsor"
@@ -3830,62 +3966,6 @@
"react-dom": ">=16.9.0" "react-dom": ">=16.9.0"
} }
}, },
"node_modules/@uiw/react-color-hue/node_modules/@uiw/color-convert": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@uiw/color-convert/-/color-convert-2.9.0.tgz",
"integrity": "sha512-tFm6iac8iN3VL9IIAXKAVpBX6ZQH7ucCvb+EgZNM5y8prRWlk53cERnP4RycE3wnYb/HUKW0lrngoygQnEWAHg==",
"license": "MIT",
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@babel/runtime": ">=7.19.0"
}
},
"node_modules/@uiw/react-color-saturation": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@uiw/react-color-saturation/-/react-color-saturation-2.9.0.tgz",
"integrity": "sha512-tBARqsW0ap+gwByGKSid1nYaGJd6dw2MTMwCABF9rebPQHtYbN/YgBjl9dNnp6XDGd+VBhH4RbpM38WV7J1F4g==",
"license": "MIT",
"dependencies": {
"@uiw/color-convert": "2.9.0",
"@uiw/react-drag-event-interactive": "2.9.0"
},
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@babel/runtime": ">=7.19.0",
"react": ">=16.9.0",
"react-dom": ">=16.9.0"
}
},
"node_modules/@uiw/react-color-saturation/node_modules/@uiw/color-convert": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@uiw/color-convert/-/color-convert-2.9.0.tgz",
"integrity": "sha512-tFm6iac8iN3VL9IIAXKAVpBX6ZQH7ucCvb+EgZNM5y8prRWlk53cERnP4RycE3wnYb/HUKW0lrngoygQnEWAHg==",
"license": "MIT",
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@babel/runtime": ">=7.19.0"
}
},
"node_modules/@uiw/react-drag-event-interactive": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@uiw/react-drag-event-interactive/-/react-drag-event-interactive-2.9.0.tgz",
"integrity": "sha512-oBqrClsSuYA3IjkSHp4uHOBLgPkR98KeKeheqCRltF8w/a1CPaFCl43pyKyxwXaTV6qM6cb3VkT3JRCHMNqTzA==",
"license": "MIT",
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@babel/runtime": ">=7.19.0",
"react": ">=16.9.0",
"react-dom": ">=16.9.0"
}
},
"node_modules/@ungap/structured-clone": { "node_modules/@ungap/structured-clone": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
@@ -6291,6 +6371,21 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/emoji-regex": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -6905,6 +7000,18 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/fix-webm-duration": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/fix-webm-duration/-/fix-webm-duration-1.0.6.tgz",
"integrity": "sha512-zVAqi4gE+8ywxJuAyV/rlJVX6CMtvyapEbQx6jyoeX9TMjdqAlt/FdG5d7rXSSkDVzTvS0H7CtwzHcH/vh4FPA==",
"license": "MIT"
},
"node_modules/flairup": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/flairup/-/flairup-1.0.0.tgz",
"integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==",
"license": "MIT"
},
"node_modules/flat-cache": { "node_modules/flat-cache": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
@@ -7005,6 +7112,33 @@
"url": "https://github.com/sponsors/rawify" "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": { "node_modules/fs-constants": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@@ -7426,6 +7560,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/har-schema": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz",
@@ -9319,6 +9459,47 @@
"node": ">=10" "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": { "node_modules/mp4box": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/mp4box/-/mp4box-2.2.0.tgz", "resolved": "https://registry.npmjs.org/mp4box/-/mp4box-2.2.0.tgz",
@@ -10414,6 +10595,17 @@
"node": ">=10" "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": { "node_modules/psl": {
"version": "1.15.0", "version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
@@ -10497,6 +10689,16 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/react": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -10522,6 +10724,29 @@
"react": "^18.3.1" "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": { "node_modules/react-icons": {
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
@@ -10531,6 +10756,12 @@
"react": "*" "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": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -10598,6 +10829,27 @@
"react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" "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": { "node_modules/react-style-singleton": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
+13 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "openscreen", "name": "openscreen",
"private": true, "private": true,
"version": "0.0.0", "version": "1.0.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -9,30 +9,40 @@
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview", "preview": "vite preview",
"build:mac": "tsc && vite build && electron-builder --mac", "build:mac": "tsc && vite build && electron-builder --mac",
"build:win": "tsc && vite build && electron-builder --win" "build:win": "tsc && vite build && electron-builder --win",
"build:linux": "tsc && vite build && electron-builder --linux"
}, },
"dependencies": { "dependencies": {
"@fix-webm-duration/fix": "^1.0.1", "@fix-webm-duration/fix": "^1.0.1",
"@pixi/filter-drop-shadow": "^5.2.0", "@pixi/filter-drop-shadow": "^5.2.0",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@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-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@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/color-convert": "^2.9.2",
"@uiw/react-color-colorful": "^2.9.0", "@uiw/react-color-block": "^2.9.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dnd-timeline": "^2.2.0", "dnd-timeline": "^2.2.0",
"emoji-picker-react": "^4.16.1",
"fix-webm-duration": "^1.0.6",
"gsap": "^3.13.0",
"lucide-react": "^0.545.0", "lucide-react": "^0.545.0",
"mediabunny": "^1.25.1", "mediabunny": "^1.25.1",
"motion": "^12.23.24",
"mp4box": "^2.2.0", "mp4box": "^2.2.0",
"pixi.js": "^8.14.0", "pixi.js": "^8.14.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-resizable-panels": "^3.0.6", "react-resizable-panels": "^3.0.6",
"react-rnd": "^10.5.2",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
-8053
View File
File diff suppressed because it is too large Load Diff
-5
View File
@@ -1,5 +0,0 @@
onlyBuiltDependencies:
- electron
- esbuild
- lzma-native
- phantomjs-prebuilt
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 460 KiB

After

Width:  |  Height:  |  Size: 776 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 549 KiB

After

Width:  |  Height:  |  Size: 534 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 627 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 581 KiB

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 890 KiB

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

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