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 =