diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 60f1690a..be25b8fd 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -1,4 +1,4 @@ -import { ASPECT_RATIOS, type AspectRatio } from "@/utils/aspectRatioUtils"; +import { ASPECT_RATIOS, type AspectRatio, isCustomAspectRatio } from "@/utils/aspectRatioUtils"; import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter"; import { WALLPAPER_PATHS } from "@/lib/wallpapers"; import { @@ -331,7 +331,11 @@ export function normalizeProjectEditor(editor: Partial): Pro trimRegions: normalizedTrimRegions, speedRegions: normalizedSpeedRegions, annotationRegions: normalizedAnnotationRegions, - aspectRatio: editor.aspectRatio && validAspectRatios.has(editor.aspectRatio) ? editor.aspectRatio : "16:9", + aspectRatio: + typeof editor.aspectRatio === "string" && + (validAspectRatios.has(editor.aspectRatio as AspectRatio) || isCustomAspectRatio(editor.aspectRatio)) + ? (editor.aspectRatio as AspectRatio) + : "16:9", exportQuality: editor.exportQuality === "medium" || editor.exportQuality === "source" ? editor.exportQuality : "good", exportFormat: editor.exportFormat === "gif" ? "gif" : "mp4", gifFrameRate: diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index e60db942..c381bca8 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type WheelEvent } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type WheelEvent } from "react"; import { useTimelineContext } from "dnd-timeline"; import { Button } from "@/components/ui/button"; import { Plus, Scissors, ZoomIn, MessageSquare, ChevronDown, Check, Gauge, WandSparkles } from "lucide-react"; @@ -17,7 +17,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { type AspectRatio, getAspectRatioLabel, ASPECT_RATIOS } from "@/utils/aspectRatioUtils"; +import { type AspectRatio, getAspectRatioLabel, ASPECT_RATIOS, isCustomAspectRatio } from "@/utils/aspectRatioUtils"; import { formatShortcut } from "@/utils/platformUtils"; import { TutorialHelp } from "../TutorialHelp"; import { useShortcuts } from "@/contexts/ShortcutsContext"; @@ -610,6 +610,8 @@ export default function TimelineEditor({ const [range, setRange] = useState(() => createInitialRange(totalMs)); const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]); const [selectedKeyframeId, setSelectedKeyframeId] = useState(null); + const [customAspectWidth, setCustomAspectWidth] = useState('16'); + const [customAspectHeight, setCustomAspectHeight] = useState('9'); const [scrollLabels, setScrollLabels] = useState({ pan: 'Shift + Ctrl + Scroll', zoom: 'Ctrl + Scroll' @@ -617,6 +619,36 @@ export default function TimelineEditor({ const timelineContainerRef = useRef(null); const { shortcuts: keyShortcuts, isMac } = useShortcuts(); + useEffect(() => { + if (aspectRatio === 'native') { + return; + } + const [width, height] = aspectRatio.split(':'); + if (width && height) { + setCustomAspectWidth(width); + setCustomAspectHeight(height); + } + }, [aspectRatio]); + + const applyCustomAspectRatio = useCallback(() => { + const width = Number.parseInt(customAspectWidth, 10); + const height = Number.parseInt(customAspectHeight, 10); + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + toast.error('Custom aspect ratio must be positive numbers.'); + return; + } + onAspectRatioChange(`${width}:${height}` as AspectRatio); + }, [customAspectHeight, customAspectWidth, onAspectRatioChange]); + + const handleCustomAspectRatioKeyDown = useCallback((event: ReactKeyboardEvent) => { + // Prevent Radix DropdownMenu typeahead from selecting preset items while typing. + event.stopPropagation(); + if (event.key === 'Enter') { + event.preventDefault(); + applyCustomAspectRatio(); + } + }, [applyCustomAspectRatio]); + useEffect(() => { formatShortcut(['shift', 'mod', 'Scroll']).then(pan => { formatShortcut(['mod', 'Scroll']).then(zoom => { @@ -1243,6 +1275,38 @@ export default function TimelineEditor({ {aspectRatio === ratio && } ))} +
+
+ Custom + setCustomAspectWidth(event.target.value.replace(/\D/g, ''))} + onKeyDown={handleCustomAspectRatioKeyDown} + className="w-12 h-7 rounded border border-white/15 bg-black/20 px-1.5 text-sm text-slate-100 focus:outline-none focus:ring-1 focus:ring-[#2563EB]" + aria-label="Custom aspect width" + /> + : + setCustomAspectHeight(event.target.value.replace(/\D/g, ''))} + onKeyDown={handleCustomAspectRatioKeyDown} + className="w-12 h-7 rounded border border-white/15 bg-black/20 px-1.5 text-sm text-slate-100 focus:outline-none focus:ring-1 focus:ring-[#2563EB]" + aria-label="Custom aspect height" + /> + + {isCustomAspectRatio(aspectRatio) && } +
diff --git a/src/utils/aspectRatioUtils.ts b/src/utils/aspectRatioUtils.ts index b085a558..983f93a6 100644 --- a/src/utils/aspectRatioUtils.ts +++ b/src/utils/aspectRatioUtils.ts @@ -1,6 +1,37 @@ export const ASPECT_RATIOS = ['native', '16:9', '9:16', '1:1', '4:3', '4:5', '16:10', '10:16'] as const; -export type AspectRatio = typeof ASPECT_RATIOS[number]; +type PresetAspectRatio = typeof ASPECT_RATIOS[number]; +type CustomAspectRatio = `${number}:${number}`; + +export type AspectRatio = PresetAspectRatio | CustomAspectRatio; + +const CUSTOM_ASPECT_RATIO_REGEX = /^(\d+):(\d+)$/; + +export function isCustomAspectRatio(aspectRatio: string): aspectRatio is CustomAspectRatio { + if ((ASPECT_RATIOS as readonly string[]).includes(aspectRatio)) { + return false; + } + const match = aspectRatio.match(CUSTOM_ASPECT_RATIO_REGEX); + if (!match) { + return false; + } + const width = Number(match[1]); + const height = Number(match[2]); + return Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0; +} + +function parseCustomAspectRatioValue(aspectRatio: string): number | null { + if (!isCustomAspectRatio(aspectRatio)) { + return null; + } + const [widthText, heightText] = aspectRatio.split(':'); + const width = Number(widthText); + const height = Number(heightText); + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + return null; + } + return width / height; +} /** * Returns the numeric value of an aspect ratio. @@ -17,11 +48,8 @@ export function getAspectRatioValue(aspectRatio: AspectRatio, nativeAspectRatio 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; - } + default: + return parseCustomAspectRatioValue(aspectRatio) ?? 16 / 9; } } @@ -41,6 +69,9 @@ export function getAspectRatioLabel(aspectRatio: AspectRatio): string { if (aspectRatio === 'native') { return 'Native'; } + if (isCustomAspectRatio(aspectRatio)) { + return `Custom ${aspectRatio}`; + } return aspectRatio; }