Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/components/ThumbnailStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
16 changes: 13 additions & 3 deletions src/components/TrimControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) => {
Expand Down Expand Up @@ -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);
Expand All @@ -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.");
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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"
Expand Down
16 changes: 9 additions & 7 deletions src/components/VideoPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
}
}
})();
Expand Down
6 changes: 3 additions & 3 deletions src/lib/exportEstimate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { describe, test, expect } from "vitest";
// Minimal recipe factory — only the fields estimateExportSize cares about
function makeRecipe(overrides: Partial<EditRecipe> = {}): EditRecipe {
return {
preset: "1080p",
preset: "landscape-16-9",
customWidth: 1920,
customHeight: 1080,
quality: 23, // default CRF
Expand Down Expand Up @@ -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);
});

Expand Down
26 changes: 5 additions & 21 deletions src/lib/exportEstimate.ts
Original file line number Diff line number Diff line change
@@ -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<string, { width: number; height: number }> = {
"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.
Expand All @@ -26,8 +8,10 @@ const PRESET_DIMENSIONS: Record<string, { width: number; height: number }> = {
*/
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,
Expand Down