Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5dbcb3a7a | ||
|
|
e1760020ac | ||
|
|
c8a4becaf0 | ||
|
|
e6db74f183 | ||
|
|
271a3ee443 | ||
|
|
cef987cd33 | ||
|
|
23aced6007 | ||
|
|
afbd0740a3 | ||
|
|
f30e2d654e | ||
|
|
f2c6d8ff0f | ||
|
|
9821e926d9 | ||
|
|
a2ca0799d4 | ||
|
|
6d2e1edb5b | ||
|
|
08f58b3539 | ||
|
|
1586dbe65e | ||
|
|
3a63617c55 | ||
|
|
e8d2c19b7d | ||
|
|
23ede0fcfa | ||
|
|
78cfb64f98 | ||
|
|
3124342309 | ||
|
|
a9bd2ac820 | ||
|
|
e190915c48 | ||
|
|
171a02aef4 | ||
|
|
085ebad38f | ||
|
|
f3e12629c2 | ||
|
|
f00d381f94 | ||
|
|
fb92b0b6d9 | ||
|
|
8ca2b8362a | ||
|
|
134f392553 | ||
|
|
c7e81c6b7f | ||
|
|
f58b8b2897 | ||
|
|
6e6ecba172 | ||
|
|
175bb36eda | ||
|
|
2ca99136ba | ||
|
|
b7485865f3 | ||
|
|
7db2fa4e01 | ||
|
|
81b59cad7c | ||
|
|
7e0ce53df0 | ||
|
|
d57140b031 | ||
|
|
5dd85abaee | ||
|
|
78fbd30b15 | ||
|
|
ebb1d29375 | ||
|
|
250fc5d221 | ||
|
|
58b5ea0f9b | ||
|
|
4bc8a1e970 | ||
|
|
16752a7ae8 | ||
|
|
5f4d20b26d | ||
|
|
8cbdcf2d7a | ||
|
|
f1f507e6e9 | ||
|
|
a7fb7670a7 | ||
|
|
eccccf583b | ||
|
|
d6d1a3eca6 | ||
|
|
c5aa622898 | ||
|
|
1345c8109c | ||
|
|
d91ed78fc2 | ||
|
|
5d7b817586 | ||
|
|
fecb9a9b22 | ||
|
|
cbdef41667 | ||
|
|
f34bd19183 | ||
|
|
7a7db0b277 | ||
|
|
3a4ec9c470 | ||
|
|
d2a62b137d | ||
|
|
c9e9d1d1bd | ||
|
|
cce88b3dab | ||
|
|
4018741648 | ||
|
|
ed3cdab64e | ||
|
|
4ffa9c6ecb | ||
|
|
899e55d257 | ||
|
|
977be1e3b1 | ||
|
|
262745a97f | ||
|
|
6ac712eaac | ||
|
|
79e40cef68 | ||
|
|
c847953a52 | ||
|
|
391938049b | ||
|
|
71ba4e4cea | ||
|
|
ec3b9b46a1 | ||
|
|
2ad5899417 | ||
|
|
1f08d3ca26 | ||
|
|
bae17c0d1b | ||
|
|
0c89e3e01a | ||
|
|
d2ee511466 | ||
|
|
4c725dfceb | ||
|
|
71e2b51f5b | ||
|
|
c9321240d8 | ||
|
|
159f770da8 | ||
|
|
443e4b0581 | ||
|
|
65bc21f153 | ||
|
|
59807662d8 | ||
|
|
8f31bde518 | ||
|
|
6fb9a24834 |
@@ -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']
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,149 @@
|
||||
name: Bug Report
|
||||
description: Create a report to help us improve
|
||||
title: "[Bug]: "
|
||||
labels: ["bug", "triage"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Search existing issues
|
||||
description: Please search to see if an issue already exists for the bug you encountered.
|
||||
options:
|
||||
- label: I have searched the existing issues
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: bug-description
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: A clear and concise description of what the bug is.
|
||||
placeholder: e.g., When I click submit, nothing happens...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: A clear and concise description of what you expected to happen.
|
||||
placeholder: e.g., The form should submit and show a success message
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps-to-reproduce
|
||||
attributes:
|
||||
label: To Reproduce
|
||||
description: Steps to reproduce the behavior.
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: If applicable, add screenshots to help explain your problem.
|
||||
placeholder: Drag and drop images here or paste them
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: os-type
|
||||
attributes:
|
||||
label: OS
|
||||
description: Operating system
|
||||
options:
|
||||
- Windows
|
||||
- macOS
|
||||
- Linux
|
||||
- iOS
|
||||
- Android
|
||||
- Other
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: os-version
|
||||
attributes:
|
||||
label: OS Version
|
||||
description: Please specify your OS version
|
||||
placeholder: e.g., Windows 11, macOS Sonoma, Ubuntu 22.04
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: os-other
|
||||
attributes:
|
||||
label: Other OS
|
||||
description: If you selected "Other" for OS, please specify your operating system
|
||||
placeholder: e.g., FreeBSD, Solaris
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: browser
|
||||
attributes:
|
||||
label: Browser
|
||||
description: What browser are you using?
|
||||
options:
|
||||
- Chrome
|
||||
- Firefox
|
||||
- Safari
|
||||
- Edge
|
||||
- Other
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: browser-version
|
||||
attributes:
|
||||
label: Browser Version
|
||||
description: Please specify your browser version
|
||||
placeholder: e.g., 120.0, 121.0.1
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: browser-other
|
||||
attributes:
|
||||
label: Other Browser
|
||||
description: If you selected "Other" for Browser, please specify your browser
|
||||
placeholder: e.g., Brave, Vivaldi, Opera
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: device-type
|
||||
attributes:
|
||||
label: Device Type
|
||||
description: Device category
|
||||
options:
|
||||
- Desktop
|
||||
- Laptop
|
||||
- Tablet
|
||||
- Mobile
|
||||
- Other
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: device-other
|
||||
attributes:
|
||||
label: Other Device
|
||||
description: If you selected "Other" for Device Type, please specify your device
|
||||
placeholder: e.g., Smart TV, IoT device
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context about the problem here.
|
||||
placeholder: Links, references, or any additional information
|
||||
validations:
|
||||
required: false
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -0,0 +1,48 @@
|
||||
name: Feature Request
|
||||
description: Suggest an idea for this project
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement", "feature-request"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Search existing issues
|
||||
description: Please search to see if an issue already exists for this feature request.
|
||||
options:
|
||||
- label: I have searched the existing issues
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem-description
|
||||
attributes:
|
||||
label: Is your feature request related to a problem?
|
||||
description: A clear and concise description of what the problem is.
|
||||
placeholder: e.g., I'm always frustrated when I have to...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution-description
|
||||
attributes:
|
||||
label: Describe the solution you'd like
|
||||
description: A clear and concise description of what you want to happen.
|
||||
placeholder: Describe the feature or change you're proposing
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Describe alternatives you've considered
|
||||
description: A clear and concise description of any alternative solutions or features you've considered.
|
||||
placeholder: Have you considered any workarounds or alternative approaches?
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context or screenshots about the feature request here.
|
||||
placeholder: Links, mockups, or any additional information
|
||||
validations:
|
||||
required: false
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -23,5 +23,5 @@ dist-ssr
|
||||
*.sln
|
||||
*.sw?
|
||||
release/**
|
||||
|
||||
*.kiro/
|
||||
# npx electron-builder --mac --win
|
||||
@@ -1,67 +1,84 @@
|
||||
<p align="center">
|
||||
<img src="openscreen.png" alt="OpenScreen Logo" width="64" />
|
||||
</p>
|
||||
|
||||
|
||||
# <p align="center">OpenScreen</p>
|
||||
|
||||
<p align="center"><strong>OpenScreen is your free, open-source alternative to Screen Studio (sort of).</strong></p>
|
||||
|
||||
|
||||
If you don't want to pay $29/month for Screen Studio but want a much simpler version that does what most people seem to need, making beautiful product demos and walkthroughs, here's a free-to-use app for you. OpenScreen does not offer all Screen Studio features, but covers the basics well!
|
||||
|
||||
Screen Studio is an awesome product and this is definitely not a 1:1 clone. OpenScreen is a much simpler take, just the basics for folks who want control and don't want to pay. If you need all the fancy features, your best bet is to support Screen Studio (they really do a great job, haha). But if you just want something free (no gotchas) and open, this project does the job!
|
||||
|
||||
OpenScreen is 100% free for personal and commercial use. Use it, modify it, distribute it. (Just be cool 😁 and give a shoutout if you feel like it !)
|
||||
|
||||
|
||||
|
||||
**⚠️ DISCLAIMER: This is very much in beta and might be buggy here and there (but hope you have a good experience!).**
|
||||
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="preview.png" alt="OpenScreen App Preview" style="height: 320px; margin-right: 12px;" />
|
||||
<img src="preview2.png" alt="OpenScreen App Preview 2" style="height: 320px; margin-right: 12px;" />
|
||||
<img src="preview3.png" alt="OpenScreen App Preview 3" style="height: 320px; margin-right: 12px;" />
|
||||
<img src="preview4.png" alt="OpenScreen App Preview 4" style="height: 320px; margin-right: 12px;" />
|
||||
|
||||
</p>
|
||||
</p>
|
||||
|
||||
## Core Features
|
||||
- Record your whole screen or specific apps
|
||||
- Add manual zooms (customizable depth levels)
|
||||
- Customize the duration and position of zooms however you please
|
||||
- Crop video recordings to hide parts
|
||||
- Choose between wallpapers, solid colors, gradients or your own picture for your background
|
||||
- Motion blur and exponential easing for smoother pan and zoom effects
|
||||
|
||||
## macOS Installation instructions
|
||||
|
||||
Download the latest installer for your platform from the [GitHub Releases](https://github.com/siddharthvaddem/openscreen/releases) page.
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
## Built with
|
||||
- Electron
|
||||
- React
|
||||
- TypeScript
|
||||
- Vite
|
||||
- PixiJS
|
||||
- dnd-timeline
|
||||
|
||||
---
|
||||
|
||||
|
||||
_I'm new to open source, idk what I'm doing lol. If something is wrong please raise an issue 🙏_
|
||||
|
||||
## License
|
||||
|
||||
|
||||
This project is licensed under the [MIT License](./LICENSE). By using this software, you agree that the authors are not liable for any issues, damages, or claims arising from its use.
|
||||
> [!WARNING]
|
||||
> This is very much in beta and might be buggy here and there (but hope you have a good experience!).
|
||||
|
||||
<p align="center">
|
||||
<img src="public/openscreen.png" alt="OpenScreen Logo" width="64" />
|
||||
<br />
|
||||
<br />
|
||||
<a href="https://deepwiki.com/siddharthvaddem/openscreen">
|
||||
<img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# <p align="center">OpenScreen</p>
|
||||
|
||||
<p align="center"><strong>OpenScreen is your free, open-source alternative to Screen Studio (sort of).</strong></p>
|
||||
|
||||
If you don't want to pay $29/month for Screen Studio but want a much simpler version that does what most people seem to need, making beautiful product demos and walkthroughs, here's a free-to-use app for you. OpenScreen does not offer all Screen Studio features, but covers the basics well!
|
||||
|
||||
Screen Studio is an awesome product and this is definitely not a 1:1 clone. OpenScreen is a much simpler take, just the basics for folks who want control and don't want to pay. If you need all the fancy features, your best bet is to support Screen Studio (they really do a great job, haha). But if you just want something free (no gotchas) and open, this project does the job!
|
||||
|
||||
OpenScreen is 100% free for personal and commercial use. Use it, modify it, distribute it. (Just be cool 😁 and give a shoutout if you feel like it !)
|
||||
|
||||
<p align="center">
|
||||
<img src="public/preview.png" alt="OpenScreen App Preview" style="height: 320px; margin-right: 12px;" />
|
||||
<img src="public/preview2.png" alt="OpenScreen App Preview 2" style="height: 320px; margin-right: 12px;" />
|
||||
<img src="public/preview3.png" alt="OpenScreen App Preview 3" style="height: 320px; margin-right: 12px;" />
|
||||
<img src="public/preview4.png" alt="OpenScreen App Preview 4" style="height: 320px; margin-right: 12px;" />
|
||||
</p>
|
||||
|
||||
## Core Features
|
||||
- Record your whole screen or specific apps
|
||||
- Add manual zooms (customizable depth levels)
|
||||
- Customize the duration and position of zooms however you please
|
||||
- Crop video recordings to hide parts
|
||||
- Choose between wallpapers, solid colors, gradients or your own picture for your background
|
||||
- Motion blur for smoother pan and zoom effects
|
||||
- Add annotations (text, arrows, images)
|
||||
- Trim sections of the clip
|
||||
- Export in different aspect ratios and resolutions
|
||||
|
||||
## 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
|
||||
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
|
||||
- TypeScript
|
||||
- Vite
|
||||
- PixiJS
|
||||
- dnd-timeline
|
||||
|
||||
---
|
||||
|
||||
_I'm new to open source, idk what I'm doing lol. If something is wrong please raise an issue 🙏_
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! If you’d like to help out or see what’s currently being worked on, take a look at the open issues and the [project roadmap](https://github.com/users/siddharthvaddem/projects/3) to understand the current direction of the project and find ways to contribute.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the [MIT License](./LICENSE). By using this software, you agree that the authors are not liable for any issues, damages, or claims arising from its use.
|
||||
|
||||
@@ -60,13 +60,16 @@ function createHudOverlayWindow() {
|
||||
return win;
|
||||
}
|
||||
function createEditorWindow() {
|
||||
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,
|
||||
@@ -223,12 +226,12 @@ function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, g
|
||||
});
|
||||
ipcMain.handle("save-exported-video", async (_, videoData, fileName) => {
|
||||
try {
|
||||
const isGif = fileName.toLowerCase().endsWith(".gif");
|
||||
const filters = isGif ? [{ name: "GIF Image", extensions: ["gif"] }] : [{ name: "MP4 Video", extensions: ["mp4"] }];
|
||||
const result = await dialog.showSaveDialog({
|
||||
title: "Save Exported Video",
|
||||
title: isGif ? "Save Exported GIF" : "Save Exported Video",
|
||||
defaultPath: path.join(app.getPath("downloads"), fileName),
|
||||
filters: [
|
||||
{ name: "MP4 Video", extensions: ["mp4"] }
|
||||
],
|
||||
filters,
|
||||
properties: ["createDirectory", "showOverwriteConfirmation"]
|
||||
});
|
||||
if (result.canceled || !result.filePath) {
|
||||
@@ -292,6 +295,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");
|
||||
@@ -313,19 +319,26 @@ let mainWindow = null;
|
||||
let sourceSelectorWindow = null;
|
||||
let tray = null;
|
||||
let selectedSourceName = "";
|
||||
const defaultTrayIcon = getTrayIcon("openscreen.png");
|
||||
const recordingTrayIcon = getTrayIcon("rec-button.png");
|
||||
function createWindow() {
|
||||
mainWindow = createHudOverlayWindow();
|
||||
}
|
||||
function createTray() {
|
||||
const iconPath = path.join(process.env.VITE_PUBLIC || RENDERER_DIST, "rec-button.png");
|
||||
let icon = nativeImage.createFromPath(iconPath);
|
||||
icon = icon.resize({ width: 24, height: 24, quality: "best" });
|
||||
tray = new Tray(icon);
|
||||
updateTrayMenu();
|
||||
tray = new Tray(defaultTrayIcon);
|
||||
}
|
||||
function updateTrayMenu() {
|
||||
function getTrayIcon(filename) {
|
||||
return nativeImage.createFromPath(path.join(process.env.VITE_PUBLIC || RENDERER_DIST, filename)).resize({
|
||||
width: 24,
|
||||
height: 24,
|
||||
quality: "best"
|
||||
});
|
||||
}
|
||||
function updateTrayMenu(recording = false) {
|
||||
if (!tray) return;
|
||||
const menuTemplate = [
|
||||
const trayIcon = recording ? recordingTrayIcon : defaultTrayIcon;
|
||||
const trayToolTip = recording ? `Recording: ${selectedSourceName}` : "OpenScreen";
|
||||
const menuTemplate = recording ? [
|
||||
{
|
||||
label: "Stop Recording",
|
||||
click: () => {
|
||||
@@ -334,10 +347,27 @@ function updateTrayMenu() {
|
||||
}
|
||||
}
|
||||
}
|
||||
] : [
|
||||
{
|
||||
label: "Open",
|
||||
click: () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.isMinimized() && mainWindow.restore();
|
||||
} else {
|
||||
createWindow();
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "Quit",
|
||||
click: () => {
|
||||
app.quit();
|
||||
}
|
||||
}
|
||||
];
|
||||
const contextMenu = Menu.buildFromTemplate(menuTemplate);
|
||||
tray.setContextMenu(contextMenu);
|
||||
tray.setToolTip(`Recording: ${selectedSourceName}`);
|
||||
tray.setImage(trayIcon);
|
||||
tray.setToolTip(trayToolTip);
|
||||
tray.setContextMenu(Menu.buildFromTemplate(menuTemplate));
|
||||
}
|
||||
function createEditorWindowWrapper() {
|
||||
if (mainWindow) {
|
||||
@@ -363,10 +393,10 @@ app.on("activate", () => {
|
||||
app.whenReady().then(async () => {
|
||||
const { ipcMain: ipcMain2 } = await import("electron");
|
||||
ipcMain2.on("hud-overlay-close", () => {
|
||||
if (process.platform === "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
app.quit();
|
||||
});
|
||||
createTray();
|
||||
updateTrayMenu();
|
||||
await ensureRecordingsDir();
|
||||
registerIpcHandlers(
|
||||
createEditorWindowWrapper,
|
||||
@@ -375,14 +405,9 @@ app.whenReady().then(async () => {
|
||||
() => sourceSelectorWindow,
|
||||
(recording, sourceName) => {
|
||||
selectedSourceName = sourceName;
|
||||
if (recording) {
|
||||
if (!tray) createTray();
|
||||
updateTrayMenu();
|
||||
} else {
|
||||
if (tray) {
|
||||
tray.destroy();
|
||||
tray = null;
|
||||
}
|
||||
if (!tray) createTray();
|
||||
updateTrayMenu(recording);
|
||||
if (!recording) {
|
||||
if (mainWindow) mainWindow.restore();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,5 +56,8 @@ electron.contextBridge.exposeInMainWorld("electronAPI", {
|
||||
},
|
||||
clearCurrentVideoPath: () => {
|
||||
return electron.ipcRenderer.invoke("clear-current-video-path");
|
||||
},
|
||||
getPlatform: () => {
|
||||
return electron.ipcRenderer.invoke("get-platform");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"productName": "Openscreen",
|
||||
"npmRebuild": true,
|
||||
"buildDependenciesFromSource": true,
|
||||
"compression": "maximum",
|
||||
"compression": "normal",
|
||||
"directories": {
|
||||
"output": "release/${version}"
|
||||
},
|
||||
@@ -41,7 +41,9 @@
|
||||
"target": [
|
||||
"AppImage"
|
||||
],
|
||||
"artifactName": "${productName}-Linux-${version}.${ext}"
|
||||
"icon": "icons/icons/png",
|
||||
"artifactName": "${productName}-Linux-${version}.${ext}",
|
||||
"category": "AudioVideo"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ export function registerIpcHandlers(
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
ipcMain.handle('open-external-url', async (_, url: string) => {
|
||||
try {
|
||||
await shell.openExternal(url)
|
||||
@@ -129,12 +130,16 @@ export function registerIpcHandlers(
|
||||
|
||||
ipcMain.handle('save-exported-video', async (_, videoData: ArrayBuffer, fileName: string) => {
|
||||
try {
|
||||
// Determine file type from extension
|
||||
const isGif = fileName.toLowerCase().endsWith('.gif');
|
||||
const filters = isGif
|
||||
? [{ name: 'GIF Image', extensions: ['gif'] }]
|
||||
: [{ name: 'MP4 Video', extensions: ['mp4'] }];
|
||||
|
||||
const result = await dialog.showSaveDialog({
|
||||
title: 'Save Exported Video',
|
||||
title: isGif ? 'Save Exported GIF' : 'Save Exported Video',
|
||||
defaultPath: path.join(app.getPath('downloads'), fileName),
|
||||
filters: [
|
||||
{ name: 'MP4 Video', extensions: ['mp4'] }
|
||||
],
|
||||
filters,
|
||||
properties: ['createDirectory', 'showOverwriteConfirmation']
|
||||
});
|
||||
|
||||
@@ -145,8 +150,9 @@ export function registerIpcHandlers(
|
||||
message: 'Export cancelled'
|
||||
};
|
||||
}
|
||||
|
||||
await fs.writeFile(result.filePath, Buffer.from(videoData));
|
||||
|
||||
|
||||
return {
|
||||
success: true,
|
||||
path: result.filePath,
|
||||
@@ -207,4 +213,8 @@ export function registerIpcHandlers(
|
||||
currentVideoPath = null;
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('get-platform', () => {
|
||||
return process.platform;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -45,33 +45,63 @@ let sourceSelectorWindow: BrowserWindow | null = null
|
||||
let tray: Tray | null = null
|
||||
let selectedSourceName = ''
|
||||
|
||||
// Tray Icons
|
||||
const defaultTrayIcon = getTrayIcon('openscreen.png');
|
||||
const recordingTrayIcon = getTrayIcon('rec-button.png');
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = createHudOverlayWindow()
|
||||
}
|
||||
|
||||
function createTray() {
|
||||
const iconPath = path.join(process.env.VITE_PUBLIC || RENDERER_DIST, 'rec-button.png');
|
||||
let icon = nativeImage.createFromPath(iconPath);
|
||||
icon = icon.resize({ width: 24, height: 24, quality: 'best' });
|
||||
tray = new Tray(icon);
|
||||
updateTrayMenu();
|
||||
tray = new Tray(defaultTrayIcon);
|
||||
}
|
||||
|
||||
function updateTrayMenu() {
|
||||
function getTrayIcon(filename: string) {
|
||||
return nativeImage.createFromPath(path.join(process.env.VITE_PUBLIC || RENDERER_DIST, filename)).resize({
|
||||
width: 24,
|
||||
height: 24,
|
||||
quality: 'best'
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function updateTrayMenu(recording: boolean = false) {
|
||||
if (!tray) return;
|
||||
const menuTemplate = [
|
||||
{
|
||||
label: 'Stop Recording',
|
||||
click: () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('stop-recording-from-tray');
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
const contextMenu = Menu.buildFromTemplate(menuTemplate);
|
||||
tray.setContextMenu(contextMenu);
|
||||
tray.setToolTip(`Recording: ${selectedSourceName}`);
|
||||
const trayIcon = recording ? recordingTrayIcon : defaultTrayIcon;
|
||||
const trayToolTip = recording ? `Recording: ${selectedSourceName}` : "OpenScreen";
|
||||
const menuTemplate = recording
|
||||
? [
|
||||
{
|
||||
label: "Stop Recording",
|
||||
click: () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("stop-recording-from-tray");
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: "Open",
|
||||
click: () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.isMinimized() && mainWindow.restore();
|
||||
} else {
|
||||
createWindow();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Quit",
|
||||
click: () => {
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
];
|
||||
tray.setImage(trayIcon);
|
||||
tray.setToolTip(trayToolTip);
|
||||
tray.setContextMenu(Menu.buildFromTemplate(menuTemplate));
|
||||
}
|
||||
|
||||
function createEditorWindowWrapper() {
|
||||
@@ -111,10 +141,10 @@ app.whenReady().then(async () => {
|
||||
// Listen for HUD overlay quit event (macOS only)
|
||||
const { ipcMain } = await import('electron');
|
||||
ipcMain.on('hud-overlay-close', () => {
|
||||
if (process.platform === 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
app.quit();
|
||||
});
|
||||
createTray()
|
||||
updateTrayMenu()
|
||||
// Ensure recordings directory exists
|
||||
await ensureRecordingsDir()
|
||||
|
||||
@@ -125,14 +155,9 @@ app.whenReady().then(async () => {
|
||||
() => sourceSelectorWindow,
|
||||
(recording: boolean, sourceName: string) => {
|
||||
selectedSourceName = sourceName
|
||||
if (recording) {
|
||||
if (!tray) createTray();
|
||||
updateTrayMenu();
|
||||
} else {
|
||||
if (tray) {
|
||||
tray.destroy();
|
||||
tray = null;
|
||||
}
|
||||
if (!tray) createTray();
|
||||
updateTrayMenu(recording);
|
||||
if (!recording) {
|
||||
if (mainWindow) mainWindow.restore();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,4 +60,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
clearCurrentVideoPath: () => {
|
||||
return ipcRenderer.invoke('clear-current-video-path')
|
||||
},
|
||||
getPlatform: () => {
|
||||
return ipcRenderer.invoke('get-platform')
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "openscreen",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.1.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -9,36 +9,52 @@
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"build:mac": "tsc && vite build && electron-builder --mac",
|
||||
"build:win": "tsc && vite build && electron-builder --win"
|
||||
"build:win": "tsc && vite build && electron-builder --win",
|
||||
"build:linux": "tsc && vite build && electron-builder --linux",
|
||||
"test": "vitest --run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fix-webm-duration/fix": "^1.0.1",
|
||||
"@pixi/filter-drop-shadow": "^5.2.0",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@types/gif.js": "^0.2.5",
|
||||
"@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",
|
||||
"gif.js": "^0.2.0",
|
||||
"gsap": "^3.13.0",
|
||||
"lucide-react": "^0.545.0",
|
||||
"mediabunny": "^1.25.1",
|
||||
"motion": "^12.23.24",
|
||||
"mp4box": "^2.2.0",
|
||||
"pixi.js": "^8.14.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-rnd": "^10.5.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^18.2.64",
|
||||
"@types/react-dom": "^18.2.21",
|
||||
"@types/uuid": "^10.0.0",
|
||||
@@ -46,20 +62,22 @@
|
||||
"@typescript-eslint/parser": "^7.1.1",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"electron": "^30.0.1",
|
||||
"electron": "^39.2.7",
|
||||
"electron-builder": "^24.13.3",
|
||||
"electron-icon-builder": "^2.0.1",
|
||||
"electron-rebuild": "^3.2.9",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"fast-check": "^4.5.2",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"terser": "^5.44.1",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.1.6",
|
||||
"vite-plugin-electron": "^0.28.6",
|
||||
"vite-plugin-electron-renderer": "^0.14.5"
|
||||
"vite-plugin-electron-renderer": "^0.14.5",
|
||||
"vitest": "^4.0.16"
|
||||
},
|
||||
"main": "dist-electron/main.js"
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
onlyBuiltDependencies:
|
||||
- electron
|
||||
- esbuild
|
||||
- lzma-native
|
||||
- phantomjs-prebuilt
|
||||
|
Before Width: | Height: | Size: 549 KiB |
|
Before Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 581 KiB |
@@ -1,34 +0,0 @@
|
||||
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M63.9202 127.84C99.2223 127.84 127.84 99.2223 127.84 63.9202C127.84 28.6181 99.2223 0 63.9202 0C28.6181 0 0 28.6181 0 63.9202C0 99.2223 28.6181 127.84 63.9202 127.84Z" fill="url(#paint0_linear_103_2)"/>
|
||||
<g id="not-lightning" clip-path="url(#clip0_103_2)">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
attributeType="XML"
|
||||
type="rotate"
|
||||
from="0 64 64"
|
||||
to="360 64 64"
|
||||
dur="20s"
|
||||
repeatCount="indefinite"/>
|
||||
<path d="M51.3954 39.5028C52.3733 39.6812 53.3108 39.033 53.4892 38.055C53.6676 37.0771 53.0194 36.1396 52.0414 35.9612L51.3954 39.5028ZM28.9393 60.9358C29.4332 61.7985 30.5329 62.0976 31.3957 61.6037C32.2585 61.1098 32.5575 60.0101 32.0636 59.1473L28.9393 60.9358ZM37.6935 66.7457C37.025 66.01 35.8866 65.9554 35.1508 66.6239C34.415 67.2924 34.3605 68.4308 35.029 69.1666L37.6935 66.7457ZM96.9206 89.515C97.7416 88.9544 97.9526 87.8344 97.3919 87.0135C96.8313 86.1925 95.7113 85.9815 94.8904 86.5422L96.9206 89.515ZM52.0414 35.9612C46.4712 34.9451 41.2848 34.8966 36.9738 35.9376C32.6548 36.9806 29.0841 39.1576 27.0559 42.6762L30.1748 44.4741C31.5693 42.0549 34.1448 40.3243 37.8188 39.4371C41.5009 38.5479 46.1547 38.5468 51.3954 39.5028L52.0414 35.9612ZM27.0559 42.6762C24.043 47.9029 25.2781 54.5399 28.9393 60.9358L32.0636 59.1473C28.6579 53.1977 28.1088 48.0581 30.1748 44.4741L27.0559 42.6762ZM35.029 69.1666C39.6385 74.24 45.7158 79.1355 52.8478 83.2597L54.6499 80.1432C47.8081 76.1868 42.0298 71.5185 37.6935 66.7457L35.029 69.1666ZM52.8478 83.2597C61.344 88.1726 70.0465 91.2445 77.7351 92.3608C85.359 93.4677 92.2744 92.6881 96.9206 89.515L94.8904 86.5422C91.3255 88.9767 85.4902 89.849 78.2524 88.7982C71.0793 87.7567 62.809 84.8612 54.6499 80.1432L52.8478 83.2597ZM105.359 84.9077C105.359 81.4337 102.546 78.6127 99.071 78.6127V82.2127C100.553 82.2127 101.759 83.4166 101.759 84.9077H105.359ZM99.071 78.6127C95.5956 78.6127 92.7831 81.4337 92.7831 84.9077H96.3831C96.3831 83.4166 97.5892 82.2127 99.071 82.2127V78.6127ZM92.7831 84.9077C92.7831 88.3817 95.5956 91.2027 99.071 91.2027V87.6027C97.5892 87.6027 96.3831 86.3988 96.3831 84.9077H92.7831ZM99.071 91.2027C102.546 91.2027 105.359 88.3817 105.359 84.9077H101.759C101.759 86.3988 100.553 87.6027 99.071 87.6027V91.2027Z" fill="#A2ECFB"/>
|
||||
<path d="M91.4873 65.382C90.8456 66.1412 90.9409 67.2769 91.7002 67.9186C92.4594 68.5603 93.5951 68.465 94.2368 67.7058L91.4873 65.382ZM84.507 35.2412C83.513 35.2282 82.6967 36.0236 82.6838 37.0176C82.6708 38.0116 83.4661 38.8279 84.4602 38.8409L84.507 35.2412ZM74.9407 39.8801C75.9127 39.6716 76.5315 38.7145 76.323 37.7425C76.1144 36.7706 75.1573 36.1517 74.1854 36.3603L74.9407 39.8801ZM25.5491 80.9047C25.6932 81.8883 26.6074 82.5688 27.5911 82.4247C28.5747 82.2806 29.2552 81.3664 29.1111 80.3828L25.5491 80.9047ZM94.2368 67.7058C97.8838 63.3907 100.505 58.927 101.752 54.678C103.001 50.4213 102.9 46.2472 100.876 42.7365L97.7574 44.5344C99.1494 46.9491 99.3603 50.0419 98.2974 53.6644C97.2323 57.2945 94.9184 61.3223 91.4873 65.382L94.2368 67.7058ZM100.876 42.7365C97.9119 37.5938 91.7082 35.335 84.507 35.2412L84.4602 38.8409C91.1328 38.9278 95.7262 41.0106 97.7574 44.5344L100.876 42.7365ZM74.1854 36.3603C67.4362 37.8086 60.0878 40.648 52.8826 44.8146L54.6847 47.931C61.5972 43.9338 68.5948 41.2419 74.9407 39.8801L74.1854 36.3603ZM52.8826 44.8146C44.1366 49.872 36.9669 56.0954 32.1491 62.3927C27.3774 68.63 24.7148 75.2115 25.5491 80.9047L29.1111 80.3828C28.4839 76.1026 30.4747 70.5062 35.0084 64.5802C39.496 58.7143 46.2839 52.7889 54.6847 47.931L52.8826 44.8146Z" fill="#A2ECFB"/>
|
||||
<path d="M49.0825 87.2295C48.7478 86.2934 47.7176 85.8059 46.7816 86.1406C45.8455 86.4753 45.358 87.5055 45.6927 88.4416L49.0825 87.2295ZM78.5635 96.4256C79.075 95.5732 78.7988 94.4675 77.9464 93.9559C77.0941 93.4443 75.9884 93.7205 75.4768 94.5729L78.5635 96.4256ZM79.5703 85.1795C79.2738 86.1284 79.8027 87.1379 80.7516 87.4344C81.7004 87.7308 82.71 87.2019 83.0064 86.2531L79.5703 85.1795ZM69.156 22.5301C68.2477 22.1261 67.1838 22.535 66.7799 23.4433C66.3759 24.3517 66.7848 25.4155 67.6931 25.8194L69.156 22.5301ZM45.6927 88.4416C47.5994 93.7741 50.1496 98.2905 53.2032 101.505C56.2623 104.724 59.9279 106.731 63.9835 106.731V103.131C61.1984 103.131 58.4165 101.765 55.8131 99.0249C53.2042 96.279 50.8768 92.2477 49.0825 87.2295L45.6927 88.4416ZM63.9835 106.731C69.8694 106.731 74.8921 102.542 78.5635 96.4256L75.4768 94.5729C72.0781 100.235 68.0122 103.131 63.9835 103.131V106.731ZM83.0064 86.2531C85.0269 79.7864 86.1832 72.1831 86.1832 64.0673H82.5832C82.5832 71.8536 81.4723 79.0919 79.5703 85.1795L83.0064 86.2531ZM86.1832 64.0673C86.1832 54.1144 84.4439 44.922 81.4961 37.6502C78.5748 30.4436 74.3436 24.8371 69.156 22.5301L67.6931 25.8194C71.6364 27.5731 75.3846 32.1564 78.1598 39.0026C80.9086 45.7836 82.5832 54.507 82.5832 64.0673H86.1832Z" fill="#A2ECFB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M103.559 84.9077C103.559 82.4252 101.55 80.4127 99.071 80.4127C96.5924 80.4127 94.5831 82.4252 94.5831 84.9077C94.5831 87.3902 96.5924 89.4027 99.071 89.4027C101.55 89.4027 103.559 87.3902 103.559 84.9077Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.8143 89.4027C31.2929 89.4027 33.3023 87.3902 33.3023 84.9077C33.3023 82.4252 31.2929 80.4127 28.8143 80.4127C26.3357 80.4127 24.3264 82.4252 24.3264 84.9077C24.3264 87.3902 26.3357 89.4027 28.8143 89.4027Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
<path d="M63.9835 27.6986C66.4621 27.6986 68.4714 25.6861 68.4714 23.2036C68.4714 20.7211 66.4621 18.7086 63.9835 18.7086C61.5049 18.7086 59.4956 20.7211 59.4956 23.2036C59.4956 25.6861 61.5049 27.6986 63.9835 27.6986Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
</g>
|
||||
<path d="M70.7175 48.0096L56.3133 50.676C56.0766 50.7199 55.9013 50.9094 55.887 51.1369L55.001 65.2742C54.9801 65.6072 55.3038 65.8656 55.6478 65.7907L59.6582 64.9163C60.0334 64.8346 60.3724 65.1468 60.2953 65.5033L59.1038 71.0151C59.0237 71.386 59.3923 71.7032 59.7758 71.5932L62.2528 70.8822C62.6368 70.7721 63.0057 71.0902 62.9245 71.4615L61.031 80.1193C60.9126 80.6608 61.6751 80.9561 61.9931 80.4918L62.2055 80.1817L73.9428 58.053C74.1393 57.6825 73.8004 57.26 73.3696 57.3385L69.2417 58.0912C68.8538 58.1618 68.5237 57.8206 68.6332 57.462L71.3274 48.6385C71.437 48.2794 71.1058 47.9378 70.7175 48.0096Z" fill="url(#paint1_linear_103_2)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_103_2" x1="1.43824" y1="7.91009" x2="56.3296" y2="82.4569" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#41D1FF"/>
|
||||
<stop offset="1" stop-color="#BD34FE"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_103_2" x1="60.3173" y1="48.7336" x2="64.237" y2="77.1962" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFEA83"/>
|
||||
<stop offset="0.0833333" stop-color="#FFDD35"/>
|
||||
<stop offset="1" stop-color="#FFA800"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_103_2">
|
||||
<rect width="128" height="128" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.9 KiB |
@@ -1,26 +0,0 @@
|
||||
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_103_2)">
|
||||
<path d="M63.9202 127.84C99.2223 127.84 127.84 99.2223 127.84 63.9202C127.84 28.6181 99.2223 0 63.9202 0C28.6181 0 0 28.6181 0 63.9202C0 99.2223 28.6181 127.84 63.9202 127.84Z" fill="url(#paint0_linear_103_2)"/>
|
||||
<path d="M51.3954 39.5028C52.3733 39.6812 53.3108 39.033 53.4892 38.055C53.6676 37.0771 53.0194 36.1396 52.0414 35.9612L51.3954 39.5028ZM28.9393 60.9358C29.4332 61.7985 30.5329 62.0976 31.3957 61.6037C32.2585 61.1098 32.5575 60.0101 32.0636 59.1473L28.9393 60.9358ZM37.6935 66.7457C37.025 66.01 35.8866 65.9554 35.1508 66.6239C34.415 67.2924 34.3605 68.4308 35.029 69.1666L37.6935 66.7457ZM96.9206 89.515C97.7416 88.9544 97.9526 87.8344 97.3919 87.0135C96.8313 86.1925 95.7113 85.9815 94.8904 86.5422L96.9206 89.515ZM52.0414 35.9612C46.4712 34.9451 41.2848 34.8966 36.9738 35.9376C32.6548 36.9806 29.0841 39.1576 27.0559 42.6762L30.1748 44.4741C31.5693 42.0549 34.1448 40.3243 37.8188 39.4371C41.5009 38.5479 46.1547 38.5468 51.3954 39.5028L52.0414 35.9612ZM27.0559 42.6762C24.043 47.9029 25.2781 54.5399 28.9393 60.9358L32.0636 59.1473C28.6579 53.1977 28.1088 48.0581 30.1748 44.4741L27.0559 42.6762ZM35.029 69.1666C39.6385 74.24 45.7158 79.1355 52.8478 83.2597L54.6499 80.1432C47.8081 76.1868 42.0298 71.5185 37.6935 66.7457L35.029 69.1666ZM52.8478 83.2597C61.344 88.1726 70.0465 91.2445 77.7351 92.3608C85.359 93.4677 92.2744 92.6881 96.9206 89.515L94.8904 86.5422C91.3255 88.9767 85.4902 89.849 78.2524 88.7982C71.0793 87.7567 62.809 84.8612 54.6499 80.1432L52.8478 83.2597ZM105.359 84.9077C105.359 81.4337 102.546 78.6127 99.071 78.6127V82.2127C100.553 82.2127 101.759 83.4166 101.759 84.9077H105.359ZM99.071 78.6127C95.5956 78.6127 92.7831 81.4337 92.7831 84.9077H96.3831C96.3831 83.4166 97.5892 82.2127 99.071 82.2127V78.6127ZM92.7831 84.9077C92.7831 88.3817 95.5956 91.2027 99.071 91.2027V87.6027C97.5892 87.6027 96.3831 86.3988 96.3831 84.9077H92.7831ZM99.071 91.2027C102.546 91.2027 105.359 88.3817 105.359 84.9077H101.759C101.759 86.3988 100.553 87.6027 99.071 87.6027V91.2027Z" fill="#A2ECFB"/>
|
||||
<path d="M91.4873 65.382C90.8456 66.1412 90.9409 67.2769 91.7002 67.9186C92.4594 68.5603 93.5951 68.465 94.2368 67.7058L91.4873 65.382ZM84.507 35.2412C83.513 35.2282 82.6967 36.0236 82.6838 37.0176C82.6708 38.0116 83.4661 38.8279 84.4602 38.8409L84.507 35.2412ZM74.9407 39.8801C75.9127 39.6716 76.5315 38.7145 76.323 37.7425C76.1144 36.7706 75.1573 36.1517 74.1854 36.3603L74.9407 39.8801ZM25.5491 80.9047C25.6932 81.8883 26.6074 82.5688 27.5911 82.4247C28.5747 82.2806 29.2552 81.3664 29.1111 80.3828L25.5491 80.9047ZM94.2368 67.7058C97.8838 63.3907 100.505 58.927 101.752 54.678C103.001 50.4213 102.9 46.2472 100.876 42.7365L97.7574 44.5344C99.1494 46.9491 99.3603 50.0419 98.2974 53.6644C97.2323 57.2945 94.9184 61.3223 91.4873 65.382L94.2368 67.7058ZM100.876 42.7365C97.9119 37.5938 91.7082 35.335 84.507 35.2412L84.4602 38.8409C91.1328 38.9278 95.7262 41.0106 97.7574 44.5344L100.876 42.7365ZM74.1854 36.3603C67.4362 37.8086 60.0878 40.648 52.8826 44.8146L54.6847 47.931C61.5972 43.9338 68.5948 41.2419 74.9407 39.8801L74.1854 36.3603ZM52.8826 44.8146C44.1366 49.872 36.9669 56.0954 32.1491 62.3927C27.3774 68.63 24.7148 75.2115 25.5491 80.9047L29.1111 80.3828C28.4839 76.1026 30.4747 70.5062 35.0084 64.5802C39.496 58.7143 46.2839 52.7889 54.6847 47.931L52.8826 44.8146Z" fill="#A2ECFB"/>
|
||||
<path d="M49.0825 87.2295C48.7478 86.2934 47.7176 85.8059 46.7816 86.1406C45.8455 86.4753 45.358 87.5055 45.6927 88.4416L49.0825 87.2295ZM78.5635 96.4256C79.075 95.5732 78.7988 94.4675 77.9464 93.9559C77.0941 93.4443 75.9884 93.7205 75.4768 94.5729L78.5635 96.4256ZM79.5703 85.1795C79.2738 86.1284 79.8027 87.1379 80.7516 87.4344C81.7004 87.7308 82.71 87.2019 83.0064 86.2531L79.5703 85.1795ZM69.156 22.5301C68.2477 22.1261 67.1838 22.535 66.7799 23.4433C66.3759 24.3517 66.7848 25.4155 67.6931 25.8194L69.156 22.5301ZM45.6927 88.4416C47.5994 93.7741 50.1496 98.2905 53.2032 101.505C56.2623 104.724 59.9279 106.731 63.9835 106.731V103.131C61.1984 103.131 58.4165 101.765 55.8131 99.0249C53.2042 96.279 50.8768 92.2477 49.0825 87.2295L45.6927 88.4416ZM63.9835 106.731C69.8694 106.731 74.8921 102.542 78.5635 96.4256L75.4768 94.5729C72.0781 100.235 68.0122 103.131 63.9835 103.131V106.731ZM83.0064 86.2531C85.0269 79.7864 86.1832 72.1831 86.1832 64.0673H82.5832C82.5832 71.8536 81.4723 79.0919 79.5703 85.1795L83.0064 86.2531ZM86.1832 64.0673C86.1832 54.1144 84.4439 44.922 81.4961 37.6502C78.5748 30.4436 74.3436 24.8371 69.156 22.5301L67.6931 25.8194C71.6364 27.5731 75.3846 32.1564 78.1598 39.0026C80.9086 45.7836 82.5832 54.507 82.5832 64.0673H86.1832Z" fill="#A2ECFB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M103.559 84.9077C103.559 82.4252 101.55 80.4127 99.071 80.4127C96.5924 80.4127 94.5831 82.4252 94.5831 84.9077C94.5831 87.3902 96.5924 89.4027 99.071 89.4027C101.55 89.4027 103.559 87.3902 103.559 84.9077Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.8143 89.4027C31.2929 89.4027 33.3023 87.3902 33.3023 84.9077C33.3023 82.4252 31.2929 80.4127 28.8143 80.4127C26.3357 80.4127 24.3264 82.4252 24.3264 84.9077C24.3264 87.3902 26.3357 89.4027 28.8143 89.4027Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
<path d="M63.9835 27.6986C66.4621 27.6986 68.4714 25.6861 68.4714 23.2036C68.4714 20.7211 66.4621 18.7086 63.9835 18.7086C61.5049 18.7086 59.4956 20.7211 59.4956 23.2036C59.4956 25.6861 61.5049 27.6986 63.9835 27.6986Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
<path d="M70.7175 48.0096L56.3133 50.676C56.0766 50.7199 55.9013 50.9094 55.887 51.1369L55.001 65.2742C54.9801 65.6072 55.3038 65.8656 55.6478 65.7907L59.6582 64.9163C60.0334 64.8346 60.3724 65.1468 60.2953 65.5033L59.1038 71.0151C59.0237 71.386 59.3923 71.7032 59.7758 71.5932L62.2528 70.8822C62.6368 70.7721 63.0057 71.0902 62.9245 71.4615L61.031 80.1193C60.9126 80.6608 61.6751 80.9561 61.9931 80.4918L62.2055 80.1817L73.9428 58.053C74.1393 57.6825 73.8004 57.26 73.3696 57.3385L69.2417 58.0912C68.8538 58.1618 68.5237 57.8206 68.6332 57.462L71.3274 48.6385C71.437 48.2794 71.1058 47.9378 70.7175 48.0096Z" fill="url(#paint1_linear_103_2)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_103_2" x1="1.43824" y1="7.91009" x2="56.3296" y2="82.4569" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#41D1FF"/>
|
||||
<stop offset="1" stop-color="#BD34FE"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_103_2" x1="60.3173" y1="48.7336" x2="64.237" y2="77.1962" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFEA83"/>
|
||||
<stop offset="0.0833333" stop-color="#FFDD35"/>
|
||||
<stop offset="1" stop-color="#FFA800"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_103_2">
|
||||
<rect width="128" height="128" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 853 KiB After Width: | Height: | Size: 853 KiB |
|
After Width: | Height: | Size: 776 KiB |
|
After Width: | Height: | Size: 534 KiB |
|
After Width: | Height: | Size: 627 KiB |
|
Before Width: | Height: | Size: 460 KiB After Width: | Height: | Size: 460 KiB |
|
Before Width: | Height: | Size: 3.1 MiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 890 KiB After Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 496 KiB |
|
Before Width: | Height: | Size: 496 KiB |
|
Before Width: | Height: | Size: 3.0 MiB |
@@ -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();
|
||||
@@ -118,9 +114,10 @@ export function LaunchWindow() {
|
||||
size="sm"
|
||||
className={`gap-1 text-white bg-transparent hover:bg-transparent px-0 flex-1 text-left text-xs ${styles.electronNoDrag}`}
|
||||
onClick={openSourceSelector}
|
||||
disabled={recording}
|
||||
>
|
||||
<MdMonitor size={14} className="text-white" />
|
||||
{truncateText(selectedSource, 6)}
|
||||
<ContentClamp truncateLength={6}>{selectedSource}</ContentClamp>
|
||||
</Button>
|
||||
|
||||
<div className="w-px h-6 bg-white/30" />
|
||||
@@ -130,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 ? (
|
||||
<>
|
||||
@@ -154,6 +151,7 @@ export function LaunchWindow() {
|
||||
size="sm"
|
||||
onClick={openVideoFile}
|
||||
className={`gap-1 text-white bg-transparent hover:bg-transparent px-0 flex-1 text-right text-xs ${styles.electronNoDrag} ${styles.folderButton}`}
|
||||
disabled={recording}
|
||||
>
|
||||
<FaFolderMinus size={14} className="text-white" />
|
||||
<span className={styles.folderText}>Open</span>
|
||||
|
||||
@@ -75,7 +75,7 @@ export function SourceSelector() {
|
||||
<TabsTrigger value="screens" className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-zinc-200 rounded-full text-xs py-1">Screens</TabsTrigger>
|
||||
<TabsTrigger value="windows" className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-zinc-200 rounded-full text-xs py-1">Windows</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="h-60 flex flex-col justify-stretch">
|
||||
<div className="h-72 flex flex-col justify-stretch">
|
||||
<TabsContent value="screens" className="h-full">
|
||||
<div className="grid grid-cols-2 gap-2 h-full overflow-y-auto pr-1 relative">
|
||||
{screenSources.map(source => (
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Accordion = AccordionPrimitive.Root
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn("border-b border-white/5", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AccordionItem.displayName = "AccordionItem"
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-3 text-sm font-medium text-slate-200 transition-all hover:text-white [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-slate-400 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
))
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
))
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toggleVariants } from "@/components/ui/toggle"
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants>
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
})
|
||||
|
||||
const ToggleGroup = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, children, ...props }, ref) => (
|
||||
<ToggleGroupPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("flex items-center justify-center gap-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
))
|
||||
|
||||
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
|
||||
|
||||
const ToggleGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, children, variant, size, ...props }, ref) => {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
|
||||
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-3",
|
||||
sm: "h-9 px-2.5",
|
||||
lg: "h-11 px-5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toggle = React.forwardRef<
|
||||
React.ElementRef<typeof TogglePrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, ...props }, ref) => (
|
||||
<TogglePrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
Toggle.displayName = TogglePrimitive.Root.displayName
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useRef } from "react";
|
||||
import { Rnd } from "react-rnd";
|
||||
import type { AnnotationRegion } from "./types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getArrowComponent } from "./ArrowSvgs";
|
||||
|
||||
interface AnnotationOverlayProps {
|
||||
annotation: AnnotationRegion;
|
||||
isSelected: boolean;
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
onPositionChange: (id: string, position: { x: number; y: number }) => void;
|
||||
onSizeChange: (id: string, size: { width: number; height: number }) => void;
|
||||
onClick: (id: string) => void;
|
||||
zIndex: number;
|
||||
isSelectedBoost: boolean; // Boost z-index when selected for easy editing
|
||||
}
|
||||
|
||||
export function AnnotationOverlay({
|
||||
annotation,
|
||||
isSelected,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
onPositionChange,
|
||||
onSizeChange,
|
||||
onClick,
|
||||
zIndex,
|
||||
isSelectedBoost,
|
||||
}: AnnotationOverlayProps) {
|
||||
const x = (annotation.position.x / 100) * containerWidth;
|
||||
const y = (annotation.position.y / 100) * containerHeight;
|
||||
const width = (annotation.size.width / 100) * containerWidth;
|
||||
const height = (annotation.size.height / 100) * containerHeight;
|
||||
|
||||
const isDraggingRef = useRef(false);
|
||||
|
||||
const renderArrow = () => {
|
||||
const direction = annotation.figureData?.arrowDirection || 'right';
|
||||
const color = annotation.figureData?.color || '#34B27B';
|
||||
const strokeWidth = annotation.figureData?.strokeWidth || 4;
|
||||
|
||||
const ArrowComponent = getArrowComponent(direction);
|
||||
return <ArrowComponent color={color} strokeWidth={strokeWidth} />;
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
switch (annotation.type) {
|
||||
case 'text':
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full flex items-center p-2 overflow-hidden"
|
||||
style={{
|
||||
justifyContent: annotation.style.textAlign === 'left' ? 'flex-start' :
|
||||
annotation.style.textAlign === 'right' ? 'flex-end' : 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: annotation.style.color,
|
||||
backgroundColor: annotation.style.backgroundColor,
|
||||
fontSize: `${annotation.style.fontSize}px`,
|
||||
fontFamily: annotation.style.fontFamily,
|
||||
fontWeight: annotation.style.fontWeight,
|
||||
fontStyle: annotation.style.fontStyle,
|
||||
textDecoration: annotation.style.textDecoration,
|
||||
textAlign: annotation.style.textAlign,
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'pre-wrap',
|
||||
boxDecorationBreak: 'clone',
|
||||
WebkitBoxDecorationBreak: 'clone',
|
||||
padding: '0.1em 0.2em',
|
||||
borderRadius: '4px',
|
||||
lineHeight: '1.4',
|
||||
}}
|
||||
>
|
||||
{annotation.content}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'image':
|
||||
if (annotation.content && annotation.content.startsWith('data:image')) {
|
||||
return (
|
||||
<img
|
||||
src={annotation.content}
|
||||
alt="Annotation"
|
||||
className="w-full h-full object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center text-slate-400 text-sm">
|
||||
No image
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'figure':
|
||||
if (!annotation.figureData) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center text-slate-400 text-sm">
|
||||
No arrow data
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center p-2">
|
||||
{renderArrow()}
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Rnd
|
||||
position={{ x, y }}
|
||||
size={{ width, height }}
|
||||
onDragStart={() => {
|
||||
isDraggingRef.current = true;
|
||||
}}
|
||||
onDragStop={(_e, d) => {
|
||||
const xPercent = (d.x / containerWidth) * 100;
|
||||
const yPercent = (d.y / containerHeight) * 100;
|
||||
onPositionChange(annotation.id, { x: xPercent, y: yPercent });
|
||||
|
||||
// Reset dragging flag after a short delay to prevent click event
|
||||
setTimeout(() => {
|
||||
isDraggingRef.current = false;
|
||||
}, 100);
|
||||
}}
|
||||
onResizeStop={(_e, _direction, ref, _delta, position) => {
|
||||
const xPercent = (position.x / containerWidth) * 100;
|
||||
const yPercent = (position.y / containerHeight) * 100;
|
||||
const widthPercent = (ref.offsetWidth / containerWidth) * 100;
|
||||
const heightPercent = (ref.offsetHeight / containerHeight) * 100;
|
||||
onPositionChange(annotation.id, { x: xPercent, y: yPercent });
|
||||
onSizeChange(annotation.id, { width: widthPercent, height: heightPercent });
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isDraggingRef.current) return;
|
||||
onClick(annotation.id);
|
||||
}}
|
||||
bounds="parent"
|
||||
className={cn(
|
||||
"cursor-move transition-all",
|
||||
isSelected && "ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent"
|
||||
)}
|
||||
style={{
|
||||
zIndex: isSelectedBoost ? zIndex + 1000 : zIndex, // Boost selected annotation to ensure it's on top
|
||||
pointerEvents: isSelected ? 'auto' : 'none',
|
||||
border: isSelected ? '2px solid rgba(52, 178, 123, 0.8)' : 'none',
|
||||
backgroundColor: isSelected ? 'rgba(52, 178, 123, 0.1)' : 'transparent',
|
||||
boxShadow: isSelected ? '0 0 0 1px rgba(52, 178, 123, 0.35)' : 'none',
|
||||
}}
|
||||
enableResizing={isSelected}
|
||||
disableDragging={!isSelected}
|
||||
resizeHandleStyles={{
|
||||
topLeft: {
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
backgroundColor: isSelected ? 'white' : 'transparent',
|
||||
border: isSelected ? '2px solid #34B27B' : 'none',
|
||||
borderRadius: '50%',
|
||||
left: '-6px',
|
||||
top: '-6px',
|
||||
cursor: 'nwse-resize',
|
||||
},
|
||||
topRight: {
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
backgroundColor: isSelected ? 'white' : 'transparent',
|
||||
border: isSelected ? '2px solid #34B27B' : 'none',
|
||||
borderRadius: '50%',
|
||||
right: '-6px',
|
||||
top: '-6px',
|
||||
cursor: 'nesw-resize',
|
||||
},
|
||||
bottomLeft: {
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
backgroundColor: isSelected ? 'white' : 'transparent',
|
||||
border: isSelected ? '2px solid #34B27B' : 'none',
|
||||
borderRadius: '50%',
|
||||
left: '-6px',
|
||||
bottom: '-6px',
|
||||
cursor: 'nesw-resize',
|
||||
},
|
||||
bottomRight: {
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
backgroundColor: isSelected ? 'white' : 'transparent',
|
||||
border: isSelected ? '2px solid #34B27B' : 'none',
|
||||
borderRadius: '50%',
|
||||
right: '-6px',
|
||||
bottom: '-6px',
|
||||
cursor: 'nwse-resize',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full h-full rounded-lg",
|
||||
annotation.type === 'text' && "bg-transparent",
|
||||
annotation.type === 'image' && "bg-transparent",
|
||||
annotation.type === 'figure' && "bg-transparent",
|
||||
isSelected && "shadow-lg"
|
||||
)}
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</Rnd>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { ArrowDirection } from './types';
|
||||
|
||||
interface ArrowSvgProps {
|
||||
color: string;
|
||||
strokeWidth: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline SVG arrow components for 8 directions.
|
||||
* These match the visual style of the previous icon-based arrows but use
|
||||
* pure SVG paths for easy replication in export.
|
||||
*/
|
||||
|
||||
export function ArrowUp({ color, strokeWidth, className }: ArrowSvgProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 50 20 L 50 80 M 50 20 L 35 35 M 50 20 L 65 35"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowDown({ color, strokeWidth, className }: ArrowSvgProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 50 20 L 50 80 M 50 80 L 35 65 M 50 80 L 65 65"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowLeft({ color, strokeWidth, className }: ArrowSvgProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 80 50 L 20 50 M 20 50 L 35 35 M 20 50 L 35 65"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowRight({ color, strokeWidth, className }: ArrowSvgProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 20 50 L 80 50 M 80 50 L 65 35 M 80 50 L 65 65"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowUpRight({ color, strokeWidth, className }: ArrowSvgProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 25 75 L 75 25 M 75 25 L 60 30 M 75 25 L 70 40"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowUpLeft({ color, strokeWidth, className }: ArrowSvgProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 75 75 L 25 25 M 25 25 L 40 30 M 25 25 L 30 40"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowDownRight({ color, strokeWidth, className }: ArrowSvgProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 25 25 L 75 75 M 75 75 L 70 60 M 75 75 L 60 70"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowDownLeft({ color, strokeWidth, className }: ArrowSvgProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 75 25 L 25 75 M 25 75 L 30 60 M 25 75 L 40 70"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function getArrowComponent(direction: ArrowDirection) {
|
||||
switch (direction) {
|
||||
case 'up': return ArrowUp;
|
||||
case 'down': return ArrowDown;
|
||||
case 'left': return ArrowLeft;
|
||||
case 'right': return ArrowRight;
|
||||
case 'up-right': return ArrowUpRight;
|
||||
case 'up-left': return ArrowUpLeft;
|
||||
case 'down-right': return ArrowDownRight;
|
||||
case 'down-left': return ArrowDownLeft;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type AspectRatio } from "@/utils/aspectRatioUtils";
|
||||
|
||||
interface CropRegion {
|
||||
x: number; // 0-1 normalized
|
||||
@@ -12,6 +13,7 @@ interface CropControlProps {
|
||||
videoElement: HTMLVideoElement | null;
|
||||
cropRegion: CropRegion;
|
||||
onCropChange: (region: CropRegion) => void;
|
||||
aspectRatio: AspectRatio;
|
||||
}
|
||||
|
||||
type DragHandle = 'top' | 'right' | 'bottom' | 'left' | null;
|
||||
@@ -114,12 +116,22 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
|
||||
const cropPixelY = cropRegion.y * 100;
|
||||
const cropPixelWidth = cropRegion.width * 100;
|
||||
const cropPixelHeight = cropRegion.height * 100;
|
||||
const videoAspectRatio = videoElement ? videoElement.videoWidth / videoElement.videoHeight : 16/9;
|
||||
const isVideoPortrait = videoAspectRatio < 1;
|
||||
const maxContainerWidth = isVideoPortrait ? '40vw' : '75vw';
|
||||
const maxContainerHeight = '75vh';
|
||||
|
||||
return (
|
||||
<div className="w-full p-8">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full aspect-video bg-black rounded-lg overflow-visible cursor-default select-none shadow-2xl"
|
||||
className="relative w-full bg-black rounded-lg overflow-visible cursor-default select-none shadow-2xl"
|
||||
style={{
|
||||
aspectRatio: videoAspectRatio,
|
||||
maxWidth: maxContainerWidth,
|
||||
maxHeight: maxContainerHeight,
|
||||
margin: '0 auto',
|
||||
}}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
|
||||
@@ -10,6 +10,7 @@ interface ExportDialogProps {
|
||||
isExporting: boolean;
|
||||
error: string | null;
|
||||
onCancel?: () => void;
|
||||
exportFormat?: 'mp4' | 'gif';
|
||||
}
|
||||
|
||||
export function ExportDialog({
|
||||
@@ -19,9 +20,24 @@ export function ExportDialog({
|
||||
isExporting,
|
||||
error,
|
||||
onCancel,
|
||||
exportFormat = 'mp4',
|
||||
}: ExportDialogProps) {
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
|
||||
// Reset showSuccess when a new export starts or dialog reopens
|
||||
useEffect(() => {
|
||||
if (isExporting) {
|
||||
setShowSuccess(false);
|
||||
}
|
||||
}, [isExporting]);
|
||||
|
||||
// Reset showSuccess when dialog opens fresh
|
||||
useEffect(() => {
|
||||
if (isOpen && !isExporting && !progress) {
|
||||
setShowSuccess(false);
|
||||
}
|
||||
}, [isOpen, isExporting, progress]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExporting && progress && progress.percentage >= 100 && !error) {
|
||||
setShowSuccess(true);
|
||||
@@ -35,6 +51,32 @@ export function ExportDialog({
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const formatLabel = exportFormat === 'gif' ? 'GIF' : 'Video';
|
||||
|
||||
// Determine if we're in the compiling phase (frames done but still exporting)
|
||||
const isCompiling = isExporting && progress && progress.percentage >= 100 && exportFormat === 'gif';
|
||||
const isFinalizing = progress?.phase === 'finalizing';
|
||||
const renderProgress = progress?.renderProgress;
|
||||
|
||||
// Get status message based on phase
|
||||
const getStatusMessage = () => {
|
||||
if (error) return 'Please try again';
|
||||
if (isCompiling || isFinalizing) {
|
||||
if (renderProgress !== undefined && renderProgress > 0) {
|
||||
return `Compiling GIF... ${renderProgress}%`;
|
||||
}
|
||||
return 'Compiling GIF... This may take a while';
|
||||
}
|
||||
return 'This may take a moment...';
|
||||
};
|
||||
|
||||
// Get title based on phase
|
||||
const getTitle = () => {
|
||||
if (error) return 'Export Failed';
|
||||
if (isCompiling || isFinalizing) return 'Compiling GIF';
|
||||
return `Exporting ${formatLabel}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -42,7 +84,7 @@ export function ExportDialog({
|
||||
onClick={isExporting ? undefined : onClose}
|
||||
/>
|
||||
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-[60] bg-[#09090b] rounded-2xl shadow-2xl border border-white/10 p-8 w-[90vw] max-w-md animate-in zoom-in-95 duration-200">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
{showSuccess ? (
|
||||
<>
|
||||
@@ -51,7 +93,7 @@ export function ExportDialog({
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xl font-bold text-slate-200 block">Export Complete</span>
|
||||
<span className="text-sm text-slate-400">Your video is ready</span>
|
||||
<span className="text-sm text-slate-400">Your {formatLabel.toLowerCase()} is ready</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
@@ -67,10 +109,10 @@ export function ExportDialog({
|
||||
)}
|
||||
<div>
|
||||
<span className="text-xl font-bold text-slate-200 block">
|
||||
{error ? 'Export Failed' : isExporting ? 'Exporting Video' : 'Export Video'}
|
||||
{getTitle()}
|
||||
</span>
|
||||
<span className="text-sm text-slate-400">
|
||||
{error ? 'Please try again' : isExporting ? 'This may take a moment...' : 'Ready to start'}
|
||||
{getStatusMessage()}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
@@ -103,29 +145,68 @@ export function ExportDialog({
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
<span>Progress</span>
|
||||
<span className="font-mono text-slate-200">{progress.percentage.toFixed(0)}%</span>
|
||||
<span>{isCompiling || isFinalizing ? 'Compiling' : 'Rendering Frames'}</span>
|
||||
<span className="font-mono text-slate-200">
|
||||
{isCompiling || isFinalizing ? (
|
||||
renderProgress !== undefined && renderProgress > 0 ? (
|
||||
`${renderProgress}%`
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
Processing...
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
`${progress.percentage.toFixed(0)}%`
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-white/5 rounded-full overflow-hidden border border-white/5">
|
||||
<div
|
||||
className="h-full bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.3)] transition-all duration-300 ease-out"
|
||||
style={{ width: `${Math.min(progress.percentage, 100)}%` }}
|
||||
/>
|
||||
{isCompiling || isFinalizing ? (
|
||||
// Show render progress if available, otherwise animated indeterminate bar
|
||||
renderProgress !== undefined && renderProgress > 0 ? (
|
||||
<div
|
||||
className="h-full bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.3)] transition-all duration-300 ease-out"
|
||||
style={{ width: `${renderProgress}%` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full relative overflow-hidden">
|
||||
<div
|
||||
className="absolute h-full w-1/3 bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.3)]"
|
||||
style={{
|
||||
animation: 'indeterminate 1.5s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
<style>{`
|
||||
@keyframes indeterminate {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(400%); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
className="h-full bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.3)] transition-all duration-300 ease-out"
|
||||
style={{ width: `${Math.min(progress.percentage, 100)}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
|
||||
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">Current Frame</div>
|
||||
<div className="text-slate-200 font-mono text-lg font-medium">
|
||||
{progress.currentFrame} <span className="text-slate-500 text-sm">/ {progress.totalFrames}</span>
|
||||
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">
|
||||
{isCompiling || isFinalizing ? 'Status' : 'Format'}
|
||||
</div>
|
||||
<div className="text-slate-200 font-medium text-sm">
|
||||
{isCompiling || isFinalizing ? 'Compiling...' : formatLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
|
||||
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">Status</div>
|
||||
<div className="text-slate-200 font-medium text-sm flex items-center gap-2 h-[28px]">
|
||||
<span className="w-2 h-2 rounded-full bg-[#34B27B] animate-pulse" />
|
||||
Processing
|
||||
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">Frames</div>
|
||||
<div className="text-slate-200 font-medium text-sm">
|
||||
{progress.currentFrame} / {progress.totalFrames}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -146,7 +227,9 @@ export function ExportDialog({
|
||||
|
||||
{showSuccess && (
|
||||
<div className="text-center py-4 animate-in zoom-in-95">
|
||||
<p className="text-lg text-slate-200 font-medium">Video saved successfully!</p>
|
||||
<p className="text-lg text-slate-200 font-medium">
|
||||
{formatLabel} saved successfully!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Film, Image } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ExportFormat } from '@/lib/exporter/types';
|
||||
|
||||
interface FormatSelectorProps {
|
||||
selectedFormat: ExportFormat;
|
||||
onFormatChange: (format: ExportFormat) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface FormatOption {
|
||||
value: ExportFormat;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const formatOptions: FormatOption[] = [
|
||||
{
|
||||
value: 'mp4',
|
||||
label: 'MP4 Video',
|
||||
description: 'High quality video file',
|
||||
icon: <Film className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
value: 'gif',
|
||||
label: 'GIF Animation',
|
||||
description: 'Animated image for sharing',
|
||||
icon: <Image className="w-5 h-5" />,
|
||||
},
|
||||
];
|
||||
|
||||
export function FormatSelector({
|
||||
selectedFormat,
|
||||
onFormatChange,
|
||||
disabled = false,
|
||||
}: FormatSelectorProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{formatOptions.map((option) => {
|
||||
const isSelected = selectedFormat === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onFormatChange(option.value)}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center gap-2 p-4 rounded-xl border transition-all duration-200',
|
||||
'focus:outline-none focus:ring-2 focus:ring-[#34B27B]/50 focus:ring-offset-2 focus:ring-offset-[#09090b]',
|
||||
isSelected
|
||||
? 'bg-[#34B27B]/10 border-[#34B27B]/50 text-white'
|
||||
: 'bg-white/5 border-white/10 text-slate-400 hover:bg-white/10 hover:border-white/20 hover:text-slate-200',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'w-10 h-10 rounded-full flex items-center justify-center transition-colors',
|
||||
isSelected ? 'bg-[#34B27B]/20 text-[#34B27B]' : 'bg-white/5'
|
||||
)}
|
||||
>
|
||||
{option.icon}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-medium text-sm">{option.label}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">{option.description}</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34B27B]" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS, type GifFrameRate, type GifSizePreset } from '@/lib/exporter/types';
|
||||
|
||||
interface GifOptionsPanelProps {
|
||||
frameRate: GifFrameRate;
|
||||
onFrameRateChange: (rate: GifFrameRate) => void;
|
||||
loop: boolean;
|
||||
onLoopChange: (loop: boolean) => void;
|
||||
sizePreset: GifSizePreset;
|
||||
onSizePresetChange: (preset: GifSizePreset) => void;
|
||||
outputDimensions: { width: number; height: number };
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function GifOptionsPanel({
|
||||
frameRate,
|
||||
onFrameRateChange,
|
||||
loop,
|
||||
onLoopChange,
|
||||
sizePreset,
|
||||
onSizePresetChange,
|
||||
outputDimensions,
|
||||
disabled = false,
|
||||
}: GifOptionsPanelProps) {
|
||||
const sizePresetOptions = Object.entries(GIF_SIZE_PRESETS).map(([key, value]) => ({
|
||||
value: key as GifSizePreset,
|
||||
label: value.label,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-in slide-in-from-bottom-2 duration-200">
|
||||
{/* Frame Rate */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
Frame Rate
|
||||
</label>
|
||||
<Select
|
||||
value={String(frameRate)}
|
||||
onValueChange={(value) => onFrameRateChange(Number(value) as GifFrameRate)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#1a1a1f] border-white/10 z-[100]">
|
||||
{GIF_FRAME_RATES.map((rate) => (
|
||||
<SelectItem
|
||||
key={rate.value}
|
||||
value={String(rate.value)}
|
||||
className="text-slate-200 focus:bg-white/10 focus:text-white"
|
||||
>
|
||||
{rate.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Size Preset */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
Output Size
|
||||
</label>
|
||||
<Select
|
||||
value={sizePreset}
|
||||
onValueChange={(value) => onSizePresetChange(value as GifSizePreset)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#1a1a1f] border-white/10 z-[100]">
|
||||
{sizePresetOptions.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className="text-slate-200 focus:bg-white/10 focus:text-white"
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="text-xs text-slate-500">
|
||||
Output: {outputDimensions.width} × {outputDimensions.height}px
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loop Toggle */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-200">Loop Animation</label>
|
||||
<p className="text-xs text-slate-500">GIF will play continuously</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={loop}
|
||||
onCheckedChange={onLoopChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,28 @@
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { formatShortcut } from "@/utils/platformUtils";
|
||||
|
||||
export function KeyboardShortcutsHelp() {
|
||||
const [shortcuts, setShortcuts] = useState({
|
||||
delete: 'Ctrl + D',
|
||||
pan: 'Shift + Ctrl + Scroll',
|
||||
zoom: 'Ctrl + Scroll'
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
formatShortcut(['mod', 'D']),
|
||||
formatShortcut(['shift', 'mod', 'Scroll']),
|
||||
formatShortcut(['mod', 'Scroll'])
|
||||
]).then(([deleteKey, panKey, zoomKey]) => {
|
||||
setShortcuts({
|
||||
delete: deleteKey,
|
||||
pan: panKey,
|
||||
zoom: zoomKey
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<HelpCircle className="w-4 h-4 text-slate-500 hover:text-[#34B27B] transition-colors cursor-help" />
|
||||
@@ -11,21 +33,33 @@ export function KeyboardShortcutsHelp() {
|
||||
<span className="text-slate-400">Add Zoom</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">Z</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Add Annotation</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">A</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Add Keyframe</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">F</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Add Trim</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">T</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Delete Selected</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">⌘ + D</kbd>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{shortcuts.delete}</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Pan Timeline</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">⇧ + ⌘ + Scroll</kbd>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{shortcuts.pan}</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Zoom Timeline</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">⌘ + Scroll</kbd>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{shortcuts.zoom}</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Pause/Play</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">Space</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,16 +6,19 @@ 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, Film, Image, Sparkles, Palette } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { ZoomDepth, CropRegion } from "./types";
|
||||
import type { ZoomDepth, CropRegion, AnnotationRegion, AnnotationType } from "./types";
|
||||
import { CropControl } from "./CropControl";
|
||||
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
|
||||
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
|
||||
import { type AspectRatio } from "@/utils/aspectRatioUtils";
|
||||
import type { ExportQuality, ExportFormat, GifFrameRate, GifSizePreset } from "@/lib/exporter";
|
||||
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
|
||||
const WALLPAPER_COUNT = 20;
|
||||
const WALLPAPER_COUNT = 18;
|
||||
const WALLPAPER_RELATIVE = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `wallpapers/wallpaper${i + 1}.jpg`);
|
||||
const GRADIENTS = [
|
||||
"linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(253,129,82,1) 54.8%, rgba(249,202,86,1) 86.3% )",
|
||||
@@ -51,14 +54,42 @@ interface SettingsPanelProps {
|
||||
onZoomDepthChange?: (depth: ZoomDepth) => void;
|
||||
selectedZoomId?: string | null;
|
||||
onZoomDelete?: (id: string) => void;
|
||||
selectedTrimId?: string | null;
|
||||
onTrimDelete?: (id: string) => void;
|
||||
shadowIntensity?: number;
|
||||
onShadowChange?: (intensity: number) => void;
|
||||
showBlur?: boolean;
|
||||
onBlurChange?: (showBlur: boolean) => void;
|
||||
motionBlurEnabled?: boolean;
|
||||
onMotionBlurChange?: (enabled: boolean) => void;
|
||||
borderRadius?: number;
|
||||
onBorderRadiusChange?: (radius: number) => void;
|
||||
padding?: number;
|
||||
onPaddingChange?: (padding: number) => void;
|
||||
cropRegion?: CropRegion;
|
||||
onCropChange?: (region: CropRegion) => void;
|
||||
aspectRatio: AspectRatio;
|
||||
videoElement?: HTMLVideoElement | null;
|
||||
exportQuality?: ExportQuality;
|
||||
onExportQualityChange?: (quality: ExportQuality) => void;
|
||||
// Export format settings
|
||||
exportFormat?: ExportFormat;
|
||||
onExportFormatChange?: (format: ExportFormat) => void;
|
||||
gifFrameRate?: GifFrameRate;
|
||||
onGifFrameRateChange?: (rate: GifFrameRate) => void;
|
||||
gifLoop?: boolean;
|
||||
onGifLoopChange?: (loop: boolean) => void;
|
||||
gifSizePreset?: GifSizePreset;
|
||||
onGifSizePresetChange?: (preset: GifSizePreset) => void;
|
||||
gifOutputDimensions?: { width: number; height: number };
|
||||
onExport?: () => void;
|
||||
selectedAnnotationId?: string | null;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
onAnnotationContentChange?: (id: string, content: string) => void;
|
||||
onAnnotationTypeChange?: (id: string, type: AnnotationType) => void;
|
||||
onAnnotationStyleChange?: (id: string, style: Partial<AnnotationRegion['style']>) => void;
|
||||
onAnnotationFigureDataChange?: (id: string, figureData: any) => void;
|
||||
onAnnotationDelete?: (id: string) => void;
|
||||
}
|
||||
|
||||
export default SettingsPanel;
|
||||
@@ -72,7 +103,49 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [
|
||||
{ depth: 6, label: "5×" },
|
||||
];
|
||||
|
||||
export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth, onZoomDepthChange, selectedZoomId, onZoomDelete, shadowIntensity = 0, onShadowChange, showBlur, onBlurChange, cropRegion, onCropChange, videoElement, onExport }: SettingsPanelProps) {
|
||||
export function SettingsPanel({
|
||||
selected,
|
||||
onWallpaperChange,
|
||||
selectedZoomDepth,
|
||||
onZoomDepthChange,
|
||||
selectedZoomId,
|
||||
onZoomDelete,
|
||||
selectedTrimId,
|
||||
onTrimDelete,
|
||||
shadowIntensity = 0,
|
||||
onShadowChange,
|
||||
showBlur,
|
||||
onBlurChange,
|
||||
motionBlurEnabled = false,
|
||||
onMotionBlurChange,
|
||||
borderRadius = 0,
|
||||
onBorderRadiusChange,
|
||||
padding = 50,
|
||||
onPaddingChange,
|
||||
cropRegion,
|
||||
onCropChange,
|
||||
aspectRatio,
|
||||
videoElement,
|
||||
exportQuality = 'good',
|
||||
onExportQualityChange,
|
||||
exportFormat = 'mp4',
|
||||
onExportFormatChange,
|
||||
gifFrameRate = 15,
|
||||
onGifFrameRateChange,
|
||||
gifLoop = true,
|
||||
onGifLoopChange,
|
||||
gifSizePreset = 'medium',
|
||||
onGifSizePresetChange,
|
||||
gifOutputDimensions = { width: 1280, height: 720 },
|
||||
onExport,
|
||||
selectedAnnotationId,
|
||||
annotationRegions = [],
|
||||
onAnnotationContentChange,
|
||||
onAnnotationTypeChange,
|
||||
onAnnotationStyleChange,
|
||||
onAnnotationFigureDataChange,
|
||||
onAnnotationDelete,
|
||||
}: SettingsPanelProps) {
|
||||
const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]);
|
||||
const [customImages, setCustomImages] = useState<string[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -89,11 +162,18 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
|
||||
})()
|
||||
return () => { mounted = false }
|
||||
}, [])
|
||||
const [hsva, setHsva] = useState({ h: 0, s: 0, v: 68, a: 1 });
|
||||
const colorPalette = [
|
||||
'#FF0000', '#FFD700', '#00FF00', '#FFFFFF', '#0000FF', '#FF6B00',
|
||||
'#9B59B6', '#E91E63', '#00BCD4', '#FF5722', '#8BC34A', '#FFC107',
|
||||
'#34B27B', '#000000', '#607D8B', '#795548',
|
||||
];
|
||||
|
||||
const [selectedColor, setSelectedColor] = useState('#ADADAD');
|
||||
const [gradient, setGradient] = useState<string>(GRADIENTS[0]);
|
||||
const [showCropDropdown, setShowCropDropdown] = useState(false);
|
||||
|
||||
const zoomEnabled = Boolean(selectedZoomDepth);
|
||||
const trimEnabled = Boolean(selectedTrimId);
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
if (selectedZoomId && onZoomDelete) {
|
||||
@@ -101,6 +181,12 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
|
||||
}
|
||||
};
|
||||
|
||||
const handleTrimDeleteClick = () => {
|
||||
if (selectedTrimId && onTrimDelete) {
|
||||
onTrimDelete(selectedTrimId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
@@ -148,109 +234,315 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
|
||||
}
|
||||
};
|
||||
|
||||
// Find selected annotation
|
||||
const selectedAnnotation = selectedAnnotationId
|
||||
? annotationRegions.find(a => a.id === selectedAnnotationId)
|
||||
: null;
|
||||
|
||||
// If an annotation is selected, show annotation settings instead
|
||||
if (selectedAnnotation && onAnnotationContentChange && onAnnotationTypeChange && onAnnotationStyleChange && onAnnotationDelete) {
|
||||
return (
|
||||
<AnnotationSettingsPanel
|
||||
annotation={selectedAnnotation}
|
||||
onContentChange={(content) => onAnnotationContentChange(selectedAnnotation.id, content)}
|
||||
onTypeChange={(type) => onAnnotationTypeChange(selectedAnnotation.id, type)}
|
||||
onStyleChange={(style) => onAnnotationStyleChange(selectedAnnotation.id, style)}
|
||||
onFigureDataChange={onAnnotationFigureDataChange ? (figureData) => onAnnotationFigureDataChange(selectedAnnotation.id, figureData) : undefined}
|
||||
onDelete={() => onAnnotationDelete(selectedAnnotation.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl p-4 flex flex-col shadow-xl h-full overflow-y-auto custom-scrollbar">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm font-medium text-slate-200">Zoom Level</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{zoomEnabled && selectedZoomDepth && (
|
||||
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-1 rounded-full">
|
||||
{ZOOM_DEPTH_OPTIONS.find(o => o.depth === selectedZoomDepth)?.label} Active
|
||||
</span>
|
||||
)}
|
||||
<KeyboardShortcutsHelp />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
{ZOOM_DEPTH_OPTIONS.map((option) => {
|
||||
const isActive = selectedZoomDepth === option.depth;
|
||||
return (
|
||||
<Button
|
||||
key={option.depth}
|
||||
type="button"
|
||||
disabled={!zoomEnabled}
|
||||
onClick={() => onZoomDepthChange?.(option.depth)}
|
||||
className={cn(
|
||||
"h-auto w-full rounded-xl border px-1 py-3 text-center shadow-sm transition-all flex flex-col items-center justify-center gap-1.5",
|
||||
"duration-200 ease-out",
|
||||
zoomEnabled ? "opacity-100 cursor-pointer" : "opacity-40 cursor-not-allowed",
|
||||
isActive
|
||||
? "border-[#34B27B] bg-[#34B27B] text-white shadow-[#34B27B]/20 scale-105 ring-2 ring-[#34B27B]/20"
|
||||
: "border-white/5 bg-white/5 text-slate-400 hover:bg-white/10 hover:border-white/10 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
<span className={cn("text-sm font-semibold tracking-tight")}>{option.label}</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!zoomEnabled && (
|
||||
<p className="text-xs text-slate-500 mt-3 text-center">Select a zoom region in the timeline to adjust depth.</p>
|
||||
)}
|
||||
{zoomEnabled && (
|
||||
<Button
|
||||
onClick={handleDeleteClick}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="mt-4 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 Zoom Region
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Drop Shadow Slider */}
|
||||
<div className="p-3 rounded-xl bg-white/5 border border-white/5 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-medium text-slate-200">Shadow</div>
|
||||
<span className="text-[10px] text-slate-400 font-mono">{Math.round(shadowIntensity * 100)}%</span>
|
||||
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl flex flex-col shadow-xl h-full overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 pb-0">
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-slate-200">Zoom Level</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{zoomEnabled && selectedZoomDepth && (
|
||||
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-0.5 rounded-full">
|
||||
{ZOOM_DEPTH_OPTIONS.find(o => o.depth === selectedZoomDepth)?.label}
|
||||
</span>
|
||||
)}
|
||||
<KeyboardShortcutsHelp />
|
||||
</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>
|
||||
{/* Blur Background Switch */}
|
||||
<div className="flex items-center justify-between p-3 rounded-xl bg-white/5 border border-white/5">
|
||||
<div className="text-xs font-medium text-slate-200">Blur</div>
|
||||
<Switch
|
||||
checked={showBlur}
|
||||
onCheckedChange={onBlurChange}
|
||||
className="data-[state=checked]:bg-[#34B27B]"
|
||||
/>
|
||||
<div className="grid grid-cols-6 gap-1.5">
|
||||
{ZOOM_DEPTH_OPTIONS.map((option) => {
|
||||
const isActive = selectedZoomDepth === option.depth;
|
||||
return (
|
||||
<Button
|
||||
key={option.depth}
|
||||
type="button"
|
||||
disabled={!zoomEnabled}
|
||||
onClick={() => onZoomDepthChange?.(option.depth)}
|
||||
className={cn(
|
||||
"h-auto w-full rounded-lg border px-1 py-2 text-center shadow-sm transition-all",
|
||||
"duration-200 ease-out",
|
||||
zoomEnabled ? "opacity-100 cursor-pointer" : "opacity-40 cursor-not-allowed",
|
||||
isActive
|
||||
? "border-[#34B27B] bg-[#34B27B] text-white shadow-[#34B27B]/20"
|
||||
: "border-white/5 bg-white/5 text-slate-400 hover:bg-white/10 hover:border-white/10 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
<span className="text-xs font-semibold">{option.label}</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!zoomEnabled && (
|
||||
<p className="text-[10px] text-slate-500 mt-2 text-center">Select a zoom region to adjust</p>
|
||||
)}
|
||||
{zoomEnabled && (
|
||||
<Button
|
||||
onClick={handleDeleteClick}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="mt-2 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 h-8 text-xs"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
Delete Zoom
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{trimEnabled && (
|
||||
<div className="mb-4">
|
||||
<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 h-8 text-xs"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
Delete Trim Region
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Accordion type="multiple" defaultValue={["effects", "background"]} className="space-y-1">
|
||||
<AccordionItem value="effects" className="border-white/5 rounded-xl bg-white/[0.02] px-3">
|
||||
<AccordionTrigger className="py-2.5 hover:no-underline">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-[#34B27B]" />
|
||||
<span className="text-xs font-medium">Video Effects</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-3">
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
<div className="flex items-center justify-between p-2 rounded-lg bg-white/5 border border-white/5">
|
||||
<div className="text-[10px] font-medium text-slate-300">Motion Blur</div>
|
||||
<Switch
|
||||
checked={motionBlurEnabled}
|
||||
onCheckedChange={onMotionBlurChange}
|
||||
className="data-[state=checked]:bg-[#34B27B] scale-90"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-2 rounded-lg bg-white/5 border border-white/5">
|
||||
<div className="text-[10px] font-medium text-slate-300">Blur BG</div>
|
||||
<Switch
|
||||
checked={showBlur}
|
||||
onCheckedChange={onBlurChange}
|
||||
className="data-[state=checked]:bg-[#34B27B] scale-90"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-[10px] font-medium text-slate-300">Shadow</div>
|
||||
<span className="text-[10px] text-slate-500 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] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-[10px] font-medium text-slate-300">Roundness</div>
|
||||
<span className="text-[10px] text-slate-500 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] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-[10px] font-medium text-slate-300">Padding</div>
|
||||
<span className="text-[10px] text-slate-500 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] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => setShowCropDropdown(!showCropDropdown)}
|
||||
variant="outline"
|
||||
className="w-full mt-2 gap-1.5 bg-white/5 text-slate-200 border-white/10 hover:bg-white/10 hover:border-white/20 hover:text-white text-[10px] h-8 transition-all"
|
||||
>
|
||||
<Crop className="w-3 h-3" />
|
||||
Crop Video
|
||||
</Button>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="background" className="border-white/5 rounded-xl bg-white/[0.02] px-3">
|
||||
<AccordionTrigger className="py-2.5 hover:no-underline">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="w-4 h-4 text-[#34B27B]" />
|
||||
<span className="text-xs font-medium">Background</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-3">
|
||||
<Tabs defaultValue="image" className="w-full">
|
||||
<TabsList className="mb-2 bg-white/5 border border-white/5 p-0.5 w-full grid grid-cols-3 h-7 rounded-lg">
|
||||
<TabsTrigger value="image" className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 text-[10px] py-1 rounded-md transition-all">Image</TabsTrigger>
|
||||
<TabsTrigger value="color" className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 text-[10px] py-1 rounded-md transition-all">Color</TabsTrigger>
|
||||
<TabsTrigger value="gradient" className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 text-[10px] py-1 rounded-md transition-all">Gradient</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="max-h-[min(200px,25vh)] overflow-y-auto custom-scrollbar">
|
||||
<TabsContent value="image" className="mt-0 space-y-2">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleImageUpload}
|
||||
accept=".jpg,.jpeg,image/jpeg"
|
||||
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 h-7 text-[10px]"
|
||||
>
|
||||
<Upload className="w-3 h-3" />
|
||||
Upload Custom
|
||||
</Button>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1.5">
|
||||
{customImages.map((imageUrl, idx) => {
|
||||
const isSelected = selected === imageUrl;
|
||||
return (
|
||||
<div
|
||||
key={`custom-${idx}`}
|
||||
className={cn(
|
||||
"aspect-square w-9 h-9 rounded-md border-2 overflow-hidden cursor-pointer transition-all duration-200 relative group shadow-sm",
|
||||
isSelected
|
||||
? "border-[#34B27B] ring-1 ring-[#34B27B]/30"
|
||||
: "border-white/10 hover:border-[#34B27B]/40 opacity-80 hover:opacity-100 bg-white/5"
|
||||
)}
|
||||
style={{ backgroundImage: `url(${imageUrl})`, backgroundSize: "cover", backgroundPosition: "center" }}
|
||||
onClick={() => onWallpaperChange(imageUrl)}
|
||||
role="button"
|
||||
>
|
||||
<button
|
||||
onClick={(e) => handleRemoveCustomImage(imageUrl, e)}
|
||||
className="absolute top-0.5 right-0.5 w-3 h-3 bg-red-500/90 hover:bg-red-500 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10"
|
||||
>
|
||||
<X className="w-2 h-2 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{(wallpaperPaths.length > 0 ? wallpaperPaths : WALLPAPER_RELATIVE.map(p => `/${p}`)).map((path) => {
|
||||
const isSelected = (() => {
|
||||
if (!selected) return false;
|
||||
if (selected === path) return true;
|
||||
try {
|
||||
const clean = (s: string) => s.replace(/^file:\/\//, '').replace(/^\//, '')
|
||||
if (clean(selected).endsWith(clean(path))) return true;
|
||||
if (clean(path).endsWith(clean(selected))) return true;
|
||||
} catch {}
|
||||
return false;
|
||||
})();
|
||||
return (
|
||||
<div
|
||||
key={path}
|
||||
className={cn(
|
||||
"aspect-square w-9 h-9 rounded-md border-2 overflow-hidden cursor-pointer transition-all duration-200 shadow-sm",
|
||||
isSelected
|
||||
? "border-[#34B27B] ring-1 ring-[#34B27B]/30"
|
||||
: "border-white/10 hover:border-[#34B27B]/40 opacity-80 hover:opacity-100 bg-white/5"
|
||||
)}
|
||||
style={{ backgroundImage: `url(${path})`, backgroundSize: "cover", backgroundPosition: "center" }}
|
||||
onClick={() => onWallpaperChange(path)}
|
||||
role="button"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="color" className="mt-0">
|
||||
<div className="p-1">
|
||||
<Block
|
||||
color={selectedColor}
|
||||
colors={colorPalette}
|
||||
onChange={(color) => {
|
||||
setSelectedColor(color.hex);
|
||||
onWallpaperChange(color.hex);
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="gradient" className="mt-0">
|
||||
<div className="grid grid-cols-7 gap-1.5">
|
||||
{GRADIENTS.map((g, idx) => (
|
||||
<div
|
||||
key={g}
|
||||
className={cn(
|
||||
"aspect-square w-9 h-9 rounded-md border-2 overflow-hidden cursor-pointer transition-all duration-200 shadow-sm",
|
||||
gradient === g
|
||||
? "border-[#34B27B] ring-1 ring-[#34B27B]/30"
|
||||
: "border-white/10 hover:border-[#34B27B]/40 opacity-80 hover:opacity-100 bg-white/5"
|
||||
)}
|
||||
style={{ background: g }}
|
||||
aria-label={`Gradient ${idx + 1}`}
|
||||
onClick={() => { setGradient(g); onWallpaperChange(g); }}
|
||||
role="button"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
onClick={() => setShowCropDropdown(!showCropDropdown)}
|
||||
variant="outline"
|
||||
className="w-full gap-2 bg-white/5 text-slate-200 border-white/10 hover:bg-white/10 hover:border-white/20 hover:text-white h-11 transition-all"
|
||||
>
|
||||
<Crop className="w-4 h-4" />
|
||||
Crop Video
|
||||
</Button>
|
||||
<p className="text-[10px] text-slate-500 text-center mt-3 px-4 leading-relaxed">
|
||||
If the preview looks weirdly positioned or doesn't load, try force reloading the app a few times till it works.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showCropDropdown && cropRegion && onCropChange && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 animate-in fade-in duration-200"
|
||||
onClick={() => setShowCropDropdown(false)}
|
||||
/>
|
||||
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-[60] bg-[#09090b] rounded-2xl shadow-2xl border border-white/10 p-8 w-[90vw] max-w-5xl animate-in zoom-in-95 duration-200">
|
||||
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-[60] bg-[#09090b] rounded-2xl shadow-2xl border border-white/10 p-8 w-[90vw] max-w-5xl max-h-[90vh] overflow-auto animate-in zoom-in-95 duration-200">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<span className="text-xl font-bold text-slate-200">Crop Video</span>
|
||||
@@ -269,6 +561,7 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
|
||||
videoElement={videoElement || null}
|
||||
cropRegion={cropRegion}
|
||||
onCropChange={onCropChange}
|
||||
aspectRatio={aspectRatio}
|
||||
/>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button
|
||||
@@ -283,158 +576,142 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
|
||||
</>
|
||||
)}
|
||||
|
||||
<Tabs defaultValue="image" className="flex-1 flex flex-col min-h-0">
|
||||
<TabsList className="mb-4 bg-white/5 border border-white/5 p-1 w-full grid grid-cols-3 h-auto rounded-xl">
|
||||
<TabsTrigger value="image" className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all">Image</TabsTrigger>
|
||||
<TabsTrigger value="color" className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all">Color</TabsTrigger>
|
||||
<TabsTrigger value="gradient" className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all">Gradient</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar pr-2">
|
||||
<TabsContent value="image" className="mt-0 space-y-3">
|
||||
{/* Upload Button */}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleImageUpload}
|
||||
accept=".jpg,.jpeg,image/jpeg"
|
||||
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"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
Upload Custom Image
|
||||
</Button>
|
||||
|
||||
<div className="grid grid-cols-6 gap-2.5">
|
||||
{/* Custom Images */}
|
||||
{customImages.map((imageUrl, idx) => {
|
||||
const isSelected = selected === imageUrl;
|
||||
return (
|
||||
<div
|
||||
key={`custom-${idx}`}
|
||||
className={cn(
|
||||
"aspect-square w-12 h-12 rounded-md border-2 overflow-hidden cursor-pointer transition-all duration-200 relative group shadow-sm",
|
||||
isSelected
|
||||
? "border-[#34B27B] ring-2 ring-[#34B27B]/30 scale-105 shadow-lg shadow-[#34B27B]/10"
|
||||
: "border-white/10 hover:border-[#34B27B]/40 hover:scale-105 opacity-80 hover:opacity-100 bg-white/5"
|
||||
)}
|
||||
style={{ backgroundImage: `url(${imageUrl})`, backgroundSize: "cover", backgroundPosition: "center" }}
|
||||
aria-label={`Custom Image ${idx + 1}`}
|
||||
onClick={() => onWallpaperChange(imageUrl)}
|
||||
role="button"
|
||||
>
|
||||
<button
|
||||
onClick={(e) => handleRemoveCustomImage(imageUrl, e)}
|
||||
className="absolute top-1 right-1 w-4 h-4 bg-red-500/90 hover:bg-red-500 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10"
|
||||
aria-label="Remove custom image"
|
||||
>
|
||||
<X className="w-2.5 h-2.5 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Preset Wallpapers */}
|
||||
{(wallpaperPaths.length > 0 ? wallpaperPaths : WALLPAPER_RELATIVE.map(p => `/${p}`)).map((path, idx) => {
|
||||
const isSelected = (() => {
|
||||
if (!selected) return false;
|
||||
if (selected === path) return true;
|
||||
try {
|
||||
const clean = (s: string) => s.replace(/^file:\/\//, '').replace(/^\//, '')
|
||||
if (clean(selected).endsWith(clean(path))) return true;
|
||||
if (clean(path).endsWith(clean(selected))) return true;
|
||||
} catch {}
|
||||
return false;
|
||||
})();
|
||||
return (
|
||||
<div
|
||||
key={path}
|
||||
className={cn(
|
||||
"aspect-square w-12 h-12 rounded-md border-2 overflow-hidden cursor-pointer transition-all duration-200 shadow-sm",
|
||||
isSelected
|
||||
? "border-[#34B27B] ring-2 ring-[#34B27B]/30 scale-105 shadow-lg shadow-[#34B27B]/10"
|
||||
: "border-white/10 hover:border-[#34B27B]/40 hover:scale-105 opacity-80 hover:opacity-100 bg-white/5"
|
||||
)}
|
||||
style={{ backgroundImage: `url(${path})`, backgroundSize: "cover", backgroundPosition: "center" }}
|
||||
aria-label={`Wallpaper ${idx + 1}`}
|
||||
onClick={() => onWallpaperChange(path)}
|
||||
role="button"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="color" className="mt-0">
|
||||
<div className="p-1">
|
||||
<Colorful
|
||||
color={hsva}
|
||||
disableAlpha={true}
|
||||
onChange={(color) => {
|
||||
setHsva(color.hsva);
|
||||
onWallpaperChange(hsvaToHex(color.hsva));
|
||||
}}
|
||||
style={{ width: '100%', borderRadius: '12px' }}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="gradient" className="mt-0">
|
||||
<div className="grid grid-cols-6 gap-2.5">
|
||||
{GRADIENTS.map((g, idx) => (
|
||||
<div
|
||||
key={g}
|
||||
className={cn(
|
||||
"aspect-square w-12 h-12 rounded-md border-2 overflow-hidden cursor-pointer transition-all duration-200 shadow-sm",
|
||||
gradient === g
|
||||
? "border-[#34B27B] ring-2 ring-[#34B27B]/30 scale-105 shadow-lg shadow-[#34B27B]/10"
|
||||
: "border-white/10 hover:border-[#34B27B]/40 hover:scale-105 opacity-80 hover:opacity-100 bg-white/5"
|
||||
)}
|
||||
style={{ background: g }}
|
||||
aria-label={`Gradient ${idx + 1}`}
|
||||
onClick={() => { setGradient(g); onWallpaperChange(g); }}
|
||||
role="button"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<div className="flex-shrink-0 p-4 pt-3 border-t border-white/5 bg-[#09090b]">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<button
|
||||
onClick={() => onExportFormatChange?.('mp4')}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg border transition-all text-xs font-medium",
|
||||
exportFormat === 'mp4'
|
||||
? "bg-[#34B27B]/10 border-[#34B27B]/50 text-white"
|
||||
: "bg-white/5 border-white/10 text-slate-400 hover:bg-white/10 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
<Film className="w-3.5 h-3.5" />
|
||||
MP4
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onExportFormatChange?.('gif')}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg border transition-all text-xs font-medium",
|
||||
exportFormat === 'gif'
|
||||
? "bg-[#34B27B]/10 border-[#34B27B]/50 text-white"
|
||||
: "bg-white/5 border-white/10 text-slate-400 hover:bg-white/10 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
<Image className="w-3.5 h-3.5" />
|
||||
GIF
|
||||
</button>
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-white/5">
|
||||
{exportFormat === 'mp4' && (
|
||||
<div className="mb-3 bg-white/5 border border-white/5 p-0.5 w-full grid grid-cols-3 h-7 rounded-lg">
|
||||
<button
|
||||
onClick={() => onExportQualityChange?.('medium')}
|
||||
className={cn(
|
||||
"rounded-md transition-all text-[10px] font-medium",
|
||||
exportQuality === 'medium' ? "bg-white text-black" : "text-slate-400 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
Low
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onExportQualityChange?.('good')}
|
||||
className={cn(
|
||||
"rounded-md transition-all text-[10px] font-medium",
|
||||
exportQuality === 'good' ? "bg-white text-black" : "text-slate-400 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
Medium
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onExportQualityChange?.('source')}
|
||||
className={cn(
|
||||
"rounded-md transition-all text-[10px] font-medium",
|
||||
exportQuality === 'source' ? "bg-white text-black" : "text-slate-400 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
High
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{exportFormat === 'gif' && (
|
||||
<div className="mb-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-white/5 border border-white/5 p-0.5 grid grid-cols-4 h-7 rounded-lg">
|
||||
{GIF_FRAME_RATES.map((rate) => (
|
||||
<button
|
||||
key={rate.value}
|
||||
onClick={() => onGifFrameRateChange?.(rate.value)}
|
||||
className={cn(
|
||||
"rounded-md transition-all text-[10px] font-medium",
|
||||
gifFrameRate === rate.value ? "bg-white text-black" : "text-slate-400 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
{rate.value}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 bg-white/5 border border-white/5 p-0.5 grid grid-cols-3 h-7 rounded-lg">
|
||||
{Object.entries(GIF_SIZE_PRESETS).map(([key, _preset]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => onGifSizePresetChange?.(key as GifSizePreset)}
|
||||
className={cn(
|
||||
"rounded-md transition-all text-[10px] font-medium",
|
||||
gifSizePreset === key ? "bg-white text-black" : "text-slate-400 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
{key === 'original' ? 'Orig' : key.charAt(0).toUpperCase() + key.slice(1, 3)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-slate-500">{gifOutputDimensions.width} × {gifOutputDimensions.height}px</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-slate-400">Loop</span>
|
||||
<Switch
|
||||
checked={gifLoop}
|
||||
onCheckedChange={onGifLoopChange}
|
||||
className="data-[state=checked]:bg-[#34B27B] scale-75"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
onClick={onExport}
|
||||
className="w-full py-6 text-lg font-semibold flex items-center justify-center gap-3 bg-[#34B27B] text-white rounded-xl shadow-lg shadow-[#34B27B]/20 hover:bg-[#34B27B]/90 hover:scale-[1.02] active:scale-[0.98] transition-all duration-200"
|
||||
className="w-full py-5 text-sm font-semibold flex items-center justify-center gap-2 bg-[#34B27B] text-white rounded-xl shadow-lg shadow-[#34B27B]/20 hover:bg-[#34B27B]/90 hover:scale-[1.02] active:scale-[0.98] transition-all duration-200"
|
||||
>
|
||||
<Download className="w-5 h-5" />
|
||||
<span>Export Video</span>
|
||||
<Download className="w-4 h-4" />
|
||||
Export {exportFormat === 'gif' ? 'GIF' : 'Video'}
|
||||
</Button>
|
||||
<div className="flex gap-2 mt-4">
|
||||
|
||||
<div className="flex gap-2 mt-3">
|
||||
<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"
|
||||
className="flex-1 flex items-center justify-center gap-1.5 text-[10px] text-slate-500 hover:text-slate-300 py-1.5 transition-colors"
|
||||
>
|
||||
<Bug className="w-3 h-3 text-[#34B27B]" />
|
||||
<span>Report a Bug</span>
|
||||
Report Bug
|
||||
</button>
|
||||
<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"
|
||||
className="flex-1 flex items-center justify-center gap-1.5 text-[10px] text-slate-500 hover:text-slate-300 py-1.5 transition-colors"
|
||||
>
|
||||
<GiHearts className="w-3 h-3 text-red-500" />
|
||||
<span>Support my work</span>
|
||||
<Star className="w-3 h-3 text-yellow-400" />
|
||||
Star on GitHub
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HelpCircle, Scissors, ArrowRight } from "lucide-react";
|
||||
|
||||
export function TutorialHelp() {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-slate-400 hover:text-slate-200 hover:bg-white/10 transition-all gap-1.5"
|
||||
>
|
||||
<HelpCircle className="w-3.5 h-3.5" />
|
||||
<span className="font-medium">How trimming works</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl bg-[#09090b] border-white/10 [&>button]:text-slate-400 [&>button:hover]:text-white">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-semibold text-slate-200 flex items-center gap-2">
|
||||
<Scissors className="w-5 h-5 text-[#ef4444]" /> How Trimming Works
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
Understanding how to cut out unwanted parts of your video.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="mt-4 space-y-8">
|
||||
{/* Explanation */}
|
||||
<div className="bg-white/5 rounded-lg p-4 border border-white/5">
|
||||
<p className="text-slate-300 leading-relaxed">
|
||||
The Trim tool works by defining the segments you want to
|
||||
<span className="text-[#ef4444] font-bold"> remove</span>. Any part
|
||||
of the timeline that is
|
||||
<span className="text-[#ef4444] font-bold"> covered</span> by a red
|
||||
trim segment will be cut out when you export.
|
||||
</p>
|
||||
</div>
|
||||
{/* Visual Illustration */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-slate-400 uppercase tracking-wider">
|
||||
Visual Example
|
||||
</h3>
|
||||
<div className="relative h-24 bg-[#000] rounded-lg border border-white/10 flex items-center px-4 overflow-hidden select-none">
|
||||
{/* Background track (Kept parts) */}
|
||||
<div className="absolute inset-x-4 h-2 bg-slate-600 rounded-full overflow-hidden">
|
||||
{/* Solid line representing video */}
|
||||
</div>
|
||||
{/* Removed Segment 1 */}
|
||||
<div
|
||||
className="absolute left-[20%] h-8 bg-[#ef4444]/20 border border-[#ef4444] rounded flex flex-col items-center justify-center z-10"
|
||||
style={{ width: "20%" }}
|
||||
>
|
||||
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
|
||||
REMOVED
|
||||
</span>
|
||||
</div>
|
||||
{/* Removed Segment 2 */}
|
||||
<div
|
||||
className="absolute left-[65%] h-8 bg-[#ef4444]/20 border border-[#ef4444] rounded flex flex-col items-center justify-center z-10"
|
||||
style={{ width: "15%" }}
|
||||
>
|
||||
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
|
||||
REMOVED
|
||||
</span>
|
||||
</div>
|
||||
{/* Labels for kept parts */}
|
||||
<div className="absolute left-[5%] text-[10px] text-slate-400 font-medium">
|
||||
Kept
|
||||
</div>
|
||||
<div className="absolute left-[50%] text-[10px] text-slate-400 font-medium">
|
||||
Kept
|
||||
</div>
|
||||
<div className="absolute left-[90%] text-[10px] text-slate-400 font-medium">
|
||||
Kept
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center mt-2">
|
||||
|
||||
<ArrowRight className="w-4 h-4 text-slate-600 rotate-90" />
|
||||
</div>
|
||||
{/* Result */}
|
||||
<div className="relative h-12 bg-[#000] rounded-lg border border-white/10 flex items-center justify-center gap-1 px-4 select-none">
|
||||
<div
|
||||
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
|
||||
style={{ width: "30%" }}
|
||||
>
|
||||
<span className="text-[10px] text-white font-medium">
|
||||
Part 1
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
|
||||
style={{ width: "30%" }}
|
||||
>
|
||||
<span className="text-[10px] text-white font-medium">
|
||||
Part 2
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
|
||||
style={{ width: "30%" }}
|
||||
>
|
||||
<span className="text-[10px] text-white font-medium">
|
||||
Part 3
|
||||
</span>
|
||||
</div>
|
||||
<span className="absolute right-4 text-xs text-slate-400">
|
||||
Final Video
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Steps */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-3 rounded bg-white/5 border border-white/5">
|
||||
<div className="text-[#ef4444] font-bold mb-1">
|
||||
1. Add Trim
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
Press
|
||||
<kbd className="bg-white/10 px-1 rounded text-slate-300">T</kbd>
|
||||
or click the scissors icon to mark a section for removal.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded bg-white/5 border border-white/5">
|
||||
<div className="text-[#ef4444] font-bold mb-1">
|
||||
2. Adjust
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
Drag the edges of the red region to cover exactly what you want
|
||||
to cut out.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -16,15 +16,23 @@ import {
|
||||
DEFAULT_ZOOM_DEPTH,
|
||||
clampFocusToDepth,
|
||||
DEFAULT_CROP_REGION,
|
||||
DEFAULT_ANNOTATION_POSITION,
|
||||
DEFAULT_ANNOTATION_SIZE,
|
||||
DEFAULT_ANNOTATION_STYLE,
|
||||
DEFAULT_FIGURE_DATA,
|
||||
type ZoomDepth,
|
||||
type ZoomFocus,
|
||||
type ZoomRegion,
|
||||
type TrimRegion,
|
||||
type AnnotationRegion,
|
||||
type CropRegion,
|
||||
type FigureData,
|
||||
} from "./types";
|
||||
import { VideoExporter, type ExportProgress } from "@/lib/exporter";
|
||||
import { VideoExporter, GifExporter, type ExportProgress, type ExportQuality, type ExportSettings, type ExportFormat, type GifFrameRate, type GifSizePreset, GIF_SIZE_PRESETS, calculateOutputDimensions } from "@/lib/exporter";
|
||||
import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils";
|
||||
import { getAssetPath } from "@/lib/assetPath";
|
||||
|
||||
const WALLPAPER_COUNT = 20;
|
||||
const WALLPAPER_COUNT = 18;
|
||||
const WALLPAPER_PATHS = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `/wallpapers/wallpaper${i + 1}.jpg`);
|
||||
|
||||
export default function VideoEditor() {
|
||||
@@ -37,19 +45,32 @@ export default function VideoEditor() {
|
||||
const [wallpaper, setWallpaper] = useState<string>(WALLPAPER_PATHS[0]);
|
||||
const [shadowIntensity, setShadowIntensity] = useState(0);
|
||||
const [showBlur, setShowBlur] = useState(false);
|
||||
const [motionBlurEnabled, setMotionBlurEnabled] = useState(false);
|
||||
const [borderRadius, setBorderRadius] = useState(0);
|
||||
const [padding, setPadding] = useState(50);
|
||||
const [cropRegion, setCropRegion] = useState<CropRegion>(DEFAULT_CROP_REGION);
|
||||
const [zoomRegions, setZoomRegions] = useState<ZoomRegion[]>([]);
|
||||
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
|
||||
const [trimRegions, setTrimRegions] = useState<TrimRegion[]>([]);
|
||||
const [selectedTrimId, setSelectedTrimId] = useState<string | null>(null);
|
||||
const [annotationRegions, setAnnotationRegions] = useState<AnnotationRegion[]>([]);
|
||||
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [exportProgress, setExportProgress] = useState<ExportProgress | null>(null);
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
const [showExportDialog, setShowExportDialog] = useState(false);
|
||||
const [aspectRatio, setAspectRatio] = useState<AspectRatio>('16:9');
|
||||
const [exportQuality, setExportQuality] = useState<ExportQuality>('good');
|
||||
const [exportFormat, setExportFormat] = useState<ExportFormat>('mp4');
|
||||
const [gifFrameRate, setGifFrameRate] = useState<GifFrameRate>(15);
|
||||
const [gifLoop, setGifLoop] = useState(true);
|
||||
const [gifSizePreset, setGifSizePreset] = useState<GifSizePreset>('medium');
|
||||
|
||||
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
|
||||
const nextZoomIdRef = useRef(1);
|
||||
const nextTrimIdRef = useRef(1);
|
||||
const nextAnnotationIdRef = useRef(1);
|
||||
const nextAnnotationZIndexRef = useRef(1); // Track z-index for stacking order
|
||||
const exporterRef = useRef<VideoExporter | null>(null);
|
||||
|
||||
// Helper to convert file path to proper file:// URL
|
||||
@@ -88,6 +109,23 @@ export default function VideoEditor() {
|
||||
loadVideo();
|
||||
}, []);
|
||||
|
||||
// Initialize default wallpaper with resolved asset path
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
const resolvedPath = await getAssetPath('wallpapers/wallpaper1.jpg');
|
||||
if (mounted) {
|
||||
setWallpaper(resolvedPath);
|
||||
}
|
||||
} catch (err) {
|
||||
// If resolution fails, keep the fallback
|
||||
console.warn('Failed to resolve default wallpaper path:', err);
|
||||
}
|
||||
})();
|
||||
return () => { mounted = false };
|
||||
}, []);
|
||||
|
||||
function togglePlayPause() {
|
||||
const playback = videoPlaybackRef.current;
|
||||
const video = playback?.video;
|
||||
@@ -113,7 +151,18 @@ export default function VideoEditor() {
|
||||
|
||||
const handleSelectTrim = useCallback((id: string | null) => {
|
||||
setSelectedTrimId(id);
|
||||
if (id) setSelectedZoomId(null);
|
||||
if (id) {
|
||||
setSelectedZoomId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSelectAnnotation = useCallback((id: string | null) => {
|
||||
setSelectedAnnotationId(id);
|
||||
if (id) {
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleZoomAdded = useCallback((span: Span) => {
|
||||
@@ -125,10 +174,10 @@ export default function VideoEditor() {
|
||||
depth: DEFAULT_ZOOM_DEPTH,
|
||||
focus: { cx: 0.5, cy: 0.5 },
|
||||
};
|
||||
console.log('Zoom region added:', newRegion);
|
||||
setZoomRegions((prev) => [...prev, newRegion]);
|
||||
setSelectedZoomId(id);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
}, []);
|
||||
|
||||
const handleTrimAdded = useCallback((span: Span) => {
|
||||
@@ -138,14 +187,13 @@ export default function VideoEditor() {
|
||||
startMs: Math.round(span.start),
|
||||
endMs: Math.round(span.end),
|
||||
};
|
||||
console.log('Trim region added:', newRegion);
|
||||
setTrimRegions((prev) => [...prev, newRegion]);
|
||||
setSelectedTrimId(id);
|
||||
setSelectedZoomId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
}, []);
|
||||
|
||||
const handleZoomSpanChange = useCallback((id: string, span: Span) => {
|
||||
console.log('Zoom span changed:', { id, start: Math.round(span.start), end: Math.round(span.end) });
|
||||
setZoomRegions((prev) =>
|
||||
prev.map((region) =>
|
||||
region.id === id
|
||||
@@ -160,7 +208,6 @@ export default function VideoEditor() {
|
||||
}, []);
|
||||
|
||||
const handleTrimSpanChange = useCallback((id: string, span: Span) => {
|
||||
console.log('Trim span changed:', { id, start: Math.round(span.start), end: Math.round(span.end) });
|
||||
setTrimRegions((prev) =>
|
||||
prev.map((region) =>
|
||||
region.id === id
|
||||
@@ -203,7 +250,6 @@ export default function VideoEditor() {
|
||||
}, [selectedZoomId]);
|
||||
|
||||
const handleZoomDelete = useCallback((id: string) => {
|
||||
console.log('Zoom region deleted:', id);
|
||||
setZoomRegions((prev) => prev.filter((region) => region.id !== id));
|
||||
if (selectedZoomId === id) {
|
||||
setSelectedZoomId(null);
|
||||
@@ -211,13 +257,169 @@ export default function VideoEditor() {
|
||||
}, [selectedZoomId]);
|
||||
|
||||
const handleTrimDelete = useCallback((id: string) => {
|
||||
console.log('Trim region deleted:', id);
|
||||
setTrimRegions((prev) => prev.filter((region) => region.id !== id));
|
||||
if (selectedTrimId === id) {
|
||||
setSelectedTrimId(null);
|
||||
}
|
||||
}, [selectedTrimId]);
|
||||
|
||||
const handleAnnotationAdded = useCallback((span: Span) => {
|
||||
const id = `annotation-${nextAnnotationIdRef.current++}`;
|
||||
const zIndex = nextAnnotationZIndexRef.current++; // Assign z-index based on creation order
|
||||
const newRegion: AnnotationRegion = {
|
||||
id,
|
||||
startMs: Math.round(span.start),
|
||||
endMs: Math.round(span.end),
|
||||
type: 'text',
|
||||
content: 'Enter text...',
|
||||
position: { ...DEFAULT_ANNOTATION_POSITION },
|
||||
size: { ...DEFAULT_ANNOTATION_SIZE },
|
||||
style: { ...DEFAULT_ANNOTATION_STYLE },
|
||||
zIndex,
|
||||
};
|
||||
setAnnotationRegions((prev) => [...prev, newRegion]);
|
||||
setSelectedAnnotationId(id);
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
}, []);
|
||||
|
||||
const handleAnnotationSpanChange = useCallback((id: string, span: Span) => {
|
||||
setAnnotationRegions((prev) =>
|
||||
prev.map((region) =>
|
||||
region.id === id
|
||||
? {
|
||||
...region,
|
||||
startMs: Math.round(span.start),
|
||||
endMs: Math.round(span.end),
|
||||
}
|
||||
: region,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleAnnotationDelete = useCallback((id: string) => {
|
||||
setAnnotationRegions((prev) => prev.filter((region) => region.id !== id));
|
||||
if (selectedAnnotationId === id) {
|
||||
setSelectedAnnotationId(null);
|
||||
}
|
||||
}, [selectedAnnotationId]);
|
||||
|
||||
const handleAnnotationContentChange = useCallback((id: string, content: string) => {
|
||||
setAnnotationRegions((prev) => {
|
||||
const updated = prev.map((region) => {
|
||||
if (region.id !== id) return region;
|
||||
|
||||
// Store content in type-specific fields
|
||||
if (region.type === 'text') {
|
||||
return { ...region, content, textContent: content };
|
||||
} else if (region.type === 'image') {
|
||||
return { ...region, content, imageContent: content };
|
||||
} else {
|
||||
return { ...region, content };
|
||||
}
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleAnnotationTypeChange = useCallback((id: string, type: AnnotationRegion['type']) => {
|
||||
setAnnotationRegions((prev) => {
|
||||
const updated = prev.map((region) => {
|
||||
if (region.id !== id) return region;
|
||||
|
||||
const updatedRegion = { ...region, type };
|
||||
|
||||
// Restore content from type-specific storage
|
||||
if (type === 'text') {
|
||||
updatedRegion.content = region.textContent || 'Enter text...';
|
||||
} else if (type === 'image') {
|
||||
updatedRegion.content = region.imageContent || '';
|
||||
} else if (type === 'figure') {
|
||||
updatedRegion.content = '';
|
||||
if (!region.figureData) {
|
||||
updatedRegion.figureData = { ...DEFAULT_FIGURE_DATA };
|
||||
}
|
||||
}
|
||||
|
||||
return updatedRegion;
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleAnnotationStyleChange = useCallback((id: string, style: Partial<AnnotationRegion['style']>) => {
|
||||
setAnnotationRegions((prev) =>
|
||||
prev.map((region) =>
|
||||
region.id === id
|
||||
? { ...region, style: { ...region.style, ...style } }
|
||||
: region,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleAnnotationFigureDataChange = useCallback((id: string, figureData: FigureData) => {
|
||||
setAnnotationRegions((prev) =>
|
||||
prev.map((region) =>
|
||||
region.id === id
|
||||
? { ...region, figureData }
|
||||
: region,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleAnnotationPositionChange = useCallback((id: string, position: { x: number; y: number }) => {
|
||||
setAnnotationRegions((prev) =>
|
||||
prev.map((region) =>
|
||||
region.id === id
|
||||
? { ...region, position }
|
||||
: region,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleAnnotationSizeChange = useCallback((id: string, size: { width: number; height: number }) => {
|
||||
setAnnotationRegions((prev) =>
|
||||
prev.map((region) =>
|
||||
region.id === id
|
||||
? { ...region, size }
|
||||
: region,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Global Tab prevention
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Tab') {
|
||||
// Allow tab only in inputs/textareas
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (e.key === ' ' || e.code === 'Space') {
|
||||
// Allow space only in inputs/textareas
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
|
||||
const playback = videoPlaybackRef.current;
|
||||
if (playback?.video) {
|
||||
if (playback.video.paused) {
|
||||
playback.play().catch(console.error);
|
||||
} else {
|
||||
playback.pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, { capture: true });
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedZoomId && !zoomRegions.some((region) => region.id === selectedZoomId)) {
|
||||
setSelectedZoomId(null);
|
||||
@@ -230,7 +432,13 @@ export default function VideoEditor() {
|
||||
}
|
||||
}, [selectedTrimId, trimRegions]);
|
||||
|
||||
const handleExport = useCallback(async () => {
|
||||
useEffect(() => {
|
||||
if (selectedAnnotationId && !annotationRegions.some((region) => region.id === selectedAnnotationId)) {
|
||||
setSelectedAnnotationId(null);
|
||||
}
|
||||
}, [selectedAnnotationId, annotationRegions]);
|
||||
|
||||
const handleExport = useCallback(async (settings: ExportSettings) => {
|
||||
if (!videoPath) {
|
||||
toast.error('No video loaded');
|
||||
return;
|
||||
@@ -242,7 +450,6 @@ export default function VideoEditor() {
|
||||
return;
|
||||
}
|
||||
|
||||
setShowExportDialog(true);
|
||||
setIsExporting(true);
|
||||
setExportProgress(null);
|
||||
setExportError(null);
|
||||
@@ -253,65 +460,191 @@ export default function VideoEditor() {
|
||||
videoPlaybackRef.current?.pause();
|
||||
}
|
||||
|
||||
// Get actual video dimensions to match recording resolution
|
||||
const video = videoPlaybackRef.current?.video;
|
||||
if (!video) {
|
||||
toast.error('Video not ready');
|
||||
return;
|
||||
}
|
||||
|
||||
const width = video.videoWidth || 1920;
|
||||
const height = video.videoHeight || 1080;
|
||||
const aspectRatioValue = getAspectRatioValue(aspectRatio);
|
||||
const sourceWidth = video.videoWidth || 1920;
|
||||
const sourceHeight = video.videoHeight || 1080;
|
||||
|
||||
// Calculate visually lossless bitrate matching screen recording optimization
|
||||
const totalPixels = width * height;
|
||||
let bitrate = 30_000_000;
|
||||
if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) {
|
||||
bitrate = 50_000_000;
|
||||
} else if (totalPixels > 2560 * 1440) {
|
||||
bitrate = 80_000_000;
|
||||
}
|
||||
// Get preview CONTAINER dimensions for scaling
|
||||
const playbackRef = videoPlaybackRef.current;
|
||||
const containerElement = playbackRef?.containerRef?.current;
|
||||
const previewWidth = containerElement?.clientWidth || 1920;
|
||||
const previewHeight = containerElement?.clientHeight || 1080;
|
||||
|
||||
const exporter = new VideoExporter({
|
||||
videoUrl: videoPath,
|
||||
width,
|
||||
height,
|
||||
frameRate: 60,
|
||||
bitrate,
|
||||
codec: 'avc1.640033',
|
||||
wallpaper,
|
||||
zoomRegions,
|
||||
trimRegions,
|
||||
showShadow: shadowIntensity > 0,
|
||||
shadowIntensity,
|
||||
showBlur,
|
||||
cropRegion,
|
||||
onProgress: (progress: ExportProgress) => {
|
||||
setExportProgress(progress);
|
||||
},
|
||||
});
|
||||
if (settings.format === 'gif' && settings.gifConfig) {
|
||||
// GIF Export
|
||||
const gifExporter = new GifExporter({
|
||||
videoUrl: videoPath,
|
||||
width: settings.gifConfig.width,
|
||||
height: settings.gifConfig.height,
|
||||
frameRate: settings.gifConfig.frameRate,
|
||||
loop: settings.gifConfig.loop,
|
||||
sizePreset: settings.gifConfig.sizePreset,
|
||||
wallpaper,
|
||||
zoomRegions,
|
||||
trimRegions,
|
||||
showShadow: shadowIntensity > 0,
|
||||
shadowIntensity,
|
||||
showBlur,
|
||||
motionBlurEnabled,
|
||||
borderRadius,
|
||||
padding,
|
||||
videoPadding: padding,
|
||||
cropRegion,
|
||||
annotationRegions,
|
||||
previewWidth,
|
||||
previewHeight,
|
||||
onProgress: (progress: ExportProgress) => {
|
||||
setExportProgress(progress);
|
||||
},
|
||||
});
|
||||
|
||||
exporterRef.current = exporter;
|
||||
const result = await exporter.export();
|
||||
exporterRef.current = gifExporter as unknown as VideoExporter;
|
||||
const result = await gifExporter.export();
|
||||
|
||||
if (result.success && result.blob) {
|
||||
const arrayBuffer = await result.blob.arrayBuffer();
|
||||
const timestamp = Date.now();
|
||||
const fileName = `export-${timestamp}.mp4`;
|
||||
|
||||
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
|
||||
|
||||
if (saveResult.cancelled) {
|
||||
toast.info('Export cancelled');
|
||||
} else if (saveResult.success) {
|
||||
toast.success(`Video exported successfully to ${saveResult.path}`);
|
||||
if (result.success && result.blob) {
|
||||
const arrayBuffer = await result.blob.arrayBuffer();
|
||||
const timestamp = Date.now();
|
||||
const fileName = `export-${timestamp}.gif`;
|
||||
|
||||
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
|
||||
|
||||
if (saveResult.cancelled) {
|
||||
toast.info('Export cancelled');
|
||||
} else if (saveResult.success) {
|
||||
toast.success(`GIF exported successfully to ${saveResult.path}`);
|
||||
} else {
|
||||
setExportError(saveResult.message || 'Failed to save GIF');
|
||||
toast.error(saveResult.message || 'Failed to save GIF');
|
||||
}
|
||||
} else {
|
||||
setExportError(saveResult.message || 'Failed to save video');
|
||||
toast.error(saveResult.message || 'Failed to save video');
|
||||
setExportError(result.error || 'GIF export failed');
|
||||
toast.error(result.error || 'GIF export failed');
|
||||
}
|
||||
} else {
|
||||
setExportError(result.error || 'Export failed');
|
||||
toast.error(result.error || 'Export failed');
|
||||
// MP4 Export
|
||||
const quality = settings.quality || exportQuality;
|
||||
let exportWidth: number;
|
||||
let exportHeight: number;
|
||||
let bitrate: number;
|
||||
|
||||
if (quality === 'source') {
|
||||
// Use source resolution
|
||||
exportWidth = sourceWidth;
|
||||
exportHeight = sourceHeight;
|
||||
|
||||
if (aspectRatioValue === 1) {
|
||||
// Square (1:1): use smaller dimension to avoid codec limits
|
||||
const baseDimension = Math.floor(Math.min(sourceWidth, sourceHeight) / 2) * 2;
|
||||
exportWidth = baseDimension;
|
||||
exportHeight = baseDimension;
|
||||
} else if (aspectRatioValue > 1) {
|
||||
// Landscape: find largest even dimensions that exactly match aspect ratio
|
||||
const baseWidth = Math.floor(sourceWidth / 2) * 2;
|
||||
let found = false;
|
||||
for (let w = baseWidth; w >= 100 && !found; w -= 2) {
|
||||
const h = Math.round(w / aspectRatioValue);
|
||||
if (h % 2 === 0 && Math.abs((w / h) - aspectRatioValue) < 0.0001) {
|
||||
exportWidth = w;
|
||||
exportHeight = h;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
exportWidth = baseWidth;
|
||||
exportHeight = Math.floor((baseWidth / aspectRatioValue) / 2) * 2;
|
||||
}
|
||||
} else {
|
||||
// Portrait: find largest even dimensions that exactly match aspect ratio
|
||||
const baseHeight = Math.floor(sourceHeight / 2) * 2;
|
||||
let found = false;
|
||||
for (let h = baseHeight; h >= 100 && !found; h -= 2) {
|
||||
const w = Math.round(h * aspectRatioValue);
|
||||
if (w % 2 === 0 && Math.abs((w / h) - aspectRatioValue) < 0.0001) {
|
||||
exportWidth = w;
|
||||
exportHeight = h;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
exportHeight = baseHeight;
|
||||
exportWidth = Math.floor((baseHeight * aspectRatioValue) / 2) * 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate visually lossless bitrate matching screen recording optimization
|
||||
const totalPixels = exportWidth * exportHeight;
|
||||
bitrate = 30_000_000;
|
||||
if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) {
|
||||
bitrate = 50_000_000;
|
||||
} else if (totalPixels > 2560 * 1440) {
|
||||
bitrate = 80_000_000;
|
||||
}
|
||||
} else {
|
||||
// Use quality-based target resolution
|
||||
const targetHeight = quality === 'medium' ? 720 : 1080;
|
||||
|
||||
// Calculate dimensions maintaining aspect ratio
|
||||
exportHeight = Math.floor(targetHeight / 2) * 2;
|
||||
exportWidth = Math.floor((exportHeight * aspectRatioValue) / 2) * 2;
|
||||
|
||||
// Adjust bitrate for lower resolutions
|
||||
const totalPixels = exportWidth * exportHeight;
|
||||
if (totalPixels <= 1280 * 720) {
|
||||
bitrate = 10_000_000;
|
||||
} else if (totalPixels <= 1920 * 1080) {
|
||||
bitrate = 20_000_000;
|
||||
} else {
|
||||
bitrate = 30_000_000;
|
||||
}
|
||||
}
|
||||
|
||||
const exporter = new VideoExporter({
|
||||
videoUrl: videoPath,
|
||||
width: exportWidth,
|
||||
height: exportHeight,
|
||||
frameRate: 60,
|
||||
bitrate,
|
||||
codec: 'avc1.640033',
|
||||
wallpaper,
|
||||
zoomRegions,
|
||||
trimRegions,
|
||||
showShadow: shadowIntensity > 0,
|
||||
shadowIntensity,
|
||||
showBlur,
|
||||
motionBlurEnabled,
|
||||
borderRadius,
|
||||
padding,
|
||||
cropRegion,
|
||||
annotationRegions,
|
||||
previewWidth,
|
||||
previewHeight,
|
||||
onProgress: (progress: ExportProgress) => {
|
||||
setExportProgress(progress);
|
||||
},
|
||||
});
|
||||
|
||||
exporterRef.current = exporter;
|
||||
const result = await exporter.export();
|
||||
|
||||
if (result.success && result.blob) {
|
||||
const arrayBuffer = await result.blob.arrayBuffer();
|
||||
const timestamp = Date.now();
|
||||
const fileName = `export-${timestamp}.mp4`;
|
||||
|
||||
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
|
||||
|
||||
if (saveResult.cancelled) {
|
||||
toast.info('Export cancelled');
|
||||
} else if (saveResult.success) {
|
||||
toast.success(`Video exported successfully to ${saveResult.path}`);
|
||||
} else {
|
||||
setExportError(saveResult.message || 'Failed to save video');
|
||||
toast.error(saveResult.message || 'Failed to save video');
|
||||
}
|
||||
} else {
|
||||
setExportError(result.error || 'Export failed');
|
||||
toast.error(result.error || 'Export failed');
|
||||
}
|
||||
}
|
||||
|
||||
if (wasPlaying) {
|
||||
@@ -325,8 +658,48 @@ export default function VideoEditor() {
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
exporterRef.current = null;
|
||||
// Reset dialog state to ensure it can be opened again on next export
|
||||
// This fixes the bug where second export doesn't show save dialog
|
||||
setShowExportDialog(false);
|
||||
setExportProgress(null);
|
||||
}
|
||||
}, [videoPath, wallpaper, zoomRegions, trimRegions, shadowIntensity, showBlur, cropRegion, isPlaying]);
|
||||
}, [videoPath, wallpaper, zoomRegions, trimRegions, shadowIntensity, showBlur, motionBlurEnabled, borderRadius, padding, cropRegion, annotationRegions, isPlaying, aspectRatio, exportQuality]);
|
||||
|
||||
const handleOpenExportDialog = useCallback(() => {
|
||||
if (!videoPath) {
|
||||
toast.error('No video loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
const video = videoPlaybackRef.current?.video;
|
||||
if (!video) {
|
||||
toast.error('Video not ready');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build export settings from current state
|
||||
const sourceWidth = video.videoWidth || 1920;
|
||||
const sourceHeight = video.videoHeight || 1080;
|
||||
const gifDimensions = calculateOutputDimensions(sourceWidth, sourceHeight, gifSizePreset, GIF_SIZE_PRESETS);
|
||||
|
||||
const settings: ExportSettings = {
|
||||
format: exportFormat,
|
||||
quality: exportFormat === 'mp4' ? exportQuality : undefined,
|
||||
gifConfig: exportFormat === 'gif' ? {
|
||||
frameRate: gifFrameRate,
|
||||
loop: gifLoop,
|
||||
sizePreset: gifSizePreset,
|
||||
width: gifDimensions.width,
|
||||
height: gifDimensions.height,
|
||||
} : undefined,
|
||||
};
|
||||
|
||||
setShowExportDialog(true);
|
||||
setExportError(null);
|
||||
|
||||
// Start export immediately
|
||||
handleExport(settings);
|
||||
}, [videoPath, exportFormat, exportQuality, gifFrameRate, gifLoop, gifSizePreset, handleExport]);
|
||||
|
||||
const handleCancelExport = useCallback(() => {
|
||||
if (exporterRef.current) {
|
||||
@@ -373,12 +746,14 @@ export default function VideoEditor() {
|
||||
<div className="w-full h-full flex flex-col items-center justify-center bg-black/40 rounded-2xl border border-white/5 shadow-2xl overflow-hidden">
|
||||
{/* Video preview */}
|
||||
<div className="w-full flex justify-center items-center" style={{ flex: '1 1 auto', margin: '6px 0 0' }}>
|
||||
<div className="relative" style={{ width: 'auto', height: '100%', aspectRatio: '16/9', maxWidth: '100%', margin: '0 auto', boxSizing: 'border-box' }}>
|
||||
<div className="relative" style={{ width: 'auto', height: '100%', aspectRatio: getAspectRatioValue(aspectRatio), maxWidth: '100%', margin: '0 auto', boxSizing: 'border-box' }}>
|
||||
<VideoPlayback
|
||||
aspectRatio={aspectRatio}
|
||||
ref={videoPlaybackRef}
|
||||
videoPath={videoPath || ''}
|
||||
onDurationChange={setDuration}
|
||||
onTimeUpdate={setCurrentTime}
|
||||
currentTime={currentTime}
|
||||
onPlayStateChange={setIsPlaying}
|
||||
onError={setError}
|
||||
wallpaper={wallpaper}
|
||||
@@ -390,8 +765,16 @@ export default function VideoEditor() {
|
||||
showShadow={shadowIntensity > 0}
|
||||
shadowIntensity={shadowIntensity}
|
||||
showBlur={showBlur}
|
||||
motionBlurEnabled={motionBlurEnabled}
|
||||
borderRadius={borderRadius}
|
||||
padding={padding}
|
||||
cropRegion={cropRegion}
|
||||
trimRegions={trimRegions}
|
||||
annotationRegions={annotationRegions}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
onSelectAnnotation={handleSelectAnnotation}
|
||||
onAnnotationPositionChange={handleAnnotationPositionChange}
|
||||
onAnnotationSizeChange={handleAnnotationSizeChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -418,43 +801,83 @@ export default function VideoEditor() {
|
||||
<Panel defaultSize={30} minSize={20}>
|
||||
<div className="h-full bg-[#09090b] rounded-2xl border border-white/5 shadow-lg overflow-hidden flex flex-col">
|
||||
<TimelineEditor
|
||||
videoDuration={duration}
|
||||
currentTime={currentTime}
|
||||
onSeek={handleSeek}
|
||||
zoomRegions={zoomRegions}
|
||||
onZoomAdded={handleZoomAdded}
|
||||
onZoomSpanChange={handleZoomSpanChange}
|
||||
onZoomDelete={handleZoomDelete}
|
||||
selectedZoomId={selectedZoomId}
|
||||
onSelectZoom={handleSelectZoom}
|
||||
trimRegions={trimRegions}
|
||||
onTrimAdded={handleTrimAdded}
|
||||
onTrimSpanChange={handleTrimSpanChange}
|
||||
onTrimDelete={handleTrimDelete}
|
||||
selectedTrimId={selectedTrimId}
|
||||
onSelectTrim={handleSelectTrim}
|
||||
/>
|
||||
videoDuration={duration}
|
||||
currentTime={currentTime}
|
||||
onSeek={handleSeek}
|
||||
zoomRegions={zoomRegions}
|
||||
onZoomAdded={handleZoomAdded}
|
||||
onZoomSpanChange={handleZoomSpanChange}
|
||||
onZoomDelete={handleZoomDelete}
|
||||
selectedZoomId={selectedZoomId}
|
||||
onSelectZoom={handleSelectZoom}
|
||||
trimRegions={trimRegions}
|
||||
onTrimAdded={handleTrimAdded}
|
||||
onTrimSpanChange={handleTrimSpanChange}
|
||||
onTrimDelete={handleTrimDelete}
|
||||
selectedTrimId={selectedTrimId}
|
||||
onSelectTrim={handleSelectTrim}
|
||||
annotationRegions={annotationRegions}
|
||||
onAnnotationAdded={handleAnnotationAdded}
|
||||
onAnnotationSpanChange={handleAnnotationSpanChange}
|
||||
onAnnotationDelete={handleAnnotationDelete}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
onSelectAnnotation={handleSelectAnnotation}
|
||||
aspectRatio={aspectRatio}
|
||||
onAspectRatioChange={setAspectRatio}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</div>
|
||||
|
||||
{/* Right section: settings panel */}
|
||||
<SettingsPanel
|
||||
<SettingsPanel
|
||||
selected={wallpaper}
|
||||
onWallpaperChange={setWallpaper}
|
||||
selectedZoomDepth={selectedZoomId ? zoomRegions.find(z => z.id === selectedZoomId)?.depth : null}
|
||||
onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)}
|
||||
selectedZoomId={selectedZoomId}
|
||||
onZoomDelete={handleZoomDelete}
|
||||
selectedTrimId={selectedTrimId}
|
||||
onTrimDelete={handleTrimDelete}
|
||||
shadowIntensity={shadowIntensity}
|
||||
onShadowChange={setShadowIntensity}
|
||||
showBlur={showBlur}
|
||||
onBlurChange={setShowBlur}
|
||||
motionBlurEnabled={motionBlurEnabled}
|
||||
onMotionBlurChange={setMotionBlurEnabled}
|
||||
borderRadius={borderRadius}
|
||||
onBorderRadiusChange={setBorderRadius}
|
||||
padding={padding}
|
||||
onPaddingChange={setPadding}
|
||||
cropRegion={cropRegion}
|
||||
onCropChange={setCropRegion}
|
||||
aspectRatio={aspectRatio}
|
||||
videoElement={videoPlaybackRef.current?.video || null}
|
||||
onExport={handleExport}
|
||||
exportQuality={exportQuality}
|
||||
onExportQualityChange={setExportQuality}
|
||||
exportFormat={exportFormat}
|
||||
onExportFormatChange={setExportFormat}
|
||||
gifFrameRate={gifFrameRate}
|
||||
onGifFrameRateChange={setGifFrameRate}
|
||||
gifLoop={gifLoop}
|
||||
onGifLoopChange={setGifLoop}
|
||||
gifSizePreset={gifSizePreset}
|
||||
onGifSizePresetChange={setGifSizePreset}
|
||||
gifOutputDimensions={calculateOutputDimensions(
|
||||
videoPlaybackRef.current?.video?.videoWidth || 1920,
|
||||
videoPlaybackRef.current?.video?.videoHeight || 1080,
|
||||
gifSizePreset,
|
||||
GIF_SIZE_PRESETS
|
||||
)}
|
||||
onExport={handleOpenExportDialog}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
annotationRegions={annotationRegions}
|
||||
onAnnotationContentChange={handleAnnotationContentChange}
|
||||
onAnnotationTypeChange={handleAnnotationTypeChange}
|
||||
onAnnotationStyleChange={handleAnnotationStyleChange}
|
||||
onAnnotationFigureDataChange={handleAnnotationFigureDataChange}
|
||||
onAnnotationDelete={handleAnnotationDelete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -467,6 +890,7 @@ export default function VideoEditor() {
|
||||
isExporting={isExporting}
|
||||
error={exportError}
|
||||
onCancel={handleCancelExport}
|
||||
exportFormat={exportFormat}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import type React from "react";
|
||||
import { useEffect, useRef, useImperativeHandle, forwardRef, useState, useMemo, useCallback } from "react";
|
||||
import { getAssetPath } from "@/lib/assetPath";
|
||||
import { Application, Container, Sprite, Graphics, BlurFilter, Texture, VideoSource } from 'pixi.js';
|
||||
import { ZOOM_DEPTH_SCALES, type ZoomRegion, type ZoomFocus, type ZoomDepth, type TrimRegion } from "./types";
|
||||
import { ZOOM_DEPTH_SCALES, type ZoomRegion, type ZoomFocus, type ZoomDepth, type TrimRegion, type AnnotationRegion } from "./types";
|
||||
import { DEFAULT_FOCUS, SMOOTHING_FACTOR, MIN_DELTA } from "./videoPlayback/constants";
|
||||
import { clamp01 } from "./videoPlayback/mathUtils";
|
||||
import { findDominantRegion } from "./videoPlayback/zoomRegionUtils";
|
||||
@@ -11,11 +11,14 @@ import { updateOverlayIndicator } from "./videoPlayback/overlayUtils";
|
||||
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
|
||||
import { applyZoomTransform } from "./videoPlayback/zoomTransform";
|
||||
import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers";
|
||||
import { type AspectRatio, formatAspectRatioForCSS } from "@/utils/aspectRatioUtils";
|
||||
import { AnnotationOverlay } from "./AnnotationOverlay";
|
||||
|
||||
interface VideoPlaybackProps {
|
||||
videoPath: string;
|
||||
onDurationChange: (duration: number) => void;
|
||||
onTimeUpdate: (time: number) => void;
|
||||
currentTime: number;
|
||||
onPlayStateChange: (playing: boolean) => void;
|
||||
onError: (error: string) => void;
|
||||
wallpaper?: string;
|
||||
@@ -27,8 +30,17 @@ interface VideoPlaybackProps {
|
||||
showShadow?: boolean;
|
||||
shadowIntensity?: number;
|
||||
showBlur?: boolean;
|
||||
motionBlurEnabled?: boolean;
|
||||
borderRadius?: number;
|
||||
padding?: number;
|
||||
cropRegion?: import('./types').CropRegion;
|
||||
trimRegions?: TrimRegion[];
|
||||
aspectRatio: AspectRatio;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
selectedAnnotationId?: string | null;
|
||||
onSelectAnnotation?: (id: string | null) => void;
|
||||
onAnnotationPositionChange?: (id: string, position: { x: number; y: number }) => void;
|
||||
onAnnotationSizeChange?: (id: string, size: { width: number; height: number }) => void;
|
||||
}
|
||||
|
||||
export interface VideoPlaybackRef {
|
||||
@@ -36,6 +48,7 @@ export interface VideoPlaybackRef {
|
||||
app: Application | null;
|
||||
videoSprite: Sprite | null;
|
||||
videoContainer: Container | null;
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
play: () => Promise<void>;
|
||||
pause: () => void;
|
||||
}
|
||||
@@ -44,6 +57,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
videoPath,
|
||||
onDurationChange,
|
||||
onTimeUpdate,
|
||||
currentTime,
|
||||
onPlayStateChange,
|
||||
onError,
|
||||
wallpaper,
|
||||
@@ -55,8 +69,17 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
showShadow,
|
||||
shadowIntensity = 0,
|
||||
showBlur,
|
||||
motionBlurEnabled = false,
|
||||
borderRadius = 0,
|
||||
padding = 50,
|
||||
cropRegion,
|
||||
trimRegions = [],
|
||||
aspectRatio,
|
||||
annotationRegions = [],
|
||||
selectedAnnotationId,
|
||||
onSelectAnnotation,
|
||||
onAnnotationPositionChange,
|
||||
onAnnotationSizeChange,
|
||||
}, ref) => {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -88,6 +111,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
const lockedVideoDimensionsRef = useRef<{ width: number; height: number } | null>(null);
|
||||
const layoutVideoContentRef = useRef<(() => void) | null>(null);
|
||||
const trimRegionsRef = useRef<TrimRegion[]>([]);
|
||||
const motionBlurEnabledRef = useRef(motionBlurEnabled);
|
||||
const videoReadyRafRef = useRef<number | null>(null);
|
||||
|
||||
const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
|
||||
return clampFocusToStageUtil(focus, depth, stageSizeRef.current);
|
||||
@@ -147,6 +172,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
videoElement,
|
||||
cropRegion,
|
||||
lockedVideoDimensions: lockedVideoDimensionsRef.current,
|
||||
borderRadius,
|
||||
padding,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
@@ -168,7 +195,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
|
||||
updateOverlayForRegion(activeRegion);
|
||||
}
|
||||
}, [updateOverlayForRegion, cropRegion]);
|
||||
}, [updateOverlayForRegion, cropRegion, borderRadius, padding]);
|
||||
|
||||
useEffect(() => {
|
||||
layoutVideoContentRef.current = layoutVideoContent;
|
||||
@@ -184,15 +211,13 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
app: appRef.current,
|
||||
videoSprite: videoSpriteRef.current,
|
||||
videoContainer: videoContainerRef.current,
|
||||
containerRef,
|
||||
play: async () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) {
|
||||
allowPlaybackRef.current = false;
|
||||
return;
|
||||
}
|
||||
allowPlaybackRef.current = true;
|
||||
const vid = videoRef.current;
|
||||
if (!vid) return;
|
||||
try {
|
||||
await video.play();
|
||||
allowPlaybackRef.current = true;
|
||||
await vid.play();
|
||||
} catch (error) {
|
||||
allowPlaybackRef.current = false;
|
||||
throw error;
|
||||
@@ -294,6 +319,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
trimRegionsRef.current = trimRegions;
|
||||
}, [trimRegions]);
|
||||
|
||||
useEffect(() => {
|
||||
motionBlurEnabledRef.current = motionBlurEnabled;
|
||||
}, [motionBlurEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pixiReady || !videoReady) return;
|
||||
|
||||
@@ -351,6 +380,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
focusY: DEFAULT_FOCUS.cy,
|
||||
motionIntensity: 0,
|
||||
isPlaying: false,
|
||||
motionBlurEnabled: motionBlurEnabledRef.current,
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
@@ -463,6 +493,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
video.pause();
|
||||
video.currentTime = 0;
|
||||
allowPlaybackRef.current = false;
|
||||
lockedVideoDimensionsRef.current = null;
|
||||
setVideoReady(false);
|
||||
if (videoReadyRafRef.current) {
|
||||
cancelAnimationFrame(videoReadyRafRef.current);
|
||||
videoReadyRafRef.current = null;
|
||||
}
|
||||
}, [videoPath]);
|
||||
|
||||
|
||||
@@ -585,6 +621,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
focusY: state.focusY,
|
||||
motionIntensity,
|
||||
isPlaying: isPlayingRef.current,
|
||||
motionBlurEnabled: motionBlurEnabledRef.current,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -673,13 +710,24 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
video.pause();
|
||||
allowPlaybackRef.current = false;
|
||||
currentTimeRef.current = 0;
|
||||
|
||||
// hacky fix: To ensure video is fully ready for PixiJS
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
|
||||
if (videoReadyRafRef.current) {
|
||||
cancelAnimationFrame(videoReadyRafRef.current);
|
||||
videoReadyRafRef.current = null;
|
||||
}
|
||||
|
||||
const waitForRenderableFrame = () => {
|
||||
const hasDimensions = video.videoWidth > 0 && video.videoHeight > 0;
|
||||
const hasData = video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA;
|
||||
if (hasDimensions && hasData) {
|
||||
videoReadyRafRef.current = null;
|
||||
setVideoReady(true);
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
videoReadyRafRef.current = requestAnimationFrame(waitForRenderableFrame);
|
||||
};
|
||||
|
||||
videoReadyRafRef.current = requestAnimationFrame(waitForRenderableFrame);
|
||||
};
|
||||
|
||||
const [resolvedWallpaper, setResolvedWallpaper] = useState<string | null>(null);
|
||||
@@ -726,13 +774,22 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
return () => { mounted = false }
|
||||
}, [wallpaper])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (videoReadyRafRef.current) {
|
||||
cancelAnimationFrame(videoReadyRafRef.current);
|
||||
videoReadyRafRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [])
|
||||
|
||||
const isImageUrl = Boolean(resolvedWallpaper && (resolvedWallpaper.startsWith('file://') || resolvedWallpaper.startsWith('http') || resolvedWallpaper.startsWith('/') || resolvedWallpaper.startsWith('data:')))
|
||||
const backgroundStyle = isImageUrl
|
||||
? { backgroundImage: `url(${resolvedWallpaper || ''})` }
|
||||
: { background: resolvedWallpaper || '' };
|
||||
|
||||
return (
|
||||
<div className="relative aspect-video rounded-sm overflow-hidden" style={{ width: '100%' }}>
|
||||
<div className="relative rounded-sm overflow-hidden" style={{ width: '100%', aspectRatio: formatAspectRatioForCSS(aspectRatio) }}>
|
||||
{/* Background layer - always render as DOM element with blur */}
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center"
|
||||
@@ -766,6 +823,50 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
className="absolute rounded-md border border-[#34B27B]/80 bg-[#34B27B]/20 shadow-[0_0_0_1px_rgba(52,178,123,0.35)]"
|
||||
style={{ display: 'none', pointerEvents: 'none' }}
|
||||
/>
|
||||
{(() => {
|
||||
const filtered = (annotationRegions || []).filter((annotation) => {
|
||||
if (typeof annotation.startMs !== 'number' || typeof annotation.endMs !== 'number') return false;
|
||||
|
||||
if (annotation.id === selectedAnnotationId) return true;
|
||||
|
||||
const timeMs = Math.round(currentTime * 1000);
|
||||
return timeMs >= annotation.startMs && timeMs <= annotation.endMs;
|
||||
});
|
||||
|
||||
// Sort by z-index (lowest to highest) so higher z-index renders on top
|
||||
const sorted = [...filtered].sort((a, b) => a.zIndex - b.zIndex);
|
||||
|
||||
// Handle click-through cycling: when clicking same annotation, cycle to next
|
||||
const handleAnnotationClick = (clickedId: string) => {
|
||||
if (!onSelectAnnotation) return;
|
||||
|
||||
// If clicking on already selected annotation and there are multiple overlapping
|
||||
if (clickedId === selectedAnnotationId && sorted.length > 1) {
|
||||
// Find current index and cycle to next
|
||||
const currentIndex = sorted.findIndex(a => a.id === clickedId);
|
||||
const nextIndex = (currentIndex + 1) % sorted.length;
|
||||
onSelectAnnotation(sorted[nextIndex].id);
|
||||
} else {
|
||||
// First click or clicking different annotation
|
||||
onSelectAnnotation(clickedId);
|
||||
}
|
||||
};
|
||||
|
||||
return sorted.map((annotation) => (
|
||||
<AnnotationOverlay
|
||||
key={annotation.id}
|
||||
annotation={annotation}
|
||||
isSelected={annotation.id === selectedAnnotationId}
|
||||
containerWidth={overlayRef.current?.clientWidth || 800}
|
||||
containerHeight={overlayRef.current?.clientHeight || 600}
|
||||
onPositionChange={(id, position) => onAnnotationPositionChange?.(id, position)}
|
||||
onSizeChange={(id, size) => onAnnotationSizeChange?.(id, size)}
|
||||
onClick={handleAnnotationClick}
|
||||
zIndex={annotation.zIndex}
|
||||
isSelectedBoost={annotation.id === selectedAnnotationId}
|
||||
/>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
<video
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useItem } from "dnd-timeline";
|
||||
import type { Span } from "dnd-timeline";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ZoomIn, Scissors } from "lucide-react";
|
||||
import { ZoomIn, Scissors, MessageSquare } from "lucide-react";
|
||||
import glassStyles from "./ItemGlass.module.css";
|
||||
|
||||
interface ItemProps {
|
||||
@@ -12,7 +12,7 @@ interface ItemProps {
|
||||
isSelected?: boolean;
|
||||
onSelect?: () => void;
|
||||
zoomDepth?: number;
|
||||
variant?: 'zoom' | 'trim';
|
||||
variant?: 'zoom' | 'trim' | 'annotation';
|
||||
}
|
||||
|
||||
// Map zoom depth to multiplier labels
|
||||
@@ -22,6 +22,7 @@ const ZOOM_LABELS: Record<number, string> = {
|
||||
3: "1.8×",
|
||||
4: "2.2×",
|
||||
5: "3.5×",
|
||||
6: "5×",
|
||||
};
|
||||
|
||||
export default function Item({
|
||||
@@ -31,7 +32,8 @@ export default function Item({
|
||||
isSelected = false,
|
||||
onSelect,
|
||||
zoomDepth = 1,
|
||||
variant = 'zoom'
|
||||
variant = 'zoom',
|
||||
children
|
||||
}: ItemProps) {
|
||||
const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({
|
||||
id,
|
||||
@@ -40,8 +42,19 @@ export default function Item({
|
||||
});
|
||||
|
||||
const isZoom = variant === 'zoom';
|
||||
const glassClass = isZoom ? glassStyles.glassGreen : glassStyles.glassRed;
|
||||
const endCapColor = isZoom ? '#21916A' : '#ef4444';
|
||||
const isTrim = variant === 'trim';
|
||||
|
||||
const glassClass = isZoom
|
||||
? glassStyles.glassGreen
|
||||
: isTrim
|
||||
? glassStyles.glassRed
|
||||
: glassStyles.glassYellow;
|
||||
|
||||
const endCapColor = isZoom
|
||||
? '#21916A'
|
||||
: isTrim
|
||||
? '#ef4444'
|
||||
: '#B4A046';
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -84,13 +97,20 @@ export default function Item({
|
||||
{ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
) : isTrim ? (
|
||||
<>
|
||||
<Scissors className="w-3.5 h-3.5" />
|
||||
<span className="text-[11px] font-semibold tracking-tight">
|
||||
Trim
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MessageSquare className="w-3.5 h-3.5" />
|
||||
<span className="text-[11px] font-semibold tracking-tight">
|
||||
{children}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,6 +50,32 @@
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.glassYellow {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
-corner-smoothing: antialiased;
|
||||
background: rgba(180, 160, 70, 0.15);
|
||||
border: 1px solid rgba(180, 160, 70, 0.3);
|
||||
box-shadow: 0 2px 12px 0 rgba(180, 160, 70, 0.1) inset;
|
||||
margin: 2px 0;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.glassYellow:hover {
|
||||
background: rgba(180, 160, 70, 0.25);
|
||||
border-color: rgba(180, 160, 70, 0.5);
|
||||
box-shadow: 0 4px 20px 0 rgba(180, 160, 70, 0.2) inset;
|
||||
}
|
||||
|
||||
.glassYellow.selected {
|
||||
background: rgba(180, 160, 70, 0.35);
|
||||
border-color: #B4A046;
|
||||
box-shadow: 0 0 0 1px #B4A046, 0 4px 20px 0 rgba(180, 160, 70, 0.3) inset;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.zoomEndCap {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -62,7 +88,11 @@
|
||||
}
|
||||
|
||||
.glassGreen:hover .zoomEndCap,
|
||||
.glassGreen.selected .zoomEndCap {
|
||||
.glassGreen.selected .zoomEndCap,
|
||||
.glassRed:hover .zoomEndCap,
|
||||
.glassRed.selected .zoomEndCap,
|
||||
.glassYellow:hover .zoomEndCap,
|
||||
.glassYellow.selected .zoomEndCap {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -72,9 +102,10 @@
|
||||
border-top-left-radius: 7px;
|
||||
border-bottom-left-radius: 7px;
|
||||
}
|
||||
|
||||
.zoomEndCap.right {
|
||||
right: 0;
|
||||
cursor: ew-resize;
|
||||
border-top-right-radius: 7px;
|
||||
border-bottom-right-radius: 7px;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTimelineContext } from "dnd-timeline";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, Scissors, ZoomIn } from "lucide-react";
|
||||
import { Plus, Scissors, ZoomIn, MessageSquare, ChevronDown, Check } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import TimelineWrapper from "./TimelineWrapper";
|
||||
@@ -9,11 +9,21 @@ import Row from "./Row";
|
||||
import Item from "./Item";
|
||||
import KeyframeMarkers from "./KeyframeMarkers";
|
||||
import type { Range, Span } from "dnd-timeline";
|
||||
import type { ZoomRegion, TrimRegion } from "../types";
|
||||
import type { ZoomRegion, TrimRegion, AnnotationRegion } from "../types";
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { type AspectRatio, getAspectRatioLabel } from "@/utils/aspectRatioUtils";
|
||||
import { formatShortcut } from "@/utils/platformUtils";
|
||||
import { TutorialHelp } from "../TutorialHelp";
|
||||
|
||||
const ZOOM_ROW_ID = "row-zoom";
|
||||
const TRIM_ROW_ID = "row-trim";
|
||||
const ANNOTATION_ROW_ID = "row-annotation";
|
||||
const FALLBACK_RANGE_MS = 1000;
|
||||
const TARGET_MARKER_COUNT = 12;
|
||||
|
||||
@@ -27,13 +37,20 @@ interface TimelineEditorProps {
|
||||
onZoomDelete: (id: string) => void;
|
||||
selectedZoomId: string | null;
|
||||
onSelectZoom: (id: string | null) => void;
|
||||
// Trim props
|
||||
trimRegions?: TrimRegion[];
|
||||
onTrimAdded?: (span: Span) => void;
|
||||
onTrimSpanChange?: (id: string, span: Span) => void;
|
||||
onTrimDelete?: (id: string) => void;
|
||||
selectedTrimId?: string | null;
|
||||
onSelectTrim?: (id: string | null) => void;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
onAnnotationAdded?: (span: Span) => void;
|
||||
onAnnotationSpanChange?: (id: string, span: Span) => void;
|
||||
onAnnotationDelete?: (id: string) => void;
|
||||
selectedAnnotationId?: string | null;
|
||||
onSelectAnnotation?: (id: string | null) => void;
|
||||
aspectRatio: AspectRatio;
|
||||
onAspectRatioChange: (aspectRatio: AspectRatio) => void;
|
||||
}
|
||||
|
||||
interface TimelineScaleConfig {
|
||||
@@ -50,7 +67,7 @@ interface TimelineRenderItem {
|
||||
span: Span;
|
||||
label: string;
|
||||
zoomDepth?: number;
|
||||
variant: 'zoom' | 'trim';
|
||||
variant: 'zoom' | 'trim' | 'annotation';
|
||||
}
|
||||
|
||||
const SCALE_CANDIDATES = [
|
||||
@@ -140,13 +157,50 @@ function formatTimeLabel(milliseconds: number, intervalMs: number) {
|
||||
|
||||
function PlaybackCursor({
|
||||
currentTimeMs,
|
||||
videoDurationMs
|
||||
videoDurationMs,
|
||||
onSeek,
|
||||
timelineRef,
|
||||
}: {
|
||||
currentTimeMs: number;
|
||||
videoDurationMs: number;
|
||||
onSeek?: (time: number) => void;
|
||||
timelineRef: React.RefObject<HTMLDivElement>;
|
||||
}) {
|
||||
const { sidebarWidth, direction, range, valueToPixels } = useTimelineContext();
|
||||
const { sidebarWidth, direction, range, valueToPixels, pixelsToValue } = useTimelineContext();
|
||||
const sideProperty = direction === "rtl" ? "right" : "left";
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!timelineRef.current || !onSeek) return;
|
||||
|
||||
const rect = timelineRef.current.getBoundingClientRect();
|
||||
const clickX = e.clientX - rect.left - sidebarWidth;
|
||||
|
||||
// Allow dragging outside to 0 or max, but clamp the value
|
||||
const relativeMs = pixelsToValue(clickX);
|
||||
const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
|
||||
|
||||
onSeek(absoluteMs / 1000);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
document.body.style.cursor = '';
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = 'ew-resize';
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
};
|
||||
}, [isDragging, onSeek, timelineRef, sidebarWidth, range.start, videoDurationMs, pixelsToValue]);
|
||||
|
||||
if (videoDurationMs <= 0 || currentTimeMs < 0) {
|
||||
return null;
|
||||
@@ -162,22 +216,27 @@ function PlaybackCursor({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 pointer-events-none z-50"
|
||||
className="absolute top-0 bottom-0 z-50 group/cursor"
|
||||
style={{
|
||||
[sideProperty === "right" ? "marginRight" : "marginLeft"]: `${sidebarWidth - 1}px`,
|
||||
pointerEvents: 'none', // Allow clicks to pass through to timeline, but we'll enable pointer events on the handle
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-[2px] bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.5)]"
|
||||
className="absolute top-0 bottom-0 w-[2px] bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.5)] cursor-ew-resize pointer-events-auto hover:shadow-[0_0_15px_rgba(52,178,123,0.7)] transition-shadow"
|
||||
style={{
|
||||
[sideProperty]: `${offset}px`,
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation(); // Prevent timeline click
|
||||
setIsDragging(true);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute -top-1 left-1/2 -translate-x-1/2"
|
||||
style={{ width: '12px', height: '12px' }}
|
||||
className="absolute -top-1 left-1/2 -translate-x-1/2 hover:scale-125 transition-transform"
|
||||
style={{ width: '16px', height: '16px' }}
|
||||
>
|
||||
<div className="w-full h-full bg-[#34B27B] rotate-45 rounded-sm shadow-lg border border-white/20" />
|
||||
<div className="w-3 h-3 mx-auto mt-[2px] bg-[#34B27B] rotate-45 rounded-sm shadow-lg border border-white/20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -309,8 +368,10 @@ function Timeline({
|
||||
onSeek,
|
||||
onSelectZoom,
|
||||
onSelectTrim,
|
||||
onSelectAnnotation,
|
||||
selectedZoomId,
|
||||
selectedTrimId,
|
||||
selectedAnnotationId,
|
||||
}: {
|
||||
items: TimelineRenderItem[];
|
||||
videoDurationMs: number;
|
||||
@@ -319,10 +380,18 @@ function Timeline({
|
||||
onSeek?: (time: number) => void;
|
||||
onSelectZoom?: (id: string | null) => void;
|
||||
onSelectTrim?: (id: string | null) => void;
|
||||
onSelectAnnotation?: (id: string | null) => void;
|
||||
selectedZoomId: string | null;
|
||||
selectedTrimId?: string | null;
|
||||
selectedAnnotationId?: string | null;
|
||||
}) {
|
||||
const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext();
|
||||
const localTimelineRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const setRefs = useCallback((node: HTMLDivElement | null) => {
|
||||
setTimelineRef(node);
|
||||
localTimelineRef.current = node;
|
||||
}, [setTimelineRef]);
|
||||
|
||||
const handleTimelineClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!onSeek || videoDurationMs <= 0) return;
|
||||
@@ -331,6 +400,7 @@ function Timeline({
|
||||
// This is handled by event propagation - items stop propagation
|
||||
onSelectZoom?.(null);
|
||||
onSelectTrim?.(null);
|
||||
onSelectAnnotation?.(null);
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const clickX = e.clientX - rect.left - sidebarWidth;
|
||||
@@ -342,21 +412,27 @@ function Timeline({
|
||||
const timeInSeconds = absoluteMs / 1000;
|
||||
|
||||
onSeek(timeInSeconds);
|
||||
}, [onSeek, onSelectZoom, onSelectTrim, videoDurationMs, sidebarWidth, range.start, pixelsToValue]);
|
||||
}, [onSeek, onSelectZoom, onSelectTrim, onSelectAnnotation, videoDurationMs, sidebarWidth, range.start, pixelsToValue]);
|
||||
|
||||
const zoomItems = items.filter(item => item.rowId === ZOOM_ROW_ID);
|
||||
const trimItems = items.filter(item => item.rowId === TRIM_ROW_ID);
|
||||
const annotationItems = items.filter(item => item.rowId === ANNOTATION_ROW_ID);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setTimelineRef}
|
||||
ref={setRefs}
|
||||
style={style}
|
||||
className="select-none bg-[#09090b] min-h-[140px] relative cursor-pointer group"
|
||||
onClick={handleTimelineClick}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff03_1px,transparent_1px)] bg-[length:20px_100%] pointer-events-none" />
|
||||
<TimelineAxis intervalMs={intervalMs} videoDurationMs={videoDurationMs} currentTimeMs={currentTimeMs} />
|
||||
<PlaybackCursor currentTimeMs={currentTimeMs} videoDurationMs={videoDurationMs} />
|
||||
<PlaybackCursor
|
||||
currentTimeMs={currentTimeMs}
|
||||
videoDurationMs={videoDurationMs}
|
||||
onSeek={onSeek}
|
||||
timelineRef={localTimelineRef}
|
||||
/>
|
||||
|
||||
<Row id={ZOOM_ROW_ID}>
|
||||
{zoomItems.map((item) => (
|
||||
@@ -390,6 +466,22 @@ function Timeline({
|
||||
</Item>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Row id={ANNOTATION_ROW_ID}>
|
||||
{annotationItems.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
rowId={item.rowId}
|
||||
span={item.span}
|
||||
isSelected={item.id === selectedAnnotationId}
|
||||
onSelect={() => onSelectAnnotation?.(item.id)}
|
||||
variant="annotation"
|
||||
>
|
||||
{item.label}
|
||||
</Item>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -410,6 +502,14 @@ export default function TimelineEditor({
|
||||
onTrimDelete,
|
||||
selectedTrimId,
|
||||
onSelectTrim,
|
||||
annotationRegions = [],
|
||||
onAnnotationAdded,
|
||||
onAnnotationSpanChange,
|
||||
onAnnotationDelete,
|
||||
selectedAnnotationId,
|
||||
onSelectAnnotation,
|
||||
aspectRatio,
|
||||
onAspectRatioChange,
|
||||
}: TimelineEditorProps) {
|
||||
const totalMs = useMemo(() => Math.max(0, Math.round(videoDuration * 1000)), [videoDuration]);
|
||||
const currentTimeMs = useMemo(() => Math.round(currentTime * 1000), [currentTime]);
|
||||
@@ -422,6 +522,18 @@ export default function TimelineEditor({
|
||||
const [range, setRange] = useState<Range>(() => createInitialRange(totalMs));
|
||||
const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]);
|
||||
const [selectedKeyframeId, setSelectedKeyframeId] = useState<string | null>(null);
|
||||
const [shortcuts, setShortcuts] = useState({
|
||||
pan: 'Shift + Ctrl + Scroll',
|
||||
zoom: 'Ctrl + Scroll'
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
formatShortcut(['shift', 'mod', 'Scroll']).then(pan => {
|
||||
formatShortcut(['mod', 'Scroll']).then(zoom => {
|
||||
setShortcuts({ pan, zoom });
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Add keyframe at current playhead position
|
||||
const addKeyframe = useCallback(() => {
|
||||
@@ -452,6 +564,12 @@ export default function TimelineEditor({
|
||||
onSelectTrim(null);
|
||||
}, [selectedTrimId, onTrimDelete, onSelectTrim]);
|
||||
|
||||
const deleteSelectedAnnotation = useCallback(() => {
|
||||
if (!selectedAnnotationId || !onAnnotationDelete || !onSelectAnnotation) return;
|
||||
onAnnotationDelete(selectedAnnotationId);
|
||||
onSelectAnnotation(null);
|
||||
}, [selectedAnnotationId, onAnnotationDelete, onSelectAnnotation]);
|
||||
|
||||
useEffect(() => {
|
||||
setRange(createInitialRange(totalMs));
|
||||
}, [totalMs]);
|
||||
@@ -484,12 +602,17 @@ export default function TimelineEditor({
|
||||
onTrimSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd });
|
||||
}
|
||||
});
|
||||
}, [zoomRegions, trimRegions, totalMs, safeMinDurationMs, onZoomSpanChange, onTrimSpanChange]);
|
||||
}, [zoomRegions, trimRegions, annotationRegions, totalMs, safeMinDurationMs, onZoomSpanChange, onTrimSpanChange, onAnnotationSpanChange]);
|
||||
|
||||
const hasOverlap = useCallback((newSpan: Span, excludeId?: string): boolean => {
|
||||
// Determine which row the item belongs to
|
||||
const isZoomItem = zoomRegions.some(r => r.id === excludeId);
|
||||
const isTrimItem = trimRegions.some(r => r.id === excludeId);
|
||||
const isAnnotationItem = annotationRegions.some(r => r.id === excludeId);
|
||||
|
||||
if (isAnnotationItem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Helper to check overlap against a specific set of regions
|
||||
const checkOverlap = (regions: (ZoomRegion | TrimRegion)[]) => {
|
||||
@@ -511,8 +634,9 @@ export default function TimelineEditor({
|
||||
if (isTrimItem) {
|
||||
return checkOverlap(trimRegions);
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [zoomRegions, trimRegions]);
|
||||
}, [zoomRegions, trimRegions, annotationRegions]);
|
||||
|
||||
const handleAddZoom = useCallback(() => {
|
||||
if (!videoDuration || videoDuration === 0 || totalMs === 0) {
|
||||
@@ -574,10 +698,25 @@ export default function TimelineEditor({
|
||||
onTrimAdded({ start: startPos, end: startPos + actualDuration });
|
||||
}, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded]);
|
||||
|
||||
// Listen for F key to add keyframe, Z key to add zoom, T key to add trim, Ctrl+D to remove selected keyframe or zoom item
|
||||
const handleAddAnnotation = useCallback(() => {
|
||||
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultDuration = Math.min(1000, totalMs);
|
||||
if (defaultDuration <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Multiple annotations can exist at the same timestamp
|
||||
const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
|
||||
const endPos = Math.min(startPos + defaultDuration, totalMs);
|
||||
|
||||
onAnnotationAdded({ start: startPos, end: endPos });
|
||||
}, [videoDuration, totalMs, currentTimeMs, onAnnotationAdded]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Ignore if typing in an input
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
return;
|
||||
}
|
||||
@@ -591,6 +730,32 @@ export default function TimelineEditor({
|
||||
if (e.key === 't' || e.key === 'T') {
|
||||
handleAddTrim();
|
||||
}
|
||||
if (e.key === 'a' || e.key === 'A') {
|
||||
handleAddAnnotation();
|
||||
}
|
||||
|
||||
// Tab: Cycle through overlapping annotations at current time
|
||||
if (e.key === 'Tab' && annotationRegions.length > 0) {
|
||||
const currentTimeMs = Math.round(currentTime * 1000);
|
||||
const overlapping = annotationRegions
|
||||
.filter(a => currentTimeMs >= a.startMs && currentTimeMs <= a.endMs)
|
||||
.sort((a, b) => a.zIndex - b.zIndex); // Sort by z-index
|
||||
|
||||
if (overlapping.length > 0) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!selectedAnnotationId || !overlapping.some(a => a.id === selectedAnnotationId)) {
|
||||
onSelectAnnotation?.(overlapping[0].id);
|
||||
} else {
|
||||
// Cycle to next annotation
|
||||
const currentIndex = overlapping.findIndex(a => a.id === selectedAnnotationId);
|
||||
const nextIndex = e.shiftKey
|
||||
? (currentIndex - 1 + overlapping.length) % overlapping.length // Shift+Tab = backward
|
||||
: (currentIndex + 1) % overlapping.length; // Tab = forward
|
||||
onSelectAnnotation?.(overlapping[nextIndex].id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ((e.key === 'd' || e.key === 'D') && (e.ctrlKey || e.metaKey)) {
|
||||
if (selectedKeyframeId) {
|
||||
deleteSelectedKeyframe();
|
||||
@@ -598,12 +763,14 @@ export default function TimelineEditor({
|
||||
deleteSelectedZoom();
|
||||
} else if (selectedTrimId) {
|
||||
deleteSelectedTrim();
|
||||
} else if (selectedAnnotationId) {
|
||||
deleteSelectedAnnotation();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [addKeyframe, handleAddZoom, handleAddTrim, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedTrim, selectedKeyframeId, selectedZoomId, selectedTrimId]);
|
||||
}, [addKeyframe, handleAddZoom, handleAddTrim, handleAddAnnotation, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedTrim, deleteSelectedAnnotation, selectedKeyframeId, selectedZoomId, selectedTrimId, selectedAnnotationId, annotationRegions, currentTime, onSelectAnnotation]);
|
||||
|
||||
const clampedRange = useMemo<Range>(() => {
|
||||
if (totalMs === 0) {
|
||||
@@ -634,8 +801,30 @@ export default function TimelineEditor({
|
||||
variant: 'trim',
|
||||
}));
|
||||
|
||||
return [...zooms, ...trims];
|
||||
}, [zoomRegions, trimRegions]);
|
||||
const annotations: TimelineRenderItem[] = annotationRegions.map((region) => {
|
||||
let label: string;
|
||||
|
||||
if (region.type === 'text') {
|
||||
// Show text preview
|
||||
const preview = region.content.trim() || 'Empty text';
|
||||
label = preview.length > 20 ? `${preview.substring(0, 20)}...` : preview;
|
||||
} else if (region.type === 'image') {
|
||||
label = 'Image';
|
||||
} else {
|
||||
label = 'Annotation';
|
||||
}
|
||||
|
||||
return {
|
||||
id: region.id,
|
||||
rowId: ANNOTATION_ROW_ID,
|
||||
span: { start: region.startMs, end: region.endMs },
|
||||
label,
|
||||
variant: 'annotation',
|
||||
};
|
||||
});
|
||||
|
||||
return [...zooms, ...trims, ...annotations];
|
||||
}, [zoomRegions, trimRegions, annotationRegions]);
|
||||
|
||||
const handleItemSpanChange = useCallback((id: string, span: Span) => {
|
||||
// Check if it's a zoom or trim item
|
||||
@@ -643,8 +832,10 @@ export default function TimelineEditor({
|
||||
onZoomSpanChange(id, span);
|
||||
} else if (trimRegions.some(r => r.id === id)) {
|
||||
onTrimSpanChange?.(id, span);
|
||||
} else if (annotationRegions.some(r => r.id === id)) {
|
||||
onAnnotationSpanChange?.(id, span);
|
||||
}
|
||||
}, [zoomRegions, trimRegions, onZoomSpanChange, onTrimSpanChange]);
|
||||
}, [zoomRegions, trimRegions, annotationRegions, onZoomSpanChange, onTrimSpanChange, onAnnotationSpanChange]);
|
||||
|
||||
if (!videoDuration || videoDuration === 0) {
|
||||
return (
|
||||
@@ -682,15 +873,52 @@ export default function TimelineEditor({
|
||||
>
|
||||
<Scissors className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddAnnotation}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-[#B4A046] hover:bg-[#B4A046]/10 transition-all"
|
||||
title="Add Annotation (A)"
|
||||
>
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-slate-400 hover:text-slate-200 hover:bg-white/10 transition-all gap-1"
|
||||
>
|
||||
<span className="font-medium">{getAspectRatioLabel(aspectRatio)}</span>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="bg-[#1a1a1a] border-white/10">
|
||||
{(['16:9', '9:16', '1:1', '4:3', '4:5'] as AspectRatio[]).map((ratio) => (
|
||||
<DropdownMenuItem
|
||||
key={ratio}
|
||||
onClick={() => onAspectRatioChange(ratio)}
|
||||
className="text-slate-300 hover:text-white hover:bg-white/10 cursor-pointer flex items-center justify-between gap-3"
|
||||
>
|
||||
<span>{getAspectRatioLabel(ratio)}</span>
|
||||
{aspectRatio === ratio && <Check className="w-3 h-3 text-[#34B27B]" />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="w-[1px] h-4 bg-white/10" />
|
||||
<TutorialHelp />
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-4 text-[10px] text-slate-500 font-medium">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">⇧ + ⌘ + Scroll</kbd>
|
||||
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">{shortcuts.pan}</kbd>
|
||||
<span>Pan</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">⌘ + Scroll</kbd>
|
||||
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">{shortcuts.zoom}</kbd>
|
||||
<span>Zoom</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -721,8 +949,10 @@ export default function TimelineEditor({
|
||||
onSeek={onSeek}
|
||||
onSelectZoom={onSelectZoom}
|
||||
onSelectTrim={onSelectTrim}
|
||||
onSelectAnnotation={onSelectAnnotation}
|
||||
selectedZoomId={selectedZoomId}
|
||||
selectedTrimId={selectedTrimId}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
/>
|
||||
</TimelineWrapper>
|
||||
</div>
|
||||
|
||||
@@ -19,11 +19,86 @@ export interface TrimRegion {
|
||||
endMs: number;
|
||||
}
|
||||
|
||||
export type AnnotationType = 'text' | 'image' | 'figure';
|
||||
|
||||
export type ArrowDirection = 'up' | 'down' | 'left' | 'right' | 'up-right' | 'up-left' | 'down-right' | 'down-left';
|
||||
|
||||
export interface FigureData {
|
||||
arrowDirection: ArrowDirection;
|
||||
color: string;
|
||||
strokeWidth: number;
|
||||
}
|
||||
|
||||
export interface AnnotationPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface AnnotationSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface AnnotationTextStyle {
|
||||
color: string;
|
||||
backgroundColor: string;
|
||||
fontSize: number; // pixels
|
||||
fontFamily: string;
|
||||
fontWeight: 'normal' | 'bold';
|
||||
fontStyle: 'normal' | 'italic';
|
||||
textDecoration: 'none' | 'underline';
|
||||
textAlign: 'left' | 'center' | 'right';
|
||||
}
|
||||
|
||||
export interface AnnotationRegion {
|
||||
id: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
type: AnnotationType;
|
||||
content: string; // Legacy - still used for current type
|
||||
textContent?: string; // Separate storage for text
|
||||
imageContent?: string; // Separate storage for image data URL
|
||||
position: AnnotationPosition;
|
||||
size: AnnotationSize;
|
||||
style: AnnotationTextStyle;
|
||||
zIndex: number;
|
||||
figureData?: FigureData;
|
||||
}
|
||||
|
||||
export const DEFAULT_ANNOTATION_POSITION: AnnotationPosition = {
|
||||
x: 50,
|
||||
y: 50,
|
||||
};
|
||||
|
||||
export const DEFAULT_ANNOTATION_SIZE: AnnotationSize = {
|
||||
width: 30,
|
||||
height: 20,
|
||||
};
|
||||
|
||||
export const DEFAULT_ANNOTATION_STYLE: AnnotationTextStyle = {
|
||||
color: '#ffffff',
|
||||
backgroundColor: 'transparent',
|
||||
fontSize: 32,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: 'bold',
|
||||
fontStyle: 'normal',
|
||||
textDecoration: 'none',
|
||||
textAlign: 'center',
|
||||
};
|
||||
|
||||
export const DEFAULT_FIGURE_DATA: FigureData = {
|
||||
arrowDirection: 'right',
|
||||
color: '#34B27B',
|
||||
strokeWidth: 4,
|
||||
};
|
||||
|
||||
|
||||
|
||||
export interface CropRegion {
|
||||
x: number; // 0-1 normalized
|
||||
y: number; // 0-1 normalized
|
||||
width: number; // 0-1 normalized
|
||||
height: number; // 0-1 normalized
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_CROP_REGION: CropRegion = {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Application, Sprite, Graphics } from 'pixi.js';
|
||||
import { VIEWPORT_SCALE } from "./constants";
|
||||
import type { CropRegion } from '../types';
|
||||
|
||||
interface LayoutParams {
|
||||
@@ -10,6 +9,8 @@ interface LayoutParams {
|
||||
videoElement: HTMLVideoElement;
|
||||
cropRegion?: CropRegion;
|
||||
lockedVideoDimensions?: { width: number; height: number } | null;
|
||||
borderRadius?: number;
|
||||
padding?: number;
|
||||
}
|
||||
|
||||
interface LayoutResult {
|
||||
@@ -22,8 +23,7 @@ interface LayoutResult {
|
||||
}
|
||||
|
||||
export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
||||
const { container, app, videoSprite, maskGraphics, videoElement, cropRegion, lockedVideoDimensions } = params;
|
||||
|
||||
const { container, app, videoSprite, maskGraphics, videoElement, cropRegion, lockedVideoDimensions, borderRadius = 0, padding = 0 } = params;
|
||||
|
||||
const videoWidth = lockedVideoDimensions?.width || videoElement.videoWidth;
|
||||
const videoHeight = lockedVideoDimensions?.height || videoElement.videoHeight;
|
||||
@@ -56,8 +56,10 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
||||
const cropEndY = cropStartY + croppedVideoHeight;
|
||||
|
||||
// Calculate scale to fit the cropped area in the viewport
|
||||
const maxDisplayWidth = width * VIEWPORT_SCALE;
|
||||
const maxDisplayHeight = height * VIEWPORT_SCALE;
|
||||
// Padding is a percentage (0-100), where 50 matches the original VIEWPORT_SCALE of 0.8
|
||||
const paddingScale = 1.0 - (padding / 100) * 0.4;
|
||||
const maxDisplayWidth = width * paddingScale;
|
||||
const maxDisplayHeight = height * paddingScale;
|
||||
|
||||
const scale = Math.min(
|
||||
maxDisplayWidth / croppedVideoWidth,
|
||||
@@ -92,9 +94,10 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
||||
// Create a mask that only shows the cropped region (centered in container)
|
||||
const maskX = centerOffsetX;
|
||||
const maskY = centerOffsetY;
|
||||
const radius = Math.min(croppedDisplayWidth, croppedDisplayHeight) * 0.02;
|
||||
|
||||
// Apply border radius
|
||||
maskGraphics.clear();
|
||||
maskGraphics.roundRect(maskX, maskY, croppedDisplayWidth, croppedDisplayHeight, radius);
|
||||
maskGraphics.roundRect(maskX, maskY, croppedDisplayWidth, croppedDisplayHeight, borderRadius);
|
||||
maskGraphics.fill({ color: 0xffffff });
|
||||
|
||||
return {
|
||||
|
||||
@@ -10,6 +10,7 @@ interface TransformParams {
|
||||
focusY: number;
|
||||
motionIntensity: number;
|
||||
isPlaying: boolean;
|
||||
motionBlurEnabled?: boolean;
|
||||
}
|
||||
|
||||
export function applyZoomTransform({
|
||||
@@ -22,6 +23,7 @@ export function applyZoomTransform({
|
||||
focusY,
|
||||
motionIntensity,
|
||||
isPlaying,
|
||||
motionBlurEnabled = false,
|
||||
}: TransformParams) {
|
||||
if (
|
||||
stageSize.width <= 0 ||
|
||||
@@ -52,7 +54,7 @@ export function applyZoomTransform({
|
||||
cameraContainer.position.set(cameraX, cameraY);
|
||||
|
||||
if (blurFilter) {
|
||||
const shouldBlur = isPlaying && motionIntensity > 0.0005;
|
||||
const shouldBlur = motionBlurEnabled && isPlaying && motionIntensity > 0.0005;
|
||||
const motionBlur = shouldBlur ? Math.min(6, motionIntensity * 120) : 0;
|
||||
blurFilter.blur = motionBlur;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,38 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
const chunks = useRef<Blob[]>([]);
|
||||
const startTime = useRef<number>(0);
|
||||
|
||||
// Target visually lossless 4K @ 60fps; fall back gracefully when hardware cannot keep up
|
||||
const TARGET_FRAME_RATE = 60;
|
||||
const TARGET_WIDTH = 3840;
|
||||
const TARGET_HEIGHT = 2160;
|
||||
const FOUR_K_PIXELS = TARGET_WIDTH * TARGET_HEIGHT;
|
||||
const selectMimeType = () => {
|
||||
const preferred = [
|
||||
"video/webm;codecs=av1",
|
||||
"video/webm;codecs=h264",
|
||||
"video/webm;codecs=vp9",
|
||||
"video/webm;codecs=vp8",
|
||||
"video/webm"
|
||||
];
|
||||
|
||||
return preferred.find(type => MediaRecorder.isTypeSupported(type)) ?? "video/webm";
|
||||
};
|
||||
|
||||
const computeBitrate = (width: number, height: number) => {
|
||||
const pixels = width * height;
|
||||
const highFrameRateBoost = TARGET_FRAME_RATE >= 60 ? 1.7 : 1;
|
||||
|
||||
if (pixels >= FOUR_K_PIXELS) {
|
||||
return Math.round(45_000_000 * highFrameRateBoost);
|
||||
}
|
||||
|
||||
if (pixels >= 2560 * 1440) {
|
||||
return Math.round(28_000_000 * highFrameRateBoost);
|
||||
}
|
||||
|
||||
return Math.round(18_000_000 * highFrameRateBoost);
|
||||
};
|
||||
|
||||
const stopRecording = useRef(() => {
|
||||
if (mediaRecorder.current?.state === "recording") {
|
||||
if (stream.current) {
|
||||
@@ -55,14 +87,16 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture screen at source resolution without constraints
|
||||
const mediaStream = await (navigator.mediaDevices as any).getUserMedia({
|
||||
audio: false,
|
||||
video: {
|
||||
mandatory: {
|
||||
chromeMediaSource: "desktop",
|
||||
chromeMediaSourceId: selectedSource.id,
|
||||
frameRate: { ideal: 60, max: 60 }
|
||||
maxWidth: TARGET_WIDTH,
|
||||
maxHeight: TARGET_HEIGHT,
|
||||
maxFrameRate: TARGET_FRAME_RATE,
|
||||
minFrameRate: 30,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -71,31 +105,36 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
throw new Error("Media stream is not available.");
|
||||
}
|
||||
const videoTrack = stream.current.getVideoTracks()[0];
|
||||
let { width = 1920, height = 1080 } = videoTrack.getSettings();
|
||||
try {
|
||||
await videoTrack.applyConstraints({
|
||||
frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE },
|
||||
width: { ideal: TARGET_WIDTH, max: TARGET_WIDTH },
|
||||
height: { ideal: TARGET_HEIGHT, max: TARGET_HEIGHT },
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("Unable to lock 4K/60fps constraints, using best available track settings.", error);
|
||||
}
|
||||
|
||||
let { width = 1920, height = 1080, frameRate = TARGET_FRAME_RATE } = videoTrack.getSettings();
|
||||
|
||||
// Ensure dimensions are divisible by 2 for VP9/AV1 codec compatibility
|
||||
width = Math.floor(width / 2) * 2;
|
||||
height = Math.floor(height / 2) * 2;
|
||||
|
||||
console.log(`Recording at ${width}x${height}`);
|
||||
const videoBitsPerSecond = computeBitrate(width, height);
|
||||
const mimeType = selectMimeType();
|
||||
|
||||
console.log(
|
||||
`Recording at ${width}x${height} @ ${frameRate ?? TARGET_FRAME_RATE}fps using ${mimeType} / ${Math.round(
|
||||
videoBitsPerSecond / 1_000_000
|
||||
)} Mbps`
|
||||
);
|
||||
|
||||
const totalPixels = width * height;
|
||||
// Use visually lossless bitrates optimized for quality and file size balance
|
||||
let bitrate = 30_000_000;
|
||||
if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) {
|
||||
bitrate = 50_000_000;
|
||||
} else if (totalPixels > 2560 * 1440) {
|
||||
bitrate = 80_000_000;
|
||||
}
|
||||
chunks.current = [];
|
||||
// Prefer AV1 codec for better compression, fallback to VP9 then VP8
|
||||
const supportedCodecs = [
|
||||
'video/webm;codecs=av1',
|
||||
'video/webm;codecs=vp9',
|
||||
'video/webm;codecs=vp8'
|
||||
];
|
||||
const mimeType = supportedCodecs.find(codec => MediaRecorder.isTypeSupported(codec)) || 'video/webm;codecs=vp9';
|
||||
const recorder = new MediaRecorder(stream.current, { mimeType, videoBitsPerSecond: bitrate });
|
||||
const recorder = new MediaRecorder(stream.current, {
|
||||
mimeType,
|
||||
videoBitsPerSecond,
|
||||
});
|
||||
mediaRecorder.current = recorder;
|
||||
recorder.ondataavailable = e => {
|
||||
if (e.data && e.data.size > 0) chunks.current.push(e.data);
|
||||
@@ -104,7 +143,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
stream.current = null;
|
||||
if (chunks.current.length === 0) return;
|
||||
const duration = Date.now() - startTime.current;
|
||||
const buggyBlob = new Blob(chunks.current, { type: mimeType });
|
||||
const recordedChunks = chunks.current;
|
||||
const buggyBlob = new Blob(recordedChunks, { type: mimeType });
|
||||
// Clear chunks early to free memory immediately after blob creation
|
||||
chunks.current = [];
|
||||
const timestamp = Date.now();
|
||||
@@ -119,14 +159,17 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
return;
|
||||
}
|
||||
|
||||
if (videoResult.path) {
|
||||
await window.electronAPI.setCurrentVideoPath(videoResult.path);
|
||||
}
|
||||
|
||||
await window.electronAPI.switchToEditor();
|
||||
} catch (error) {
|
||||
console.error('Error saving recording:', error);
|
||||
}
|
||||
};
|
||||
recorder.onerror = () => setRecording(false);
|
||||
// Use larger timeslice to reduce recording overhead and improve smoothness
|
||||
recorder.start(5000);
|
||||
recorder.start(1000);
|
||||
startTime.current = Date.now();
|
||||
setRecording(true);
|
||||
window.electronAPI?.setRecordingState(true);
|
||||
|
||||
@@ -65,6 +65,8 @@
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +77,16 @@
|
||||
transition: left 33ms linear, right 33ms linear;
|
||||
}
|
||||
|
||||
/* Hidden scrollbar - still scrollable but invisible */
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE/Edge */
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari, Opera */
|
||||
}
|
||||
|
||||
/* Smooth playback scrubber */
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import type { AnnotationRegion, ArrowDirection } from '@/components/video-editor/types';
|
||||
|
||||
// SVG path data for each arrow direction
|
||||
const ARROW_PATHS: Record<ArrowDirection, string[]> = {
|
||||
'up': [
|
||||
'M 50 20 L 50 80',
|
||||
'M 50 20 L 35 35',
|
||||
'M 50 20 L 65 35',
|
||||
],
|
||||
'down': [
|
||||
'M 50 20 L 50 80',
|
||||
'M 50 80 L 35 65',
|
||||
'M 50 80 L 65 65',
|
||||
],
|
||||
'left': [
|
||||
'M 80 50 L 20 50',
|
||||
'M 20 50 L 35 35',
|
||||
'M 20 50 L 35 65',
|
||||
],
|
||||
'right': [
|
||||
'M 20 50 L 80 50',
|
||||
'M 80 50 L 65 35',
|
||||
'M 80 50 L 65 65',
|
||||
],
|
||||
'up-right': [
|
||||
'M 25 75 L 75 25',
|
||||
'M 75 25 L 60 30',
|
||||
'M 75 25 L 70 40',
|
||||
],
|
||||
'up-left': [
|
||||
'M 75 75 L 25 25',
|
||||
'M 25 25 L 40 30',
|
||||
'M 25 25 L 30 40',
|
||||
],
|
||||
'down-right': [
|
||||
'M 25 25 L 75 75',
|
||||
'M 75 75 L 70 60',
|
||||
'M 75 75 L 60 70',
|
||||
],
|
||||
'down-left': [
|
||||
'M 75 25 L 25 75',
|
||||
'M 25 75 L 30 60',
|
||||
'M 25 75 L 40 70',
|
||||
],
|
||||
};
|
||||
|
||||
function parseSvgPath(pathString: string, scaleX: number, scaleY: number): Array<{ cmd: string; args: number[] }> {
|
||||
const commands: Array<{ cmd: string; args: number[] }> = [];
|
||||
const parts = pathString.trim().split(/\s+/);
|
||||
|
||||
let i = 0;
|
||||
while (i < parts.length) {
|
||||
const cmd = parts[i];
|
||||
if (cmd === 'M' || cmd === 'L') {
|
||||
const x = parseFloat(parts[i + 1]) * scaleX;
|
||||
const y = parseFloat(parts[i + 2]) * scaleY;
|
||||
commands.push({ cmd, args: [x, y] });
|
||||
i += 3;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
|
||||
function renderArrow(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
direction: ArrowDirection,
|
||||
color: string,
|
||||
strokeWidth: number,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
_scaleFactor: number
|
||||
) {
|
||||
const paths = ARROW_PATHS[direction];
|
||||
if (!paths) return;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
|
||||
const padding = 8 * _scaleFactor;
|
||||
const availableWidth = Math.max(0, width - padding * 2);
|
||||
const availableHeight = Math.max(0, height - padding * 2);
|
||||
|
||||
const scale = Math.min(availableWidth / 100, availableHeight / 100);
|
||||
|
||||
const offsetX = padding + (availableWidth - 100 * scale) / 2;
|
||||
const offsetY = padding + (availableHeight - 100 * scale) / 2;
|
||||
|
||||
// Apply centering offset
|
||||
ctx.translate(offsetX, offsetY);
|
||||
|
||||
// Apply shadow filter
|
||||
ctx.shadowColor = 'rgba(0, 0, 0, 0.3)';
|
||||
ctx.shadowBlur = 8 * scale;
|
||||
ctx.shadowOffsetX = 0;
|
||||
ctx.shadowOffsetY = 4 * scale;
|
||||
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = strokeWidth * scale;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
|
||||
// Draw all paths as a single shape to avoid overlapping shadows/strokes
|
||||
ctx.beginPath();
|
||||
|
||||
for (const pathString of paths) {
|
||||
const commands = parseSvgPath(pathString, scale, scale);
|
||||
|
||||
|
||||
for (const { cmd, args } of commands) {
|
||||
if (cmd === 'M') {
|
||||
ctx.moveTo(args[0], args[1]);
|
||||
} else if (cmd === 'L') {
|
||||
ctx.lineTo(args[0], args[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function renderText(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
annotation: AnnotationRegion,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
scaleFactor: number
|
||||
) {
|
||||
const style = annotation.style;
|
||||
|
||||
ctx.save();
|
||||
|
||||
const fontWeight = style.fontWeight === 'bold' ? 'bold' : 'normal';
|
||||
const fontStyle = style.fontStyle === 'italic' ? 'italic' : 'normal';
|
||||
const scaledFontSize = style.fontSize * scaleFactor;
|
||||
ctx.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${style.fontFamily}`;
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const containerPadding = 8 * scaleFactor;
|
||||
|
||||
let textX = x;
|
||||
let textY = y + height / 2;
|
||||
|
||||
if (style.textAlign === 'center') {
|
||||
textX = x + width / 2;
|
||||
ctx.textAlign = 'center';
|
||||
} else if (style.textAlign === 'right') {
|
||||
textX = x + width - containerPadding;
|
||||
ctx.textAlign = 'right';
|
||||
} else {
|
||||
textX = x + containerPadding;
|
||||
ctx.textAlign = 'left';
|
||||
}
|
||||
|
||||
const lines = annotation.content.split('\n');
|
||||
const lineHeight = scaledFontSize * 1.4;
|
||||
|
||||
const startY = textY - ((lines.length - 1) * lineHeight) / 2;
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const currentY = startY + index * lineHeight;
|
||||
|
||||
if (style.backgroundColor && style.backgroundColor !== 'transparent') {
|
||||
const metrics = ctx.measureText(line);
|
||||
const verticalPadding = scaledFontSize * 0.1;
|
||||
const horizontalPadding = scaledFontSize * 0.2;
|
||||
const borderRadius = 4 * scaleFactor;
|
||||
|
||||
let bgX = textX - horizontalPadding;
|
||||
const bgWidth = metrics.width + horizontalPadding * 2;
|
||||
|
||||
const contentHeight = scaledFontSize * 1.4;
|
||||
const bgHeight = contentHeight + verticalPadding * 2;
|
||||
const bgY = currentY - bgHeight / 2;
|
||||
|
||||
if (style.textAlign === 'center') {
|
||||
bgX = textX - bgWidth / 2;
|
||||
} else if (style.textAlign === 'right') {
|
||||
bgX = textX - bgWidth;
|
||||
}
|
||||
|
||||
ctx.fillStyle = style.backgroundColor;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(bgX, bgY, bgWidth, bgHeight, borderRadius);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.fillStyle = style.color;
|
||||
ctx.fillText(line, textX, currentY);
|
||||
|
||||
if (style.textDecoration === 'underline') {
|
||||
const metrics = ctx.measureText(line);
|
||||
let underlineX = textX;
|
||||
const underlineY = currentY + scaledFontSize * 0.15;
|
||||
|
||||
if (style.textAlign === 'center') {
|
||||
underlineX = textX - metrics.width / 2;
|
||||
} else if (style.textAlign === 'right') {
|
||||
underlineX = textX - metrics.width;
|
||||
}
|
||||
|
||||
ctx.strokeStyle = style.color;
|
||||
ctx.lineWidth = Math.max(1, scaledFontSize / 16);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(underlineX, underlineY);
|
||||
ctx.lineTo(underlineX + metrics.width, underlineY);
|
||||
ctx.stroke();
|
||||
}
|
||||
});
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
async function renderImage(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
annotation: AnnotationRegion,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number
|
||||
): Promise<void> {
|
||||
if (!annotation.content || !annotation.content.startsWith('data:image')) {
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
// Preserve aspect ratio - contain the image within the bounds
|
||||
const imgAspect = img.width / img.height;
|
||||
const boxAspect = width / height;
|
||||
|
||||
let drawWidth = width;
|
||||
let drawHeight = height;
|
||||
let drawX = x;
|
||||
let drawY = y;
|
||||
|
||||
if (imgAspect > boxAspect) {
|
||||
|
||||
drawHeight = width / imgAspect;
|
||||
drawY = y + (height - drawHeight) / 2;
|
||||
} else {
|
||||
drawWidth = height * imgAspect;
|
||||
drawX = x + (width - drawWidth) / 2;
|
||||
}
|
||||
|
||||
ctx.drawImage(img, drawX, drawY, drawWidth, drawHeight);
|
||||
resolve();
|
||||
};
|
||||
img.onerror = () => {
|
||||
console.error('[AnnotationRenderer] Failed to load image annotation');
|
||||
resolve();
|
||||
};
|
||||
img.src = annotation.content;
|
||||
});
|
||||
}
|
||||
|
||||
export async function renderAnnotations(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
annotations: AnnotationRegion[],
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
currentTimeMs: number,
|
||||
scaleFactor: number = 1.0
|
||||
): Promise<void> {
|
||||
// Filter active annotations at current time
|
||||
const activeAnnotations = annotations.filter(
|
||||
(ann) => currentTimeMs >= ann.startMs && currentTimeMs <= ann.endMs
|
||||
);
|
||||
|
||||
// Sort by z-index (lower first, so higher z-index draws on top)
|
||||
const sortedAnnotations = [...activeAnnotations].sort((a, b) => a.zIndex - b.zIndex);
|
||||
|
||||
for (const annotation of sortedAnnotations) {
|
||||
const x = (annotation.position.x / 100) * canvasWidth;
|
||||
const y = (annotation.position.y / 100) * canvasHeight;
|
||||
const width = (annotation.size.width / 100) * canvasWidth;
|
||||
const height = (annotation.size.height / 100) * canvasHeight;
|
||||
|
||||
switch (annotation.type) {
|
||||
case 'text':
|
||||
renderText(ctx, annotation, x, y, width, height, scaleFactor);
|
||||
break;
|
||||
|
||||
case 'image':
|
||||
await renderImage(ctx, annotation, x, y, width, height);
|
||||
break;
|
||||
|
||||
case 'figure':
|
||||
if (annotation.figureData) {
|
||||
renderArrow(
|
||||
ctx,
|
||||
annotation.figureData.arrowDirection,
|
||||
annotation.figureData.color,
|
||||
annotation.figureData.strokeWidth,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
scaleFactor
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Application, Container, Sprite, Graphics, BlurFilter, Texture } from 'pixi.js';
|
||||
import type { ZoomRegion, CropRegion } from '@/components/video-editor/types';
|
||||
import type { ZoomRegion, CropRegion, AnnotationRegion } from '@/components/video-editor/types';
|
||||
import { ZOOM_DEPTH_SCALES } from '@/components/video-editor/types';
|
||||
import { findDominantRegion } from '@/components/video-editor/videoPlayback/zoomRegionUtils';
|
||||
import { applyZoomTransform } from '@/components/video-editor/videoPlayback/zoomTransform';
|
||||
import { DEFAULT_FOCUS, SMOOTHING_FACTOR, MIN_DELTA, VIEWPORT_SCALE } from '@/components/video-editor/videoPlayback/constants';
|
||||
import { DEFAULT_FOCUS, SMOOTHING_FACTOR, MIN_DELTA } from '@/components/video-editor/videoPlayback/constants';
|
||||
import { clampFocusToStage as clampFocusToStageUtil } from '@/components/video-editor/videoPlayback/focusUtils';
|
||||
import { renderAnnotations } from './annotationRenderer';
|
||||
|
||||
interface FrameRenderConfig {
|
||||
width: number;
|
||||
@@ -14,9 +15,15 @@ interface FrameRenderConfig {
|
||||
showShadow: boolean;
|
||||
shadowIntensity: number;
|
||||
showBlur: boolean;
|
||||
motionBlurEnabled?: boolean;
|
||||
borderRadius?: number;
|
||||
padding?: number;
|
||||
cropRegion: CropRegion;
|
||||
videoWidth: number;
|
||||
videoHeight: number;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
previewWidth?: number;
|
||||
previewHeight?: number;
|
||||
}
|
||||
|
||||
interface AnimationState {
|
||||
@@ -286,6 +293,7 @@ export class FrameRenderer {
|
||||
focusY: this.animationState.focusY,
|
||||
motionIntensity: maxMotionIntensity,
|
||||
isPlaying: true,
|
||||
motionBlurEnabled: this.config.motionBlurEnabled ?? false,
|
||||
});
|
||||
|
||||
// Render the PixiJS stage to its canvas (video only, transparent background)
|
||||
@@ -293,13 +301,32 @@ export class FrameRenderer {
|
||||
|
||||
// Composite with shadows to final output canvas
|
||||
this.compositeWithShadows();
|
||||
|
||||
// Render annotations on top if present
|
||||
if (this.config.annotationRegions && this.config.annotationRegions.length > 0 && this.compositeCtx) {
|
||||
// Calculate scale factor based on export vs preview dimensions
|
||||
const previewWidth = this.config.previewWidth || 1920;
|
||||
const previewHeight = this.config.previewHeight || 1080;
|
||||
const scaleX = this.config.width / previewWidth;
|
||||
const scaleY = this.config.height / previewHeight;
|
||||
const scaleFactor = (scaleX + scaleY) / 2;
|
||||
|
||||
await renderAnnotations(
|
||||
this.compositeCtx,
|
||||
this.config.annotationRegions,
|
||||
this.config.width,
|
||||
this.config.height,
|
||||
timeMs,
|
||||
scaleFactor
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private updateLayout(): void {
|
||||
if (!this.app || !this.videoSprite || !this.maskGraphics || !this.videoContainer) return;
|
||||
|
||||
const { width, height } = this.config;
|
||||
const { cropRegion } = this.config;
|
||||
const { cropRegion, borderRadius = 0, padding = 0 } = this.config;
|
||||
const videoWidth = this.config.videoWidth;
|
||||
const videoHeight = this.config.videoHeight;
|
||||
|
||||
@@ -311,10 +338,12 @@ export class FrameRenderer {
|
||||
|
||||
const croppedVideoWidth = videoWidth * (cropEndX - cropStartX);
|
||||
const croppedVideoHeight = videoHeight * (cropEndY - cropStartY);
|
||||
|
||||
|
||||
// Calculate scale to fit in viewport
|
||||
const viewportWidth = width * VIEWPORT_SCALE;
|
||||
const viewportHeight = height * VIEWPORT_SCALE;
|
||||
// Padding is a percentage (0-100), where 50% ~ 0.8 scale
|
||||
const paddingScale = 1.0 - (padding / 100) * 0.4;
|
||||
const viewportWidth = width * paddingScale;
|
||||
const viewportHeight = height * paddingScale;
|
||||
const scale = Math.min(viewportWidth / croppedVideoWidth, viewportHeight / croppedVideoHeight);
|
||||
|
||||
// Position video sprite
|
||||
@@ -334,10 +363,14 @@ export class FrameRenderer {
|
||||
this.videoContainer.x = centerOffsetX;
|
||||
this.videoContainer.y = centerOffsetY;
|
||||
|
||||
// Update mask
|
||||
const radius = Math.min(croppedDisplayWidth, croppedDisplayHeight) * 0.02;
|
||||
// scale border radius by export/preview canvas ratio
|
||||
const previewWidth = this.config.previewWidth || 1920;
|
||||
const previewHeight = this.config.previewHeight || 1080;
|
||||
const canvasScaleFactor = Math.min(width / previewWidth, height / previewHeight);
|
||||
const scaledBorderRadius = borderRadius * canvasScaleFactor;
|
||||
|
||||
this.maskGraphics.clear();
|
||||
this.maskGraphics.roundRect(0, 0, croppedDisplayWidth, croppedDisplayHeight, radius);
|
||||
this.maskGraphics.roundRect(0, 0, croppedDisplayWidth, croppedDisplayHeight, scaledBorderRadius);
|
||||
this.maskGraphics.fill({ color: 0xffffff });
|
||||
|
||||
// Cache layout info
|
||||
@@ -477,12 +510,6 @@ export class FrameRenderer {
|
||||
return this.compositeCanvas;
|
||||
}
|
||||
|
||||
updateConfig(config: Partial<FrameRenderConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
if (config.wallpaper) {
|
||||
this.setupBackground();
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.videoSprite) {
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as fc from 'fast-check';
|
||||
import { calculateOutputDimensions } from './gifExporter';
|
||||
import { GIF_SIZE_PRESETS, GifSizePreset } from './types';
|
||||
|
||||
/**
|
||||
* Property 2: Loop Encoding Correctness
|
||||
*
|
||||
* *For any* GIF export configuration, when loop is enabled the output GIF SHALL
|
||||
* have a loop count of 0 (infinite), and when loop is disabled the output GIF
|
||||
* SHALL have a loop count of 1 (play once).
|
||||
*
|
||||
* **Validates: Requirements 3.2, 3.3**
|
||||
*
|
||||
* Feature: gif-export, Property 2: Loop Encoding Correctness
|
||||
*/
|
||||
describe('GIF Exporter', () => {
|
||||
describe('Property 2: Loop Encoding Correctness', () => {
|
||||
/**
|
||||
* Test the loop configuration mapping logic.
|
||||
* In gif.js: repeat=0 means infinite loop, repeat=1 means play once (no loop)
|
||||
*/
|
||||
it('should map loop=true to repeat=0 (infinite) and loop=false to repeat=1 (once)', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.boolean(),
|
||||
(loopEnabled: boolean) => {
|
||||
// This is the logic used in GifExporter constructor
|
||||
const repeat = loopEnabled ? 0 : 1;
|
||||
|
||||
if (loopEnabled) {
|
||||
// When loop is enabled, repeat should be 0 (infinite loop)
|
||||
expect(repeat).toBe(0);
|
||||
} else {
|
||||
// When loop is disabled, repeat should be 1 (play once)
|
||||
expect(repeat).toBe(1);
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should always produce valid repeat values (0 or 1)', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.boolean(),
|
||||
(loopEnabled: boolean) => {
|
||||
const repeat = loopEnabled ? 0 : 1;
|
||||
expect([0, 1]).toContain(repeat);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Property 4: Aspect Ratio Preservation
|
||||
*
|
||||
* *For any* source video with aspect ratio R and any size preset, the exported
|
||||
* GIF SHALL have an aspect ratio within 0.01 of R.
|
||||
*
|
||||
* **Validates: Requirements 4.4**
|
||||
*
|
||||
* Feature: gif-export, Property 4: Aspect Ratio Preservation
|
||||
*/
|
||||
describe('Property 4: Aspect Ratio Preservation', () => {
|
||||
const sizePresets: GifSizePreset[] = ['medium', 'large', 'original'];
|
||||
|
||||
it('should preserve aspect ratio within 0.01 tolerance for all size presets', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 100, max: 4000 }), // sourceWidth
|
||||
fc.integer({ min: 100, max: 4000 }), // sourceHeight
|
||||
fc.constantFrom(...sizePresets),
|
||||
(sourceWidth: number, sourceHeight: number, sizePreset: GifSizePreset) => {
|
||||
const originalAspectRatio = sourceWidth / sourceHeight;
|
||||
|
||||
const { width, height } = calculateOutputDimensions(
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
sizePreset,
|
||||
GIF_SIZE_PRESETS
|
||||
);
|
||||
|
||||
const outputAspectRatio = width / height;
|
||||
|
||||
// Aspect ratio should be preserved within 0.01 tolerance
|
||||
// (small deviation allowed due to rounding to even numbers)
|
||||
expect(Math.abs(originalAspectRatio - outputAspectRatio)).toBeLessThan(0.02);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should return original dimensions when source is smaller than preset max height', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 100, max: 400 }), // sourceWidth (small)
|
||||
fc.integer({ min: 100, max: 400 }), // sourceHeight (small, less than 720p)
|
||||
(sourceWidth: number, sourceHeight: number) => {
|
||||
// For 'medium' preset with maxHeight 720, if source is smaller, use original
|
||||
const { width, height } = calculateOutputDimensions(
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
'medium',
|
||||
GIF_SIZE_PRESETS
|
||||
);
|
||||
|
||||
expect(width).toBe(sourceWidth);
|
||||
expect(height).toBe(sourceHeight);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should return original dimensions for "original" preset regardless of size', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 100, max: 4000 }),
|
||||
fc.integer({ min: 100, max: 4000 }),
|
||||
(sourceWidth: number, sourceHeight: number) => {
|
||||
const { width, height } = calculateOutputDimensions(
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
'original',
|
||||
GIF_SIZE_PRESETS
|
||||
);
|
||||
|
||||
expect(width).toBe(sourceWidth);
|
||||
expect(height).toBe(sourceHeight);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should scale down to preset max height when source is larger', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 1000, max: 4000 }), // sourceWidth (large)
|
||||
fc.integer({ min: 800, max: 2000 }), // sourceHeight (larger than 720p)
|
||||
(sourceWidth: number, sourceHeight: number) => {
|
||||
// For 'medium' preset with maxHeight 720
|
||||
const { width, height } = calculateOutputDimensions(
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
'medium',
|
||||
GIF_SIZE_PRESETS
|
||||
);
|
||||
|
||||
// Height should be at most 720 (or 722 due to even rounding)
|
||||
expect(height).toBeLessThanOrEqual(722);
|
||||
// Width should be scaled proportionally
|
||||
expect(width).toBeLessThan(sourceWidth);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Property 3: Size Preset Resolution Mapping
|
||||
*
|
||||
* *For any* valid size preset and source video dimensions, the GIF_Exporter SHALL
|
||||
* produce output with height matching the preset's max height (or source height if smaller),
|
||||
* with width calculated to maintain aspect ratio.
|
||||
*
|
||||
* **Validates: Requirements 4.2**
|
||||
*
|
||||
* Feature: gif-export, Property 3: Size Preset Resolution Mapping
|
||||
*/
|
||||
describe('Property 3: Size Preset Resolution Mapping', () => {
|
||||
it('should map size presets to correct max heights', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 800, max: 4000 }), // sourceWidth (large enough to trigger scaling)
|
||||
fc.integer({ min: 800, max: 2000 }), // sourceHeight (larger than all presets except original)
|
||||
fc.constantFrom('medium', 'large') as fc.Arbitrary<GifSizePreset>,
|
||||
(sourceWidth: number, sourceHeight: number, sizePreset: GifSizePreset) => {
|
||||
const { height } = calculateOutputDimensions(
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
sizePreset,
|
||||
GIF_SIZE_PRESETS
|
||||
);
|
||||
|
||||
const expectedMaxHeight = GIF_SIZE_PRESETS[sizePreset].maxHeight;
|
||||
|
||||
// Height should be at or below the preset's max height
|
||||
// (allowing +2 for even number rounding)
|
||||
expect(height).toBeLessThanOrEqual(expectedMaxHeight + 2);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should use source dimensions when smaller than preset', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 100, max: 400 }), // sourceWidth
|
||||
fc.integer({ min: 100, max: 400 }), // sourceHeight (smaller than 720p 'medium' preset)
|
||||
fc.constantFrom('medium', 'large', 'original') as fc.Arbitrary<GifSizePreset>,
|
||||
(sourceWidth: number, sourceHeight: number, sizePreset: GifSizePreset) => {
|
||||
const { width, height } = calculateOutputDimensions(
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
sizePreset,
|
||||
GIF_SIZE_PRESETS
|
||||
);
|
||||
|
||||
// When source is smaller than preset, use original dimensions
|
||||
expect(width).toBe(sourceWidth);
|
||||
expect(height).toBe(sourceHeight);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should produce even dimensions for encoder compatibility', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 100, max: 4000 }),
|
||||
fc.integer({ min: 100, max: 4000 }),
|
||||
fc.constantFrom('medium', 'large', 'original') as fc.Arbitrary<GifSizePreset>,
|
||||
(sourceWidth: number, sourceHeight: number, sizePreset: GifSizePreset) => {
|
||||
const { width, height } = calculateOutputDimensions(
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
sizePreset,
|
||||
GIF_SIZE_PRESETS
|
||||
);
|
||||
|
||||
// When scaling occurs, dimensions should be even
|
||||
// (original dimensions are passed through as-is)
|
||||
if (sourceHeight > GIF_SIZE_PRESETS[sizePreset].maxHeight && sizePreset !== 'original') {
|
||||
expect(width % 2).toBe(0);
|
||||
expect(height % 2).toBe(0);
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Property 6: Frame Count Consistency
|
||||
*
|
||||
* *For any* video with effective duration D (excluding trim regions) and frame rate F,
|
||||
* the exported GIF SHALL contain approximately D × F frames (within ±1 frame tolerance).
|
||||
*
|
||||
* **Validates: Requirements 5.1**
|
||||
*
|
||||
* Feature: gif-export, Property 6: Frame Count Consistency
|
||||
*/
|
||||
describe('Property 6: Frame Count Consistency', () => {
|
||||
// Helper function to calculate expected frame count
|
||||
const calculateExpectedFrameCount = (durationSeconds: number, frameRate: number): number => {
|
||||
return Math.ceil(durationSeconds * frameRate);
|
||||
};
|
||||
|
||||
it('should calculate correct frame count for duration and frame rate', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.float({ min: 0.5, max: 60, noNaN: true }), // duration in seconds
|
||||
fc.constantFrom(10, 15, 20, 25, 30), // valid frame rates
|
||||
(duration: number, frameRate: number) => {
|
||||
const expectedFrames = calculateExpectedFrameCount(duration, frameRate);
|
||||
|
||||
// Frame count should be positive
|
||||
expect(expectedFrames).toBeGreaterThan(0);
|
||||
|
||||
// Frame count should be approximately duration * frameRate
|
||||
const approximateFrames = duration * frameRate;
|
||||
expect(Math.abs(expectedFrames - approximateFrames)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should produce more frames with higher frame rates', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.float({ min: 1, max: 30, noNaN: true }), // duration in seconds
|
||||
(duration: number) => {
|
||||
const frames10fps = calculateExpectedFrameCount(duration, 10);
|
||||
const frames30fps = calculateExpectedFrameCount(duration, 30);
|
||||
|
||||
// 30fps should produce approximately 3x more frames than 10fps
|
||||
expect(frames30fps).toBeGreaterThan(frames10fps);
|
||||
expect(frames30fps / frames10fps).toBeCloseTo(3, 0);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle trim regions by reducing effective duration', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.float({ min: 5, max: 60, noNaN: true }), // total duration
|
||||
fc.float({ min: 0.5, max: 2, noNaN: true }), // trim duration (smaller than total)
|
||||
fc.constantFrom(10, 15, 20, 25, 30),
|
||||
(totalDuration: number, trimDuration: number, frameRate: number) => {
|
||||
const effectiveDuration = totalDuration - trimDuration;
|
||||
const framesWithTrim = calculateExpectedFrameCount(effectiveDuration, frameRate);
|
||||
const framesWithoutTrim = calculateExpectedFrameCount(totalDuration, frameRate);
|
||||
|
||||
// Trimmed video should have fewer frames
|
||||
expect(framesWithTrim).toBeLessThan(framesWithoutTrim);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Property 5: Valid GIF Output (Configuration Validation)
|
||||
*
|
||||
* *For any* successful GIF export, the output blob SHALL be a valid GIF file.
|
||||
* This test validates the GIF configuration parameters are correctly set up.
|
||||
*
|
||||
* **Validates: Requirements 5.3**
|
||||
*
|
||||
* Feature: gif-export, Property 5: Valid GIF Output
|
||||
*
|
||||
* Note: Full GIF encoding validation requires browser environment with video.
|
||||
* This test validates configuration correctness.
|
||||
*/
|
||||
describe('Property 5: Valid GIF Output (Configuration)', () => {
|
||||
it('should generate valid GIF configuration for all frame rates', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom(10, 15, 20, 25, 30),
|
||||
fc.integer({ min: 100, max: 1920 }),
|
||||
fc.integer({ min: 100, max: 1080 }),
|
||||
fc.boolean(),
|
||||
(frameRate: number, width: number, height: number, loop: boolean) => {
|
||||
// Validate frame delay calculation (gif.js uses milliseconds)
|
||||
const frameDelay = Math.round(1000 / frameRate);
|
||||
|
||||
// Frame delay should be positive and reasonable
|
||||
expect(frameDelay).toBeGreaterThan(0);
|
||||
expect(frameDelay).toBeLessThanOrEqual(100); // 10fps = 100ms delay
|
||||
|
||||
// Loop configuration
|
||||
const repeat = loop ? 0 : 1;
|
||||
expect([0, 1]).toContain(repeat);
|
||||
|
||||
// Dimensions should be positive
|
||||
expect(width).toBeGreaterThan(0);
|
||||
expect(height).toBeGreaterThan(0);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should calculate correct frame delays for each frame rate', () => {
|
||||
const expectedDelays: Record<number, number> = {
|
||||
10: 100, // 1000ms / 10fps = 100ms
|
||||
15: 67, // 1000ms / 15fps ≈ 67ms
|
||||
20: 50, // 1000ms / 20fps = 50ms
|
||||
25: 40, // 1000ms / 25fps = 40ms
|
||||
30: 33, // 1000ms / 30fps ≈ 33ms
|
||||
};
|
||||
|
||||
for (const [fps, expectedDelay] of Object.entries(expectedDelays)) {
|
||||
const frameRate = Number(fps);
|
||||
const actualDelay = Math.round(1000 / frameRate);
|
||||
expect(actualDelay).toBe(expectedDelay);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Property 7: MP4 Export Regression
|
||||
*
|
||||
* *For any* valid MP4 export configuration that worked before this feature,
|
||||
* the Video_Exporter SHALL continue to produce valid MP4 output.
|
||||
*
|
||||
* **Validates: Requirements 7.2**
|
||||
*
|
||||
* Feature: gif-export, Property 7: MP4 Export Regression
|
||||
*
|
||||
* Note: This test validates that MP4 export configuration remains unchanged.
|
||||
*/
|
||||
describe('Property 7: MP4 Export Regression', () => {
|
||||
it('should maintain valid MP4 quality presets', () => {
|
||||
const qualityPresets = ['medium', 'good', 'source'];
|
||||
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom(...qualityPresets),
|
||||
(quality: string) => {
|
||||
// Quality presets should be valid
|
||||
expect(['medium', 'good', 'source']).toContain(quality);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should calculate valid MP4 export dimensions', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 640, max: 3840 }), // sourceWidth
|
||||
fc.integer({ min: 480, max: 2160 }), // sourceHeight
|
||||
fc.constantFrom('medium', 'good', 'source'),
|
||||
(sourceWidth: number, sourceHeight: number, quality: string) => {
|
||||
let exportWidth: number;
|
||||
let exportHeight: number;
|
||||
const aspectRatio = sourceWidth / sourceHeight;
|
||||
|
||||
if (quality === 'source') {
|
||||
// Source quality uses original dimensions (may be odd)
|
||||
exportWidth = sourceWidth;
|
||||
exportHeight = sourceHeight;
|
||||
|
||||
// Dimensions should be positive
|
||||
expect(exportWidth).toBeGreaterThan(0);
|
||||
expect(exportHeight).toBeGreaterThan(0);
|
||||
} else {
|
||||
const targetHeight = quality === 'medium' ? 720 : 1080;
|
||||
exportHeight = Math.floor(targetHeight / 2) * 2;
|
||||
exportWidth = Math.floor((exportHeight * aspectRatio) / 2) * 2;
|
||||
|
||||
// Dimensions should be positive and even for non-source quality
|
||||
expect(exportWidth).toBeGreaterThan(0);
|
||||
expect(exportHeight).toBeGreaterThan(0);
|
||||
expect(exportWidth % 2).toBe(0);
|
||||
expect(exportHeight % 2).toBe(0);
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should maintain aspect ratio in MP4 export', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 640, max: 3840 }),
|
||||
fc.integer({ min: 480, max: 2160 }),
|
||||
fc.constantFrom('medium', 'good'),
|
||||
(sourceWidth: number, sourceHeight: number, quality: string) => {
|
||||
const originalAspectRatio = sourceWidth / sourceHeight;
|
||||
const targetHeight = quality === 'medium' ? 720 : 1080;
|
||||
|
||||
const exportHeight = Math.floor(targetHeight / 2) * 2;
|
||||
const exportWidth = Math.floor((exportHeight * originalAspectRatio) / 2) * 2;
|
||||
|
||||
const exportAspectRatio = exportWidth / exportHeight;
|
||||
|
||||
// Aspect ratio should be preserved within tolerance (due to even rounding)
|
||||
expect(Math.abs(originalAspectRatio - exportAspectRatio)).toBeLessThan(0.05);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
import GIF from 'gif.js';
|
||||
import type { ExportProgress, ExportResult, GifFrameRate, GifSizePreset, GIF_SIZE_PRESETS } from './types';
|
||||
import { VideoFileDecoder } from './videoDecoder';
|
||||
import { FrameRenderer } from './frameRenderer';
|
||||
import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion } from '@/components/video-editor/types';
|
||||
|
||||
const GIF_WORKER_URL = new URL('gif.js/dist/gif.worker.js', import.meta.url).toString();
|
||||
|
||||
interface GifExporterConfig {
|
||||
videoUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
frameRate: GifFrameRate;
|
||||
loop: boolean;
|
||||
sizePreset: GifSizePreset;
|
||||
wallpaper: string;
|
||||
zoomRegions: ZoomRegion[];
|
||||
trimRegions?: TrimRegion[];
|
||||
showShadow: boolean;
|
||||
shadowIntensity: number;
|
||||
showBlur: boolean;
|
||||
motionBlurEnabled?: boolean;
|
||||
borderRadius?: number;
|
||||
padding?: number;
|
||||
videoPadding?: number;
|
||||
cropRegion: CropRegion;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
previewWidth?: number;
|
||||
previewHeight?: number;
|
||||
onProgress?: (progress: ExportProgress) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate output dimensions based on size preset and source dimensions while preserving aspect ratio.
|
||||
* @param sourceWidth - Original video width
|
||||
* @param sourceHeight - Original video height
|
||||
* @param sizePreset - The size preset to use
|
||||
* @param sizePresets - The size presets configuration
|
||||
* @returns The calculated output dimensions
|
||||
*/
|
||||
export function calculateOutputDimensions(
|
||||
sourceWidth: number,
|
||||
sourceHeight: number,
|
||||
sizePreset: GifSizePreset,
|
||||
sizePresets: typeof GIF_SIZE_PRESETS
|
||||
): { width: number; height: number } {
|
||||
const preset = sizePresets[sizePreset];
|
||||
const maxHeight = preset.maxHeight;
|
||||
|
||||
// If original is smaller than max height or preset is 'original', use source dimensions
|
||||
if (sourceHeight <= maxHeight || sizePreset === 'original') {
|
||||
return { width: sourceWidth, height: sourceHeight };
|
||||
}
|
||||
|
||||
// Calculate scaled dimensions preserving aspect ratio
|
||||
const aspectRatio = sourceWidth / sourceHeight;
|
||||
const newHeight = maxHeight;
|
||||
const newWidth = Math.round(newHeight * aspectRatio);
|
||||
|
||||
// Ensure dimensions are even (required for some encoders)
|
||||
return {
|
||||
width: newWidth % 2 === 0 ? newWidth : newWidth + 1,
|
||||
height: newHeight % 2 === 0 ? newHeight : newHeight + 1,
|
||||
};
|
||||
}
|
||||
|
||||
export class GifExporter {
|
||||
private config: GifExporterConfig;
|
||||
private decoder: VideoFileDecoder | null = null;
|
||||
private renderer: FrameRenderer | null = null;
|
||||
private gif: GIF | null = null;
|
||||
private cancelled = false;
|
||||
|
||||
constructor(config: GifExporterConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the total duration excluding trim regions (in seconds)
|
||||
*/
|
||||
private getEffectiveDuration(totalDuration: number): number {
|
||||
const trimRegions = this.config.trimRegions || [];
|
||||
const totalTrimDuration = trimRegions.reduce((sum, region) => {
|
||||
return sum + (region.endMs - region.startMs) / 1000;
|
||||
}, 0);
|
||||
return totalDuration - totalTrimDuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map effective time (excluding trims) to source time (including trims)
|
||||
*/
|
||||
private mapEffectiveToSourceTime(effectiveTimeMs: number): number {
|
||||
const trimRegions = this.config.trimRegions || [];
|
||||
// Sort trim regions by start time
|
||||
const sortedTrims = [...trimRegions].sort((a, b) => a.startMs - b.startMs);
|
||||
|
||||
let sourceTimeMs = effectiveTimeMs;
|
||||
|
||||
for (const trim of sortedTrims) {
|
||||
// If the source time hasn't reached this trim region yet, we're done
|
||||
if (sourceTimeMs < trim.startMs) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Add the duration of this trim region to the source time
|
||||
const trimDuration = trim.endMs - trim.startMs;
|
||||
sourceTimeMs += trimDuration;
|
||||
}
|
||||
|
||||
return sourceTimeMs;
|
||||
}
|
||||
|
||||
async export(): Promise<ExportResult> {
|
||||
try {
|
||||
this.cleanup();
|
||||
this.cancelled = false;
|
||||
|
||||
// Initialize decoder and load video
|
||||
this.decoder = new VideoFileDecoder();
|
||||
const videoInfo = await this.decoder.loadVideo(this.config.videoUrl);
|
||||
|
||||
// Initialize frame renderer
|
||||
this.renderer = new FrameRenderer({
|
||||
width: this.config.width,
|
||||
height: this.config.height,
|
||||
wallpaper: this.config.wallpaper,
|
||||
zoomRegions: this.config.zoomRegions,
|
||||
showShadow: this.config.showShadow,
|
||||
shadowIntensity: this.config.shadowIntensity,
|
||||
showBlur: this.config.showBlur,
|
||||
motionBlurEnabled: this.config.motionBlurEnabled,
|
||||
borderRadius: this.config.borderRadius,
|
||||
padding: this.config.padding,
|
||||
cropRegion: this.config.cropRegion,
|
||||
videoWidth: videoInfo.width,
|
||||
videoHeight: videoInfo.height,
|
||||
annotationRegions: this.config.annotationRegions,
|
||||
previewWidth: this.config.previewWidth,
|
||||
previewHeight: this.config.previewHeight,
|
||||
});
|
||||
await this.renderer.initialize();
|
||||
|
||||
// Initialize GIF encoder
|
||||
// Loop: 0 = infinite loop, 1 = play once (no loop)
|
||||
const repeat = this.config.loop ? 0 : 1;
|
||||
|
||||
this.gif = new GIF({
|
||||
workers: 4,
|
||||
quality: 10,
|
||||
width: this.config.width,
|
||||
height: this.config.height,
|
||||
workerScript: GIF_WORKER_URL,
|
||||
repeat,
|
||||
background: '#000000',
|
||||
transparent: null,
|
||||
dither: 'FloydSteinberg',
|
||||
});
|
||||
|
||||
// Get the video element for frame extraction
|
||||
const videoElement = this.decoder.getVideoElement();
|
||||
if (!videoElement) {
|
||||
throw new Error('Video element not available');
|
||||
}
|
||||
|
||||
// Calculate effective duration and frame count (excluding trim regions)
|
||||
const effectiveDuration = this.getEffectiveDuration(videoInfo.duration);
|
||||
const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate);
|
||||
|
||||
// Calculate frame delay in milliseconds (gif.js uses ms)
|
||||
const frameDelay = Math.round(1000 / this.config.frameRate);
|
||||
|
||||
console.log('[GifExporter] Original duration:', videoInfo.duration, 's');
|
||||
console.log('[GifExporter] Effective duration:', effectiveDuration, 's');
|
||||
console.log('[GifExporter] Total frames to export:', totalFrames);
|
||||
console.log('[GifExporter] Frame rate:', this.config.frameRate, 'FPS');
|
||||
console.log('[GifExporter] Frame delay:', frameDelay, 'ms');
|
||||
console.log('[GifExporter] Loop:', this.config.loop ? 'infinite' : 'once');
|
||||
|
||||
// Process frames
|
||||
const timeStep = 1 / this.config.frameRate;
|
||||
let frameIndex = 0;
|
||||
|
||||
while (frameIndex < totalFrames && !this.cancelled) {
|
||||
const i = frameIndex;
|
||||
const timestamp = i * (1_000_000 / this.config.frameRate); // in microseconds
|
||||
|
||||
// Map effective time to source time (accounting for trim regions)
|
||||
const effectiveTimeMs = (i * timeStep) * 1000;
|
||||
const sourceTimeMs = this.mapEffectiveToSourceTime(effectiveTimeMs);
|
||||
const videoTime = sourceTimeMs / 1000;
|
||||
|
||||
// Seek if needed
|
||||
const needsSeek = Math.abs(videoElement.currentTime - videoTime) > 0.001;
|
||||
|
||||
if (needsSeek) {
|
||||
const seekedPromise = new Promise<void>(resolve => {
|
||||
videoElement.addEventListener('seeked', () => resolve(), { once: true });
|
||||
});
|
||||
|
||||
videoElement.currentTime = videoTime;
|
||||
await seekedPromise;
|
||||
} else if (i === 0) {
|
||||
// Only for the very first frame, wait for it to be ready
|
||||
await new Promise<void>(resolve => {
|
||||
videoElement.requestVideoFrameCallback(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
// Create a VideoFrame from the video element
|
||||
const videoFrame = new VideoFrame(videoElement, {
|
||||
timestamp,
|
||||
});
|
||||
|
||||
// Render the frame with all effects using source timestamp
|
||||
const sourceTimestamp = sourceTimeMs * 1000; // Convert to microseconds
|
||||
await this.renderer!.renderFrame(videoFrame, sourceTimestamp);
|
||||
|
||||
videoFrame.close();
|
||||
|
||||
// Get the rendered canvas and add to GIF
|
||||
const canvas = this.renderer!.getCanvas();
|
||||
|
||||
// Add frame to GIF encoder with delay
|
||||
this.gif!.addFrame(canvas, { delay: frameDelay, copy: true });
|
||||
|
||||
frameIndex++;
|
||||
|
||||
// Update progress
|
||||
if (this.config.onProgress) {
|
||||
this.config.onProgress({
|
||||
currentFrame: frameIndex,
|
||||
totalFrames,
|
||||
percentage: (frameIndex / totalFrames) * 100,
|
||||
estimatedTimeRemaining: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (this.cancelled) {
|
||||
return { success: false, error: 'Export cancelled' };
|
||||
}
|
||||
|
||||
// Update progress to show we're now in the finalizing phase
|
||||
if (this.config.onProgress) {
|
||||
this.config.onProgress({
|
||||
currentFrame: totalFrames,
|
||||
totalFrames,
|
||||
percentage: 100,
|
||||
estimatedTimeRemaining: 0,
|
||||
phase: 'finalizing',
|
||||
});
|
||||
}
|
||||
|
||||
// Render the GIF
|
||||
const blob = await new Promise<Blob>((resolve, _reject) => {
|
||||
this.gif!.on('finished', (blob: Blob) => {
|
||||
resolve(blob);
|
||||
});
|
||||
|
||||
// Track rendering progress
|
||||
this.gif!.on('progress', (progress: number) => {
|
||||
if (this.config.onProgress) {
|
||||
this.config.onProgress({
|
||||
currentFrame: totalFrames,
|
||||
totalFrames,
|
||||
percentage: 100,
|
||||
estimatedTimeRemaining: 0,
|
||||
phase: 'finalizing',
|
||||
renderProgress: Math.round(progress * 100),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// gif.js doesn't have a typed 'error' event, but we can catch errors in the try/catch
|
||||
this.gif!.render();
|
||||
});
|
||||
|
||||
return { success: true, blob };
|
||||
} catch (error) {
|
||||
console.error('GIF Export error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
} finally {
|
||||
this.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.cancelled = true;
|
||||
if (this.gif) {
|
||||
this.gif.abort();
|
||||
}
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
if (this.decoder) {
|
||||
try {
|
||||
this.decoder.destroy();
|
||||
} catch (e) {
|
||||
console.warn('Error destroying decoder:', e);
|
||||
}
|
||||
this.decoder = null;
|
||||
}
|
||||
|
||||
if (this.renderer) {
|
||||
try {
|
||||
this.renderer.destroy();
|
||||
} catch (e) {
|
||||
console.warn('Error destroying renderer:', e);
|
||||
}
|
||||
this.renderer = null;
|
||||
}
|
||||
|
||||
this.gif = null;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,23 @@ export { VideoExporter } from './videoExporter';
|
||||
export { VideoFileDecoder } from './videoDecoder';
|
||||
export { FrameRenderer } from './frameRenderer';
|
||||
export { VideoMuxer } from './muxer';
|
||||
export type { ExportConfig, ExportProgress, ExportResult, VideoFrameData } from './types';
|
||||
export { GifExporter, calculateOutputDimensions } from './gifExporter';
|
||||
export type {
|
||||
ExportConfig,
|
||||
ExportProgress,
|
||||
ExportResult,
|
||||
VideoFrameData,
|
||||
ExportQuality,
|
||||
ExportFormat,
|
||||
GifFrameRate,
|
||||
GifSizePreset,
|
||||
GifExportConfig,
|
||||
ExportSettings,
|
||||
} from './types';
|
||||
export {
|
||||
GIF_SIZE_PRESETS,
|
||||
GIF_FRAME_RATES,
|
||||
VALID_GIF_FRAME_RATES,
|
||||
isValidGifFrameRate
|
||||
} from './types';
|
||||
|
||||
|
||||
// Ref: https://pietrasiak.com/fast-video-rendering-and-encoding-using-web-apis
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as fc from 'fast-check';
|
||||
import {
|
||||
isValidGifFrameRate,
|
||||
VALID_GIF_FRAME_RATES,
|
||||
GifFrameRate
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Property 1: Valid Frame Rate Acceptance
|
||||
*
|
||||
* *For any* frame rate value, the GIF_Exporter SHALL accept it if and only if
|
||||
* it is one of the valid presets (15, 20, 25, 30 FPS). Invalid frame rates
|
||||
* should be rejected with an error.
|
||||
*
|
||||
* **Validates: Requirements 2.2**
|
||||
*
|
||||
* Feature: gif-export, Property 1: Valid Frame Rate Acceptance
|
||||
*/
|
||||
describe('GIF Export Types', () => {
|
||||
describe('Property 1: Valid Frame Rate Acceptance', () => {
|
||||
// Property test: Valid frame rates should be accepted
|
||||
it('should accept all valid frame rates (15, 20, 25, 30)', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom(...VALID_GIF_FRAME_RATES),
|
||||
(frameRate: GifFrameRate) => {
|
||||
expect(isValidGifFrameRate(frameRate)).toBe(true);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
// Property test: Invalid frame rates should be rejected
|
||||
it('should reject any frame rate not in the valid set', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer().filter(n => !VALID_GIF_FRAME_RATES.includes(n as GifFrameRate)),
|
||||
(invalidFrameRate: number) => {
|
||||
expect(isValidGifFrameRate(invalidFrameRate)).toBe(false);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
// Property test: Frame rate validation is deterministic
|
||||
it('should return consistent results for the same input', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 1, max: 60 }),
|
||||
(frameRate: number) => {
|
||||
const result1 = isValidGifFrameRate(frameRate);
|
||||
const result2 = isValidGifFrameRate(frameRate);
|
||||
expect(result1).toBe(result2);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,8 @@ export interface ExportProgress {
|
||||
totalFrames: number;
|
||||
percentage: number;
|
||||
estimatedTimeRemaining: number; // in seconds
|
||||
phase?: 'extracting' | 'finalizing'; // Phase of export
|
||||
renderProgress?: number; // 0-100, progress of GIF rendering phase
|
||||
}
|
||||
|
||||
export interface ExportResult {
|
||||
@@ -24,3 +26,48 @@ export interface VideoFrameData {
|
||||
timestamp: number; // in microseconds
|
||||
duration: number; // in microseconds
|
||||
}
|
||||
|
||||
export type ExportQuality = 'medium' | 'good' | 'source';
|
||||
|
||||
// GIF Export Types
|
||||
export type ExportFormat = 'mp4' | 'gif';
|
||||
|
||||
export type GifFrameRate = 15 | 20 | 25 | 30;
|
||||
|
||||
export type GifSizePreset = 'medium' | 'large' | 'original';
|
||||
|
||||
export interface GifExportConfig {
|
||||
frameRate: GifFrameRate;
|
||||
loop: boolean;
|
||||
sizePreset: GifSizePreset;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface ExportSettings {
|
||||
format: ExportFormat;
|
||||
// MP4 settings
|
||||
quality?: ExportQuality;
|
||||
// GIF settings
|
||||
gifConfig?: GifExportConfig;
|
||||
}
|
||||
|
||||
export const GIF_SIZE_PRESETS: Record<GifSizePreset, { maxHeight: number; label: string }> = {
|
||||
medium: { maxHeight: 720, label: 'Medium (720p)' },
|
||||
large: { maxHeight: 1080, label: 'Large (1080p)' },
|
||||
original: { maxHeight: Infinity, label: 'Original' },
|
||||
};
|
||||
|
||||
export const GIF_FRAME_RATES: { value: GifFrameRate; label: string }[] = [
|
||||
{ value: 15, label: '15 FPS - Balanced' },
|
||||
{ value: 20, label: '20 FPS - Smooth' },
|
||||
{ value: 25, label: '25 FPS - Very smooth' },
|
||||
{ value: 30, label: '30 FPS - Maximum' },
|
||||
];
|
||||
|
||||
// Valid frame rates for validation
|
||||
export const VALID_GIF_FRAME_RATES: readonly GifFrameRate[] = [15, 20, 25, 30] as const;
|
||||
|
||||
export function isValidGifFrameRate(rate: number): rate is GifFrameRate {
|
||||
return VALID_GIF_FRAME_RATES.includes(rate as GifFrameRate);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ExportConfig, ExportProgress, ExportResult } from './types';
|
||||
import { VideoFileDecoder } from './videoDecoder';
|
||||
import { FrameRenderer } from './frameRenderer';
|
||||
import { VideoMuxer } from './muxer';
|
||||
import type { ZoomRegion, CropRegion, TrimRegion } from '@/components/video-editor/types';
|
||||
import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion } from '@/components/video-editor/types';
|
||||
|
||||
interface VideoExporterConfig extends ExportConfig {
|
||||
videoUrl: string;
|
||||
@@ -12,7 +12,14 @@ interface VideoExporterConfig extends ExportConfig {
|
||||
showShadow: boolean;
|
||||
shadowIntensity: number;
|
||||
showBlur: boolean;
|
||||
motionBlurEnabled?: boolean;
|
||||
borderRadius?: number;
|
||||
padding?: number;
|
||||
videoPadding?: number;
|
||||
cropRegion: CropRegion;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
previewWidth?: number;
|
||||
previewHeight?: number;
|
||||
onProgress?: (progress: ExportProgress) => void;
|
||||
}
|
||||
|
||||
@@ -49,20 +56,20 @@ export class VideoExporter {
|
||||
const trimRegions = this.config.trimRegions || [];
|
||||
// Sort trim regions by start time
|
||||
const sortedTrims = [...trimRegions].sort((a, b) => a.startMs - b.startMs);
|
||||
|
||||
|
||||
let sourceTimeMs = effectiveTimeMs;
|
||||
|
||||
|
||||
for (const trim of sortedTrims) {
|
||||
// If the source time hasn't reached this trim region yet, we're done
|
||||
if (sourceTimeMs < trim.startMs) {
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
// Add the duration of this trim region to the source time
|
||||
const trimDuration = trim.endMs - trim.startMs;
|
||||
sourceTimeMs += trimDuration;
|
||||
}
|
||||
|
||||
|
||||
return sourceTimeMs;
|
||||
}
|
||||
|
||||
@@ -84,9 +91,15 @@ export class VideoExporter {
|
||||
showShadow: this.config.showShadow,
|
||||
shadowIntensity: this.config.shadowIntensity,
|
||||
showBlur: this.config.showBlur,
|
||||
motionBlurEnabled: this.config.motionBlurEnabled,
|
||||
borderRadius: this.config.borderRadius,
|
||||
padding: this.config.padding,
|
||||
cropRegion: this.config.cropRegion,
|
||||
videoWidth: videoInfo.width,
|
||||
videoHeight: videoInfo.height,
|
||||
annotationRegions: this.config.annotationRegions,
|
||||
previewWidth: this.config.previewWidth,
|
||||
previewHeight: this.config.previewHeight,
|
||||
});
|
||||
await this.renderer.initialize();
|
||||
|
||||
@@ -119,7 +132,7 @@ export class VideoExporter {
|
||||
while (frameIndex < totalFrames && !this.cancelled) {
|
||||
const i = frameIndex;
|
||||
const timestamp = i * frameDuration;
|
||||
|
||||
|
||||
// Map effective time to source time (accounting for trim regions)
|
||||
const effectiveTimeMs = (i * timeStep) * 1000;
|
||||
const sourceTimeMs = this.mapEffectiveToSourceTime(effectiveTimeMs);
|
||||
@@ -127,7 +140,7 @@ export class VideoExporter {
|
||||
|
||||
// Seek if needed or wait for first frame to be ready
|
||||
const needsSeek = Math.abs(videoElement.currentTime - videoTime) > 0.001;
|
||||
|
||||
|
||||
if (needsSeek) {
|
||||
// Attach listener BEFORE setting currentTime to avoid race condition
|
||||
const seekedPromise = new Promise<void>(resolve => {
|
||||
@@ -180,10 +193,11 @@ export class VideoExporter {
|
||||
} else {
|
||||
console.warn(`[Frame ${i}] Encoder not ready! State: ${this.encoder?.state}`);
|
||||
}
|
||||
|
||||
exportFrame.close();
|
||||
|
||||
frameIndex++;
|
||||
|
||||
|
||||
// Update progress
|
||||
if (this.config.onProgress) {
|
||||
this.config.onProgress({
|
||||
@@ -240,11 +254,11 @@ export class VideoExporter {
|
||||
if (meta?.decoderConfig?.colorSpace && !this.videoColorSpace) {
|
||||
this.videoColorSpace = meta.decoderConfig.colorSpace;
|
||||
}
|
||||
|
||||
|
||||
// Stream chunk to muxer immediately (parallel processing)
|
||||
const isFirstChunk = this.chunkCount === 0;
|
||||
this.chunkCount++;
|
||||
|
||||
|
||||
const muxingPromise = (async () => {
|
||||
try {
|
||||
if (isFirstChunk && this.videoDescription) {
|
||||
@@ -255,7 +269,7 @@ export class VideoExporter {
|
||||
matrix: 'rgb',
|
||||
fullRange: true,
|
||||
};
|
||||
|
||||
|
||||
const metadata: EncodedVideoChunkMetadata = {
|
||||
decoderConfig: {
|
||||
codec: this.config.codec || 'avc1.640033',
|
||||
@@ -265,7 +279,7 @@ export class VideoExporter {
|
||||
colorSpace,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
await this.muxer!.addVideoChunk(chunk, metadata);
|
||||
} else {
|
||||
await this.muxer!.addVideoChunk(chunk, meta);
|
||||
@@ -274,7 +288,7 @@ export class VideoExporter {
|
||||
console.error('Muxing error:', error);
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
this.muxingPromises.push(muxingPromise);
|
||||
this.encodeQueue--;
|
||||
},
|
||||
@@ -300,7 +314,7 @@ export class VideoExporter {
|
||||
|
||||
// Check hardware support first
|
||||
const hardwareSupport = await VideoEncoder.isConfigSupported(encoderConfig);
|
||||
|
||||
|
||||
if (hardwareSupport.supported) {
|
||||
// Use hardware encoding
|
||||
console.log('[VideoExporter] Using hardware acceleration');
|
||||
|
||||
@@ -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(':', '/');
|
||||
}
|
||||
@@ -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(' + ');
|
||||
};
|
||||
@@ -7,6 +7,20 @@ module.exports = {
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: "0" },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: "0" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
|
||||
@@ -25,5 +25,6 @@
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src", "electron"],
|
||||
"exclude": ["**/*.test.ts", "**/*.test.tsx"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import path from 'node:path'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
})
|
||||