+
+
{
+ if (!isOpen && triggerRef.current && containerRef?.current) {
+ const containerRect =
+ containerRef.current.getBoundingClientRect();
+
+ setPickerPosition({
+ right: window.innerWidth - containerRect.left - 8,
+ bottom: window.innerHeight - containerRect.bottom,
+ });
+ }
+ setIsOpen(!isOpen);
+ }}
+ />
+
+
+
+
+
+ {isOpen &&
+ createPortal(
+
,
+ document.body
+ )}
+
+ );
+ }
+);
+ColorPicker.displayName = "ColorPicker";
+
+const ColorCircle = ({
+ size,
+ position,
+ color,
+}: {
+ size: "sm" | "md";
+ position: { left: string; top: string };
+ color: string;
+}) => (
+
+);
+
+export { ColorPicker };
diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts
index 8d2ee03a..42159b2e 100644
--- a/apps/web/src/lib/utils.ts
+++ b/apps/web/src/lib/utils.ts
@@ -7,6 +7,10 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+export function uppercase(str: string) {
+ return str.toUpperCase();
+}
+
/**
* Generates a UUID v4 string
* Uses crypto.randomUUID() if available, otherwise falls back to a custom implementation
diff --git a/bun.lock b/bun.lock
index 95f7c874..659110a6 100644
--- a/bun.lock
+++ b/bun.lock
@@ -533,7 +533,7 @@
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.16", "", { "dependencies": { "lodash.castarray": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.merge": "^4.6.2", "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA=="],
- "@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="],
+ "@types/bun": ["@types/bun@1.2.21", "", { "dependencies": { "bun-types": "1.2.21" } }, "sha512-NiDnvEqmbfQ6dmZ3EeUO577s4P5bf4HCTXtI6trMc6f6RzirY5IrF3aIookuSpyslFzrnvv2lmEWv5HyC1X79A=="],
"@types/chai": ["@types/chai@5.2.2", "", { "dependencies": { "@types/deep-eql": "*" } }, "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg=="],
@@ -1257,6 +1257,8 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+ "@types/bun/bun-types": ["bun-types@1.2.21", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-sa2Tj77Ijc/NTLS0/Odjq/qngmEPZfbfnOERi0KRUYhT9R8M4VBioWVmMWE5GrYbKMc+5lVybXygLdibHaqVqw=="],
+
"better-auth/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"motion/framer-motion": ["framer-motion@12.23.6", "", { "dependencies": { "motion-dom": "^12.23.6", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-dsJ389QImVE3lQvM8Mnk99/j8tiZDM/7706PCqvkQ8sSCnpmWxsgX+g0lj7r5OBVL0U36pIecCTBoIWcM2RuKw=="],
@@ -1323,6 +1325,8 @@
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="],
+ "@types/bun/bun-types/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="],
+
"motion/framer-motion/motion-dom": ["motion-dom@12.23.6", "", { "dependencies": { "motion-utils": "^12.23.6" } }, "sha512-G2w6Nw7ZOVSzcQmsdLc0doMe64O/Sbuc2bVAbgMz6oP/6/pRStKRiVRV4bQfHp5AHYAKEGhEdVHTM+R3FDgi5w=="],
"motion/framer-motion/motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="],
@@ -1351,6 +1355,8 @@
"opencut/next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
+ "@types/bun/bun-types/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
+
"opencut/next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
}
}
From 06e3d0ca2aba3d804a7abe58e6a72f07dbb69806 Mon Sep 17 00:00:00 2001
From: Maze Winther
Date: Wed, 27 Aug 2025 17:45:13 +0200
Subject: [PATCH 17/20] fix: color picker
---
apps/web/src/components/ui/color-picker.tsx | 55 ++++++++++++---------
1 file changed, 33 insertions(+), 22 deletions(-)
diff --git a/apps/web/src/components/ui/color-picker.tsx b/apps/web/src/components/ui/color-picker.tsx
index 6a309d5a..b94c1f98 100644
--- a/apps/web/src/components/ui/color-picker.tsx
+++ b/apps/web/src/components/ui/color-picker.tsx
@@ -37,7 +37,10 @@ const hexToHsv = (hex: string) => {
}
}
- return [h * 60, s, v];
+ h = (h * 60 + 360) % 360;
+ if (isNaN(h)) h = 0;
+
+ return [h, s, v];
};
const hsvToHex = (h: number, s: number, v: number) => {
@@ -92,12 +95,20 @@ const ColorPicker = React.forwardRef(
right: 0,
bottom: 0,
});
+ const [internalHue, setInternalHue] = React.useState(0);
+ const [inputValue, setInputValue] = React.useState(value);
+
const pickerRef = React.useRef(null);
const saturationRef = React.useRef(null);
const hueRef = React.useRef(null);
const triggerRef = React.useRef(null);
const [h, s, v] = hexToHsv(value);
+ const displayHue = s > 0 ? h : internalHue;
+
+ React.useEffect(() => {
+ setInputValue(value);
+ }, [value]);
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -130,10 +141,9 @@ const ColorPicker = React.forwardRef(
0,
Math.min(1, (e.clientY - rect.top) / rect.height)
);
-
const newS = x;
const newV = 1 - y;
- const newHex = hsvToHex(h, newS, newV);
+ const newHex = hsvToHex(displayHue, newS, newV);
onChange?.(newHex);
}
@@ -144,8 +154,11 @@ const ColorPicker = React.forwardRef(
Math.min(1, (e.clientX - rect.left) / rect.width)
);
const newH = x * 360;
- const newHex = hsvToHex(newH, s, v);
- onChange?.(newHex);
+ setInternalHue(newH);
+ if (s > 0) {
+ const newHex = hsvToHex(newH, s, v);
+ onChange?.(newHex);
+ }
}
};
@@ -161,37 +174,33 @@ const ColorPicker = React.forwardRef(
document.removeEventListener("mouseup", handleMouseUp);
};
}
- }, [isDragging, h, s, v, onChange]);
+ }, [isDragging, displayHue, s, v, onChange]);
const handleSaturationMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
setIsDragging("saturation");
-
const rect = saturationRef.current!.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
const newS = x;
const newV = 1 - y;
- const newHex = hsvToHex(h, newS, newV);
+ const newHex = hsvToHex(displayHue, newS, newV);
onChange?.(newHex);
};
const handleHueMouseDown = (e: React.MouseEvent) => {
+ e.preventDefault();
setIsDragging("hue");
-
const rect = hueRef.current!.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const newH = x * 360;
- const newHex = hsvToHex(newH, s, v);
- onChange?.(newHex);
+ setInternalHue(newH);
+ if (s > 0) {
+ const newHex = hsvToHex(newH, s, v);
+ onChange?.(newHex);
+ }
};
- const [inputValue, setInputValue] = React.useState(value);
-
- React.useEffect(() => {
- setInputValue(value);
- }, [value]);
-
const handleInputChange = (e: React.ChangeEvent) => {
const hex = e.target.value.replace("#", "");
setInputValue(hex);
@@ -209,7 +218,7 @@ const ColorPicker = React.forwardRef(
};
const saturationStyle = {
- background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${h}, 100%, 50%))`,
+ background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${displayHue}, 100%, 50%))`,
};
const hueStyle = {
@@ -235,7 +244,6 @@ const ColorPicker = React.forwardRef(
if (!isOpen && triggerRef.current && containerRef?.current) {
const containerRect =
containerRef.current.getBoundingClientRect();
-
setPickerPosition({
right: window.innerWidth - containerRect.left - 8,
bottom: window.innerHeight - containerRect.bottom,
@@ -246,7 +254,7 @@ const ColorPicker = React.forwardRef(
/>
(
>
@@ -310,7 +321,7 @@ const ColorCircle = ({
color: string;
}) => (
Date: Wed, 27 Aug 2025 17:55:54 +0200
Subject: [PATCH 18/20] fix: deselect text before timeline selection
---
apps/web/src/hooks/use-selection-box.ts | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/apps/web/src/hooks/use-selection-box.ts b/apps/web/src/hooks/use-selection-box.ts
index 46e77fa9..87026500 100644
--- a/apps/web/src/hooks/use-selection-box.ts
+++ b/apps/web/src/hooks/use-selection-box.ts
@@ -190,6 +190,22 @@ export function useSelectionBox({
};
}, [selectionBox, selectElementsInBox]);
+ useEffect(() => {
+ if (!selectionBox?.isActive) return;
+
+ const previousBodyUserSelect = document.body.style.userSelect;
+ const container = containerRef.current;
+ const previousContainerUserSelect = container?.style.userSelect ?? "";
+
+ document.body.style.userSelect = "none";
+ if (container) container.style.userSelect = "none";
+
+ return () => {
+ document.body.style.userSelect = previousBodyUserSelect;
+ if (container) container.style.userSelect = previousContainerUserSelect;
+ };
+ }, [selectionBox?.isActive, containerRef]);
+
return {
selectionBox,
handleMouseDown,
From 70a9bfebb196d5c0bde35ce0a165b696bb9ae1d0 Mon Sep 17 00:00:00 2001
From: Maze Winther
Date: Thu, 28 Aug 2025 21:52:03 +0200
Subject: [PATCH 19/20] feat: add audio support for export
---
.../src/components/editor/export-button.tsx | 22 +
apps/web/src/lib/export.ts | 443 ++++++++++++------
apps/web/src/types/export.ts | 35 +-
3 files changed, 336 insertions(+), 164 deletions(-)
diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx
index e24d20df..c9738e81 100644
--- a/apps/web/src/components/editor/export-button.tsx
+++ b/apps/web/src/components/editor/export-button.tsx
@@ -7,6 +7,7 @@ import { Button } from "../ui/button";
import { Label } from "../ui/label";
import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
import { Progress } from "../ui/progress";
+import { Checkbox } from "../ui/checkbox";
import { cn } from "@/lib/utils";
import {
exportProject,
@@ -75,6 +76,9 @@ function ExportPopover({
const [quality, setQuality] = useState(
DEFAULT_EXPORT_OPTIONS.quality
);
+ const [includeAudio, setIncludeAudio] = useState(
+ DEFAULT_EXPORT_OPTIONS.includeAudio || true
+ );
const [isExporting, setIsExporting] = useState(false);
const [progress, setProgress] = useState(0);
const [exportResult, setExportResult] = useState(null);
@@ -90,6 +94,7 @@ function ExportPopover({
format,
quality,
fps: activeProject.fps,
+ includeAudio,
onProgress: setProgress,
onCancel: () => false, // TODO: Add cancel functionality
});
@@ -197,6 +202,23 @@ function ExportPopover({
+
+
+
+ setIncludeAudio(!!checked)}
+ />
+
+
+