From ce2b697902fea77bdbdf3201d5424fdfb859f7a0 Mon Sep 17 00:00:00 2001 From: Vagventure Date: Tue, 19 May 2026 05:32:25 +0530 Subject: [PATCH 1/3] feat: integrate ffmpeg deshake filter for video stabilization Signed-off-by: Vagventure --- src/components/ExportSettings.tsx | 14 +++++++++----- src/lib/ffmpeg.ts | 10 +++++----- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/components/ExportSettings.tsx b/src/components/ExportSettings.tsx index 32aff183..026f0760 100644 --- a/src/components/ExportSettings.tsx +++ b/src/components/ExportSettings.tsx @@ -132,7 +132,7 @@ export default function ExportSettings({
-
+
+ {/* Short descriptive label explaining what the setting does */} +

+ Reduce camera shake +

+
diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts index 00b8fa73..beaeed1b 100644 --- a/src/lib/ffmpeg.ts +++ b/src/lib/ffmpeg.ts @@ -96,6 +96,11 @@ function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: number): filters.push("setpts=PTS-STARTPTS"); } + + if (recipe.stabilization) { + filters.push("deshake"); + } + if (recipe.rotate === 90) { filters.push("transpose=1"); } else if (recipe.rotate === 180) { @@ -104,11 +109,6 @@ function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: number): filters.push("transpose=2"); } - // Integrated from main branch layout enhancements - if ((recipe as any).stabilization) { - filters.push("deshake=x=-1:y=-1:w=-1:h=-1:rx=16:ry=16"); - } - if (recipe.framing === "fit") { filters.push( `scale=${targetW}:${targetH}:force_original_aspect_ratio=decrease`, From 22ac5cc272e04a5634b1d1251a44ec16d7a0b9ea Mon Sep 17 00:00:00 2001 From: Vagventure Date: Tue, 19 May 2026 06:21:12 +0530 Subject: [PATCH 2/3] feat: add social media quick-action row to PresetSelector Signed-off-by: Vagventure --- src/components/PresetSelector.tsx | 85 ++++++++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/src/components/PresetSelector.tsx b/src/components/PresetSelector.tsx index 6f8aea11..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(""); @@ -83,6 +136,36 @@ 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 From 7ffd7787b22465ce4b249aadb444d8e50905c7a1 Mon Sep 17 00:00:00 2001 From: Vagventure Date: Tue, 19 May 2026 08:29:43 +0530 Subject: [PATCH 3/3] feat: implement two-pass high-quality GIF export via FFmpeg Signed-off-by: Vagventure --- src/components/ExportSettings.tsx | 10 +++++++ src/components/FormatSelector.tsx | 7 +++-- src/hooks/useVideoEditor.ts | 14 +++++++--- src/lib/ffmpeg.ts | 46 +++++++++++++++++++++++++++++-- src/lib/types.ts | 4 +-- 5 files changed, 70 insertions(+), 11 deletions(-) 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/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 =