diff --git a/src/components/ThumbnailStrip.tsx b/src/components/ThumbnailStrip.tsx index 7b5a1206..05b9b804 100644 --- a/src/components/ThumbnailStrip.tsx +++ b/src/components/ThumbnailStrip.tsx @@ -73,8 +73,13 @@ export default function ThumbnailStrip({ canvas.width = thumbW; canvas.height = thumbH; + // FIX (#930): Dynamically cap the total thumbnail count based on duration. + // This prevents massive memory usage (OOM crashes) and UI lag on long videos + // by ensuring we never generate an excessive amount of Object URLs simultaneously. + const effectiveInterval = Math.max(intervalSeconds, Math.ceil(duration / 20)); + const times: number[] = []; - for (let t = 0; t <= duration; t += intervalSeconds) { + for (let t = 0; t <= duration; t += effectiveInterval) { times.push(Math.min(t, duration - 0.1)); } if ((times[times.length - 1] ?? 0) < duration - 0.5) { diff --git a/src/components/TrimControl.tsx b/src/components/TrimControl.tsx index efe6bf47..6928e322 100644 --- a/src/components/TrimControl.tsx +++ b/src/components/TrimControl.tsx @@ -20,6 +20,7 @@ export default function TrimControl({ recipe, onChange, duration, file }: Props) const [startErrorMsg, setStartErrorMsg] = useState(""); const [endErrorMsg, setEndErrorMsg] = useState(""); const [startInput, setStartInput] = useState(recipe.trimStart.toString()); + const [endInput, setEndInput] = useState(recipe.trimEnd !== null ? recipe.trimEnd.toString() : ""); const { waveform, isLoading: waveformLoading } = useAudioWaveform(file); const hasAudio = waveform.length > 0; @@ -28,6 +29,10 @@ export default function TrimControl({ recipe, onChange, duration, file }: Props) setStartInput(recipe.trimStart.toString()); }, [recipe.trimStart]); + useEffect(() => { + setEndInput(recipe.trimEnd !== null ? recipe.trimEnd.toString() : ""); + }, [recipe.trimEnd]); + const clipLength = (recipe.trimEnd ?? duration) - recipe.trimStart; const handleStart = (val: string) => { @@ -74,6 +79,8 @@ export default function TrimControl({ recipe, onChange, duration, file }: Props) }; const handleEnd = (val: string) => { + setEndInput(val); + if (val === "") { onChange({ trimEnd: null }); setEnd(false); @@ -82,8 +89,6 @@ export default function TrimControl({ recipe, onChange, duration, file }: Props) const n = parseFloat(val); - onChange({ trimEnd: n }); - if (isNaN(n)) { setEnd(true); setEndErrorMsg("Enter a valid number."); @@ -112,6 +117,11 @@ export default function TrimControl({ recipe, onChange, duration, file }: Props) setEnd(false); setEndErrorMsg(""); + + // FIX (#930): Only commit the parsed `trimEnd` value into application state + // AFTER all validation checks have passed. This prevents invalid states (like NaN) + // from leaking into duration calculations and breaking the editor. + onChange({ trimEnd: n }); }; const inputClass = @@ -181,7 +191,7 @@ export default function TrimControl({ recipe, onChange, duration, file }: Props) min={0} max={duration > 0 ? duration : undefined} step={0.1} - value={recipe.trimEnd ?? ""} + value={endInput} spellCheck={false} onChange={(e) => handleEnd(e.target.value)} aria-label="Trim end time in seconds" diff --git a/src/components/VideoPreview.tsx b/src/components/VideoPreview.tsx index 71095330..ef0c96c5 100644 --- a/src/components/VideoPreview.tsx +++ b/src/components/VideoPreview.tsx @@ -149,15 +149,17 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) { } else { // Fill / crop: the output fills the entire 16:9 preview — show a box representing what survives the crop. if (outputRatio < containerRatio) { - // Output is taller → crops top & bottom - const visibleH = (outputRatio / containerRatio) * 100; - const cropH = (100 - visibleH) / 2; - return { mode: "fill", barTop: `${cropH}%`, barBottom: `${cropH}%`, barLeft: "0", barRight: "0" }; - } else { - // Output is wider → crops left & right - const visibleW = (containerRatio / outputRatio) * 100; + // FIX (#930): When the output is taller than the 16:9 container (e.g. 9:16), + // to fill the taller output we must crop the left & right sides of the original source video. + // The crop bars correctly appear on the left/right in the UI preview. + const visibleW = (outputRatio / containerRatio) * 100; const cropW = (100 - visibleW) / 2; return { mode: "fill", barTop: "0", barBottom: "0", barLeft: `${cropW}%`, barRight: `${cropW}%` }; + } else { + // FIX (#930): When the output is wider than the container, the top & bottom are cropped. + const visibleH = (containerRatio / outputRatio) * 100; + const cropH = (100 - visibleH) / 2; + return { mode: "fill", barTop: `${cropH}%`, barBottom: `${cropH}%`, barLeft: "0", barRight: "0" }; } } })(); diff --git a/src/lib/exportEstimate.test.ts b/src/lib/exportEstimate.test.ts index de54f52c..a9d947e8 100644 --- a/src/lib/exportEstimate.test.ts +++ b/src/lib/exportEstimate.test.ts @@ -5,7 +5,7 @@ import { describe, test, expect } from "vitest"; // Minimal recipe factory — only the fields estimateExportSize cares about function makeRecipe(overrides: Partial = {}): EditRecipe { return { - preset: "1080p", + preset: "landscape-16-9", customWidth: 1920, customHeight: 1080, quality: 23, // default CRF @@ -50,8 +50,8 @@ describe("estimateExportSize", () => { }); test("higher resolution (4k) produces a larger estimate than 720p", () => { - const hd = estimateExportSize(makeRecipe({ preset: "720p" }), 60); - const uhd = estimateExportSize(makeRecipe({ preset: "4k" }), 60); + const hd = estimateExportSize(makeRecipe({ preset: "twitter-hd" }), 60); + const uhd = estimateExportSize(makeRecipe({ preset: "instagram-panoramic" }), 60); expect(uhd).toBeGreaterThan(hd); }); diff --git a/src/lib/exportEstimate.ts b/src/lib/exportEstimate.ts index 77c22573..63f68a99 100644 --- a/src/lib/exportEstimate.ts +++ b/src/lib/exportEstimate.ts @@ -1,23 +1,5 @@ import { EditRecipe } from "./types"; - -// --------------------------------------------------------------------------- -// Preset dimension map -// Keep in sync with src/lib/presets.ts. Width × height for every named preset. -// --------------------------------------------------------------------------- -const PRESET_DIMENSIONS: Record = { - "1080p": { width: 1920, height: 1080 }, - "720p": { width: 1280, height: 720 }, - "480p": { width: 854, height: 480 }, - "360p": { width: 640, height: 360 }, - "4k": { width: 3840, height: 2160 }, - "2k": { width: 2560, height: 1440 }, - // Square / portrait presets - "square-1080": { width: 1080, height: 1080 }, - "square-720": { width: 720, height: 720 }, - "portrait-1080": { width: 1080, height: 1920 }, - "portrait-720": { width: 720, height: 1280 }, - // Fallback — if a preset name is unrecognised we fall through to customWidth/H -}; +import { getPresetById } from "./presets"; /** * Resolve the actual output pixel dimensions for a recipe. @@ -26,8 +8,10 @@ const PRESET_DIMENSIONS: Record = { */ function getOutputDimensions(recipe: EditRecipe): { width: number; height: number } { if (recipe.preset !== "custom") { - const dims = PRESET_DIMENSIONS[recipe.preset]; - if (dims) return dims; + // FIX (#930): We now correctly look up dimensions using the actual preset ID (e.g. "vertical-9-16") + // from the PRESETS array instead of relying on a mismatched hardcoded dimensions object. + const preset = getPresetById(recipe.preset); + if (preset) return { width: preset.width, height: preset.height }; } return { width: recipe.customWidth || 1920,