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
358 changes: 279 additions & 79 deletions src/components/PresetSelector.tsx

Large diffs are not rendered by default.

10 changes: 7 additions & 3 deletions src/components/ThumbnailStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ export default function ThumbnailStrip({
objectUrlsRef.current = [];
}, []);

const cancelThumbnailRun = useCallback(() => {
lastRunIdRef.current += 1;
}, []);

const generateThumbnails = useCallback(async () => {
if (!videoSrc || duration <= 0) return;

Expand Down Expand Up @@ -144,10 +148,10 @@ export default function ThumbnailStrip({
generateThumbnails();
}
return () => {
lastRunIdRef.current++;
cancelThumbnailRun();
revokeAllObjectUrls();
};
}, [generateThumbnails, revokeAllObjectUrls, videoSrc, duration]);
}, [cancelThumbnailRun, generateThumbnails, revokeAllObjectUrls, videoSrc, duration]);

const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60);
Expand Down Expand Up @@ -438,4 +442,4 @@ export default function ThumbnailStrip({
`}</style>
</div>
);
}
}
18 changes: 13 additions & 5 deletions src/components/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ import ImageOverlay from "./ImageOverlay"

import { cn } from "@/lib/utils";
import {
Layers, Crop, Scissors, RotateCw, Volume2,
SlidersHorizontal, Zap, AlertTriangle, Github, Copy
Layers, Scissors, RotateCw, Volume2,
SlidersHorizontal, Zap, AlertTriangle, Copy
} from "lucide-react";
import OnboardingTour from "./OnboardingTour";
import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts";
Expand Down Expand Up @@ -206,7 +206,8 @@ function KeyboardShortcutsPanel() {
export default function VideoEditor() {
const {
file, duration, recipe, status, progress,
result, error, updateRecipe,
result, error, customPresets, updateRecipe,
saveCustomPreset, deleteCustomPreset, loadCustomPreset,
handleFileSelect, fileError, handleExport, cancelExport, reset, resetSettings,
videoRef,
seekTo,
Expand Down Expand Up @@ -571,7 +572,14 @@ export default function VideoEditor() {
</p>
</div>
)}
<PresetSelector recipe={recipe} onChange={updateRecipe} />
<PresetSelector
recipe={recipe}
customPresets={customPresets}
onChange={updateRecipe}
onSavePreset={saveCustomPreset}
onDeletePreset={deleteCustomPreset}
onSelectCustomPreset={loadCustomPreset}
/>
<div className="mt-3">
<FramingControl recipe={recipe} onChange={updateRecipe} />
</div>
Expand Down Expand Up @@ -628,4 +636,4 @@ export default function VideoEditor() {
</div>
</div>
);
}
}
8 changes: 2 additions & 6 deletions src/components/VideoPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import { useEffect, useRef, useState, useCallback, RefObject } from "react";
import { EditRecipe } from "@/lib/types";
import { getPresetById } from "@/lib/presets";
import { getRecipeDimensions } from "@/lib/presets";
import { cn } from "@/lib/utils";
import { Camera } from "lucide-react";
import ComparisonPreview from "./ComparisonPreview";
Expand Down Expand Up @@ -111,11 +111,7 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) {
const overlay = (() => {
if (!recipe || !showOverlay) return null;

const preset = recipe.preset === "custom"
? { width: recipe.customWidth, height: recipe.customHeight }
: getPresetById(recipe.preset);

if (!preset) return null;
const preset = getRecipeDimensions(recipe);

// Preview container is 16:9
const containerW = 16;
Expand Down
72 changes: 69 additions & 3 deletions src/hooks/useVideoEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
import { EditRecipe, ExportResult, ExportStatus, MAX_FILE_SIZE, OverlayPosition, isValidRecipe } from "@/lib/types";
import { DEFAULT_RECIPE, SPEED_STEPS } from "@/lib/constants";
import { getPresetById } from "@/lib/presets";
import {
CustomPreset,
MAX_CUSTOM_PRESETS,
getPresetById,
getRecipeDimensions,
loadCustomPresets,
saveCustomPresets,
} from "@/lib/presets";
import { loadFFmpeg, exportVideo, terminateFFmpeg, FFmpegLoadError } from "@/lib/ffmpeg";
import { suggestPreset } from "@/lib/presetSuggestion";
import { validateDimensions, getDownscaledDimensions } from "@/utils/video-validation";
Expand Down Expand Up @@ -137,6 +144,7 @@ export function useVideoEditor() {
const [result, setResult] = useState<ExportResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [fileError, setFileError] = useState("");
const [customPresets, setCustomPresets] = useState<CustomPreset[]>([]);
const exportAbortControllerRef = useRef<AbortController | null>(null);
const exportCancelledRef = useRef(false);
const videoRef = useRef<HTMLVideoElement>(null);
Expand All @@ -161,6 +169,61 @@ export function useVideoEditor() {
return next;
});
}, []);

useEffect(() => {
setCustomPresets(loadCustomPresets());
}, []);

const saveCustomPreset = useCallback((name: string) => {
const trimmedName = name.trim();

if (!trimmedName) {
return { ok: false, message: "Preset name is required." };
}

if (customPresets.length >= MAX_CUSTOM_PRESETS) {
return {
ok: false,
message: `You can save up to ${MAX_CUSTOM_PRESETS} custom presets. Delete one before saving another.`,
};
}

const id = `custom-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const dimensions = getRecipeDimensions(recipe);
const presetRecipe: EditRecipe = {
...recipe,
preset: id,
customWidth: dimensions.width,
customHeight: dimensions.height,
};
const nextPreset: CustomPreset = {
id,
name: trimmedName,
recipe: presetRecipe,
createdAt: Date.now(),
};
const nextPresets = [...customPresets, nextPreset];

setCustomPresets(nextPresets);
saveCustomPresets(nextPresets);
setRecipe(presetRecipe);

return { ok: true, message: `"${trimmedName}" saved as a custom preset.` };
}, [customPresets, recipe]);

