From d2ee51146610e61107d4437dee4279a93c134118 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Fri, 28 Nov 2025 23:54:58 -0700 Subject: [PATCH 1/2] preview aspect ratio --- src/components/video-editor/CropControl.tsx | 7 ++- src/components/video-editor/SettingsPanel.tsx | 34 +++++++++++++- src/components/video-editor/VideoEditor.tsx | 7 ++- src/components/video-editor/VideoPlayback.tsx | 5 ++- src/utils/aspectRatioUtils.ts | 45 +++++++++++++++++++ 5 files changed, 92 insertions(+), 6 deletions(-) create mode 100644 src/utils/aspectRatioUtils.ts diff --git a/src/components/video-editor/CropControl.tsx b/src/components/video-editor/CropControl.tsx index 036116c..129b536 100644 --- a/src/components/video-editor/CropControl.tsx +++ b/src/components/video-editor/CropControl.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; +import { type AspectRatio, formatAspectRatioForCSS } from "@/utils/aspectRatioUtils"; interface CropRegion { x: number; // 0-1 normalized @@ -12,11 +13,12 @@ interface CropControlProps { videoElement: HTMLVideoElement | null; cropRegion: CropRegion; onCropChange: (region: CropRegion) => void; + aspectRatio: AspectRatio; } type DragHandle = 'top' | 'right' | 'bottom' | 'left' | null; -export function CropControl({ videoElement, cropRegion, onCropChange }: CropControlProps) { +export function CropControl({ videoElement, cropRegion, onCropChange, aspectRatio }: CropControlProps) { const canvasRef = useRef(null); const containerRef = useRef(null); const [isDragging, setIsDragging] = useState(null); @@ -119,7 +121,8 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
`wallpapers/wallpaper${i + 1}.jpg`); @@ -63,6 +64,8 @@ interface SettingsPanelProps { onPaddingChange?: (padding: number) => void; cropRegion?: CropRegion; onCropChange?: (region: CropRegion) => void; + aspectRatio: AspectRatio; + onAspectRatioChange: (aspectRatio: AspectRatio) => void; videoElement?: HTMLVideoElement | null; onExport?: () => void; } @@ -78,7 +81,7 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [ { depth: 6, label: "5×" }, ]; -export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth, onZoomDepthChange, selectedZoomId, onZoomDelete, shadowIntensity = 0, onShadowChange, showBlur, onBlurChange, motionBlurEnabled = true, onMotionBlurChange, borderRadius = 0, onBorderRadiusChange, padding = 50, onPaddingChange, cropRegion, onCropChange, videoElement, onExport }: SettingsPanelProps) { +export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth, onZoomDepthChange, selectedZoomId, onZoomDelete, shadowIntensity = 0, onShadowChange, showBlur, onBlurChange, motionBlurEnabled = true, onMotionBlurChange, borderRadius = 0, onBorderRadiusChange, padding = 50, onPaddingChange, cropRegion, onCropChange, aspectRatio, onAspectRatioChange, videoElement, onExport }: SettingsPanelProps) { const [wallpaperPaths, setWallpaperPaths] = useState([]); const [customImages, setCustomImages] = useState([]); const fileInputRef = useRef(null); @@ -230,11 +233,37 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
+
+ Aspect Ratio +
+ {(['16:9', '9:16', '1:1', '4:3'] as AspectRatio[]).map((ratio) => { + const isActive = aspectRatio === ratio; + return ( + + ); + })} +
+
+ +
{/* Drop Shadow Slider */}
-
+
\n
Shadow
{Math.round(shadowIntensity * 100)}%
@@ -319,6 +348,7 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth, videoElement={videoElement || null} cropRegion={cropRegion} onCropChange={onCropChange} + aspectRatio={aspectRatio} />
- ); - })} -
-
- -
{/* Drop Shadow Slider */}
-
\n +
Shadow
{Math.round(shadowIntensity * 100)}%
@@ -329,7 +302,7 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth, className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 animate-in fade-in duration-200" onClick={() => setShowCropDropdown(false)} /> -
+
Crop Video diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 5ee267a..847f932 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -265,24 +265,53 @@ export default function VideoEditor() { return; } + const aspectRatioValue = getAspectRatioValue(aspectRatio); const sourceWidth = video.videoWidth || 1920; const sourceHeight = video.videoHeight || 1080; - const targetAspectRatio = 16 / 9; - const sourceAspectRatio = sourceWidth / sourceHeight; - let exportWidth: number; - let exportHeight: number; - - if (sourceAspectRatio > targetAspectRatio) { - exportHeight = sourceHeight; - exportWidth = Math.round(exportHeight * targetAspectRatio); + let exportWidth: number = sourceWidth; + let exportHeight: number = sourceHeight; + + if (aspectRatioValue === 1) { + // Square (1:1): use smaller dimension to avoid codec limits + const baseDimension = Math.floor(Math.min(sourceWidth, sourceHeight) / 2) * 2; + exportWidth = baseDimension; + exportHeight = baseDimension; + } else if (aspectRatioValue > 1) { + // Landscape: find largest even dimensions that exactly match aspect ratio + const baseWidth = Math.floor(sourceWidth / 2) * 2; + // Iterate down from baseWidth to find exact match + let found = false; + for (let w = baseWidth; w >= 100 && !found; w -= 2) { + const h = Math.round(w / aspectRatioValue); + if (h % 2 === 0 && Math.abs((w / h) - aspectRatioValue) < 0.0001) { + exportWidth = w; + exportHeight = h; + found = true; + } + } + if (!found) { + exportWidth = baseWidth; + exportHeight = Math.floor((baseWidth / aspectRatioValue) / 2) * 2; + } } else { - exportWidth = sourceWidth; - exportHeight = Math.round(exportWidth / targetAspectRatio); + // Portrait: find largest even dimensions that exactly match aspect ratio + const baseHeight = Math.floor(sourceHeight / 2) * 2; + // Iterate down from baseHeight to find exact match + let found = false; + for (let h = baseHeight; h >= 100 && !found; h -= 2) { + const w = Math.round(h * aspectRatioValue); + if (w % 2 === 0 && Math.abs((w / h) - aspectRatioValue) < 0.0001) { + exportWidth = w; + exportHeight = h; + found = true; + } + } + if (!found) { + exportHeight = baseHeight; + exportWidth = Math.floor((baseHeight * aspectRatioValue) / 2) * 2; + } } - - exportWidth = Math.round(exportWidth / 2) * 2; - exportHeight = Math.round(exportHeight / 2) * 2; // Calculate visually lossless bitrate matching screen recording optimization const totalPixels = exportWidth * exportHeight; @@ -350,7 +379,7 @@ export default function VideoEditor() { setIsExporting(false); exporterRef.current = null; } - }, [videoPath, wallpaper, zoomRegions, trimRegions, shadowIntensity, showBlur, motionBlurEnabled, borderRadius, padding, cropRegion, isPlaying]); + }, [videoPath, wallpaper, zoomRegions, trimRegions, shadowIntensity, showBlur, motionBlurEnabled, borderRadius, padding, cropRegion, isPlaying, aspectRatio]); const handleCancelExport = useCallback(() => { if (exporterRef.current) { @@ -446,22 +475,24 @@ export default function VideoEditor() {
+ videoDuration={duration} + currentTime={currentTime} + onSeek={handleSeek} + zoomRegions={zoomRegions} + onZoomAdded={handleZoomAdded} + onZoomSpanChange={handleZoomSpanChange} + onZoomDelete={handleZoomDelete} + selectedZoomId={selectedZoomId} + onSelectZoom={handleSelectZoom} + trimRegions={trimRegions} + onTrimAdded={handleTrimAdded} + onTrimSpanChange={handleTrimSpanChange} + onTrimDelete={handleTrimDelete} + selectedTrimId={selectedTrimId} + onSelectTrim={handleSelectTrim} + aspectRatio={aspectRatio} + onAspectRatioChange={setAspectRatio} + />
@@ -488,7 +519,6 @@ export default function VideoEditor() { cropRegion={cropRegion} onCropChange={setCropRegion} aspectRatio={aspectRatio} - onAspectRatioChange={setAspectRatio} videoElement={videoPlaybackRef.current?.video || null} onExport={handleExport} /> diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 14688ab..907081d 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useTimelineContext } from "dnd-timeline"; import { Button } from "@/components/ui/button"; -import { Plus, Scissors, ZoomIn } from "lucide-react"; +import { Plus, Scissors, ZoomIn, ChevronDown, Check } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import TimelineWrapper from "./TimelineWrapper"; @@ -11,6 +11,13 @@ import KeyframeMarkers from "./KeyframeMarkers"; import type { Range, Span } from "dnd-timeline"; import type { ZoomRegion, TrimRegion } from "../types"; import { v4 as uuidv4 } from 'uuid'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { type AspectRatio, getAspectRatioLabel } from "@/utils/aspectRatioUtils"; const ZOOM_ROW_ID = "row-zoom"; const TRIM_ROW_ID = "row-trim"; @@ -34,6 +41,8 @@ interface TimelineEditorProps { onTrimDelete?: (id: string) => void; selectedTrimId?: string | null; onSelectTrim?: (id: string | null) => void; + aspectRatio: AspectRatio; + onAspectRatioChange: (aspectRatio: AspectRatio) => void; } interface TimelineScaleConfig { @@ -410,6 +419,8 @@ export default function TimelineEditor({ onTrimDelete, selectedTrimId, onSelectTrim, + aspectRatio, + onAspectRatioChange, }: TimelineEditorProps) { const totalMs = useMemo(() => Math.max(0, Math.round(videoDuration * 1000)), [videoDuration]); const currentTimeMs = useMemo(() => Math.round(currentTime * 1000), [currentTime]); @@ -683,6 +694,32 @@ export default function TimelineEditor({
+
+ + + + + + {(['16:9', '9:16', '1:1', '4:3', '4:5'] as AspectRatio[]).map((ratio) => ( + onAspectRatioChange(ratio)} + className="text-slate-300 hover:text-white hover:bg-white/10 cursor-pointer flex items-center justify-between gap-3" + > + {getAspectRatioLabel(ratio)} + {aspectRatio === ratio && } + + ))} + + +
diff --git a/src/utils/aspectRatioUtils.ts b/src/utils/aspectRatioUtils.ts index 38af19e..aaad804 100644 --- a/src/utils/aspectRatioUtils.ts +++ b/src/utils/aspectRatioUtils.ts @@ -1,29 +1,15 @@ -export type AspectRatio = '16:9' | '9:16' | '1:1' | '4:3'; +export type AspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '4:5'; -/** - * Converts aspect ratio string to numeric value - * @param aspectRatio - Aspect ratio as string (e.g., '16:9') - * @returns Numeric aspect ratio value (e.g., 1.777... for 16:9) - */ export function getAspectRatioValue(aspectRatio: AspectRatio): number { switch (aspectRatio) { - case '16:9': - return 16 / 9; - case '9:16': - return 9 / 16; - case '1:1': - return 1; - case '4:3': - return 4 / 3; + case '16:9': return 16 / 9; + case '9:16': return 9 / 16; + case '1:1': return 1; + case '4:3': return 4 / 3; + case '4:5': return 4 / 5; } } -/** - * Calculates dimensions for a given aspect ratio based on a base width - * @param aspectRatio - Aspect ratio as string - * @param baseWidth - Base width to calculate from - * @returns Object with width and height - */ export function getAspectRatioDimensions( aspectRatio: AspectRatio, baseWidth: number @@ -35,11 +21,11 @@ export function getAspectRatioDimensions( }; } -/** - * Formats aspect ratio for CSS - * @param aspectRatio - Aspect ratio as string - * @returns CSS-compatible aspect ratio string - */ +export function getAspectRatioLabel(aspectRatio: AspectRatio): string { + return aspectRatio; +} + + export function formatAspectRatioForCSS(aspectRatio: AspectRatio): string { return aspectRatio.replace(':', '/'); -} +} \ No newline at end of file