Skip to content
97 changes: 61 additions & 36 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"mediabunny": "^1.25.1",
"motion": "^12.23.24",
"mp4box": "^2.2.0",
"pixi-filters": "^6.1.5",
"pixi.js": "^8.14.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
Expand Down
9 changes: 6 additions & 3 deletions src/components/launch/SourceSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,19 +104,22 @@ export function SourceSelector() {
return (
<div className={`min-h-screen flex flex-col ${styles.glassContainer}`}>
<div className="flex-1 flex flex-col w-full px-4 pt-4">
<Tabs defaultValue="screens" className="flex-1 flex flex-col">
<Tabs
defaultValue={screenSources.length === 0 ? "windows" : "screens"}
className="flex-1 flex flex-col"
>
<TabsList className="grid grid-cols-2 mb-3 bg-white/5 rounded-full">
<TabsTrigger
value="screens"
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-full text-xs py-1 transition-all"
>
Screens
Screens ({screenSources.length})
</TabsTrigger>
<TabsTrigger
value="windows"
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-full text-xs py-1 transition-all"
>
Windows
Windows ({windowSources.length})
</TabsTrigger>
</TabsList>
<div className="flex-1 min-h-0">
Expand Down
19 changes: 18 additions & 1 deletion src/components/video-editor/CropControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ interface CropControlProps {
aspectRatio: AspectRatio;
}

type DragHandle = "top" | "right" | "bottom" | "left" | null;
type DragHandle = "top" | "right" | "bottom" | "left" | "move" | null;

export function CropControl({ videoElement, cropRegion, onCropChange }: CropControlProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
Expand Down Expand Up @@ -99,6 +99,11 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
case "right":
newCrop.width = Math.max(0.1, Math.min(initialCrop.width + deltaX, 1 - initialCrop.x));
break;
case "move": {
newCrop.x = Math.max(0, Math.min(initialCrop.x + deltaX, 1 - initialCrop.width));
newCrop.y = Math.max(0, Math.min(initialCrop.y + deltaY, 1 - initialCrop.height));
break;
}
}

onCropChange(newCrop);
Expand Down Expand Up @@ -178,6 +183,18 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
</svg>
</div>

<div
className="absolute z-10 pointer-events-auto cursor-move"
style={{
left: `${cropPixelX}%`,
top: `${cropPixelY}%`,
width: `${cropPixelWidth}%`,
height: `${cropPixelHeight}%`,
transition: "none",
}}
onPointerDown={(e) => handlePointerDown(e, "move")}
/>

<div
className={cn("absolute h-[3px] cursor-ns-resize z-20 pointer-events-auto bg-[#34B27B]")}
style={{
Expand Down
200 changes: 191 additions & 9 deletions src/components/video-editor/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@ import {
Film,
FolderOpen,
Image,
Lock,
Palette,
Save,
Sparkles,
Star,
Trash2,
Unlock,
Upload,
X,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import {
Accordion,
Expand Down Expand Up @@ -234,6 +236,105 @@ export function SettingsPanel({
const [gradient, setGradient] = useState<string>(GRADIENTS[0]);
const [showCropModal, setShowCropModal] = useState(false);
const cropSnapshotRef = useRef<CropRegion | null>(null);
const [cropAspectLocked, setCropAspectLocked] = useState(false);
const [cropAspectRatio, setCropAspectRatio] = useState("");

const videoWidth = videoElement?.videoWidth || 1920;
const videoHeight = videoElement?.videoHeight || 1080;

const handleCropNumericChange = useCallback(
(field: "x" | "y" | "width" | "height", pixelValue: number) => {
if (!cropRegion || !onCropChange) return;

const next = { ...cropRegion };
switch (field) {
case "x":
next.x = Math.max(0, Math.min(pixelValue / videoWidth, 1 - next.width));
break;
case "y":
next.y = Math.max(0, Math.min(pixelValue / videoHeight, 1 - next.height));
break;
case "width": {
const newWidth = Math.max(0.05, Math.min(pixelValue / videoWidth, 1 - next.x));
if (cropAspectLocked && next.width > 0 && next.height > 0) {
const ratio = next.width / next.height;
const newHeight = newWidth / ratio;
if (next.y + newHeight <= 1) {
next.width = newWidth;
next.height = newHeight;
}
} else {
next.width = newWidth;
}
break;
}
case "height": {
const newHeight = Math.max(0.05, Math.min(pixelValue / videoHeight, 1 - next.y));
if (cropAspectLocked && next.width > 0 && next.height > 0) {
const ratio = next.width / next.height;
const newWidth = newHeight * ratio;
if (next.x + newWidth <= 1) {
next.height = newHeight;
next.width = newWidth;
}
} else {
next.height = newHeight;
}
break;
}
}

onCropChange(next);
},
[cropRegion, onCropChange, videoWidth, videoHeight, cropAspectLocked],
);

const applyCropAspectPreset = useCallback(
(preset: string) => {
if (!cropRegion || !onCropChange) return;

setCropAspectRatio(preset);
if (preset === "") {
setCropAspectLocked(false);
return;
}

const [wStr, hStr] = preset.split(":");
const targetRatio = Number(wStr) / Number(hStr);
const next = { ...cropRegion };

const nextHeight = (next.width * videoWidth) / (targetRatio * videoHeight);
if (next.y + nextHeight <= 1 && nextHeight >= 0.05) {
next.height = nextHeight;
} else {
const nextWidth = (next.height * videoHeight * targetRatio) / videoWidth;
if (next.x + nextWidth <= 1 && nextWidth >= 0.05) {
next.width = nextWidth;
}
}

onCropChange(next);
setCropAspectLocked(true);
},
[cropRegion, onCropChange, videoWidth, videoHeight],
);

const getCropPixelValue = useCallback(
(field: "x" | "y" | "width" | "height"): number => {
if (!cropRegion) return 0;
switch (field) {
case "x":
return Math.round(cropRegion.x * videoWidth);
case "y":
return Math.round(cropRegion.y * videoHeight);
case "width":
return Math.round(cropRegion.width * videoWidth);
case "height":
return Math.round(cropRegion.height * videoHeight);
}
},
[cropRegion, videoWidth, videoHeight],
);

const zoomEnabled = Boolean(selectedZoomDepth);
const trimEnabled = Boolean(selectedTrimId);
Expand Down Expand Up @@ -747,14 +848,95 @@ export function SettingsPanel({
onCropChange={onCropChange}
aspectRatio={aspectRatio}
/>
<div className="mt-6 flex justify-end">
<Button
onClick={() => setShowCropModal(false)}
size="lg"
className="bg-[#34B27B] hover:bg-[#34B27B]/90 text-white"
>
Done
</Button>
<div className="mt-6 space-y-4">
<div className="flex flex-wrap items-end gap-3">
{[
{ label: "X", field: "x" as const, max: videoWidth },
{ label: "Y", field: "y" as const, max: videoHeight },
{ label: "W", field: "width" as const, max: videoWidth },
{ label: "H", field: "height" as const, max: videoHeight },
].map(({ label, field, max }) => (
<div key={field} className="flex flex-col gap-1">
<label className="text-[10px] font-medium text-slate-400 uppercase tracking-wider">
{label}
</label>
<input
type="number"
min={0}
max={max}
value={getCropPixelValue(field)}
onChange={(e) => handleCropNumericChange(field, Number(e.target.value))}
className="w-[90px] h-8 rounded-md border border-white/10 bg-white/5 px-2 text-xs text-slate-200 outline-none focus:border-[#34B27B]/50 focus:ring-1 focus:ring-[#34B27B]/30 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
))}

<div className="flex flex-col gap-1">
<label className="text-[10px] font-medium text-slate-400 uppercase tracking-wider">
Ratio
</label>
<div className="flex items-center gap-1.5">
<select
value={cropAspectRatio}
onChange={(e) => applyCropAspectPreset(e.target.value)}
className="h-8 rounded-md border border-white/10 bg-[#1a1a1f] px-2 text-xs text-slate-200 outline-none focus:border-[#34B27B]/50 cursor-pointer"
>
<option value="" className="bg-[#1a1a1f] text-slate-200">
Free
</option>
<option value="16:9" className="bg-[#1a1a1f] text-slate-200">
16:9
</option>
<option value="9:16" className="bg-[#1a1a1f] text-slate-200">
9:16
</option>
<option value="4:3" className="bg-[#1a1a1f] text-slate-200">
4:3
</option>
<option value="3:4" className="bg-[#1a1a1f] text-slate-200">
3:4
</option>
<option value="1:1" className="bg-[#1a1a1f] text-slate-200">
1:1
</option>
<option value="21:9" className="bg-[#1a1a1f] text-slate-200">
21:9
</option>
</select>
<button
type="button"
onClick={() => setCropAspectLocked((prev) => !prev)}
className={cn(
"h-8 w-8 flex items-center justify-center rounded-md border transition-all",
cropAspectLocked
? "border-[#34B27B]/50 bg-[#34B27B]/10 text-[#34B27B]"
: "border-white/10 bg-white/5 text-slate-400 hover:text-slate-200",
)}
title={cropAspectLocked ? "Unlock aspect ratio" : "Lock aspect ratio"}
>
{cropAspectLocked ? (
<Lock className="w-3.5 h-3.5" />
) : (
<Unlock className="w-3.5 h-3.5" />
)}
</button>
</div>
</div>

<p className="text-[10px] text-slate-500 self-center ml-2">
{videoWidth} × {videoHeight}px
</p>
</div>

<div className="flex justify-end">
<Button
onClick={() => setShowCropModal(false)}
size="lg"
className="bg-[#34B27B] hover:bg-[#34B27B]/90 text-white"
>
Done
</Button>
</div>
</div>
</div>
</>
Expand Down
16 changes: 13 additions & 3 deletions src/components/video-editor/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
VideoExporter,
} from "@/lib/exporter";
import { matchesShortcut } from "@/lib/shortcuts";
import { getAspectRatioValue } from "@/utils/aspectRatioUtils";
import { getAspectRatioValue, getNativeAspectRatioValue } from "@/utils/aspectRatioUtils";
import { ExportDialog } from "./ExportDialog";
import PlaybackControls from "./PlaybackControls";
import {
Expand Down Expand Up @@ -904,9 +904,12 @@ export default function VideoEditor() {
videoPlaybackRef.current?.pause();
}

const aspectRatioValue = getAspectRatioValue(aspectRatio);
const sourceWidth = video.videoWidth || 1920;
const sourceHeight = video.videoHeight || 1080;
const aspectRatioValue =
aspectRatio === "native"
? getNativeAspectRatioValue(sourceWidth, sourceHeight, cropRegion)
: getAspectRatioValue(aspectRatio);

// Get preview CONTAINER dimensions for scaling
const playbackRef = videoPlaybackRef.current;
Expand Down Expand Up @@ -1234,7 +1237,14 @@ export default function VideoEditor() {
style={{
width: "auto",
height: "100%",
aspectRatio: getAspectRatioValue(aspectRatio),
aspectRatio:
aspectRatio === "native"
? getNativeAspectRatioValue(
videoPlaybackRef.current?.video?.videoWidth || 1920,
videoPlaybackRef.current?.video?.videoHeight || 1080,
cropRegion,
)
: getAspectRatioValue(aspectRatio),
maxWidth: "100%",
margin: "0 auto",
boxSizing: "border-box",
Expand Down
Loading
Loading