Merge branch 'main' into feat-add-biome-formatter-linter

This commit is contained in:
Idris Gadi
2026-02-22 09:34:51 +05:30
39 changed files with 16343 additions and 13910 deletions
-38
View File
@@ -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.
+149
View File
@@ -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
-20
View File
@@ -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
+91 -94
View File
@@ -1,94 +1,91 @@
<p align="center">
<img src="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 !)
**⚠️ 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 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 youd like to help out or see whats 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.
> [!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
```
Note: Give your terminal Full Disk Access in **System Settings > Privacy & Security** to grant you access and then run the above command.
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.
**Note:** If the app fails to launch due to a "sandbox" error, run it with --no-sandbox:
```bash
./Openscreen-Linux-*.AppImage --no-sandbox
```
## 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 youd like to help out or see whats 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.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 853 KiB

+14645 -13271
View File
File diff suppressed because it is too large Load Diff
+10 -7
View File
@@ -1,14 +1,12 @@
{
"name": "openscreen",
"private": true,
"version": "1.1.0",
"version": "1.1.3",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build && electron-builder",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write .",
"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",
@@ -53,20 +51,25 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"uuid": "^13.0.0"
"uuid": "^13.0.0",
"web-demuxer": "^4.0.0"
},
"devDependencies": {
"@biomejs/biome": "2.3.13",
"@types/node": "^25.0.3",
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^7.1.1",
"@typescript-eslint/parser": "^7.1.1",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.21",
"electron": "^39.2.7",
"electron-builder": "^24.13.3",
"electron-builder": "^26.7.0",
"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",
-34
View File
@@ -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

-26
View File
@@ -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

View File

Before

Width:  |  Height:  |  Size: 776 KiB

After

Width:  |  Height:  |  Size: 776 KiB

Before

Width:  |  Height:  |  Size: 534 KiB

After

Width:  |  Height:  |  Size: 534 KiB

Before

Width:  |  Height:  |  Size: 627 KiB

After

Width:  |  Height:  |  Size: 627 KiB

Before

Width:  |  Height:  |  Size: 460 KiB

After

Width:  |  Height:  |  Size: 460 KiB

BIN
View File
Binary file not shown.
+6
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { LaunchWindow } from "./components/launch/LaunchWindow";
import { SourceSelector } from "./components/launch/SourceSelector";
import VideoEditor from "./components/video-editor/VideoEditor";
import { loadAllCustomFonts } from "./lib/customFonts";
export default function App() {
const [windowType, setWindowType] = useState('');
@@ -15,6 +16,11 @@ export default function App() {
document.documentElement.style.background = 'transparent';
document.getElementById('root')?.style.setProperty('background', 'transparent');
}
// Load custom fonts on app initialization
loadAllCustomFonts().catch((error) => {
console.error('Failed to load custom fonts:', error);
});
}, []);
switch (windowType) {
@@ -2,6 +2,11 @@
-webkit-app-region: drag;
}
.hudBar {
isolation: isolate;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
}
.electronNoDrag {
-webkit-app-region: no-drag;
}
+5 -6
View File
@@ -96,14 +96,13 @@ export function LaunchWindow() {
return (
<div className="w-full h-full flex items-center bg-transparent">
<div
className={`w-full max-w-[500px] mx-auto flex items-center justify-between px-4 py-2 ${styles.electronDrag}`}
className={`w-full max-w-[500px] mx-auto flex items-center justify-between px-4 py-2 ${styles.electronDrag} ${styles.hudBar}`}
style={{
borderRadius: 16,
background: 'linear-gradient(135deg, rgba(30,30,40,0.92) 0%, rgba(20,20,30,0.85) 100%)',
backdropFilter: 'blur(32px) saturate(180%)',
WebkitBackdropFilter: 'blur(32px) saturate(180%)',
boxShadow: '0 4px 24px 0 rgba(0,0,0,0.28), 0 1px 3px 0 rgba(0,0,0,0.14) inset',
border: '1px solid rgba(80,80,120,0.22)',
background: 'linear-gradient(135deg, rgba(28,28,36,0.97) 0%, rgba(18,18,26,0.96) 100%)',
backdropFilter: 'blur(16px) saturate(140%)',
WebkitBackdropFilter: 'blur(16px) saturate(140%)',
border: '1px solid rgba(80,80,120,0.25)',
minHeight: 44,
}}
>
@@ -39,3 +39,32 @@
color: #a1a1aa;
font-size: 0.75rem;
}
/* scrollbar */
.sourceGridScroll {
scrollbar-width: thin;
scrollbar-color: rgba(52, 178, 123, 0.5) rgba(40, 40, 50, 0.6);
}
.sourceGridScroll::-webkit-scrollbar {
width: 8px;
}
.sourceGridScroll::-webkit-scrollbar-track {
background: rgba(30, 30, 38, 0.5);
border-radius: 4px;
margin: 4px 0;
}
.sourceGridScroll::-webkit-scrollbar-thumb {
background: rgba(80, 80, 100, 0.6);
border-radius: 4px;
}
.sourceGridScroll::-webkit-scrollbar-thumb:hover {
background: rgba(52, 178, 123, 0.6);
}
.sourceGridScroll::-webkit-scrollbar-thumb:active {
background: rgba(52, 178, 123, 0.8);
}
+2 -2
View File
@@ -77,7 +77,7 @@ export function SourceSelector() {
</TabsList>
<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">
<div className={`grid grid-cols-2 gap-2 h-full overflow-y-auto pr-1 relative ${styles.sourceGridScroll}`}>
{screenSources.map(source => (
<Card
key={source.id}
@@ -107,7 +107,7 @@ export function SourceSelector() {
</div>
</TabsContent>
<TabsContent value="windows" className="h-full">
<div className="grid grid-cols-2 gap-2 h-full overflow-y-auto pr-1 relative">
<div className={`grid grid-cols-2 gap-2 h-full overflow-y-auto pr-1 relative ${styles.sourceGridScroll}`}>
{windowSources.map(source => (
<Card
key={source.id}
+2 -2
View File
@@ -19,7 +19,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-[9999] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
@@ -36,7 +36,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"fixed left-[50%] top-[50%] z-[10000] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
+24
View File
@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
+23
View File
@@ -0,0 +1,23 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface LabelProps
extends React.LabelHTMLAttributes<HTMLLabelElement> {}
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
({ className, ...props }, ref) => {
return (
<label
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className
)}
{...props}
/>
)
}
)
Label.displayName = "Label"
export { Label }
@@ -0,0 +1,181 @@
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Plus } from 'lucide-react';
import { toast } from 'sonner';
import {
addCustomFont,
generateFontId,
parseFontFamilyFromImport,
isValidGoogleFontsUrl,
type CustomFont,
} from '@/lib/customFonts';
interface AddCustomFontDialogProps {
onFontAdded?: (font: CustomFont) => void;
}
export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
const [open, setOpen] = useState(false);
const [importUrl, setImportUrl] = useState('');
const [fontName, setFontName] = useState('');
const [loading, setLoading] = useState(false);
const handleImportUrlChange = (url: string) => {
setImportUrl(url);
// Auto-extract font name if valid Google Fonts URL
if (isValidGoogleFontsUrl(url)) {
const extracted = parseFontFamilyFromImport(url);
if (extracted && !fontName) {
setFontName(extracted);
}
}
};
const handleAdd = async () => {
// Validate inputs
if (!importUrl.trim()) {
toast.error('Please enter a Google Fonts import URL');
return;
}
if (!isValidGoogleFontsUrl(importUrl)) {
toast.error('Please enter a valid Google Fonts URL');
return;
}
if (!fontName.trim()) {
toast.error('Please enter a font name');
return;
}
setLoading(true);
try {
// Extract font family from URL
const fontFamily = parseFontFamilyFromImport(importUrl);
if (!fontFamily) {
toast.error('Could not extract font family from URL');
setLoading(false);
return;
}
// Create custom font object
const newFont: CustomFont = {
id: generateFontId(fontName),
name: fontName.trim(),
fontFamily: fontFamily,
importUrl: importUrl.trim(),
};
// Add font (this will load and verify it) - throws if it fails
await addCustomFont(newFont);
// Notify parent
if (onFontAdded) {
onFontAdded(newFont);
}
toast.success(`Font "${fontName}" added successfully`);
// Reset and close
setImportUrl('');
setFontName('');
setOpen(false);
} catch (error) {
console.error('Failed to add custom font:', error);
const errorMessage = error instanceof Error ? error.message : 'Failed to load font';
toast.error('Failed to add font', {
description: errorMessage.includes('timeout')
? 'Font took too long to load. Please check the URL and try again.'
: 'The font could not be loaded. Please verify the Google Fonts URL is correct.',
});
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10 h-9 text-xs"
>
<Plus className="w-3 h-3 mr-1" />
Add Google Font
</Button>
</DialogTrigger>
<DialogContent className="bg-[#1a1a1c] border-white/10 text-slate-200">
<DialogHeader>
<DialogTitle>Add Google Font</DialogTitle>
<DialogDescription className="text-slate-400">
Add a custom font from Google Fonts to use in your annotations.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 mt-4">
<div className="space-y-2">
<Label htmlFor="import-url" className="text-slate-200">
Google Fonts Import URL
</Label>
<Input
id="import-url"
placeholder="https://fonts.googleapis.com/css2?family=Roboto&display=swap"
value={importUrl}
onChange={(e) => handleImportUrlChange(e.target.value)}
className="bg-white/5 border-white/10 text-slate-200"
/>
<p className="text-xs text-slate-400">
Get this from Google Fonts: Select a font Click "Get font" Copy the @import URL
</p>
</div>
<div className="space-y-2">
<Label htmlFor="font-name" className="text-slate-200">
Display Name
</Label>
<Input
id="font-name"
placeholder="My Custom Font"
value={fontName}
onChange={(e) => setFontName(e.target.value)}
className="bg-white/5 border-white/10 text-slate-200"
/>
<p className="text-xs text-slate-400">
This is how the font will appear in the font selector
</p>
</div>
<div className="flex justify-end gap-2 mt-6">
<Button
variant="outline"
onClick={() => setOpen(false)}
className="bg-white/5 border-white/10 text-slate-200 hover:bg-white/10"
>
Cancel
</Button>
<Button
onClick={handleAdd}
disabled={loading}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
{loading ? 'Adding...' : 'Add Font'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,4 +1,4 @@
import {useRef } from "react";
import { useRef, useState, useEffect } 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";
@@ -11,6 +11,8 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Slider } from "@/components/ui/slider";
import { cn } from "@/lib/utils";
import { getArrowComponent } from "./ArrowSvgs";
import { AddCustomFontDialog } from "./AddCustomFontDialog";
import { getCustomFonts, type CustomFont } from "@/lib/customFonts";
interface AnnotationSettingsPanelProps {
annotation: AnnotationRegion;
@@ -43,6 +45,13 @@ export function AnnotationSettingsPanel({
onDelete,
}: AnnotationSettingsPanelProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [customFonts, setCustomFonts] = useState<CustomFont[]>([]);
// Load custom fonts on mount
useEffect(() => {
setCustomFonts(getCustomFonts());
}, []);
const colorPalette = [
'#FF0000', // Red
'#FFD700', // Yellow/Gold
@@ -148,19 +157,35 @@ export function AnnotationSettingsPanel({
<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}
<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">
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200 max-h-[300px]">
{FONT_FAMILIES.map((font) => (
<SelectItem key={font.value} value={font.value} style={{ fontFamily: font.value }}>
{font.label}
</SelectItem>
))}
{customFonts.length > 0 && (
<>
<div className="px-2 py-1.5 text-[10px] font-medium text-slate-400 uppercase tracking-wider">
Custom Fonts
</div>
{customFonts.map((font) => (
<SelectItem
key={font.id}
value={font.fontFamily}
style={{ fontFamily: font.fontFamily }}
>
{font.name}
</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
</div>
@@ -184,6 +209,16 @@ export function AnnotationSettingsPanel({
</div>
</div>
{/* Add Custom Font Button */}
<div>
<AddCustomFontDialog
onFontAdded={(font) => {
setCustomFonts(getCustomFonts());
onStyleChange({ fontFamily: font.fontFamily });
}}
/>
</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">
+43 -50
View File
@@ -438,42 +438,6 @@ export default function VideoEditor() {
}
}, [selectedAnnotationId, annotationRegions]);
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]);
const handleExport = useCallback(async (settings: ExportSettings) => {
if (!videoPath) {
toast.error('No video loaded');
@@ -496,17 +460,10 @@ 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 aspectRatioValue = getAspectRatioValue(aspectRatio);
const sourceWidth = video.videoWidth || 1920;
const sourceHeight = video.videoHeight || 1080;
// Get preview CONTAINER dimensions for scaling
const playbackRef = videoPlaybackRef.current;
const containerElement = playbackRef?.containerRef?.current;
@@ -548,9 +505,9 @@ export default function VideoEditor() {
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) {
@@ -625,11 +582,11 @@ export default function VideoEditor() {
} 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) {
@@ -673,9 +630,9 @@ export default function VideoEditor() {
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) {
@@ -708,6 +665,42 @@ export default function VideoEditor() {
}
}, [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) {
exporterRef.current.cancel();
+60 -35
View File
@@ -1,3 +1,4 @@
import { useMemo } from "react";
import { useItem } from "dnd-timeline";
import type { Span } from "dnd-timeline";
import { cn } from "@/lib/utils";
@@ -25,6 +26,16 @@ const ZOOM_LABELS: Record<number, string> = {
6: "5×",
};
function formatMs(ms: number): string {
const totalSeconds = ms / 1000;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes > 0) {
return `${minutes}:${seconds.toFixed(1).padStart(4, '0')}`;
}
return `${seconds.toFixed(1)}s`;
}
export default function Item({
id,
span,
@@ -43,19 +54,24 @@ export default function Item({
const isZoom = variant === 'zoom';
const isTrim = variant === 'trim';
const glassClass = isZoom
? glassStyles.glassGreen
: isTrim
? glassStyles.glassRed
const glassClass = isZoom
? glassStyles.glassGreen
: isTrim
? glassStyles.glassRed
: glassStyles.glassYellow;
const endCapColor = isZoom
? '#21916A'
: isTrim
? '#ef4444'
const endCapColor = isZoom
? '#21916A'
: isTrim
? '#ef4444'
: '#B4A046';
const timeLabel = useMemo(
() => `${formatMs(span.start)} ${formatMs(span.end)}`,
[span.start, span.end],
);
return (
<div
ref={setNodeRef}
@@ -65,14 +81,14 @@ export default function Item({
onPointerDownCapture={() => onSelect?.()}
className="group"
>
<div style={itemContentStyle}>
<div style={{ ...itemContentStyle, minWidth: 24 }}>
<div
className={cn(
glassClass,
"w-full h-full overflow-hidden flex items-center justify-center gap-1.5 cursor-grab active:cursor-grabbing relative",
isSelected && glassStyles.selected
)}
style={{ height: 40, color: '#fff' }}
style={{ height: 40, color: '#fff', minWidth: 24 }}
onClick={(event) => {
event.stopPropagation();
onSelect?.();
@@ -89,29 +105,38 @@ export default function Item({
title="Resize right"
/>
{/* Content */}
<div className="relative z-10 flex items-center gap-1.5 text-white/90 opacity-80 group-hover:opacity-100 transition-opacity select-none">
{isZoom ? (
<>
<ZoomIn className="w-3.5 h-3.5" />
<span className="text-[11px] font-semibold tracking-tight">
{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 className="relative z-10 flex flex-col items-center justify-center text-white/90 opacity-80 group-hover:opacity-100 transition-opacity select-none overflow-hidden">
<div className="flex items-center gap-1.5">
{isZoom ? (
<>
<ZoomIn className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
{ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
</span>
</>
) : isTrim ? (
<>
<Scissors className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
Trim
</span>
</>
) : (
<>
<MessageSquare className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
{children}
</span>
</>
)}
</div>
<span
className={`text-[9px] tabular-nums tracking-tight whitespace-nowrap transition-opacity ${
isSelected ? 'opacity-60' : 'opacity-0 group-hover:opacity-40'
}`}
>
{timeLabel}
</span>
</div>
</div>
</div>
@@ -83,7 +83,7 @@
width: 4px;
pointer-events: none;
z-index: 2;
opacity: 0;
opacity: 0.45;
transition: opacity 0.2s, width 0.2s;
}
@@ -1,4 +1,4 @@
import React from "react";
import React, { useState, useEffect } from "react";
import { useTimelineContext } from "dnd-timeline";
interface Keyframe {
@@ -10,25 +10,80 @@ interface KeyframeMarkersProps {
keyframes: Keyframe[];
selectedKeyframeId: string | null;
setSelectedKeyframeId: (id: string | null) => void;
onKeyframeMove: (id: string, newTime: number) => void;
videoDurationMs: number;
timelineRef: React.RefObject<HTMLDivElement>;
}
const KeyframeMarkers: React.FC<KeyframeMarkersProps> = ({ keyframes, selectedKeyframeId, setSelectedKeyframeId }) => {
const { sidebarWidth, range, valueToPixels } = useTimelineContext();
const KeyframeMarkers: React.FC<KeyframeMarkersProps> = ({
keyframes,
selectedKeyframeId,
setSelectedKeyframeId,
onKeyframeMove,
videoDurationMs,
timelineRef
}) => {
const { sidebarWidth, range, valueToPixels, pixelsToValue } = useTimelineContext();
const [draggingKeyframeId, setDraggingKeyframeId] = useState<string | null>(null);
useEffect(() => {
if (!draggingKeyframeId) return;
const handleMouseMove = (e: MouseEvent) => {
if (!timelineRef.current) return;
const rect = timelineRef.current.getBoundingClientRect();
const clickX = e.clientX - rect.left - sidebarWidth;
const relativeMs = pixelsToValue(clickX);
const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
// Update the keyframe position in real-time
onKeyframeMove(draggingKeyframeId, absoluteMs);
};
const handleMouseUp = () => {
setDraggingKeyframeId(null);
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 = '';
};
}, [draggingKeyframeId, onKeyframeMove, timelineRef, sidebarWidth, range.start, videoDurationMs, pixelsToValue]);
return (
<>
{keyframes.map(kf => {
const offset = valueToPixels(kf.time - range.start);
const isSelected = kf.id === selectedKeyframeId;
const isDragging = kf.id === draggingKeyframeId;
return (
<div
key={kf.id}
className={`absolute top-8 cursor-pointer ${isSelected ? 'ring-2 ring-[#34B27B]' : ''}`}
style={{ left: `${sidebarWidth + offset - 8}px`, zIndex: 40 }}
onClick={e => {
className={`absolute top-8 cursor-grab active:cursor-grabbing ${isSelected ? 'ring-2 ring-[#34B27B]' : ''}`}
style={{
left: `${sidebarWidth + offset - 8}px`,
zIndex: isDragging ? 50 : 40,
transition: isDragging ? 'none' : 'left 0.1s ease-out'
}}
onMouseDown={e => {
e.stopPropagation();
setSelectedKeyframeId(kf.id);
setDraggingKeyframeId(kf.id);
}}
onContextMenu={e => {
e.preventDefault();
e.stopPropagation();
setSelectedKeyframeId(kf.id);
}}
title={`Keyframe @ ${kf.time}ms`}
title={`Keyframe @ ${Math.round(kf.time)}ms (drag to move, Delete/Backspace to remove)`}
>
<div style={{
width: '10px',
@@ -36,7 +91,6 @@ const KeyframeMarkers: React.FC<KeyframeMarkersProps> = ({ keyframes, selectedKe
background: '#ffe100ff',
transform: 'rotate(45deg)',
border: 'none',
opacity: isSelected ? 1 : 0.6,
transition: 'opacity 0.15s',
}} />
+20 -3
View File
@@ -3,19 +3,36 @@ import type { RowDefinition } from "dnd-timeline";
interface RowProps extends RowDefinition {
children: React.ReactNode;
label?: string;
hint?: string;
isEmpty?: boolean;
labelColor?: string;
}
export default function Row({ id, children }: RowProps) {
export default function Row({ id, children, label, hint, isEmpty, labelColor = '#666' }: RowProps) {
const { setNodeRef, rowWrapperStyle, rowStyle } = useRow({ id });
return (
<div
className="border-b border-[#18181b] bg-[#18181b]"
className="border-b border-[#18181b] bg-[#18181b] relative"
style={{ ...rowWrapperStyle, minHeight: 48, marginBottom: 4 }}
>
{label && (
<div
className="absolute left-1.5 top-1/2 -translate-y-1/2 text-[9px] font-semibold uppercase tracking-widest z-20 pointer-events-none select-none"
style={{ color: labelColor, writingMode: 'horizontal-tb' }}
>
{label}
</div>
)}
{isEmpty && hint && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none select-none z-10">
<span className="text-[11px] text-white/15 font-medium">{hint}</span>
</div>
)}
<div ref={setNodeRef} style={rowStyle}>
{children}
</div>
</div>
);
}
}
@@ -17,7 +17,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { type AspectRatio, getAspectRatioLabel } from "@/utils/aspectRatioUtils";
import { type AspectRatio, getAspectRatioLabel, ASPECT_RATIOS } from "@/utils/aspectRatioUtils";
import { formatShortcut } from "@/utils/platformUtils";
import { TutorialHelp } from "../TutorialHelp";
@@ -155,16 +155,26 @@ function formatTimeLabel(milliseconds: number, intervalMs: number) {
return `${minutes}:${Math.floor(seconds).toString().padStart(2, "0")}`;
}
function PlaybackCursor({
currentTimeMs,
function formatPlayheadTime(ms: number): string {
const s = ms / 1000;
const min = Math.floor(s / 60);
const sec = s % 60;
if (min > 0) return `${min}:${sec.toFixed(1).padStart(4, '0')}`;
return `${sec.toFixed(1)}s`;
}
function PlaybackCursor({
currentTimeMs,
videoDurationMs,
onSeek,
timelineRef,
}: {
currentTimeMs: number;
keyframes = [],
}: {
currentTimeMs: number;
videoDurationMs: number;
onSeek?: (time: number) => void;
timelineRef: React.RefObject<HTMLDivElement>;
keyframes?: { id: string; time: number }[];
}) {
const { sidebarWidth, direction, range, valueToPixels, pixelsToValue } = useTimelineContext();
const sideProperty = direction === "rtl" ? "right" : "left";
@@ -175,14 +185,26 @@ function PlaybackCursor({
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));
let absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
// Snap to nearby keyframe if within threshold (150ms)
const snapThresholdMs = 150;
const nearbyKeyframe = keyframes.find(kf =>
Math.abs(kf.time - absoluteMs) <= snapThresholdMs &&
kf.time >= range.start &&
kf.time <= range.end
);
if (nearbyKeyframe) {
absoluteMs = nearbyKeyframe.time;
}
onSeek(absoluteMs / 1000);
};
@@ -200,14 +222,14 @@ function PlaybackCursor({
window.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = '';
};
}, [isDragging, onSeek, timelineRef, sidebarWidth, range.start, videoDurationMs, pixelsToValue]);
}, [isDragging, onSeek, timelineRef, sidebarWidth, range.start, range.end, videoDurationMs, pixelsToValue, keyframes]);
if (videoDurationMs <= 0 || currentTimeMs < 0) {
return null;
}
const clampedTime = Math.min(currentTimeMs, videoDurationMs);
if (clampedTime < range.start || clampedTime > range.end) {
return null;
}
@@ -238,6 +260,11 @@ function PlaybackCursor({
>
<div className="w-3 h-3 mx-auto mt-[2px] bg-[#34B27B] rotate-45 rounded-sm shadow-lg border border-white/20" />
</div>
{isDragging && (
<div className="absolute -top-6 left-1/2 -translate-x-1/2 px-1.5 py-0.5 rounded bg-black/80 text-[10px] text-white/90 font-medium tabular-nums whitespace-nowrap border border-white/10 shadow-lg pointer-events-none">
{formatPlayheadTime(clampedTime)}
</div>
)}
</div>
</div>
);
@@ -276,7 +303,7 @@ function TimelineAxis({
if (visibleStart <= maxTime) {
markerTimes.add(Math.round(visibleStart));
}
if (videoDurationMs > 0) {
markerTimes.add(Math.round(videoDurationMs));
}
@@ -288,7 +315,7 @@ function TimelineAxis({
// Generate minor ticks (4 ticks between major intervals)
const minorTicks = [];
const minorInterval = intervalMs / 5;
for (let time = firstMarker; time <= maxTime; time += minorInterval) {
if (time >= visibleStart && time <= visibleEnd) {
// Skip if it's close to a major marker
@@ -299,12 +326,12 @@ function TimelineAxis({
}
}
return {
return {
markers: sorted.map((time) => ({
time,
label: formatTimeLabel(time, intervalMs),
})),
minorTicks
})),
minorTicks
};
}, [intervalMs, range.end, range.start, videoDurationMs]);
@@ -372,6 +399,7 @@ function Timeline({
selectedZoomId,
selectedTrimId,
selectedAnnotationId,
keyframes = [],
}: {
items: TimelineRenderItem[];
videoDurationMs: number;
@@ -384,6 +412,7 @@ function Timeline({
selectedZoomId: string | null;
selectedTrimId?: string | null;
selectedAnnotationId?: string | null;
keyframes?: { id: string; time: number }[];
}) {
const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext();
const localTimelineRef = useRef<HTMLDivElement | null>(null);
@@ -395,7 +424,7 @@ function Timeline({
const handleTimelineClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!onSeek || videoDurationMs <= 0) return;
// Only clear selection if clicking on empty space (not on items)
// This is handled by event propagation - items stop propagation
onSelectZoom?.(null);
@@ -404,13 +433,13 @@ function Timeline({
const rect = e.currentTarget.getBoundingClientRect();
const clickX = e.clientX - rect.left - sidebarWidth;
if (clickX < 0) return;
const relativeMs = pixelsToValue(clickX);
const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
const timeInSeconds = absoluteMs / 1000;
onSeek(timeInSeconds);
}, [onSeek, onSelectZoom, onSelectTrim, onSelectAnnotation, videoDurationMs, sidebarWidth, range.start, pixelsToValue]);
@@ -427,14 +456,15 @@ function Timeline({
>
<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}
keyframes={keyframes}
/>
<Row id={ZOOM_ROW_ID}>
<Row id={ZOOM_ROW_ID} isEmpty={zoomItems.length === 0} hint="Press Z to add zoom">
{zoomItems.map((item) => (
<Item
id={item.id}
@@ -451,7 +481,7 @@ function Timeline({
))}
</Row>
<Row id={TRIM_ROW_ID}>
<Row id={TRIM_ROW_ID} isEmpty={trimItems.length === 0} hint="Press T to add trim">
{trimItems.map((item) => (
<Item
id={item.id}
@@ -467,7 +497,7 @@ function Timeline({
))}
</Row>
<Row id={ANNOTATION_ROW_ID}>
<Row id={ANNOTATION_ROW_ID} isEmpty={annotationItems.length === 0} hint="Press A to add annotation">
{annotationItems.map((item) => (
<Item
id={item.id}
@@ -526,6 +556,7 @@ export default function TimelineEditor({
pan: 'Shift + Ctrl + Scroll',
zoom: 'Ctrl + Scroll'
});
const timelineContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
formatShortcut(['shift', 'mod', 'Scroll']).then(pan => {
@@ -550,6 +581,11 @@ export default function TimelineEditor({
setSelectedKeyframeId(null);
}, [selectedKeyframeId]);
// Move keyframe to new time position
const handleKeyframeMove = useCallback((id: string, newTime: number) => {
setKeyframes(prev => prev.map(kf => kf.id === id ? { ...kf, time: Math.max(0, Math.min(newTime, totalMs)) } : kf));
}, [totalMs]);
// Delete selected zoom item
const deleteSelectedZoom = useCallback(() => {
if (!selectedZoomId) return;
@@ -574,12 +610,20 @@ export default function TimelineEditor({
setRange(createInitialRange(totalMs));
}, [totalMs]);
// Normalize regions only when timeline bounds change (not on every region edit).
// Using refs to read current regions avoids a dependency-loop that re-fires
// this effect on every drag/resize and races with dnd-timeline's internal state.
const zoomRegionsRef = useRef(zoomRegions);
const trimRegionsRef = useRef(trimRegions);
zoomRegionsRef.current = zoomRegions;
trimRegionsRef.current = trimRegions;
useEffect(() => {
if (totalMs === 0 || safeMinDurationMs <= 0) {
return;
}
zoomRegions.forEach((region) => {
zoomRegionsRef.current.forEach((region) => {
const clampedStart = Math.max(0, Math.min(region.startMs, totalMs));
const minEnd = clampedStart + safeMinDurationMs;
const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs));
@@ -591,7 +635,7 @@ export default function TimelineEditor({
}
});
trimRegions.forEach((region) => {
trimRegionsRef.current.forEach((region) => {
const clampedStart = Math.max(0, Math.min(region.startMs, totalMs));
const minEnd = clampedStart + safeMinDurationMs;
const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs));
@@ -602,7 +646,8 @@ export default function TimelineEditor({
onTrimSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd });
}
});
}, [zoomRegions, trimRegions, annotationRegions, totalMs, safeMinDurationMs, onZoomSpanChange, onTrimSpanChange, onAnnotationSpanChange]);
// Only re-run when the timeline scale changes, not on every region edit
}, [totalMs, safeMinDurationMs, onZoomSpanChange, onTrimSpanChange]);
const hasOverlap = useCallback((newSpan: Span, excludeId?: string): boolean => {
// Determine which row the item belongs to
@@ -618,12 +663,8 @@ export default function TimelineEditor({
const checkOverlap = (regions: (ZoomRegion | TrimRegion)[]) => {
return regions.some((region) => {
if (region.id === excludeId) return false;
const gapBefore = newSpan.start - region.endMs;
const gapAfter = region.startMs - newSpan.end;
// Snap if gap is 2ms or less
if (gapBefore > 0 && gapBefore <= 2) return true;
if (gapAfter > 0 && gapAfter <= 2) return true;
return !(newSpan.end <= region.startMs || newSpan.start >= region.endMs);
// True overlap: regions actually intersect (not just adjacent)
return newSpan.end > region.startMs && newSpan.start < region.endMs;
});
};
@@ -638,12 +679,19 @@ export default function TimelineEditor({
return false;
}, [zoomRegions, trimRegions, annotationRegions]);
// At least 5% of the timeline or 1000ms, whichever is larger, so the region
// is always wide enough to grab and resize comfortably.
const defaultRegionDurationMs = useMemo(
() => Math.max(1000, Math.round(totalMs * 0.05)),
[totalMs],
);
const handleAddZoom = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0) {
return;
}
const defaultDuration = Math.min(1000, totalMs);
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
if (defaultDuration <= 0) {
return;
}
@@ -664,16 +712,16 @@ export default function TimelineEditor({
return;
}
const actualDuration = Math.min(1000, gapToNext);
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
onZoomAdded({ start: startPos, end: startPos + actualDuration });
}, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded]);
}, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded, defaultRegionDurationMs]);
const handleAddTrim = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onTrimAdded) {
return;
}
const defaultDuration = Math.min(1000, totalMs);
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
if (defaultDuration <= 0) {
return;
}
@@ -694,16 +742,16 @@ export default function TimelineEditor({
return;
}
const actualDuration = Math.min(1000, gapToNext);
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
onTrimAdded({ start: startPos, end: startPos + actualDuration });
}, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded]);
}, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded, defaultRegionDurationMs]);
const handleAddAnnotation = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) {
return;
}
const defaultDuration = Math.min(1000, totalMs);
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
if (defaultDuration <= 0) {
return;
}
@@ -711,9 +759,9 @@ export default function TimelineEditor({
// 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]);
}, [videoDuration, totalMs, currentTimeMs, onAnnotationAdded, defaultRegionDurationMs]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -733,30 +781,31 @@ export default function TimelineEditor({
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();
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
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)) {
}
// Delete key or Ctrl+D / Cmd+D
if (e.key === 'Delete' || e.key === 'Backspace' || ((e.key === 'd' || e.key === 'D') && (e.ctrlKey || e.metaKey))) {
if (selectedKeyframeId) {
deleteSelectedKeyframe();
} else if (selectedZoomId) {
@@ -803,7 +852,7 @@ export default function TimelineEditor({
const annotations: TimelineRenderItem[] = annotationRegions.map((region) => {
let label: string;
if (region.type === 'text') {
// Show text preview
const preview = region.content.trim() || 'Empty text';
@@ -813,7 +862,7 @@ export default function TimelineEditor({
} else {
label = 'Annotation';
}
return {
id: region.id,
rowId: ANNOTATION_ROW_ID,
@@ -826,6 +875,13 @@ export default function TimelineEditor({
return [...zooms, ...trims, ...annotations];
}, [zoomRegions, trimRegions, annotationRegions]);
// Flat list of all non-annotation region spans for neighbour-clamping during drag/resize
const allRegionSpans = useMemo(() => {
const zooms = zoomRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
const trims = trimRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
return [...zooms, ...trims];
}, [zoomRegions, trimRegions]);
const handleItemSpanChange = useCallback((id: string, span: Span) => {
// Check if it's a zoom or trim item
if (zoomRegions.some(r => r.id === id)) {
@@ -896,7 +952,7 @@ export default function TimelineEditor({
</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) => (
{ASPECT_RATIOS.map((ratio) => (
<DropdownMenuItem
key={ratio}
onClick={() => onAspectRatioChange(ratio)}
@@ -918,12 +974,14 @@ export default function TimelineEditor({
<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">{shortcuts.zoom}</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>
</div>
<div className="flex-1 overflow-hidden bg-[#09090b] relative"
<div
ref={timelineContainerRef}
className="flex-1 overflow-hidden bg-[#09090b] relative"
onClick={() => setSelectedKeyframeId(null)}
>
<TimelineWrapper
@@ -935,11 +993,15 @@ export default function TimelineEditor({
minVisibleRangeMs={timelineScale.minVisibleRangeMs}
gridSizeMs={timelineScale.gridMs}
onItemSpanChange={handleItemSpanChange}
allRegionSpans={allRegionSpans}
>
<KeyframeMarkers
keyframes={keyframes}
selectedKeyframeId={selectedKeyframeId}
setSelectedKeyframeId={setSelectedKeyframeId}
onKeyframeMove={handleKeyframeMove}
videoDurationMs={totalMs}
timelineRef={timelineContainerRef}
/>
<Timeline
items={timelineItems}
@@ -953,6 +1015,8 @@ export default function TimelineEditor({
selectedZoomId={selectedZoomId}
selectedTrimId={selectedTrimId}
selectedAnnotationId={selectedAnnotationId}
keyframes={keyframes}
/>
</TimelineWrapper>
</div>
@@ -1,7 +1,15 @@
import { useCallback } from "react";
import { useCallback, useRef } from "react";
import type { Dispatch, ReactNode, SetStateAction } from "react";
import { TimelineContext } from "dnd-timeline";
import type { DragEndEvent, Range, ResizeEndEvent, Span } from "dnd-timeline";
import type {
DragEndEvent,
DragMoveEvent,
DragStartEvent,
Range,
ResizeEndEvent,
ResizeMoveEvent,
Span,
} from "dnd-timeline";
interface TimelineWrapperProps {
children: ReactNode;
@@ -13,6 +21,7 @@ interface TimelineWrapperProps {
minVisibleRangeMs: number;
gridSizeMs: number;
onItemSpanChange: (id: string, span: Span) => void;
allRegionSpans?: { id: string; start: number; end: number }[];
}
export default function TimelineWrapper({
@@ -25,6 +34,7 @@ export default function TimelineWrapper({
minVisibleRangeMs,
gridSizeMs: _gridSizeMs,
onItemSpanChange,
allRegionSpans = [],
}: TimelineWrapperProps) {
const totalMs = Math.max(0, Math.round(videoDuration * 1000));
@@ -84,25 +94,63 @@ export default function TimelineWrapper({
[minVisibleRangeMs, totalMs],
);
// When a span overlaps neighbours, clamp it to the nearest boundary
const clampToNeighbours = useCallback(
(span: Span, activeItemId: string): Span => {
const siblings = allRegionSpans.filter((r) => r.id !== activeItemId);
let { start, end } = span;
for (const r of siblings) {
// Span's right edge crossed into a region to the right
if (end > r.start && start < r.start) {
end = r.start;
}
// Span's left edge crossed into a region to the left
if (start < r.end && end > r.end) {
start = r.end;
}
}
// Ensure minimum duration after clamping
const minDur = Math.min(minItemDurationMs, totalMs || minItemDurationMs);
if (end - start < minDur) {
// Try extending in the direction that has room
if (end + minDur - (end - start) <= totalMs) {
end = start + minDur;
} else {
start = end - minDur;
}
}
return { start: Math.max(0, start), end: Math.min(end, totalMs || end) };
},
[allRegionSpans, minItemDurationMs, totalMs],
);
const onResizeEnd = useCallback(
(event: ResizeEndEvent) => {
const updatedSpan = event.active.data.current.getSpanFromResizeEvent?.(event);
if (!updatedSpan) return;
const activeItemId = event.active.id as string;
const clampedSpan = clampSpanToBounds(updatedSpan);
let clampedSpan = clampSpanToBounds(updatedSpan);
if (clampedSpan.end - clampedSpan.start < Math.min(minItemDurationMs, totalMs || minItemDurationMs)) {
return;
}
// Clamp to neighbour boundaries instead of rejecting
if (hasOverlap(clampedSpan, activeItemId)) {
return;
clampedSpan = clampToNeighbours(clampedSpan, activeItemId);
// If still overlapping after clamping, fall back to original position
if (hasOverlap(clampedSpan, activeItemId)) {
return;
}
}
onItemSpanChange(activeItemId, clampedSpan);
},
[clampSpanToBounds, hasOverlap, minItemDurationMs, onItemSpanChange, totalMs]
[clampSpanToBounds, clampToNeighbours, hasOverlap, minItemDurationMs, onItemSpanChange, totalMs]
);
const onDragEnd = useCallback(
@@ -110,17 +158,103 @@ export default function TimelineWrapper({
const activeRowId = event.over?.id as string;
const updatedSpan = event.active.data.current.getSpanFromDragEvent?.(event);
if (!updatedSpan || !activeRowId) return;
const activeItemId = event.active.id as string;
const clampedSpan = clampSpanToBounds(updatedSpan);
let clampedSpan = clampSpanToBounds(updatedSpan);
// Clamp to neighbour boundaries instead of rejecting
if (hasOverlap(clampedSpan, activeItemId)) {
return;
clampedSpan = clampToNeighbours(clampedSpan, activeItemId);
if (hasOverlap(clampedSpan, activeItemId)) {
return;
}
}
onItemSpanChange(activeItemId, clampedSpan);
},
[clampSpanToBounds, hasOverlap, onItemSpanChange]
[clampSpanToBounds, clampToNeighbours, hasOverlap, onItemSpanChange]
);
// Drag/resize tooltip (direct DOM updates, no re-renders)
const tooltipRef = useRef<HTMLDivElement>(null);
const formatTooltipMs = (ms: number) => {
const s = ms / 1000;
const min = Math.floor(s / 60);
const sec = s % 60;
return min > 0
? `${min}:${sec.toFixed(1).padStart(4, '0')}`
: `${sec.toFixed(1)}s`;
};
const showTooltip = useCallback(
(span: { start: number; end: number } | null, screenX?: number) => {
const el = tooltipRef.current;
if (!el) return;
if (!span) {
el.style.opacity = '0';
return;
}
el.textContent = `${formatTooltipMs(span.start)} ${formatTooltipMs(span.end)}`;
el.style.opacity = '1';
if (screenX !== undefined) {
const parent = el.parentElement;
if (parent) {
const rect = parent.getBoundingClientRect();
const x = Math.max(0, Math.min(screenX - rect.left, rect.width - 100));
el.style.left = `${x}px`;
}
}
},
[],
);
const onDragStart = useCallback(
(event: DragStartEvent) => {
const span = event.active.data.current.getSpanFromDragEvent?.(event);
if (span) showTooltip(span);
},
[showTooltip],
);
const onDragMove = useCallback(
(event: DragMoveEvent) => {
const span = event.active.data.current.getSpanFromDragEvent?.(event);
const screenX = event.activatorEvent && 'clientX' in event.activatorEvent
? (event.activatorEvent as PointerEvent).clientX + (event.delta?.x ?? 0)
: undefined;
if (span) showTooltip(span, screenX);
},
[showTooltip],
);
const onResizeMove = useCallback(
(event: ResizeMoveEvent) => {
const span = event.active.data.current.getSpanFromResizeEvent?.(event);
const screenX = event.activatorEvent && 'clientX' in event.activatorEvent
? (event.activatorEvent as PointerEvent).clientX + (event.delta?.x ?? 0)
: undefined;
if (span) showTooltip(span, screenX);
},
[showTooltip],
);
const hideTooltip = useCallback(() => showTooltip(null), [showTooltip]);
const onResizeEndWithTooltip = useCallback(
(event: ResizeEndEvent) => {
hideTooltip();
onResizeEnd(event);
},
[hideTooltip, onResizeEnd],
);
const onDragEndWithTooltip = useCallback(
(event: DragEndEvent) => {
hideTooltip();
onDragEnd(event);
},
[hideTooltip, onDragEnd],
);
const handleRangeChange = useCallback(
@@ -153,11 +287,22 @@ export default function TimelineWrapper({
<TimelineContext
range={range}
onRangeChanged={handleRangeChange}
onResizeEnd={onResizeEnd}
onDragEnd={onDragEnd}
onResizeEnd={onResizeEndWithTooltip}
onResizeMove={onResizeMove}
onDragStart={onDragStart}
onDragMove={onDragMove}
onDragEnd={onDragEndWithTooltip}
autoScroll={{ enabled: false }}
>
{children}
<div className="relative">
{children}
{/* Floating tooltip shown during drag/resize */}
<div
ref={tooltipRef}
className="absolute top-1 pointer-events-none z-[60] px-1.5 py-0.5 rounded bg-black/80 text-[10px] text-white/90 font-medium tabular-nums whitespace-nowrap border border-white/10 shadow-lg"
style={{ opacity: 0, transition: 'opacity 0.1s' }}
/>
</div>
</TimelineContext>
);
}
+178
View File
@@ -0,0 +1,178 @@
// Google Fonts loading and management utility
export interface CustomFont {
id: string;
name: string; // Display name
fontFamily: string; // CSS font-family value
importUrl: string; // Google Fonts @import URL
}
const STORAGE_KEY = 'openscreen_custom_fonts';
const loadedFonts = new Set<string>();
// Load custom fonts from localStorage
export function getCustomFonts(): CustomFont[] {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
} catch (error) {
console.error('Failed to load custom fonts from storage:', error);
return [];
}
}
// Save custom fonts to localStorage
export function saveCustomFonts(fonts: CustomFont[]): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(fonts));
} catch (error) {
console.error('Failed to save custom fonts to storage:', error);
}
}
// Add a new custom font (throws error if font fails to load)
export async function addCustomFont(font: CustomFont): Promise<CustomFont[]> {
const fonts = getCustomFonts();
const exists = fonts.some(f => f.id === font.id || f.fontFamily === font.fontFamily);
if (exists) {
return fonts;
}
// Try to load the font first - this will throw if it fails
await loadFont(font);
// Only add to storage if font loaded successfully
fonts.push(font);
saveCustomFonts(fonts);
return fonts;
}
// Remove a custom font
export function removeCustomFont(fontId: string): CustomFont[] {
const fonts = getCustomFonts();
const filtered = fonts.filter(f => f.id !== fontId);
saveCustomFonts(filtered);
// Remove the style element
const styleEl = document.getElementById(`custom-font-${fontId}`);
if (styleEl) {
styleEl.remove();
}
loadedFonts.delete(fontId);
return filtered;
}
// Load a Google Font into the document
export function loadFont(font: CustomFont): Promise<void> {
return new Promise((resolve, reject) => {
// Skip if already loaded
if (loadedFonts.has(font.id)) {
resolve();
return;
}
try {
const styleId = `custom-font-${font.id}`;
// Remove existing style if present
const existing = document.getElementById(styleId);
if (existing) {
existing.remove();
}
// Create style element with @import
const style = document.createElement('style');
style.id = styleId;
style.textContent = `@import url('${font.importUrl}');`;
document.head.appendChild(style);
// Wait for font to load
waitForFont(font.fontFamily)
.then(() => {
loadedFonts.add(font.id);
resolve();
})
.catch(reject);
} catch (error) {
console.error('Failed to load font:', font, error);
reject(error);
}
});
}
// Wait for a font to be available and verify it loaded
function waitForFont(fontFamily: string, timeout = 5000): Promise<void> {
return new Promise((resolve, reject) => {
// Use CSS Font Loading API if available
if ('fonts' in document) {
Promise.race([
document.fonts.load(`16px "${fontFamily}"`),
new Promise((_, rej) => setTimeout(() => rej(new Error('Font load timeout')), timeout))
])
.then(() => {
// Verify the font actually loaded by checking if it's available
const isAvailable = document.fonts.check(`16px "${fontFamily}"`);
if (isAvailable) {
resolve();
} else {
reject(new Error(`Font "${fontFamily}" failed to load`));
}
})
.catch((error) => {
reject(error);
});
} else {
// Fallback for browsers without Font Loading API
// Wait a bit and hope for the best
setTimeout(() => resolve(), 1000);
}
});
}
// Load all stored custom fonts on app initialization
export function loadAllCustomFonts(): Promise<void[]> {
const fonts = getCustomFonts();
return Promise.all(fonts.map(font => loadFont(font).catch(err => {
console.error('Failed to load custom font:', font.name, err);
})));
}
// Generate a unique ID for a font
export function generateFontId(name: string): string {
return `${name.toLowerCase().replace(/\s+/g, '-')}-${Date.now()}`;
}
// Parse Google Fonts @import URL to extract font family name
export function parseFontFamilyFromImport(importUrl: string): string | null {
try {
// Extract from URL like: https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap
const url = new URL(importUrl);
const familyParam = url.searchParams.get('family');
if (familyParam) {
// Remove weight/style info: "Roboto:wght@400;700" -> "Roboto"
const fontName = familyParam.split(':')[0];
// Replace + with spaces: "Open+Sans" -> "Open Sans"
return fontName.replace(/\+/g, ' ');
}
return null;
} catch (error) {
console.error('Failed to parse font family from import URL:', error);
return null;
}
}
// Validate if a string looks like a Google Fonts import URL
export function isValidGoogleFontsUrl(url: string): boolean {
try {
const urlObj = new URL(url);
return urlObj.hostname === 'fonts.googleapis.com' && urlObj.searchParams.has('family');
} catch {
return false;
}
}
+2 -2
View File
@@ -84,7 +84,7 @@ export class FrameRenderer {
width: this.config.width,
height: this.config.height,
backgroundAlpha: 0,
antialias: false,
antialias: true,
resolution: 1,
autoDensity: true,
});
@@ -100,7 +100,7 @@ export class FrameRenderer {
// Setup blur filter for video container
this.blurFilter = new BlurFilter();
this.blurFilter.quality = 3;
this.blurFilter.quality = 5;
this.blurFilter.resolution = this.app.renderer.resolution;
this.blurFilter.blur = 0;
this.videoContainer.filters = [this.blurFilter];
+46 -106
View File
@@ -1,6 +1,6 @@
import GIF from 'gif.js';
import type { ExportProgress, ExportResult, GifFrameRate, GifSizePreset, GIF_SIZE_PRESETS } from './types';
import { VideoFileDecoder } from './videoDecoder';
import { StreamingVideoDecoder } from './streamingDecoder';
import { FrameRenderer } from './frameRenderer';
import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion } from '@/components/video-editor/types';
@@ -66,7 +66,7 @@ export function calculateOutputDimensions(
export class GifExporter {
private config: GifExporterConfig;
private decoder: VideoFileDecoder | null = null;
private streamingDecoder: StreamingVideoDecoder | null = null;
private renderer: FrameRenderer | null = null;
private gif: GIF | null = null;
private cancelled = false;
@@ -75,49 +75,14 @@ export class GifExporter {
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 streaming decoder and load video metadata
this.streamingDecoder = new StreamingVideoDecoder();
const videoInfo = await this.streamingDecoder.loadMetadata(this.config.videoUrl);
// Initialize frame renderer
this.renderer = new FrameRenderer({
@@ -143,7 +108,7 @@ export class GifExporter {
// 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,
@@ -156,16 +121,10 @@ export class GifExporter {
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 effectiveDuration = this.streamingDecoder.getEffectiveDuration(this.config.trimRegions);
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);
@@ -175,66 +134,44 @@ export class GifExporter {
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');
console.log('[GifExporter] Using streaming decode (web-demuxer + VideoDecoder)');
// 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
// Stream decode and process frames — no seeking!
await this.streamingDecoder.decodeAll(
this.config.frameRate,
this.config.trimRegions,
async (videoFrame, _exportTimestampUs, sourceTimestampMs) => {
if (this.cancelled) {
videoFrame.close();
return;
}
// Map effective time to source time (accounting for trim regions)
const effectiveTimeMs = (i * timeStep) * 1000;
const sourceTimeMs = this.mapEffectiveToSourceTime(effectiveTimeMs);
const videoTime = sourceTimeMs / 1000;
// Render the frame with all effects using source timestamp
const sourceTimestampUs = sourceTimestampMs * 1000; // Convert to microseconds
await this.renderer!.renderFrame(videoFrame, sourceTimestampUs);
videoFrame.close();
// Seek if needed
const needsSeek = Math.abs(videoElement.currentTime - videoTime) > 0.001;
// Get the rendered canvas and add to GIF
const canvas = this.renderer!.getCanvas();
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());
});
// 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,
});
}
}
// 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' };
@@ -289,6 +226,9 @@ export class GifExporter {
cancel(): void {
this.cancelled = true;
if (this.streamingDecoder) {
this.streamingDecoder.cancel();
}
if (this.gif) {
this.gif.abort();
}
@@ -296,13 +236,13 @@ export class GifExporter {
}
private cleanup(): void {
if (this.decoder) {
if (this.streamingDecoder) {
try {
this.decoder.destroy();
this.streamingDecoder.destroy();
} catch (e) {
console.warn('Error destroying decoder:', e);
console.warn('Error destroying streaming decoder:', e);
}
this.decoder = null;
this.streamingDecoder = null;
}
if (this.renderer) {
+1
View File
@@ -1,5 +1,6 @@
export { VideoExporter } from './videoExporter';
export { VideoFileDecoder } from './videoDecoder';
export { StreamingVideoDecoder } from './streamingDecoder';
export { FrameRenderer } from './frameRenderer';
export { VideoMuxer } from './muxer';
export { GifExporter, calculateOutputDimensions } from './gifExporter';
+301
View File
@@ -0,0 +1,301 @@
import { WebDemuxer } from 'web-demuxer';
import type { TrimRegion } from '@/components/video-editor/types';
export interface DecodedVideoInfo {
width: number;
height: number;
duration: number; // seconds
frameRate: number;
codec: string;
}
/** Caller must close the VideoFrame after use. */
type OnFrameCallback = (
frame: VideoFrame,
exportTimestampUs: number,
sourceTimestampMs: number
) => Promise<void>;
/**
* Decodes video frames via web-demuxer + VideoDecoder in a single forward pass.
* Way faster than seeking an HTMLVideoElement per frame.
*
* Frames in trimmed regions are decoded (needed for P/B-frame state) but discarded.
* Non-trimmed frames get buffered per segment and resampled to the target frame rate.
*/
export class StreamingVideoDecoder {
private demuxer: WebDemuxer | null = null;
private decoder: VideoDecoder | null = null;
private cancelled = false;
private metadata: DecodedVideoInfo | null = null;
async loadMetadata(videoUrl: string): Promise<DecodedVideoInfo> {
const response = await fetch(videoUrl);
const blob = await response.blob();
const filename = videoUrl.split('/').pop() || 'video';
const file = new File([blob], filename, { type: blob.type });
// Relative URL so it resolves correctly in both dev (http) and packaged (file://) builds
const wasmUrl = new URL('./wasm/web-demuxer.wasm', window.location.href).href;
this.demuxer = new WebDemuxer({ wasmFilePath: wasmUrl });
await this.demuxer.load(file);
const mediaInfo = await this.demuxer.getMediaInfo();
const videoStream = mediaInfo.streams.find(s => s.codec_type_string === 'video');
let frameRate = 60;
if (videoStream?.avg_frame_rate) {
const parts = videoStream.avg_frame_rate.split('/');
if (parts.length === 2) {
const num = parseInt(parts[0], 10);
const den = parseInt(parts[1], 10);
if (den > 0 && num > 0) frameRate = num / den;
}
}
this.metadata = {
width: videoStream?.width || 1920,
height: videoStream?.height || 1080,
duration: mediaInfo.duration,
frameRate,
codec: videoStream?.codec_string || 'unknown',
};
return this.metadata;
}
async decodeAll(
targetFrameRate: number,
trimRegions: TrimRegion[] | undefined,
onFrame: OnFrameCallback
): Promise<void> {
if (!this.demuxer || !this.metadata) {
throw new Error('Must call loadMetadata() before decodeAll()');
}
const decoderConfig = await this.demuxer.getDecoderConfig('video');
const segments = this.computeSegments(this.metadata.duration, trimRegions);
const frameDurationUs = 1_000_000 / targetFrameRate;
// Async frame queue — decoder pushes, consumer pulls
const pendingFrames: VideoFrame[] = [];
let frameResolve: ((frame: VideoFrame | null) => void) | null = null;
let decodeError: Error | null = null;
let decodeDone = false;
this.decoder = new VideoDecoder({
output: (frame: VideoFrame) => {
if (frameResolve) {
const resolve = frameResolve;
frameResolve = null;
resolve(frame);
} else {
pendingFrames.push(frame);
}
},
error: (e: DOMException) => {
decodeError = new Error(`VideoDecoder error: ${e.message}`);
if (frameResolve) {
const resolve = frameResolve;
frameResolve = null;
resolve(null);
}
},
});
this.decoder.configure(decoderConfig);
const getNextFrame = (): Promise<VideoFrame | null> => {
if (decodeError) throw decodeError;
if (pendingFrames.length > 0) return Promise.resolve(pendingFrames.shift()!);
if (decodeDone) return Promise.resolve(null);
return new Promise(resolve => { frameResolve = resolve; });
};
// One forward stream through the whole file
const reader = this.demuxer.read('video').getReader();
// Feed chunks to decoder in background with backpressure
const feedPromise = (async () => {
try {
while (!this.cancelled) {
const { done, value: chunk } = await reader.read();
if (done || !chunk) break;
while (this.decoder!.decodeQueueSize > 10 && !this.cancelled) {
await new Promise(resolve => setTimeout(resolve, 1));
}
if (this.cancelled) break;
this.decoder!.decode(chunk);
}
if (!this.cancelled && this.decoder!.state === 'configured') {
await this.decoder!.flush();
}
} catch (e) {
decodeError = e instanceof Error ? e : new Error(String(e));
} finally {
decodeDone = true;
if (frameResolve) {
const resolve = frameResolve;
frameResolve = null;
resolve(null);
}
}
})();
// Route decoded frames into segments by timestamp, then deliver with VFR→CFR resampling
let segmentIdx = 0;
let exportFrameIndex = 0;
let segmentBuffer: VideoFrame[] = [];
while (!this.cancelled && segmentIdx < segments.length) {
const frame = await getNextFrame();
if (!frame) break;
const frameTimeSec = frame.timestamp / 1_000_000;
const currentSegment = segments[segmentIdx];
// Before current segment — trimmed or pre-video
if (frameTimeSec < currentSegment.startSec - 0.001) {
frame.close();
continue;
}
// Past current segment — flush buffer and advance
if (frameTimeSec >= currentSegment.endSec - 0.001) {
exportFrameIndex = await this.deliverSegment(
segmentBuffer, currentSegment, targetFrameRate, frameDurationUs, exportFrameIndex, onFrame
);
for (const f of segmentBuffer) f.close();
segmentBuffer = [];
segmentIdx++;
while (segmentIdx < segments.length && frameTimeSec >= segments[segmentIdx].endSec - 0.001) {
segmentIdx++;
}
if (segmentIdx < segments.length && frameTimeSec >= segments[segmentIdx].startSec - 0.001) {
segmentBuffer.push(frame);
} else {
frame.close();
}
continue;
}
segmentBuffer.push(frame);
}
// Flush last segment
if (segmentBuffer.length > 0 && segmentIdx < segments.length) {
exportFrameIndex = await this.deliverSegment(
segmentBuffer, segments[segmentIdx], targetFrameRate, frameDurationUs, exportFrameIndex, onFrame
);
for (const f of segmentBuffer) f.close();
}
// Drain leftover decoded frames
while (!decodeDone) {
const frame = await getNextFrame();
if (!frame) break;
frame.close();
}
try { reader.cancel(); } catch { /* already closed */ }
await feedPromise;
for (const f of pendingFrames) f.close();
pendingFrames.length = 0;
if (this.decoder?.state === 'configured') {
this.decoder.close();
}
this.decoder = null;
}
/**
* Resample buffered frames to fill the target frame count for this segment.
* Handles VFR sources by duplicating/decimating as needed.
*/
private async deliverSegment(
frames: VideoFrame[],
segment: { startSec: number; endSec: number },
targetFrameRate: number,
frameDurationUs: number,
startExportFrameIndex: number,
onFrame: OnFrameCallback
): Promise<number> {
if (frames.length === 0) return startExportFrameIndex;
const segmentFrameCount = Math.ceil((segment.endSec - segment.startSec) * targetFrameRate);
let exportFrameIndex = startExportFrameIndex;
for (let i = 0; i < segmentFrameCount && !this.cancelled; i++) {
const sourceIdx = Math.min(
Math.floor(i * frames.length / segmentFrameCount),
frames.length - 1
);
const sourceFrame = frames[sourceIdx];
const clone = new VideoFrame(sourceFrame, { timestamp: sourceFrame.timestamp });
await onFrame(clone, exportFrameIndex * frameDurationUs, sourceFrame.timestamp / 1000);
exportFrameIndex++;
}
return exportFrameIndex;
}
private computeSegments(
totalDuration: number,
trimRegions?: TrimRegion[]
): Array<{ startSec: number; endSec: number }> {
if (!trimRegions || trimRegions.length === 0) {
return [{ startSec: 0, endSec: totalDuration }];
}
const sorted = [...trimRegions].sort((a, b) => a.startMs - b.startMs);
const segments: Array<{ startSec: number; endSec: number }> = [];
let cursor = 0;
for (const trim of sorted) {
const trimStart = trim.startMs / 1000;
const trimEnd = trim.endMs / 1000;
if (cursor < trimStart) {
segments.push({ startSec: cursor, endSec: trimStart });
}
cursor = trimEnd;
}
if (cursor < totalDuration) {
segments.push({ startSec: cursor, endSec: totalDuration });
}
return segments;
}
getEffectiveDuration(trimRegions?: TrimRegion[]): number {
if (!this.metadata) throw new Error('Must call loadMetadata() first');
const trimmed = (trimRegions || []).reduce(
(sum, r) => sum + (r.endMs - r.startMs) / 1000, 0
);
return this.metadata.duration - trimmed;
}
cancel(): void {
this.cancelled = true;
}
destroy(): void {
this.cancelled = true;
if (this.decoder) {
try {
if (this.decoder.state === 'configured') this.decoder.close();
} catch { /* ignore */ }
this.decoder = null;
}
if (this.demuxer) {
try { this.demuxer.destroy(); } catch { }
this.demuxer = null;
}
}
}
+73 -127
View File
@@ -1,5 +1,5 @@
import type { ExportConfig, ExportProgress, ExportResult } from './types';
import { VideoFileDecoder } from './videoDecoder';
import { StreamingVideoDecoder } from './streamingDecoder';
import { FrameRenderer } from './frameRenderer';
import { VideoMuxer } from './muxer';
import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion } from '@/components/video-editor/types';
@@ -25,7 +25,7 @@ interface VideoExporterConfig extends ExportConfig {
export class VideoExporter {
private config: VideoExporterConfig;
private decoder: VideoFileDecoder | null = null;
private streamingDecoder: StreamingVideoDecoder | null = null;
private renderer: FrameRenderer | null = null;
private encoder: VideoEncoder | null = null;
private muxer: VideoMuxer | null = null;
@@ -43,44 +43,14 @@ export class VideoExporter {
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;
}
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 streaming decoder and load video metadata
this.streamingDecoder = new StreamingVideoDecoder();
const videoInfo = await this.streamingDecoder.loadMetadata(this.config.videoUrl);
// Initialize frame renderer
this.renderer = new FrameRenderer({
@@ -110,104 +80,77 @@ export class VideoExporter {
this.muxer = new VideoMuxer(this.config, false);
await this.muxer.initialize();
// Get the video element for frame extraction
const videoElement = this.decoder.getVideoElement();
if (!videoElement) {
throw new Error('Video element not available');
}
// Calculate effective duration and frame count (excluding trim regions)
const effectiveDuration = this.getEffectiveDuration(videoInfo.duration);
const effectiveDuration = this.streamingDecoder.getEffectiveDuration(this.config.trimRegions);
const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate);
console.log('[VideoExporter] Original duration:', videoInfo.duration, 's');
console.log('[VideoExporter] Effective duration:', effectiveDuration, 's');
console.log('[VideoExporter] Total frames to export:', totalFrames);
console.log('[VideoExporter] Using streaming decode (web-demuxer + VideoDecoder)');
// Process frames continuously without batching delays
const frameDuration = 1_000_000 / this.config.frameRate; // in microseconds
let frameIndex = 0;
const timeStep = 1 / this.config.frameRate;
while (frameIndex < totalFrames && !this.cancelled) {
const i = frameIndex;
const timestamp = i * frameDuration;
// Stream decode and process frames — no seeking!
await this.streamingDecoder.decodeAll(
this.config.frameRate,
this.config.trimRegions,
async (videoFrame, _exportTimestampUs, sourceTimestampMs) => {
if (this.cancelled) {
videoFrame.close();
return;
}
// 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 or wait for first frame to be ready
const needsSeek = Math.abs(videoElement.currentTime - videoTime) > 0.001;
const timestamp = frameIndex * frameDuration;
if (needsSeek) {
// Attach listener BEFORE setting currentTime to avoid race condition
const seekedPromise = new Promise<void>(resolve => {
videoElement.addEventListener('seeked', () => resolve(), { once: true });
});
videoElement.currentTime = videoTime;
await 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());
// Render the frame with all effects using source timestamp
const sourceTimestampUs = sourceTimestampMs * 1000; // Convert to microseconds
await this.renderer!.renderFrame(videoFrame, sourceTimestampUs);
videoFrame.close();
const canvas = this.renderer!.getCanvas();
// Create VideoFrame from canvas on GPU without reading pixels
// @ts-ignore - colorSpace not in TypeScript definitions but works at runtime
const exportFrame = new VideoFrame(canvas, {
timestamp,
duration: frameDuration,
colorSpace: {
primaries: 'bt709',
transfer: 'iec61966-2-1',
matrix: 'rgb',
fullRange: true,
},
});
// Check encoder queue before encoding to keep it full
while (this.encodeQueue >= this.MAX_ENCODE_QUEUE && !this.cancelled) {
await new Promise(resolve => setTimeout(resolve, 0));
}
if (this.encoder && this.encoder.state === 'configured') {
this.encodeQueue++;
this.encoder.encode(exportFrame, { keyFrame: frameIndex % 150 === 0 });
} else {
console.warn(`[Frame ${frameIndex}] Encoder not ready! State: ${this.encoder?.state}`);
}
exportFrame.close();
frameIndex++;
// Update progress
if (this.config.onProgress) {
this.config.onProgress({
currentFrame: frameIndex,
totalFrames,
percentage: (frameIndex / totalFrames) * 100,
estimatedTimeRemaining: 0,
});
}
}
// Create a VideoFrame from the video element (on GPU!)
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();
const canvas = this.renderer!.getCanvas();
// Create VideoFrame from canvas on GPU without reading pixels
// @ts-ignore - colorSpace not in TypeScript definitions but works at runtime
const exportFrame = new VideoFrame(canvas, {
timestamp,
duration: frameDuration,
colorSpace: {
primaries: 'bt709',
transfer: 'iec61966-2-1',
matrix: 'rgb',
fullRange: true,
},
});
// Check encoder queue before encoding to keep it full
while (this.encodeQueue >= this.MAX_ENCODE_QUEUE && !this.cancelled) {
await new Promise(resolve => setTimeout(resolve, 0));
}
if (this.encoder && this.encoder.state === 'configured') {
this.encodeQueue++;
this.encoder.encode(exportFrame, { keyFrame: i % 150 === 0 });
} else {
console.warn(`[Frame ${i}] Encoder not ready! State: ${this.encoder?.state}`);
}
exportFrame.close();
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' };
@@ -300,7 +243,7 @@ export class VideoExporter {
});
const codec = this.config.codec || 'avc1.640033';
const encoderConfig: VideoEncoderConfig = {
codec,
width: this.config.width,
@@ -323,18 +266,21 @@ export class VideoExporter {
// Fall back to software encoding
console.log('[VideoExporter] Hardware not supported, using software encoding');
encoderConfig.hardwareAcceleration = 'prefer-software';
const softwareSupport = await VideoEncoder.isConfigSupported(encoderConfig);
if (!softwareSupport.supported) {
throw new Error('Video encoding not supported on this system');
}
this.encoder.configure(encoderConfig);
}
}
cancel(): void {
this.cancelled = true;
if (this.streamingDecoder) {
this.streamingDecoder.cancel();
}
this.cleanup();
}
@@ -350,13 +296,13 @@ export class VideoExporter {
this.encoder = null;
}
if (this.decoder) {
if (this.streamingDecoder) {
try {
this.decoder.destroy();
this.streamingDecoder.destroy();
} catch (e) {
console.warn('Error destroying decoder:', e);
console.warn('Error destroying streaming decoder:', e);
}
this.decoder = null;
this.streamingDecoder = null;
}
if (this.renderer) {
+16 -2
View File
@@ -1,5 +1,12 @@
export type AspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '4:5';
export const ASPECT_RATIOS = ['16:9', '9:16', '1:1', '4:3', '4:5', '16:10', '10:16'] as const;
export type AspectRatio = typeof ASPECT_RATIOS[number];
/**
* Returns the numeric value of an aspect ratio.
* Uses exhaustive type checking to ensure all AspectRatio cases are handled.
* If TypeScript errors here, a new ratio was added to the type but not handled.
*/
export function getAspectRatioValue(aspectRatio: AspectRatio): number {
switch (aspectRatio) {
case '16:9': return 16 / 9;
@@ -7,6 +14,13 @@ export function getAspectRatioValue(aspectRatio: AspectRatio): number {
case '1:1': return 1;
case '4:3': return 4 / 3;
case '4:5': return 4 / 5;
case '16:10': return 16 / 10;
case '10:16': return 10 / 16;
default: {
// Ensures all cases are handled - TypeScript errors if missing
const _exhaustiveCheck: never = aspectRatio;
return _exhaustiveCheck;
}
}
}
@@ -28,4 +42,4 @@ export function getAspectRatioLabel(aspectRatio: AspectRatio): string {
export function formatAspectRatioForCSS(aspectRatio: AspectRatio): string {
return aspectRatio.replace(':', '/');
}
}