Add custom aspect ratio inputs and persistence

This commit is contained in:
webadderall
2026-03-15 15:50:31 +11:00
parent e98000149c
commit 1195890022
3 changed files with 109 additions and 10 deletions
@@ -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<ProjectEditorState>): 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:
@@ -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<Range>(() => createInitialRange(totalMs));
const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]);
const [selectedKeyframeId, setSelectedKeyframeId] = useState<string | null>(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<HTMLDivElement>(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<HTMLInputElement>) => {
// 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 && <Check className="w-3 h-3 text-[#2563EB]" />}
</DropdownMenuItem>
))}
<div className="mx-1 my-1 h-px bg-white/10" />
<div className="px-2 py-1.5 flex items-center gap-2 text-slate-300">
<span className="text-sm">Custom</span>
<input
type="text"
inputMode="numeric"
value={customAspectWidth}
onChange={(event) => 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"
/>
<span className="text-slate-500">:</span>
<input
type="text"
inputMode="numeric"
value={customAspectHeight}
onChange={(event) => 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"
/>
<Button
variant="ghost"
size="sm"
onClick={applyCustomAspectRatio}
className="h-7 px-2 text-xs text-slate-300 hover:text-white hover:bg-white/10"
>
Set
</Button>
{isCustomAspectRatio(aspectRatio) && <Check className="w-3 h-3 text-[#2563EB] ml-auto" />}
</div>
</DropdownMenuContent>
</DropdownMenu>
<div className="w-[1px] h-4 bg-white/10" />
+37 -6
View File
@@ -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;
}