diff --git a/src/components/ExportSettings.tsx b/src/components/ExportSettings.tsx
index 026f0760..acec541e 100644
--- a/src/components/ExportSettings.tsx
+++ b/src/components/ExportSettings.tsx
@@ -32,6 +32,8 @@ export default function ExportSettings({
? "Balanced"
: "Small file";
+ const isGif = recipe.format === "gif";
+
const estimatedSize =
formatEstimatedSize(
estimateExportSize(
@@ -108,8 +110,15 @@ export default function ExportSettings({
{estimatedSize}
+
+ {isGif && (
+
+ ⚠ GIF files can be very large. Keep clips under 10 s for best results.
+
+ )}
+ {!isGif && (
+ )}
diff --git a/src/components/FormatSelector.tsx b/src/components/FormatSelector.tsx
index a9969ab8..81033b6b 100644
--- a/src/components/FormatSelector.tsx
+++ b/src/components/FormatSelector.tsx
@@ -13,6 +13,7 @@ const FORMAT_OPTIONS = [
{ id: "mp4", label: "MP4", description: "Best compatibility, smaller file size" },
{ id: "webm", label: "WebM", description: "Open format, optimized for web" },
{ id: "mkv", label: "MKV", description: "Container, maximum quality" },
+ { id: "gif", label: "GIF", description: "Animated image — keep clips under 10 s" },
] as const;
export default function FormatSelector({ recipe, onChange }: Props) {
@@ -24,12 +25,12 @@ export default function FormatSelector({ recipe, onChange }: Props) {
Output Format
-
+
{FORMAT_OPTIONS.map((option) => (
);
-}
+}
\ No newline at end of file
diff --git a/src/components/PresetSelector.tsx b/src/components/PresetSelector.tsx
index 53821604..052a3698 100644
--- a/src/components/PresetSelector.tsx
+++ b/src/components/PresetSelector.tsx
@@ -49,6 +49,59 @@ function RatioBox({
);
}
+const QUICK_ACTIONS = [
+ {
+ preset: "vertical-9-16",
+ label: "Reels",
+ platform: "Instagram",
+ icon: (
+
+ ),
+ },
+ {
+ preset: "vertical-9-16",
+ label: "TikTok",
+ platform: "TikTok",
+ icon: (
+
+ ),
+ },
+ {
+ preset: "vertical-9-16",
+ label: "Short",
+ platform: "YouTube",
+ icon: (
+
+ ),
+ },
+ {
+ preset: "landscape-16-9",
+ label: "YouTube",
+ platform: "YouTube",
+ icon: (
+
+ ),
+ },
+ {
+ preset: "twitter-hd",
+ label: "Twitter/X",
+ platform: "Twitter",
+ icon: (
+
+ ),
+ },
+] as const;
+
export default function PresetSelector({ recipe, onChange }: Props) {
const [search, setSearch] = useState("");
@@ -82,7 +135,37 @@ export default function PresetSelector({ recipe, onChange }: Props) {
);
return (
-
+
+ {/* Quick-action row */}
+
+ {QUICK_ACTIONS.map(({ preset, label, platform, icon }) => {
+ const isActive = recipe.preset === preset;
+ return (
+
+ );
+ })}
+
+
@@ -246,4 +329,4 @@ export default function PresetSelector({ recipe, onChange }: Props) {
)}
);
-}
+}
\ No newline at end of file
diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts
index a2283128..2f94bcbf 100644
--- a/src/hooks/useVideoEditor.ts
+++ b/src/hooks/useVideoEditor.ts
@@ -147,10 +147,16 @@ export function useVideoEditor() {
const [overlaySize, setOverlaySize] = useState(150);
const [overlayOpacity, setOverlayOpacity] = useState(100);
- const updateRecipe = useCallback((patch: Partial
) => {
- setRecipe((prev) => ({ ...prev, ...patch }));
- }, []);
-
+ const updateRecipe = useCallback((patch: Partial) => {
+ setRecipe((prev) => {
+ const next = { ...prev, ...patch };
+ // GIF has no audio — force keepAudio off
+ if (next.format === "gif") {
+ next.keepAudio = false;
+ }
+ return next;
+ });
+}, []);
useEffect(() => {
try {
const saved = localStorage.getItem("reframe-settings");
diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts
index beaeed1b..35b32a5f 100644
--- a/src/lib/ffmpeg.ts
+++ b/src/lib/ffmpeg.ts
@@ -162,7 +162,7 @@ function buildAudioTrimFilter(recipe: EditRecipe): string {
function buildArguments(
recipe: EditRecipe,
- format: "mp4" | "webm" | "mkv",
+ format: "mp4" | "webm" | "mkv" | "gif",
outputName: string,
inputName: string,
targetW: number,
@@ -312,6 +312,8 @@ export async function exportVideo(
return { filename: `output_${sessionId}.webm`, mimeType: "video/webm" };
case "mkv":
return { filename: `output_${sessionId}.mkv`, mimeType: "video/x-matroska" };
+ case "gif":
+ return { filename: `output_${sessionId}.gif`, mimeType: "image/gif" };
default:
return { filename: `output_${sessionId}.mp4`, mimeType: "video/mp4" };
}
@@ -319,7 +321,8 @@ export async function exportVideo(
const { filename: outputName, mimeType } = getOutputConfig(recipe.format);
const fallbackOutputName = `fallback_${sessionId}.webm`;
- const cleanupFiles = new Set([inputName, outputName, fallbackOutputName]);
+ const paletteName = `palette_${sessionId}.png`;
+ const cleanupFiles = new Set([inputName, outputName, fallbackOutputName, paletteName]);
const handleProgress = ({ progress }: { progress: number }) => {
onProgress(Math.min(99, Math.round(progress * 100)));
@@ -345,6 +348,45 @@ export async function exportVideo(
ffmpeg.on("progress", handleProgress);
+ // ── Two-pass GIF export ──────────────────────────────────────────────────
+ if (recipe.format === "gif") {
+ const vf = buildVideoFilter(recipe, targetW, targetH);
+ const vfWithPalette = vf ? `${vf},palettegen` : "palettegen";
+ const vfWithPaletteUse = vf
+ ? `[0:v]${vf}[x];[x][1:v]paletteuse`
+ : "[0:v][1:v]paletteuse";
+
+ // Pass 1: generate colour palette
+ const pass1Code = await ffmpeg.exec(
+ ["-i", inputName, "-vf", vfWithPalette, "-y", paletteName],
+ undefined,
+ { signal }
+ );
+ if (pass1Code !== 0) throw new Error("GIF palette generation failed");
+
+ // Pass 2: render GIF using the palette
+ const pass2Code = await ffmpeg.exec(
+ ["-i", inputName, "-i", paletteName, "-lavfi", vfWithPaletteUse, "-y", outputName],
+ undefined,
+ { signal }
+ );
+ if (pass2Code !== 0) throw new Error("GIF export failed");
+
+ const data = await ffmpeg.readFile(outputName, undefined, { signal });
+ const blob = new Blob([new Uint8Array(data as Uint8Array)], { type: "image/gif" });
+
+ ffmpeg.off("progress", handleProgress);
+ onProgress(100);
+ return {
+ blobUrl: URL.createObjectURL(blob),
+ size: blob.size,
+ width: targetW,
+ height: targetH,
+ format: "gif" as const,
+ };
+ }
+ // ────────────────────────────────────────────────────────────────────────
+
let missingAudioDetected = false;
const logListener = ({ message }: { message: string }) => {
const msg = message.toLowerCase();
diff --git a/src/lib/types.ts b/src/lib/types.ts
index bf167094..bccb5d04 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -9,7 +9,7 @@ export interface EditRecipe {
keepAudio: boolean;
speed: number;
quality: number;
- format: "mp4" | "webm" | "mkv";
+ format: "mp4" | "webm" | "mkv" | "gif";
stabilization: boolean;
brightness: number;
contrast: number;
@@ -42,7 +42,7 @@ export interface ExportResult {
size: number;
width: number;
height: number;
- format: "mp4" | "webm" | "mkv";
+ format: "mp4" | "webm" | "mkv" | "gif";
}
export type ExportStatus =