Compare commits

...
24 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
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
26 changed files with 540 additions and 8368 deletions
-15
View File
@@ -1,15 +0,0 @@
# These are supported funding model platforms
github: siddharthvaddem
patreon: # Replace with a single Patreon username
open_collective: # Replace with a sissgle Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: siddharthvaddem
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+29
View File
@@ -68,3 +68,32 @@ jobs:
path: release/**/*.dmg
retention-days: 30
build-linux:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '22'
- name: Install dependencies
run: npm ci
- name: Install app dependencies
run: npx electron-builder install-app-deps
- name: Build Linux app
run: npm run build:linux
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload Linux build
uses: actions/upload-artifact@v4
with:
name: linux-installer
path: release/**/*.AppImage
retention-days: 30
+14 -1
View File
@@ -39,10 +39,12 @@ OpenScreen is 100% free for personal and commercial use. Use it, modify it, dist
- Trim sections of the clip
- Export in different aspect ratios and resolutions
## macOS Installation instructions
## Installation
Download the latest installer for your platform from the [GitHub Releases](https://github.com/siddharthvaddem/openscreen/releases) page.
### macOS
If you encounter issues with macOS Gatekeeper blocking the app (since it does not come with a developer certificate), you can bypass this by running the following command in your terminal after installation:
```bash
@@ -51,6 +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.
### Linux
Download the `.AppImage` file from the releases page. Make it executable and run:
```bash
chmod +x Openscreen-Linux-*.AppImage
./Openscreen-Linux-*.AppImage
```
You may need to grant screen recording permissions depending on your desktop environment.
## Built with
- Electron
- React
+3
View File
@@ -292,6 +292,9 @@ function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, g
currentVideoPath = null;
return { success: true };
});
ipcMain.handle("get-platform", () => {
return process.platform;
});
}
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings");
+3
View File
@@ -56,5 +56,8 @@ electron.contextBridge.exposeInMainWorld("electronAPI", {
},
clearCurrentVideoPath: () => {
return electron.ipcRenderer.invoke("clear-current-video-path");
},
getPlatform: () => {
return electron.ipcRenderer.invoke("get-platform");
}
});
+3 -1
View File
@@ -41,7 +41,9 @@
"target": [
"AppImage"
],
"artifactName": "${productName}-Linux-${version}.${ext}"
"icon": "icons/icons/png",
"artifactName": "${productName}-Linux-${version}.${ext}",
"category": "AudioVideo"
},
"win": {
"target": [
+1
View File
@@ -39,6 +39,7 @@ interface Window {
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>
getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>
clearCurrentVideoPath: () => Promise<{ success: boolean }>
getPlatform: () => Promise<string>
hudOverlayHide: () => void;
hudOverlayClose: () => void;
}
+4
View File
@@ -208,4 +208,8 @@ export function registerIpcHandlers(
currentVideoPath = null;
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)
const { ipcMain } = await import('electron');
ipcMain.on('hud-overlay-close', () => {
if (process.platform === 'darwin') {
app.quit();
}
app.quit();
});
// Ensure recordings directory exists
await ensureRecordingsDir()
+3
View File
@@ -60,4 +60,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
clearCurrentVideoPath: () => {
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 {
const isMac = process.platform === 'darwin';
const win = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
titleBarStyle: 'hiddenInset',
trafficLightPosition: { x: 12, y: 12 },
...(isMac && {
titleBarStyle: 'hiddenInset',
trafficLightPosition: { x: 12, y: 12 },
}),
transparent: false,
resizable: true,
alwaysOnTop: false,
+26 -105
View File
@@ -1,12 +1,12 @@
{
"name": "openscreen",
"version": "0.0.0",
"version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openscreen",
"version": "0.0.0",
"version": "1.0.1",
"dependencies": {
"@fix-webm-duration/fix": "^1.0.1",
"@pixi/filter-drop-shadow": "^5.2.0",
@@ -21,11 +21,12 @@
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@uiw/color-convert": "^2.9.2",
"@uiw/react-color-colorful": "^2.9.0",
"@uiw/react-color-block": "^2.9.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dnd-timeline": "^2.2.0",
"emoji-picker-react": "^4.16.1",
"fix-webm-duration": "^1.0.6",
"gsap": "^3.13.0",
"lucide-react": "^0.545.0",
"mediabunny": "^1.25.1",
@@ -3915,14 +3916,15 @@
"@babel/runtime": ">=7.19.0"
}
},
"node_modules/@uiw/react-color-alpha": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@uiw/react-color-alpha/-/react-color-alpha-2.9.0.tgz",
"integrity": "sha512-tP9zs2KUPzIGvdtTBTNlDEdwHWcNJ3Ju/nnpPTsKERRJF2vzucb3v18JzihX0ACiOXU9+JVqgpAVgvoZXMeITA==",
"node_modules/@uiw/react-color-block": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/@uiw/react-color-block/-/react-color-block-2.9.2.tgz",
"integrity": "sha512-0EIZTELA5pnxyMlBOFo3hrpy73db+Qeq6E+QptNfD/8izor8OvY1Uquj2VqD6gDz+iVHMELIoKxpaQ8sZR7NOg==",
"license": "MIT",
"dependencies": {
"@uiw/color-convert": "2.9.0",
"@uiw/react-drag-event-interactive": "2.9.0"
"@uiw/color-convert": "2.9.2",
"@uiw/react-color-editable-input": "2.9.2",
"@uiw/react-color-swatch": "2.9.2"
},
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
@@ -3933,58 +3935,27 @@
"react-dom": ">=16.9.0"
}
},
"node_modules/@uiw/react-color-alpha/node_modules/@uiw/color-convert": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@uiw/color-convert/-/color-convert-2.9.0.tgz",
"integrity": "sha512-tFm6iac8iN3VL9IIAXKAVpBX6ZQH7ucCvb+EgZNM5y8prRWlk53cERnP4RycE3wnYb/HUKW0lrngoygQnEWAHg==",
"node_modules/@uiw/react-color-editable-input": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/@uiw/react-color-editable-input/-/react-color-editable-input-2.9.2.tgz",
"integrity": "sha512-DY7pu12+LDRn6cxDMvsy1/quaPTxicAPz/kfODV7KBf8+Hq4rFWeJ4KS6m22IKIbQxrBQgmQG0WFJLaPeY7cPw==",
"license": "MIT",
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@babel/runtime": ">=7.19.0"
}
},
"node_modules/@uiw/react-color-colorful": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@uiw/react-color-colorful/-/react-color-colorful-2.9.0.tgz",
"integrity": "sha512-qw7zhgX9J4aydK0hKKLroXMYp0tw3ASNuZM1fQNwXXIpOkEW4zO+PHTkzUBpbII68wGS8tAxsZessTWNpAVi0w==",
"license": "MIT",
"dependencies": {
"@uiw/color-convert": "2.9.0",
"@uiw/react-color-alpha": "2.9.0",
"@uiw/react-color-hue": "2.9.0",
"@uiw/react-color-saturation": "2.9.0"
},
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@babel/runtime": ">=7.19.0",
"react": ">=16.9.0",
"react-dom": ">=16.9.0"
}
},
"node_modules/@uiw/react-color-colorful/node_modules/@uiw/color-convert": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@uiw/color-convert/-/color-convert-2.9.0.tgz",
"integrity": "sha512-tFm6iac8iN3VL9IIAXKAVpBX6ZQH7ucCvb+EgZNM5y8prRWlk53cERnP4RycE3wnYb/HUKW0lrngoygQnEWAHg==",
"license": "MIT",
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@babel/runtime": ">=7.19.0"
}
},
"node_modules/@uiw/react-color-hue": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@uiw/react-color-hue/-/react-color-hue-2.9.0.tgz",
"integrity": "sha512-2Ix027ref3ppiVq+OQkbyKr6O8fLTH+n0wEW4xhHfa3kV8mBa2bEu8jAjrkSwO6uElaE6OJcuqYe2llyKRlIFg==",
"node_modules/@uiw/react-color-swatch": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/@uiw/react-color-swatch/-/react-color-swatch-2.9.2.tgz",
"integrity": "sha512-6zBy+E9NzZR672M2wPsbbNRqKy9Wi9jOuuxxyzov1CEZp+pPX7UwMlCX6RUhKdO0PzTSPCVQmbz5bplu5vsW0w==",
"license": "MIT",
"dependencies": {
"@uiw/color-convert": "2.9.0",
"@uiw/react-color-alpha": "2.9.0"
"@uiw/color-convert": "2.9.2"
},
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
@@ -3995,62 +3966,6 @@
"react-dom": ">=16.9.0"
}
},
"node_modules/@uiw/react-color-hue/node_modules/@uiw/color-convert": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@uiw/color-convert/-/color-convert-2.9.0.tgz",
"integrity": "sha512-tFm6iac8iN3VL9IIAXKAVpBX6ZQH7ucCvb+EgZNM5y8prRWlk53cERnP4RycE3wnYb/HUKW0lrngoygQnEWAHg==",
"license": "MIT",
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@babel/runtime": ">=7.19.0"
}
},
"node_modules/@uiw/react-color-saturation": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@uiw/react-color-saturation/-/react-color-saturation-2.9.0.tgz",
"integrity": "sha512-tBARqsW0ap+gwByGKSid1nYaGJd6dw2MTMwCABF9rebPQHtYbN/YgBjl9dNnp6XDGd+VBhH4RbpM38WV7J1F4g==",
"license": "MIT",
"dependencies": {
"@uiw/color-convert": "2.9.0",
"@uiw/react-drag-event-interactive": "2.9.0"
},
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@babel/runtime": ">=7.19.0",
"react": ">=16.9.0",
"react-dom": ">=16.9.0"
}
},
"node_modules/@uiw/react-color-saturation/node_modules/@uiw/color-convert": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@uiw/color-convert/-/color-convert-2.9.0.tgz",
"integrity": "sha512-tFm6iac8iN3VL9IIAXKAVpBX6ZQH7ucCvb+EgZNM5y8prRWlk53cERnP4RycE3wnYb/HUKW0lrngoygQnEWAHg==",
"license": "MIT",
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@babel/runtime": ">=7.19.0"
}
},
"node_modules/@uiw/react-drag-event-interactive": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@uiw/react-drag-event-interactive/-/react-drag-event-interactive-2.9.0.tgz",
"integrity": "sha512-oBqrClsSuYA3IjkSHp4uHOBLgPkR98KeKeheqCRltF8w/a1CPaFCl43pyKyxwXaTV6qM6cb3VkT3JRCHMNqTzA==",
"license": "MIT",
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@babel/runtime": ">=7.19.0",
"react": ">=16.9.0",
"react-dom": ">=16.9.0"
}
},
"node_modules/@ungap/structured-clone": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
@@ -7085,6 +7000,12 @@
"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",
+6 -4
View File
@@ -1,7 +1,7 @@
{
"name": "openscreen",
"private": true,
"version": "1.0.0",
"version": "1.0.2",
"type": "module",
"scripts": {
"dev": "vite",
@@ -9,7 +9,8 @@
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"build:mac": "tsc && vite build && electron-builder --mac",
"build:win": "tsc && vite build && electron-builder --win"
"build:win": "tsc && vite build && electron-builder --win",
"build:linux": "tsc && vite build && electron-builder --linux"
},
"dependencies": {
"@fix-webm-duration/fix": "^1.0.1",
@@ -25,11 +26,12 @@
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@uiw/color-convert": "^2.9.2",
"@uiw/react-color-colorful": "^2.9.0",
"@uiw/react-color-block": "^2.9.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dnd-timeline": "^2.2.0",
"emoji-picker-react": "^4.16.1",
"fix-webm-duration": "^1.0.6",
"gsap": "^3.13.0",
"lucide-react": "^0.545.0",
"mediabunny": "^1.25.1",
@@ -70,4 +72,4 @@
"vite-plugin-electron-renderer": "^0.14.5"
},
"main": "dist-electron/main.js"
}
}
-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
+3 -7
View File
@@ -8,6 +8,7 @@ import { MdMonitor } from "react-icons/md";
import { RxDragHandleDots2 } from "react-icons/rx";
import { FaFolderMinus } from "react-icons/fa6";
import { FiMinus, FiX } from "react-icons/fi";
import { ContentClamp } from "../ui/content-clamp";
export function LaunchWindow() {
const { recording, toggleRecording } = useScreenRecorder();
@@ -61,11 +62,6 @@ export function LaunchWindow() {
return () => clearInterval(interval);
}, []);
const truncateText = (text: string, maxLength: number = 6) => {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + "...";
};
const openSourceSelector = () => {
if (window.electronAPI) {
window.electronAPI.openSourceSelector();
@@ -121,7 +117,7 @@ export function LaunchWindow() {
disabled={recording}
>
<MdMonitor size={14} className="text-white" />
{truncateText(selectedSource, 6)}
<ContentClamp truncateLength={6}>{selectedSource}</ContentClamp>
</Button>
<div className="w-px h-6 bg-white/30" />
@@ -131,7 +127,7 @@ export function LaunchWindow() {
size="sm"
onClick={hasSelectedSource ? toggleRecording : openSourceSelector}
disabled={!hasSelectedSource && !recording}
className={`gap-1 bg-transparent hover:bg-transparent px-0 flex-1 text-center text-xs ${styles.electronNoDrag}`}
className={`gap-1 text-white bg-transparent hover:bg-transparent px-0 flex-1 text-center text-xs ${styles.electronNoDrag}`}
>
{recording ? (
<>
+86
View File
@@ -0,0 +1,86 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Popover, PopoverArrow, PopoverContent, PopoverTrigger } from "./popover"
interface ContentClampProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode
truncateLength?: number
}
function ContentClamp({
children,
className,
truncateLength = 50,
...props
}: ContentClampProps) {
const text = typeof children === "string" ? children : String(children ?? "")
const isTruncated = text.length > truncateLength
const [open, setOpen] = React.useState(false)
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null)
const handleMouseEnter = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
setOpen(true)
}
const handleMouseLeave = () => {
timeoutRef.current = setTimeout(() => {
setOpen(false)
}, 100)
}
React.useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [])
if (!isTruncated) {
return (
<div className={cn("inline", className)} {...props}>
{children}
</div>
)
}
const truncatedText = text.slice(0, truncateLength) + "..."
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<span
className={className}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={(e) => e.preventDefault()}
{...props}
>
{truncatedText}
</span>
</PopoverTrigger>
<PopoverContent
className="w-auto max-w-sm rounded-lg border border-white bg-popover p-3 text-sm text-popover-foreground"
sideOffset={8}
animated={false}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onPointerDownOutside={(e) => e.preventDefault()}
onClick={(e) => e.stopPropagation()}
>
<PopoverArrow className="fill-white" />
{children}
</PopoverContent>
</Popover>
)
}
export { ContentClamp }
+21 -3
View File
@@ -21,8 +21,11 @@ function PopoverContent({
className,
align = "center",
sideOffset = 4,
animated = true,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
}: React.ComponentProps<typeof PopoverPrimitive.Content> & {
animated?: boolean
}) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
@@ -30,7 +33,9 @@ function PopoverContent({
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
"bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-md outline-hidden",
animated &&
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-popover-content-transform-origin)",
className
)}
{...props}
@@ -45,4 +50,17 @@ function PopoverAnchor({
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
function PopoverArrow({
className,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Arrow>) {
return (
<PopoverPrimitive.Arrow
data-slot="popover-arrow"
className={cn("fill-popover", className)}
{...props}
/>
)
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverArrow }
@@ -1,10 +1,9 @@
import { useState, useRef } from "react";
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 Colorful from '@uiw/react-color-colorful';
import { hsvaToHex, hexToHsva } from '@uiw/color-convert';
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";
@@ -44,11 +43,24 @@ export function AnnotationSettingsPanel({
onDelete,
}: AnnotationSettingsPanelProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [textColorHsva, setTextColorHsva] = useState(hexToHsva(annotation.style.color));
const [bgColorHsva, setBgColorHsva] = useState(hexToHsva(annotation.style.backgroundColor || '#00000000'));
const [figureColorHsva, setFigureColorHsva] = useState(
hexToHsva(annotation.figureData?.color || '#34B27B')
);
const 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
];
@@ -252,18 +264,17 @@ export function AnnotationSettingsPanel({
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0 border-none bg-transparent shadow-xl">
<div className="p-2 bg-[#1a1a1c] border border-white/10 rounded-xl">
<Colorful
color={textColorHsva}
disableAlpha={true}
onChange={(color) => {
setTextColorHsva(color.hsva);
onStyleChange({ color: hsvaToHex(color.hsva) });
}}
style={{ width: '100%', borderRadius: '8px' }}
/>
</div>
<PopoverContent 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>
@@ -290,28 +301,27 @@ export function AnnotationSettingsPanel({
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0 border-none bg-transparent shadow-xl">
<div className="p-2 bg-[#1a1a1c] border border-white/10 rounded-xl">
<Colorful
color={bgColorHsva}
onChange={(color) => {
setBgColorHsva(color.hsva);
onStyleChange({ backgroundColor: hsvaToHex(color.hsva) });
}}
style={{ width: '100%', borderRadius: '8px' }}
/>
<Button
variant="ghost"
size="sm"
className="w-full mt-2 text-xs h-7 hover:bg-white/5 text-slate-400"
onClick={() => {
onStyleChange({ backgroundColor: 'transparent' });
setBgColorHsva({ h: 0, s: 0, v: 0, a: 0 });
}}
>
Clear Background
</Button>
</div>
<PopoverContent 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>
@@ -428,22 +438,21 @@ export function AnnotationSettingsPanel({
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0 border-none bg-transparent shadow-xl">
<div className="p-2 bg-[#1a1a1c] border border-white/10 rounded-xl">
<Colorful
color={figureColorHsva}
disableAlpha={true}
onChange={(color) => {
setFigureColorHsva(color.hsva);
const newFigureData: FigureData = {
...annotation.figureData!,
color: hsvaToHex(color.hsva),
};
onFigureDataChange?.(newFigureData);
}}
style={{ width: '100%', borderRadius: '8px' }}
/>
</div>
<PopoverContent 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>
@@ -1,6 +1,28 @@
import { HelpCircle } from "lucide-react";
import { useState, useEffect } from "react";
import { formatShortcut } from "@/utils/platformUtils";
export function KeyboardShortcutsHelp() {
const [shortcuts, setShortcuts] = useState({
delete: 'Ctrl + D',
pan: 'Shift + Ctrl + Scroll',
zoom: 'Ctrl + Scroll'
});
useEffect(() => {
Promise.all([
formatShortcut(['mod', 'D']),
formatShortcut(['shift', 'mod', 'Scroll']),
formatShortcut(['mod', 'Scroll'])
]).then(([deleteKey, panKey, zoomKey]) => {
setShortcuts({
delete: deleteKey,
pan: panKey,
zoom: zoomKey
});
});
}, []);
return (
<div className="relative group">
<HelpCircle className="w-4 h-4 text-slate-500 hover:text-[#34B27B] transition-colors cursor-help" />
@@ -25,15 +47,15 @@ export function KeyboardShortcutsHelp() {
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Delete Selected</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">⌘ + D</kbd>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{shortcuts.delete}</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Pan Timeline</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">⇧ + ⌘ + Scroll</kbd>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{shortcuts.pan}</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Zoom Timeline</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">⌘ + Scroll</kbd>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{shortcuts.zoom}</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Pause/Play</span>
+51 -18
View File
@@ -6,10 +6,8 @@ import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import Colorful from '@uiw/react-color-colorful';
import { hsvaToHex } from '@uiw/color-convert';
import { Trash2, Download, Crop, X, Bug, Upload } from "lucide-react";
import { GiHearts } from "react-icons/gi";
import Block from '@uiw/react-color-block';
import { Trash2, Download, Crop, X, Bug, Upload, Star } from "lucide-react";
import { toast } from "sonner";
import type { ZoomDepth, CropRegion, AnnotationRegion, AnnotationType } from "./types";
import { CropControl } from "./CropControl";
@@ -54,6 +52,8 @@ interface SettingsPanelProps {
onZoomDepthChange?: (depth: ZoomDepth) => void;
selectedZoomId?: string | null;
onZoomDelete?: (id: string) => void;
selectedTrimId?: string | null;
onTrimDelete?: (id: string) => void;
shadowIntensity?: number;
onShadowChange?: (intensity: number) => void;
showBlur?: boolean;
@@ -98,6 +98,8 @@ export function SettingsPanel({
onZoomDepthChange,
selectedZoomId,
onZoomDelete,
selectedTrimId,
onTrimDelete,
shadowIntensity = 0,
onShadowChange,
showBlur,
@@ -139,11 +141,18 @@ export function SettingsPanel({
})()
return () => { mounted = false }
}, [])
const [hsva, setHsva] = useState({ h: 0, s: 0, v: 68, a: 1 });
const colorPalette = [
'#FF0000', '#FFD700', '#00FF00', '#FFFFFF', '#0000FF', '#FF6B00',
'#9B59B6', '#E91E63', '#00BCD4', '#FF5722', '#8BC34A', '#FFC107',
'#34B27B', '#000000', '#607D8B', '#795548',
];
const [selectedColor, setSelectedColor] = useState('#ADADAD');
const [gradient, setGradient] = useState<string>(GRADIENTS[0]);
const [showCropDropdown, setShowCropDropdown] = useState(false);
const zoomEnabled = Boolean(selectedZoomDepth);
const trimEnabled = Boolean(selectedTrimId);
const handleDeleteClick = () => {
if (selectedZoomId && onZoomDelete) {
@@ -151,6 +160,12 @@ export function SettingsPanel({
}
};
const handleTrimDeleteClick = () => {
if (selectedTrimId && onTrimDelete) {
onTrimDelete(selectedTrimId);
}
};
const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files || files.length === 0) return;
@@ -270,6 +285,21 @@ export function SettingsPanel({
)}
</div>
{/* Trim Delete Section */}
<div className="mb-6">
{trimEnabled && (
<Button
onClick={handleTrimDeleteClick}
variant="destructive"
size="sm"
className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all"
>
<Trash2 className="w-4 h-4" />
Delete Trim Region
</Button>
)}
</div>
<div className="mb-6">
<div className="grid grid-cols-2 gap-3">
{/* Motion Blur Switch */}
@@ -482,14 +512,17 @@ export function SettingsPanel({
<TabsContent value="color" className="mt-0 px-2">
<div className="p-1">
<Colorful
color={hsva}
disableAlpha={true}
<Block
color={selectedColor}
colors={colorPalette}
onChange={(color) => {
setHsva(color.hsva);
onWallpaperChange(hsvaToHex(color.hsva));
setSelectedColor(color.hex);
onWallpaperChange(color.hex);
}}
style={{
width: '100%',
borderRadius: '12px',
}}
style={{ width: '100%', borderRadius: '12px' }}
/>
</div>
</TabsContent>
@@ -529,7 +562,7 @@ export function SettingsPanel({
: "text-slate-400 hover:text-slate-200"
)}
>
Medium
Low
</button>
<button
onClick={() => onExportQualityChange?.('good')}
@@ -540,7 +573,7 @@ export function SettingsPanel({
: "text-slate-400 hover:text-slate-200"
)}
>
Good
Medium
</button>
<button
onClick={() => onExportQualityChange?.('source')}
@@ -551,7 +584,7 @@ export function SettingsPanel({
: "text-slate-400 hover:text-slate-200"
)}
>
Source
High
</button>
</div>
@@ -568,7 +601,7 @@ export function SettingsPanel({
<button
type="button"
onClick={() => {
window.electronAPI?.openExternalUrl('https://github.com/siddharthvaddem/openscreen/issues/new');
window.electronAPI?.openExternalUrl('https://github.com/siddharthvaddem/openscreen/issues/new/choose');
}}
className="flex-1 flex items-center justify-center gap-2 text-xs py-2"
>
@@ -578,12 +611,12 @@ export function SettingsPanel({
<button
type="button"
onClick={() => {
window.electronAPI?.openExternalUrl('https://buymeacoffee.com/siddharthvaddem');
window.electronAPI?.openExternalUrl('https://github.com/siddharthvaddem/openscreen');
}}
className="flex-1 flex items-center justify-center gap-2 text-xs"
>
<GiHearts className="w-3 h-3 text-red-500" />
<span>Support my work</span>
<Star className="w-3 h-3 text-yellow-400" />
<span>Star on GitHub</span>
</button>
</div>
</div>
@@ -30,6 +30,7 @@ import {
} from "./types";
import { VideoExporter, type ExportProgress, type ExportQuality } from "@/lib/exporter";
import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils";
import { getAssetPath } from "@/lib/assetPath";
const WALLPAPER_COUNT = 18;
const WALLPAPER_PATHS = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `/wallpapers/wallpaper${i + 1}.jpg`);
@@ -104,6 +105,23 @@ export default function VideoEditor() {
loadVideo();
}, []);
// Initialize default wallpaper with resolved asset path
useEffect(() => {
let mounted = true;
(async () => {
try {
const resolvedPath = await getAssetPath('wallpapers/wallpaper1.jpg');
if (mounted) {
setWallpaper(resolvedPath);
}
} catch (err) {
// If resolution fails, keep the fallback
console.warn('Failed to resolve default wallpaper path:', err);
}
})();
return () => { mounted = false };
}, []);
function togglePlayPause() {
const playback = videoPlaybackRef.current;
const video = playback?.video;
@@ -735,6 +753,8 @@ export default function VideoEditor() {
onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)}
selectedZoomId={selectedZoomId}
onZoomDelete={handleZoomDelete}
selectedTrimId={selectedTrimId}
onTrimDelete={handleTrimDelete}
shadowIntensity={shadowIntensity}
onShadowChange={setShadowIntensity}
showBlur={showBlur}
@@ -18,6 +18,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { type AspectRatio, getAspectRatioLabel } from "@/utils/aspectRatioUtils";
import { formatShortcut } from "@/utils/platformUtils";
const ZOOM_ROW_ID = "row-zoom";
const TRIM_ROW_ID = "row-trim";
@@ -520,6 +521,18 @@ export default function TimelineEditor({
const [range, setRange] = useState<Range>(() => createInitialRange(totalMs));
const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]);
const [selectedKeyframeId, setSelectedKeyframeId] = useState<string | null>(null);
const [shortcuts, setShortcuts] = useState({
pan: 'Shift + Ctrl + Scroll',
zoom: 'Ctrl + Scroll'
});
useEffect(() => {
formatShortcut(['shift', 'mod', 'Scroll']).then(pan => {
formatShortcut(['mod', 'Scroll']).then(zoom => {
setShortcuts({ pan, zoom });
});
});
}, []);
// Add keyframe at current playhead position
const addKeyframe = useCallback(() => {
@@ -898,11 +911,11 @@ export default function TimelineEditor({
<div className="flex-1" />
<div className="flex items-center gap-4 text-[10px] text-slate-500 font-medium">
<span className="flex items-center gap-1.5">
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">⇧ + ⌘ + Scroll</kbd>
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">{shortcuts.pan}</kbd>
<span>Pan</span>
</span>
<span className="flex items-center gap-1.5">
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">⌘ + Scroll</kbd>
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">{shortcuts.zoom}</kbd>
<span>Zoom</span>
</span>
</div>
+2
View File
@@ -65,6 +65,8 @@
}
body {
@apply bg-background text-foreground;
-webkit-user-select: none;
user-select: none;
}
}
+91 -88
View File
@@ -31,9 +31,11 @@ export class VideoExporter {
private muxer: VideoMuxer | null = null;
private cancelled = false;
private encodeQueue = 0;
// Increased queue size for better throughput with hardware encoding
private readonly MAX_ENCODE_QUEUE = 120;
private videoDescription: Uint8Array | undefined;
private videoColorSpace: VideoColorSpaceInit | undefined;
// Track muxing promises for parallel processing
private muxingPromises: Promise<void>[] = [];
private chunkCount = 0;
@@ -54,20 +56,20 @@ export class VideoExporter {
const trimRegions = this.config.trimRegions || [];
// Sort trim regions by start time
const sortedTrims = [...trimRegions].sort((a, b) => a.startMs - b.startMs);
let sourceTimeMs = effectiveTimeMs;
for (const trim of sortedTrims) {
// If the source time hasn't reached this trim region yet, we're done
if (sourceTimeMs < trim.startMs) {
break;
}
// Add the duration of this trim region to the source time
const trimDuration = trim.endMs - trim.startMs;
sourceTimeMs += trimDuration;
}
return sourceTimeMs;
}
@@ -75,12 +77,12 @@ export class VideoExporter {
try {
this.cleanup();
this.cancelled = false;
const exportStartTime = performance.now();
// Initialize decoder and load video
this.decoder = new VideoFileDecoder();
const videoInfo = await this.decoder.loadVideo(this.config.videoUrl);
// Initialize frame renderer
this.renderer = new FrameRenderer({
width: this.config.width,
height: this.config.height,
@@ -101,82 +103,74 @@ export class VideoExporter {
});
await this.renderer.initialize();
// Initialize video encoder
await this.initializeEncoder();
// Initialize muxer
this.muxer = new VideoMuxer(this.config, false);
await this.muxer.initialize();
// Get the video element for frame extraction
const videoElement = this.decoder.getVideoElement();
if (!videoElement) {
throw new Error('Video element not available');
}
// Calculate frame count after trimming
// Calculate effective duration and frame count (excluding trim regions)
const effectiveDuration = this.getEffectiveDuration(videoInfo.duration);
const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate);
const frameDuration = 1_000_000 / this.config.frameRate;
console.log('[VideoExporter] Original duration:', videoInfo.duration, 's');
console.log('[VideoExporter] Effective duration:', effectiveDuration, 's');
console.log('[VideoExporter] Total frames to export:', totalFrames);
// Process frames continuously without batching delays
const frameDuration = 1_000_000 / this.config.frameRate; // in microseconds
let frameIndex = 0;
const timeStep = 1 / this.config.frameRate;
videoElement.muted = true;
if (videoElement.readyState < 2) {
await new Promise<void>(r => {
videoElement.addEventListener('loadeddata', () => r(), { once: true });
});
}
while (frameIndex < totalFrames && !this.cancelled) {
const i = frameIndex;
const timestamp = i * frameDuration;
// Pipeline: Decode 10 frames ahead to overlap decode/render/encode operations
const DECODE_AHEAD = 10;
const frameQueue: { frame: VideoFrame; timestamp: number; sourceTimestamp: number }[] = [];
// Decode a single frame from source video
const decodeFrame = async (idx: number) => {
if (idx >= totalFrames) return;
const timestamp = idx * frameDuration;
const effectiveTimeMs = (idx * timeStep) * 1000;
// Map effective time to source time (accounting for trim regions)
const effectiveTimeMs = (i * timeStep) * 1000;
const sourceTimeMs = this.mapEffectiveToSourceTime(effectiveTimeMs);
const videoTime = sourceTimeMs / 1000;
const sourceTimestamp = sourceTimeMs * 1000;
// Seek to frame position
// Seek if needed or wait for first frame to be ready
const needsSeek = Math.abs(videoElement.currentTime - videoTime) > 0.001;
if (needsSeek || idx === 0) {
if (needsSeek) {
// Attach listener BEFORE setting currentTime to avoid race condition
const seekedPromise = new Promise<void>(resolve => {
videoElement.addEventListener('seeked', () => resolve(), { once: true });
});
videoElement.currentTime = videoTime;
await new Promise<void>(r => {
videoElement.addEventListener('seeked', () => r(), { once: true });
await seekedPromise;
} else if (i === 0) {
// Only for the very first frame, wait for it to be ready
await new Promise<void>(resolve => {
videoElement.requestVideoFrameCallback(() => resolve());
});
}
// Create VideoFrame from current video element position
const videoFrame = new VideoFrame(videoElement, { timestamp });
frameQueue.push({ frame: videoFrame, timestamp, sourceTimestamp });
};
// Pre-decode first batch of frames
for (let i = 0; i < Math.min(DECODE_AHEAD, totalFrames); i++) {
await decodeFrame(i);
}
let frameIndex = 0;
let decodeIndex = DECODE_AHEAD;
// Main processing loop
while (frameIndex < totalFrames && !this.cancelled) {
// Wait for decoded frame to be available
while (frameQueue.length === 0 && frameIndex < totalFrames) {
await new Promise(r => setTimeout(r, 1));
}
if (frameQueue.length === 0) break;
const { frame: videoFrame, timestamp, sourceTimestamp } = frameQueue.shift()!;
// Create a VideoFrame from the video element (on GPU!)
const videoFrame = new VideoFrame(videoElement, {
timestamp,
});
// Render frame with effects using PixiJS
// Render the frame with all effects using source timestamp
const sourceTimestamp = sourceTimeMs * 1000; // Convert to microseconds
await this.renderer!.renderFrame(videoFrame, sourceTimestamp);
videoFrame.close();
// Create VideoFrame directly from canvas (GPU-level)
const canvas = this.renderer!.getCanvas();
// @ts-ignore
// Create VideoFrame from canvas on GPU without reading pixels
// @ts-ignore - colorSpace not in TypeScript definitions but works at runtime
const exportFrame = new VideoFrame(canvas, {
timestamp,
duration: frameDuration,
@@ -188,26 +182,23 @@ export class VideoExporter {
},
});
// Wait if encoder queue is full
// Check encoder queue before encoding to keep it full
while (this.encodeQueue >= this.MAX_ENCODE_QUEUE && !this.cancelled) {
await new Promise(r => setTimeout(r, 0));
await new Promise(resolve => setTimeout(resolve, 0));
}
// Encode frame using hardware acceleration
if (this.encoder && this.encoder.state === 'configured') {
this.encodeQueue++;
this.encoder.encode(exportFrame, { keyFrame: frameIndex % 150 === 0 });
this.encoder.encode(exportFrame, { keyFrame: i % 150 === 0 });
} else {
console.warn(`[Frame ${i}] Encoder not ready! State: ${this.encoder?.state}`);
}
exportFrame.close();
frameIndex++;
// Decode next frame in parallel while we process current frame
if (decodeIndex < totalFrames) {
decodeFrame(decodeIndex++).catch(e => console.error('[VideoExporter] Decode error:', e));
}
// Update progress
if (this.config.onProgress) {
this.config.onProgress({
currentFrame: frameIndex,
@@ -222,18 +213,20 @@ export class VideoExporter {
return { success: false, error: 'Export cancelled' };
}
// Finalize encoding
if (this.encoder && this.encoder.state === 'configured') {
await this.encoder.flush();
}
// Wait for all muxing operations to complete
await Promise.all(this.muxingPromises);
// Finalize muxer and get output blob
const blob = await this.muxer!.finalize();
const totalTime = performance.now() - exportStartTime;
console.log(`[VideoExporter] Export complete in ${(totalTime/1000).toFixed(2)}s (${totalFrames} frames)`);
return { success: true, blob };
} catch (error) {
console.error('[VideoExporter] Export error:', error);
console.error('Export error:', error);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
@@ -247,33 +240,36 @@ export class VideoExporter {
this.encodeQueue = 0;
this.muxingPromises = [];
this.chunkCount = 0;
let videoDescription: Uint8Array | undefined;
// Create VideoEncoder with hardware acceleration
this.encoder = new VideoEncoder({
output: (chunk, meta) => {
// Capture codec description and color space from first chunk
if (meta?.decoderConfig?.description && !this.videoDescription) {
// Capture decoder config metadata from encoder output
if (meta?.decoderConfig?.description && !videoDescription) {
const desc = meta.decoderConfig.description;
this.videoDescription = new Uint8Array(desc instanceof ArrayBuffer ? desc : (desc as any));
videoDescription = new Uint8Array(desc instanceof ArrayBuffer ? desc : (desc as any));
this.videoDescription = videoDescription;
}
// Capture colorSpace from encoder metadata if provided
if (meta?.decoderConfig?.colorSpace && !this.videoColorSpace) {
this.videoColorSpace = meta.decoderConfig.colorSpace;
}
// Stream chunk to muxer immediately (parallel processing)
const isFirstChunk = this.chunkCount === 0;
this.chunkCount++;
// Send encoded chunk to muxer
const muxingPromise = (async () => {
try {
if (isFirstChunk && this.videoDescription) {
// Add decoder config for the first chunk
const colorSpace = this.videoColorSpace || {
primaries: 'bt709',
transfer: 'iec61966-2-1',
matrix: 'rgb',
fullRange: true,
};
const metadata: EncodedVideoChunkMetadata = {
decoderConfig: {
codec: this.config.codec || 'avc1.640033',
@@ -283,27 +279,28 @@ export class VideoExporter {
colorSpace,
},
};
await this.muxer!.addVideoChunk(chunk, metadata);
} else {
await this.muxer!.addVideoChunk(chunk, meta);
}
} catch (error) {
console.error('[VideoExporter] Muxing error:', error);
console.error('Muxing error:', error);
}
})();
this.muxingPromises.push(muxingPromise);
this.encodeQueue--;
},
error: (error) => {
console.error('[VideoExporter] Encoder error:', error);
// Stop export encoding failed
this.cancelled = true;
},
});
// Configure encoder with hardware acceleration
const codec = this.config.codec || 'avc1.640033';
const encoderConfig: VideoEncoderConfig = {
codec,
width: this.config.width,
@@ -315,17 +312,23 @@ export class VideoExporter {
hardwareAcceleration: 'prefer-hardware',
};
const support = await VideoEncoder.isConfigSupported(encoderConfig);
if (support.supported) {
// Check hardware support first
const hardwareSupport = await VideoEncoder.isConfigSupported(encoderConfig);
if (hardwareSupport.supported) {
// Use hardware encoding
console.log('[VideoExporter] Using hardware acceleration');
this.encoder.configure(encoderConfig);
} else {
// Fallback to software encoding
// Fall back to software encoding
console.log('[VideoExporter] Hardware not supported, using software encoding');
encoderConfig.hardwareAcceleration = 'prefer-software';
const softwareSupport = await VideoEncoder.isConfigSupported(encoderConfig);
if (!softwareSupport.supported) {
throw new Error('Video encoding not supported');
throw new Error('Video encoding not supported on this system');
}
this.encoder.configure(encoderConfig);
}
}
+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(' + ');
};