const deleteCustomPreset = useCallback((id: string) => {
const nextPresets = customPresets.filter((preset) => preset.id !== id);
setCustomPresets(nextPresets);
saveCustomPresets(nextPresets);
setRecipe((prev) => prev.preset === id ? { ...prev, preset: "custom" } : prev);
}, [customPresets]);

const loadCustomPreset = useCallback((id: string) => {
const preset = customPresets.find((item) => item.id === id);
if (!preset) return;
setRecipe(preset.recipe);
}, [customPresets]);

const isValidValue = (key: keyof EditRecipe, val: any): boolean => {
switch (key) {
case "preset":
Expand Down Expand Up @@ -464,7 +527,6 @@ export function useVideoEditor() {
} catch (err) {
if (exportCancelledRef.current) return;

console.error("export failed:", err);
if (err instanceof FFmpegLoadError) {
setError(err.message);
} else if (err instanceof Error && err.message.includes('network')) {
Expand Down Expand Up @@ -641,9 +703,13 @@ export function useVideoEditor() {
progress,
result,
error,
customPresets,
videoRef,
seekTo,
updateRecipe,
saveCustomPreset,
deleteCustomPreset,
loadCustomPreset,
handleFileSelect,
fileError,
handleExport,
Expand All @@ -670,4 +736,4 @@ export function useVideoEditor() {
currentTime,
toggleSound,
};
}
}
15 changes: 3 additions & 12 deletions src/lib/ffmpeg.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { FFmpeg } from "@ffmpeg/ffmpeg";
import { fetchFile, toBlobURL } from "@ffmpeg/util";
import { EditRecipe, ExportResult, BackgroundMusicOptions, ImageOverlayOptions } from "./types";
import { getPresetById } from "./presets";
import { simd } from "wasm-feature-detect";
import { getRecipeDimensions } from "./presets";

const CORE_BASE_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/umd";

Expand Down Expand Up @@ -306,15 +305,7 @@ export async function exportVideo(
overlayOptions?: ImageOverlayOptions
): Promise<ExportResult> {
const sessionId = buildSessionId();
let targetW: number, targetH: number;
if (recipe.preset === "custom") {
targetW = recipe.customWidth;
targetH = recipe.customHeight;
} else {
const preset = getPresetById(recipe.preset);
targetW = preset?.width ?? 1920;
targetH = preset?.height ?? 1080;
}
let { width: targetW, height: targetH } = getRecipeDimensions(recipe);

targetW = Math.round(targetW / 2) * 2;
targetH = Math.round(targetH / 2) * 2;
Expand Down Expand Up @@ -481,7 +472,7 @@ export async function exportVideo(
size: blob.size,
width: targetW,
height: targetH,
format: recipe.format as "mp4" | "webm" | "mkv",
format: recipe.format,
};
} finally {
ffmpeg.off("progress", handleProgress);
Expand Down
122 changes: 120 additions & 2 deletions src/lib/presets.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import type { EditRecipe } from "./types";
import { DEFAULT_RECIPE } from "./constants";

export interface Preset {
id: string;
label: string;
Expand All @@ -6,7 +9,17 @@ export interface Preset {
height: number;
}

export const PRESETS: Preset[] = [
export interface CustomPreset {
id: string;
name: string;
recipe: EditRecipe;
createdAt: number;
}

export const CUSTOM_PRESET_STORAGE_KEY = "reframe.customPresets";
export const MAX_CUSTOM_PRESETS = 10;

export const BUILT_IN_PRESETS: Preset[] = [
{ id: "vertical-9-16", label: "9 : 16", platform: "Reels · TikTok · Shorts", width: 1080, height: 1920 },
{ id: "instagram-4-5", label: "4 : 5", platform: "Instagram Feed", width: 1080, height: 1350 },
{ id: "square-1-1", label: "1 : 1", platform: "Square", width: 1080, height: 1080 },
Expand All @@ -20,7 +33,112 @@ export const PRESETS: Preset[] = [
{ id: "custom", label: "Custom", platform: "Set your own", width: 1920, height: 1080 },
];

export const PRESETS = BUILT_IN_PRESETS;

/** Returns the preset matching the given ID, or undefined if no match is found. */
export function getPresetById(id: string): Preset | undefined {
return PRESETS.find((p) => p.id === id);
return BUILT_IN_PRESETS.find((p) => p.id === id);
}

export function getRecipeDimensions(recipe: Pick<EditRecipe, "preset" | "customWidth" | "customHeight">) {
if (recipe.preset === "custom") {
return { width: recipe.customWidth, height: recipe.customHeight };
}

const preset = getPresetById(recipe.preset);
return preset
? { width: preset.width, height: preset.height }
: { width: recipe.customWidth, height: recipe.customHeight };
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

function normalizeNumber(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}

function normalizeBoolean(value: unknown, fallback: boolean): boolean {
return typeof value === "boolean" ? value : fallback;
}

function normalizeRecipe(value: unknown, presetId: string): EditRecipe | null {
if (!isRecord(value)) return null;

const format = value.format === "webm" || value.format === "mkv" || value.format === "mp4" || value.format === "gif"
? value.format
: DEFAULT_RECIPE.format;
const framing = value.framing === "fill" || value.framing === "fit"
? value.framing
: DEFAULT_RECIPE.framing;
const rotate = value.rotate === 90 || value.rotate === 180 || value.rotate === 270 || value.rotate === 0
? value.rotate
: DEFAULT_RECIPE.rotate;

return {
...DEFAULT_RECIPE,
preset: presetId,
customWidth: normalizeNumber(value.customWidth, DEFAULT_RECIPE.customWidth),
customHeight: normalizeNumber(value.customHeight, DEFAULT_RECIPE.customHeight),
framing,
trimStart: normalizeNumber(value.trimStart, DEFAULT_RECIPE.trimStart),
trimEnd: value.trimEnd === null || typeof value.trimEnd !== "number"
? DEFAULT_RECIPE.trimEnd
: normalizeNumber(value.trimEnd, DEFAULT_RECIPE.trimEnd ?? 0),
rotate,
keepAudio: normalizeBoolean(value.keepAudio, DEFAULT_RECIPE.keepAudio),
normalizeAudio: normalizeBoolean(value.normalizeAudio, DEFAULT_RECIPE.normalizeAudio),
speed: normalizeNumber(value.speed, DEFAULT_RECIPE.speed),
quality: normalizeNumber(value.quality, DEFAULT_RECIPE.quality),
format,
stabilization: normalizeBoolean(value.stabilization, DEFAULT_RECIPE.stabilization),
brightness: normalizeNumber(value.brightness, DEFAULT_RECIPE.brightness),
contrast: normalizeNumber(value.contrast, DEFAULT_RECIPE.contrast),
saturation: normalizeNumber(value.saturation, DEFAULT_RECIPE.saturation),
soundOnCompletion: normalizeBoolean(value.soundOnCompletion, DEFAULT_RECIPE.soundOnCompletion),
version: DEFAULT_RECIPE.version,
};
}

function normalizeCustomPreset(value: unknown): CustomPreset | null {
if (!isRecord(value)) return null;
if (typeof value.id !== "string" || typeof value.name !== "string") return null;

const recipe = normalizeRecipe(value.recipe, value.id);
if (!recipe) return null;

return {
id: value.id,
name: value.name,
recipe,
createdAt: normalizeNumber(value.createdAt, Date.now()),
};
}

export function loadCustomPresets(): CustomPreset[] {
if (typeof window === "undefined") return [];

try {
const raw = window.localStorage.getItem(CUSTOM_PRESET_STORAGE_KEY);
if (!raw) return [];

const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];

return parsed
.map(normalizeCustomPreset)
.filter((preset): preset is CustomPreset => preset !== null)
.slice(0, MAX_CUSTOM_PRESETS);
} catch {
return [];
}
}

export function saveCustomPresets(presets: CustomPreset[]) {
if (typeof window === "undefined") return;
window.localStorage.setItem(
CUSTOM_PRESET_STORAGE_KEY,
JSON.stringify(presets.slice(0, MAX_CUSTOM_PRESETS))
);
}
Loading
Loading