From 8b068bd506cf4caacaa62356d90c31bceca7414f Mon Sep 17 00:00:00 2001 From: Pravallika21-nama Date: Wed, 20 May 2026 23:26:03 +0530 Subject: [PATCH] fix: remove leftover debug console logs from useVideoEditor --- .gitignore | 3 + src/components/AudioSpeedControl.tsx | 90 ++++---- src/components/ExportSettings.tsx | 20 +- src/components/Footer.tsx | 23 +- src/components/FormatSelector.tsx | 7 +- src/components/ImageOverlay.tsx | 15 +- src/components/PresetSelector.tsx | 93 +++++++- src/components/ThumbnailStrip.tsx | 260 +++------------------ src/components/TrimControl.tsx | 160 +++++++------ src/components/VideoEditor.tsx | 110 ++++++++- src/components/VideoPreview.tsx | 322 ++------------------------- src/components/WaveformCanvas.tsx | 95 ++++++++ src/hooks/useAudioWaveform.ts | 90 ++++++++ src/hooks/useVideoEditor.ts | 158 +++++++++++-- src/lib/constants.ts | 1 + src/lib/exportEstimate.test.ts | 121 ++++++++++ src/lib/exportEstimate.ts | 145 +++++++++--- src/lib/ffmpeg.ts | 65 +++++- src/lib/tests/ffmpeg.test.ts | 25 ++- src/lib/types.ts | 6 +- tsconfig.json | 5 +- 21 files changed, 1077 insertions(+), 737 deletions(-) create mode 100644 src/components/WaveformCanvas.tsx create mode 100644 src/hooks/useAudioWaveform.ts create mode 100644 src/lib/exportEstimate.test.ts diff --git a/.gitignore b/.gitignore index e15687ec..5ae10976 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ next-env.d.ts # bun bun.lockb + +# npm lockfile (project uses bun) +package-lock.json diff --git a/src/components/AudioSpeedControl.tsx b/src/components/AudioSpeedControl.tsx index 3200f1f5..a6c82102 100644 --- a/src/components/AudioSpeedControl.tsx +++ b/src/components/AudioSpeedControl.tsx @@ -1,5 +1,4 @@ "use client"; -import { useEffect } from "react"; import { EditRecipe } from "@/lib/types" import { SPEED_STEPS } from "@/lib/constants"; @@ -12,36 +11,6 @@ interface Props { } export default function AudioSpeedControl({ recipe, onChange }: Props) { - useEffect(() => { - const handler = (e: KeyboardEvent) => { - const target = e.target as HTMLElement; - - if ( - target.tagName === "INPUT" || - target.tagName === "TEXTAREA" || - target.isContentEditable - ) { - return; - } - - if ( - e.key.toLowerCase() === "m" && - !e.ctrlKey && - !e.metaKey - ) { - onChange({ - keepAudio: !recipe.keepAudio, - }); - } - }; - - document.addEventListener("keydown", handler); - - return () => { - document.removeEventListener("keydown", handler); - }; - }, [recipe.keepAudio, onChange]); - const speedIndex = SPEED_STEPS.indexOf(recipe.speed as (typeof SPEED_STEPS)[number]); const getSpeedDescription = (speed: number) => { @@ -60,6 +29,7 @@ export default function AudioSpeedControl({ recipe, onChange }: Props) {
@@ -97,7 +77,7 @@ export default function AudioSpeedControl({ recipe, onChange }: Props) { htmlFor="speed-control" className="text-sm font-heading font-semibold uppercase tracking-wider text-[var(--muted)] flex items-center gap-2" > - Speed +
+ {recipe.keepAudio && ( + + )} + {recipe.keepAudio && (recipe.trimStart !== 0 || recipe.trimEnd !== null) && ( -
- +
+
); -} +} \ No newline at end of file diff --git a/src/components/ExportSettings.tsx b/src/components/ExportSettings.tsx index 026f0760..ddc29a85 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( @@ -74,10 +76,10 @@ export default function ExportSettings({ min={18} max={30} step={1} - value={recipe.quality} + value={48 - recipe.quality} onChange={(e) => onChange({ - quality: Number( + quality: 48 - Number( e.target.value ), }) @@ -94,11 +96,11 @@ export default function ExportSettings({ >
- Best quality + Smallest file - Smallest file + Best quality
@@ -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 && (
+ )}
@@ -178,4 +188,4 @@ export default function ExportSettings({
); -} \ No newline at end of file +} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index a14d7abd..29edf09e 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -48,19 +48,19 @@ export default function Footer() { href="https://github.com/magic-peach/reframe" target="_blank" rel="noopener" - className="opacity-60 hover:opacity-100 hover:text-blue-500 hover:translate-x-1.5 transition-all duration-300 ease-out w-fit flex items-center gap-2 group" + className="opacity-70 hover:opacity-100 hover:text-red-400 hover:scale-110 transition-all duration-500 ease-in-out w-fit flex items-center gap-2 group" > GitHub Contact Privacy Policy @@ -78,6 +78,9 @@ export default function Footer() { ) : ( -
+
{ e.preventDefault(); setIsExpanded(false); }} className="flex w-full items-center animate-in slide-in-from-right-2 duration-500" > @@ -94,10 +100,11 @@ export default function Footer() { type="email" placeholder="ENTER EMAIL" className="bg-transparent border-none text-[10px] font-bold tracking-widest text-[var(--text)] focus:outline-none w-full py-3 placeholder:opacity-30" + aria-label="Email address for updates" onBlur={() => setIsExpanded(false)} /> -
@@ -112,9 +119,9 @@ export default function Footer() { target="_blank" rel="noopener" className="p-2.5 rounded-lg border border-[var(--border)] bg-[var(--surface)] hover:bg-[var(--border)] transition-all hover:-translate-y-1 active:scale-95 flex items-center justify-center group" - aria-label="GitHub" + aria-label="Open Reframe GitHub repository" > - +
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/ImageOverlay.tsx b/src/components/ImageOverlay.tsx index ae090992..69ebfedf 100644 --- a/src/components/ImageOverlay.tsx +++ b/src/components/ImageOverlay.tsx @@ -1,7 +1,7 @@ import { useRef, useState, useEffect } from "react"; import { OverlayPosition } from "@/lib/types"; import { ArrowUpLeft, ArrowUpRight, ArrowDownLeft, ArrowDownRight, Upload, Trash2, FileImage } from "lucide-react"; - +import Image from "next/image"; interface ImageOverlayPanelProps { overlayFile: File | null; setOverlayFile: (file: File | null) => void; @@ -70,11 +70,14 @@ export default function ImageOverlayPanel({ : "border-dashed border-[#2d4266] hover:bg-white/5 text-[#c7d8f7] hover:text-white cursor-pointer" }`}> {thumbUrl ? ( - Overlay preview +
+ Overlay preview +
) : ( <> diff --git a/src/components/PresetSelector.tsx b/src/components/PresetSelector.tsx index 53821604..817c8d06 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 ( + + ); + })} +
+
@@ -111,7 +194,7 @@ export default function PresetSelector({ recipe, onChange }: Props) { type="button" onClick={() => handlePresetSelect(preset.id)} title={`${preset.label} — ${preset.width}×${preset.height} — ${getOrientationLabel(preset.width, preset.height)}`} - aria-label={`Select ${preset.label} preset, ${preset.width} by ${preset.height} pixels`} + aria-label={`${preset.label.replaceAll(":", " is to ")} output ratio`} aria-pressed={active} className={cn( "min-h-[44px] min-w-[44px] flex flex-col items-center justify-center gap-1.5 p-3 rounded-lg border text-center transition-all duration-150 cursor-pointer hover:scale-[1.02] active:scale-[0.98]", @@ -202,7 +285,7 @@ export default function PresetSelector({ recipe, onChange }: Props) { step={2} value={recipe.customWidth} onChange={(e) => handleWidthChange(Number(e.target.value))} - className="w-full rounded-md border border-[var(--border)] bg-[var(--bg)] px-3 py-2 text-sm font-heading transition-all focus:outline-none focus:ring-2 focus:ring-film-400" + className="w-full rounded-md border border-[var(--border)] bg-[var(--bg)] px-3 py-2 text-sm font-heading transition-all focus:outline-none focus:ring-2 focus:ring-film-400 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" />
@@ -227,7 +310,7 @@ export default function PresetSelector({ recipe, onChange }: Props) { step={2} value={recipe.customHeight} onChange={(e) => handleHeightChange(Number(e.target.value))} - className="w-full rounded-md border border-[var(--border)] bg-[var(--bg)] px-3 py-2 text-sm font-heading transition-all focus:outline-none focus:ring-2 focus:ring-film-400" + className="w-full rounded-md border border-[var(--border)] bg-[var(--bg)] px-3 py-2 text-sm font-heading transition-all focus:outline-none focus:ring-2 focus:ring-film-400 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" />
@@ -246,4 +329,4 @@ export default function PresetSelector({ recipe, onChange }: Props) { )}
); -} +} \ No newline at end of file diff --git a/src/components/ThumbnailStrip.tsx b/src/components/ThumbnailStrip.tsx index c4a186ef..8fc0e8cf 100644 --- a/src/components/ThumbnailStrip.tsx +++ b/src/components/ThumbnailStrip.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useRef, useState, useCallback } from "react"; +import Image from "next/image"; interface Thumbnail { time: number; @@ -84,7 +85,10 @@ export default function ThumbnailStrip({ const onSeeked = () => { video.removeEventListener("seeked", onSeeked); ctx.drawImage(video, 0, 0, thumbW, thumbH); - captured.push({ time, dataUrl: canvas.toDataURL("image/jpeg", 0.7) }); + captured.push({ + time, + dataUrl: canvas.toDataURL("image/jpeg", 0.7), + }); setThumbnails([...captured]); setProgress(Math.round(((i + 1) / times.length) * 100)); resolve(); @@ -100,13 +104,14 @@ export default function ThumbnailStrip({ }, [videoSrc, duration, intervalSeconds]); useEffect(() => { - if (videoSrc && duration > 0) { - generateThumbnails(); - } - return () => { - abortRef.current = true; - }; - }, [generateThumbnails]); + if (videoSrc && duration > 0) { + generateThumbnails(); + } + + return () => { + abortRef.current = true; + }; +}, [videoSrc, duration, generateThumbnails]); const formatTime = (seconds: number) => { const m = Math.floor(seconds / 60); @@ -125,14 +130,8 @@ export default function ThumbnailStrip({ return (
- - - - - - - Frames - + Frames + {isGenerating && ( {progress}% )} + {!isGenerating && thumbnails.length > 0 && ( {thumbnails.length} frames · every {intervalSeconds}s @@ -153,7 +153,7 @@ export default function ThumbnailStrip({ {thumbnails.length === 0 && isGenerating && (
{Array.from({ length: 8 }).map((_, i) => ( -
+
))}
)} @@ -163,26 +163,30 @@ export default function ThumbnailStrip({ {thumbnails.map((thumb, i) => { const isActive = i === activeIndex; const inTrimRange = - thumb.time >= trimStart && thumb.time <= effectiveTrimEnd; - const isHovered = hoveredIndex === i; + thumb.time >= trimStart && + thumb.time <= effectiveTrimEnd; return ( ); @@ -190,208 +194,6 @@ export default function ThumbnailStrip({
)}
- -
); } \ No newline at end of file diff --git a/src/components/TrimControl.tsx b/src/components/TrimControl.tsx index 5ba6d682..efe6bf47 100644 --- a/src/components/TrimControl.tsx +++ b/src/components/TrimControl.tsx @@ -4,78 +4,79 @@ import { EditRecipe } from "@/lib/types"; import { useState, useEffect } from "react"; import { AlertCircle } from "lucide-react"; import { formatDuration } from "@/lib/utils"; +import { useAudioWaveform } from "@/hooks/useAudioWaveform"; +import WaveformCanvas from "@/components/WaveformCanvas"; interface Props { recipe: EditRecipe; onChange: (patch: Partial) => void; duration: number; + file: File | null; } -export default function TrimControl({ recipe, onChange, duration }: Props) { +export default function TrimControl({ recipe, onChange, duration, file }: Props) { const [invalidStart, setStart] = useState(false); const [invalidEnd, setEnd] = useState(false); const [startErrorMsg, setStartErrorMsg] = useState(""); const [endErrorMsg, setEndErrorMsg] = useState(""); - const [startInput, setStartInput] = useState( - recipe.trimStart.toString() - ); + const [startInput, setStartInput] = useState(recipe.trimStart.toString()); + + const { waveform, isLoading: waveformLoading } = useAudioWaveform(file); + const hasAudio = waveform.length > 0; useEffect(() => { setStartInput(recipe.trimStart.toString()); }, [recipe.trimStart]); - const clipLength = - (recipe.trimEnd ?? duration) - recipe.trimStart; + const clipLength = (recipe.trimEnd ?? duration) - recipe.trimStart; const handleStart = (val: string) => { - setStartInput(val); + setStartInput(val); + + if (val === "") { + setStart(false); + setStartErrorMsg(""); + return; + } + + const n = parseFloat(val); + + if (isNaN(n)) { + setStart(true); + setStartErrorMsg("Enter a valid number."); + return; + } + + if (n < 0) { + setStart(true); + setStartErrorMsg("Start time must be 0 or greater."); + return; + } + + if (duration > 0 && n >= duration) { + setStart(true); + setStartErrorMsg( + `Start time must be less than duration (${duration.toFixed(1)}s).`, + ); + return; + } + + if (recipe.trimEnd !== null && n >= recipe.trimEnd) { + setStart(true); + setStartErrorMsg("Start time must be less than the end time."); + return; + } - if (val === "") { setStart(false); setStartErrorMsg(""); - return; - } - - const n = parseFloat(val); - - if (isNaN(n)) { - setStart(true); - setStartErrorMsg("Enter a valid number."); - return; - } - - if (n < 0) { - setStart(true); - setStartErrorMsg("Start time must be 0 or greater."); - return; - } - - if (duration > 0 && n >= duration) { - setStart(true); - setStartErrorMsg( - `Start time must be less than duration (${duration.toFixed(1)}s).` - ); - return; - } - - if (recipe.trimEnd !== null && n >= recipe.trimEnd) { - setStart(true); - setStartErrorMsg("Start time must be less than the end time."); - return; - } - - setStart(false); - setStartErrorMsg(""); - - onChange({ trimStart: n }); + + onChange({ trimStart: n }); }; const handleEnd = (val: string) => { - if (val === "") { - setEnd(false); - setEndErrorMsg(""); onChange({ trimEnd: null }); + setEnd(false); return; } @@ -104,7 +105,7 @@ export default function TrimControl({ recipe, onChange, duration }: Props) { if (duration > 0 && n > duration + 0.01) { setEnd(true); setEndErrorMsg( - `End time cannot exceed duration (${duration.toFixed(1)}s).` + `End time cannot exceed duration (${duration.toFixed(1)}s).`, ); return; } @@ -114,15 +115,30 @@ export default function TrimControl({ recipe, onChange, duration }: Props) { }; const inputClass = - "w-full text-sm px-3 py-2 border border-[var(--border)] rounded-md bg-[var(--bg)] font-heading focus:outline-none focus:ring-2 focus:ring-film-400 text-[var(--text)] transition-shadow"; + "w-full text-sm px-3 py-2 border border-[var(--border)] rounded-md bg-[var(--bg)] font-heading focus:outline-none focus:ring-2 focus:ring-film-400 text-[var(--text)] transition-shadow [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"; return (
+ {/* Waveform — shown while loading or when file is present */} + {(file && (waveformLoading || hasAudio)) && ( +
+ +
+ )} +
-
+
-
-
+ {duration > 0 && ( -

- Clip: {formatDuration(clipLength)} of{" "} - {formatDuration(duration)} -

- )} -
- +

+ Clip: {formatDuration(clipLength)} of {formatDuration(duration)} +

+ )} +
); - -} - +} \ No newline at end of file diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index d43f5d18..76b7ed3f 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -19,7 +19,7 @@ import ImageOverlay from "./ImageOverlay" import { cn } from "@/lib/utils"; import { Layers, Crop, Scissors, RotateCw, Volume2, - SlidersHorizontal, Zap, AlertTriangle, Github + SlidersHorizontal, Zap, AlertTriangle, Github, Copy } from "lucide-react"; import OnboardingTour from "./OnboardingTour"; @@ -48,9 +48,69 @@ function Section({ icon, title, children, delay = 0 }: SectionProps) { ); } +/** Inline keyboard hint badge. */ +function Kbd({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +/** Collapsible panel that lists all keyboard shortcuts. */ +function KeyboardShortcutsPanel() { + const [open, setOpen] = useState(false); + + const shortcuts: { keys: React.ReactNode[]; label: string }[] = [ + { keys: [M], label: "Toggle audio mute" }, + { keys: [Ctrl, +, ], label: "Export video" }, + ]; + + return ( +
+ + + {open && ( +
    + {shortcuts.map(({ keys, label }) => ( +
  • + {label} + {keys} +
  • + ))} +
+ )} +
+ ); +} + export default function VideoEditor() { const { - file, duration, recipe, status, progress, + file, duration, recipe, status, progress, result, error, updateRecipe, handleFileSelect, fileError, handleExport, cancelExport, reset, resetSettings, videoRef, @@ -62,8 +122,17 @@ export default function VideoEditor() { recommendedPreset, } = useVideoEditor(); const [copied, setCopied] = useState(false); + const [shareCopied, setShareCopied] = useState(false); const downloadRef = useRef(null); + const handleCopyLink = () => { + if (typeof window === "undefined") return; + navigator.clipboard.writeText(window.location.href).then(() => { + setShareCopied(true); + setTimeout(() => setShareCopied(false), 2000); + }); + }; + useEffect(() => { if (status === "done" && downloadRef.current) { const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; @@ -101,7 +170,10 @@ export default function VideoEditor() {
-
+

REFRAME

@@ -122,10 +194,10 @@ export default function VideoEditor() { {!file && ( -
-

Upload a video to get started

-

Supports MP4, MOV, WebM and more

-
+
+

Upload a video to get started

+

Supports MP4, MOV, WebM and more

+
)} {file && ( @@ -158,7 +230,12 @@ export default function VideoEditor() { )}>
} title="Trim" delay={50}> - +
} title="Rotate" delay={100}> @@ -182,6 +259,7 @@ export default function VideoEditor() { type="button" onClick={() => updateRecipe({ brightness: 0 })} className="text-film-500 hover:underline" + aria-label="reset brightness" > Reset @@ -206,6 +284,7 @@ export default function VideoEditor() { type="button" onClick={() => updateRecipe({ contrast: 1 })} className="text-film-500 hover:underline" + aria-label="reset-contrast" > Reset @@ -230,6 +309,7 @@ export default function VideoEditor() { type="button" onClick={() => updateRecipe({ saturation: 1 })} className="text-film-500 hover:underline" + aria-label="reset-saturation" > Reset @@ -332,7 +412,15 @@ export default function VideoEditor() {
-
+
+
+ + - )} - - {/* Grab frame button */} - {!isLoading && ( - - )} + ref={videoRef} + controls + className="w-full h-full object-contain" + playsInline + muted={!recipe?.keepAudio} +> + +
); } \ No newline at end of file diff --git a/src/components/WaveformCanvas.tsx b/src/components/WaveformCanvas.tsx new file mode 100644 index 00000000..a170a0e7 --- /dev/null +++ b/src/components/WaveformCanvas.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +interface Props { + samples: number[]; + loading: boolean; + hasAudio: boolean; +} + +// Reads a CSS variable from :root, falling back to a default +function getCssVar(name: string, fallback: string): string { + if (typeof window === "undefined") return fallback; + return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; +} + +export default function WaveformCanvas({ samples, loading, hasAudio }: Props) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const dpr = window.devicePixelRatio ?? 1; + const { width, height } = canvas.getBoundingClientRect(); + + canvas.width = width * dpr; + canvas.height = height * dpr; + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, width, height); + + const midY = height / 2; + + // Read theme colour from :root — falls back to a visible purple + const accentColor = getCssVar("--film-500", "#8b5cf6"); + + if (!hasAudio || samples.length === 0) { + // Flat centre line for silent videos + ctx.beginPath(); + ctx.strokeStyle = accentColor; + ctx.globalAlpha = 0.4; + ctx.lineWidth = 1.5; + ctx.moveTo(0, midY); + ctx.lineTo(width, midY); + ctx.stroke(); + return; + } + + const barWidth = width / samples.length; + ctx.fillStyle = accentColor; + ctx.globalAlpha = 0.7; + + for (let i = 0; i < samples.length; i++) { + const amplitude = samples[i]; + const barHeight = Math.max(amplitude * (height * 0.92), 1.5); + const x = i * barWidth; + const y = midY - barHeight / 2; + ctx.fillRect(x, y, Math.max(barWidth - 0.5, 0.5), barHeight); + } + }, [samples, loading, hasAudio]); + + if (loading) { + return ( +
+
+ {Array.from({ length: 48 }).map((_, i) => ( +
+ ))} +
+
+ ); + } + + return ( + + ); +} \ No newline at end of file diff --git a/src/hooks/useAudioWaveform.ts b/src/hooks/useAudioWaveform.ts new file mode 100644 index 00000000..25ed57d5 --- /dev/null +++ b/src/hooks/useAudioWaveform.ts @@ -0,0 +1,90 @@ +"use client"; + +import { useEffect, useState } from "react"; + +const DEFAULT_BAR_COUNT = 96; + +type BrowserWindow = Window & + typeof globalThis & { + webkitAudioContext?: typeof AudioContext; + }; + +function downsampleWaveform(channelData: Float32Array, barCount: number): number[] { + const sampleSize = Math.max(1, Math.floor(channelData.length / barCount)); + const peaks = Array.from({ length: barCount }, (_, index) => { + const start = index * sampleSize; + const end = Math.min(start + sampleSize, channelData.length); + let peak = 0; + + for (let i = start; i < end; i += 1) { + peak = Math.max(peak, Math.abs(channelData[i] ?? 0)); + } + + return peak; + }); + + const maxPeak = Math.max(...peaks, 0.01); + return peaks.map((peak) => peak / maxPeak); +} + +export function useAudioWaveform( + file: File | null, + barCount = DEFAULT_BAR_COUNT +) { + const [waveform, setWaveform] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + let isCancelled = false; + let audioContext: AudioContext | null = null; + + async function extractWaveform() { + if (!file) { + setWaveform([]); + setIsLoading(false); + return; + } + + const AudioContextCtor = + window.AudioContext || (window as BrowserWindow).webkitAudioContext; + + if (!AudioContextCtor) { + setWaveform([]); + setIsLoading(false); + return; + } + + setIsLoading(true); + + try { + audioContext = new AudioContextCtor(); + const audioBuffer = await audioContext.decodeAudioData( + await file.arrayBuffer() + ); + const channelData = audioBuffer.getChannelData(0); + const peaks = downsampleWaveform(channelData, barCount); + + if (!isCancelled) { + setWaveform(peaks); + } + } catch { + if (!isCancelled) { + setWaveform([]); + } + } finally { + await audioContext?.close(); + if (!isCancelled) { + setIsLoading(false); + } + } + } + + extractWaveform(); + + return () => { + isCancelled = true; + }; + }, [barCount, file]); + + return { waveform, isLoading }; +} diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts index a2283128..839ae388 100644 --- a/src/hooks/useVideoEditor.ts +++ b/src/hooks/useVideoEditor.ts @@ -147,29 +147,132 @@ 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; + }); +}, []); + const isValidValue = (key: keyof EditRecipe, val: any): boolean => { + switch (key) { + case "preset": + return typeof val === "string"; + case "customWidth": + return typeof val === "number" && !isNaN(val) && val >= 16 && val <= 7680; + case "customHeight": + return typeof val === "number" && !isNaN(val) && val >= 16 && val <= 7680; + case "framing": + return val === "fit" || val === "fill"; + case "trimStart": + return typeof val === "number" && !isNaN(val) && val >= 0; + case "trimEnd": + return val === null || (typeof val === "number" && !isNaN(val) && val >= 0); + case "rotate": + return val === 0 || val === 90 || val === 180 || val === 270; + case "speed": + return typeof val === "number" && !isNaN(val) && [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 4].includes(val); + case "quality": + return typeof val === "number" && !isNaN(val) && val >= 18 && val <= 30; + case "format": + return val === "mp4" || val === "webm" || val === "mkv" || val === "gif"; + case "brightness": + return typeof val === "number" && !isNaN(val) && val >= -1 && val <= 1; + case "contrast": + return typeof val === "number" && !isNaN(val) && val >= 0 && val <= 2; + case "saturation": + return typeof val === "number" && !isNaN(val) && val >= 0 && val <= 3; + default: + return true; + } + }; useEffect(() => { + if (typeof window === "undefined") return; try { - const saved = localStorage.getItem("reframe-settings"); - if (saved) { - const parsed = JSON.parse(saved); - setRecipe(prev => ({ - ...prev, - preset: parsed.preset ?? prev.preset, - quality: parsed.quality ?? prev.quality, - speed: parsed.speed ?? prev.speed, - customWidth: parsed.customWidth ?? prev.customWidth, - customHeight: parsed.customHeight ?? prev.customHeight - })); + const params = new URLSearchParams(window.location.search); + const recipeKeys = Object.keys(DEFAULT_RECIPE) as Array; + const hasRecipeParams = recipeKeys.some(key => params.has(key)); + + if (hasRecipeParams) { + const updatedPatch: Partial = {}; + recipeKeys.forEach((key) => { + const paramVal = params.get(key); + if (paramVal !== null) { + const defaultType = typeof DEFAULT_RECIPE[key]; + let parsedVal: any; + + if (defaultType === "number") { + parsedVal = parseFloat(paramVal); + } else if (defaultType === "boolean") { + parsedVal = paramVal === "true"; + } else { + parsedVal = paramVal === "null" ? null : paramVal; + } + + if (isValidValue(key, parsedVal)) { + (updatedPatch as any)[key] = parsedVal; + } + } + }); + + if (Object.keys(updatedPatch).length > 0) { + setRecipe(prev => ({ + ...prev, + ...updatedPatch + })); + } + } else { + const saved = localStorage.getItem("reframe-settings"); + if (saved) { + const parsed = JSON.parse(saved); + setRecipe(prev => ({ + ...prev, + preset: parsed.preset ?? prev.preset, + quality: parsed.quality ?? prev.quality, + speed: parsed.speed ?? prev.speed, + customWidth: parsed.customWidth ?? prev.customWidth, + customHeight: parsed.customHeight ?? prev.customHeight + })); + } } } catch (e) { // ignore } }, []); + useEffect(() => { + if (typeof window === "undefined") return; + try { + const params = new URLSearchParams(); + const recipeKeys = Object.keys(DEFAULT_RECIPE) as Array; + + recipeKeys.forEach((key) => { + const currentVal = recipe[key]; + const defaultVal = DEFAULT_RECIPE[key]; + + if (currentVal !== defaultVal) { + params.set(key, currentVal === null ? "null" : String(currentVal)); + } + }); + + const newQuery = params.toString(); + const currentQuery = window.location.search.replace(/^\?/, ""); + + if (newQuery !== currentQuery) { + const newUrl = newQuery + ? `${window.location.pathname}?${newQuery}` + : window.location.pathname; + window.history.replaceState(null, "", newUrl); + } + } catch (e) { + // ignore + } + }, [recipe]); + useEffect(() => { try { localStorage.setItem("reframe-settings", JSON.stringify({ @@ -325,7 +428,7 @@ export function useVideoEditor() { exportAbortControllerRef.current = null; } } - }, [file, recipe, result, status, overlayFile, overlayPosition, overlaySize, overlayOpacity, duration]); + }, [file, recipe, result, status, overlayFile, overlayPosition, overlaySize, overlayOpacity, duration, loopMusic, musicFile, musicVolume, originalAudioVolume]); useEffect(() => { @@ -380,6 +483,31 @@ export function useVideoEditor() { }; }, [file, status, handleExport]); + // M key: toggle audio mute — only when a file is loaded and focus isn't in a text field + useEffect(() => { + if (!file) return; + + const handleMuteShortcut = (e: KeyboardEvent) => { + if (e.key.toLowerCase() !== "m" || e.ctrlKey || e.metaKey || e.altKey) return; + + const target = e.target as HTMLElement; + if ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable + ) { + return; + } + + setRecipe((prev) => ({ ...prev, keepAudio: !prev.keepAudio })); + }; + + document.addEventListener("keydown", handleMuteShortcut); + return () => { + document.removeEventListener("keydown", handleMuteShortcut); + }; + }, [file]); + useEffect(()=>{ return ()=>{ if(result?.blobUrl){ diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 95774741..976307a4 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -19,4 +19,5 @@ export const DEFAULT_RECIPE: EditRecipe = { saturation: 1, stabilization: false, soundOnCompletion: false, + normalizeAudio: false, }; \ No newline at end of file diff --git a/src/lib/exportEstimate.test.ts b/src/lib/exportEstimate.test.ts new file mode 100644 index 00000000..a79e7236 --- /dev/null +++ b/src/lib/exportEstimate.test.ts @@ -0,0 +1,121 @@ +import { estimateExportSize, formatEstimatedSize } from "./exportEstimate"; +import { EditRecipe } from "./types"; + +// Minimal recipe factory — only the fields estimateExportSize cares about +function makeRecipe(overrides: Partial = {}): EditRecipe { + return { + preset: "1080p", + customWidth: 1920, + customHeight: 1080, + quality: 23, // default CRF + speed: 1, + trimStart: 0, + trimEnd: null, + format: "mp4", + // fields estimateExportSize doesn't touch — kept minimal + stabilization: false, + soundOnCompletion: false, + brightness: 0, + contrast: 1, + saturation: 1, + framing: "fit", + rotation: 0, + ...overrides, + } as EditRecipe; +} + +// --------------------------------------------------------------------------- +// estimateExportSize +// --------------------------------------------------------------------------- + +describe("estimateExportSize", () => { + test("returns a positive number for a basic clip", () => { + const size = estimateExportSize(makeRecipe(), 60); + expect(size).toBeGreaterThan(0); + }); + + test("lower CRF (higher quality) produces a larger estimate", () => { + const highQ = estimateExportSize(makeRecipe({ quality: 18 }), 60); + const lowQ = estimateExportSize(makeRecipe({ quality: 30 }), 60); + expect(highQ).toBeGreaterThan(lowQ); + }); + + test("longer duration produces a larger estimate", () => { + const short = estimateExportSize(makeRecipe(), 30); + const long = estimateExportSize(makeRecipe(), 120); + expect(long).toBeGreaterThan(short); + // Should scale roughly linearly (within 5%) + expect(long / short).toBeCloseTo(4, 0); + }); + + test("higher resolution (4k) produces a larger estimate than 720p", () => { + const hd = estimateExportSize(makeRecipe({ preset: "720p" }), 60); + const uhd = estimateExportSize(makeRecipe({ preset: "4k" }), 60); + expect(uhd).toBeGreaterThan(hd); + }); + + test("trim reduces effective duration and therefore file size", () => { + const full = estimateExportSize(makeRecipe({ trimStart: 0, trimEnd: null }), 60); + const trimmed = estimateExportSize(makeRecipe({ trimStart: 0, trimEnd: 30 }), 60); + expect(trimmed).toBeLessThan(full); + expect(trimmed / full).toBeCloseTo(0.5, 1); + }); + + test("2× speed halves output duration and therefore file size", () => { + const normal = estimateExportSize(makeRecipe({ speed: 1 }), 60); + const fast = estimateExportSize(makeRecipe({ speed: 2 }), 60); + expect(fast / normal).toBeCloseTo(0.5, 1); + }); + + test("webm estimate is smaller than mp4 at identical settings", () => { + const mp4 = estimateExportSize(makeRecipe({ format: "mp4" }), 60); + const webm = estimateExportSize(makeRecipe({ format: "webm" }), 60); + expect(webm).toBeLessThan(mp4); + }); + + test("custom preset uses customWidth/Height", () => { + const small = estimateExportSize(makeRecipe({ preset: "custom", customWidth: 640, customHeight: 360 }), 60); + const large = estimateExportSize(makeRecipe({ preset: "custom", customWidth: 3840, customHeight: 2160 }), 60); + expect(large).toBeGreaterThan(small); + }); + + test("returns a reasonable size for a 1-minute 1080p CRF-23 mp4 (2–5 MB)", () => { + // Real-world expectation: a 1-min 1080p H.264 file at CRF 23 is typically + // 20–100 MB depending on content. Our estimate should be in the right ballpark. + const size = estimateExportSize(makeRecipe(), 60); + expect(size).toBeGreaterThan(5); + expect(size).toBeLessThan(200); + }); + + test("very short clip (1 s minimum) does not return zero or negative", () => { + // trimStart === trimEnd → clamped to 1 s minimum inside the function + const size = estimateExportSize(makeRecipe({ trimStart: 10, trimEnd: 10 }), 60); + expect(size).toBeGreaterThan(0); + }); +}); + +// --------------------------------------------------------------------------- +// formatEstimatedSize +// --------------------------------------------------------------------------- + +describe("formatEstimatedSize", () => { + test("formats values under 1 MB as KB", () => { + expect(formatEstimatedSize(0.5)).toBe("~512 KB"); + }); + + test("formats values between 1 MB and 1 GB as MB", () => { + expect(formatEstimatedSize(42.3)).toBe("~42.3 MB"); + expect(formatEstimatedSize(1)).toBe("~1.0 MB"); + }); + + test("formats values 1 GB and over as GB", () => { + expect(formatEstimatedSize(1024)).toBe("~1.0 GB"); + expect(formatEstimatedSize(2560)).toBe("~2.5 GB"); + }); + + test("all outputs start with ~", () => { + [0.1, 1, 100, 2000].forEach((n) => { + expect(formatEstimatedSize(n)).toMatch(/^~/); + }); + }); +}); \ No newline at end of file diff --git a/src/lib/exportEstimate.ts b/src/lib/exportEstimate.ts index 1032da37..7336f327 100644 --- a/src/lib/exportEstimate.ts +++ b/src/lib/exportEstimate.ts @@ -1,52 +1,141 @@ import { EditRecipe } from "./types"; -function getBaseBitrate(crf: number): number { - if (crf <= 18) return 6; - if (crf <= 23) return 2; - if (crf <= 28) return 1; - return 0.6; +// --------------------------------------------------------------------------- +// Preset dimension map +// Keep in sync with src/lib/presets.ts. Width × height for every named preset. +// --------------------------------------------------------------------------- +const PRESET_DIMENSIONS: Record = { + "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 +}; + +/** + * Resolve the actual output pixel dimensions for a recipe. + * When a named preset is active we look it up; otherwise we use + * the custom width/height the user typed in. + */ +function getOutputDimensions(recipe: EditRecipe): { width: number; height: number } { + if (recipe.preset !== "custom") { + const dims = PRESET_DIMENSIONS[recipe.preset]; + if (dims) return dims; + } + return { width: recipe.customWidth, height: recipe.customHeight }; +} + +// --------------------------------------------------------------------------- +// CRF → video bitrate (Mbps) — exponential fit to real-world H.264 data +// +// Reference points (1080p30, typical live-action content): +// CRF 18 ≈ 8 Mbps (visually lossless) +// CRF 23 ≈ 3 Mbps (default, good quality) +// CRF 28 ≈ 1 Mbps (acceptable) +// CRF 30 ≈ 0.6 Mbps (small file) +// +// We model this as: bitrate = A * e^(-k * crf) +// A = 8 * e^(k*18), k chosen so CRF 30 → 0.6 Mbps +// k = ln(8/0.6) / (30-18) ≈ 0.2185 +// --------------------------------------------------------------------------- +const CRF_A = 8 * Math.exp(0.2185 * 18); // ≈ 383 +const CRF_K = 0.2185; + +function videoBitrateFromCrf(crf: number): number { + return CRF_A * Math.exp(-CRF_K * crf); // Mbps at 1080p } -function getResolutionMultiplier(width: number, height: number): number { +// --------------------------------------------------------------------------- +// Resolution multiplier relative to 1080p (pixel-count ratio, sqrt-damped) +// +// Pure pixel-count scaling over-estimates for high-res footage because +// encoders are more efficient at higher resolutions. A square-root damping +// gives a better empirical fit. +// --------------------------------------------------------------------------- +function resolutionMultiplier(width: number, height: number): number { const pixels = width * height; - const fullHdPixels = 1920 * 1080; + const refPixels = 1920 * 1080; + const ratio = pixels / refPixels; + // sqrt damping: 4K (4×pixels) → ~2× bitrate, not 4× + return Math.max(Math.sqrt(ratio), 0.1); +} - return Math.max(pixels / fullHdPixels, 0.25); +// --------------------------------------------------------------------------- +// Format overhead factor +// MP4 and MKV are close; WebM (VP9) tends to produce slightly smaller files +// at the same CRF, so we apply a small discount. +// --------------------------------------------------------------------------- +function formatFactor(format: string | undefined): number { + switch (format) { + case "webm": return 0.85; + case "mkv": return 1.02; + case "mp4": + default: return 1.0; + } } -export function estimateExportSize( - recipe: EditRecipe, - duration: number -): number { +// --------------------------------------------------------------------------- +// Audio bitrate estimate (Mbps) +// AAC 128 kbps for stereo — independent of video quality settings. +// --------------------------------------------------------------------------- +const AUDIO_BITRATE_MBPS = 0.128; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Estimate the output file size in **megabytes**. + * + * @param recipe The current EditRecipe (preset, quality, speed, trim, format…) + * @param duration Source video duration in seconds (used when trimEnd is null) + * @returns Estimated size in MB (floating-point) + */ +export function estimateExportSize(recipe: EditRecipe, duration: number): number { + // 1. Effective playback duration after trimming const trimEnd = recipe.trimEnd ?? duration; + const trimmedDuration = Math.max(trimEnd - recipe.trimStart, 1); // seconds - const effectiveDuration = Math.max( - trimEnd - recipe.trimStart, - 1 - ); + // 2. Speed affects wall-clock output length but NOT the encoded content — + // a 2× speed export of a 60 s clip produces a 30 s file at the *same* + // bitrate. So we scale duration, not bitrate. + const outputDuration = trimmedDuration / Math.max(recipe.speed, 0.25); - const baseBitrate = getBaseBitrate(recipe.quality); + // 3. Resolve pixel dimensions from preset or custom fields + const { width, height } = getOutputDimensions(recipe); - const resolutionMultiplier = getResolutionMultiplier( - recipe.customWidth, - recipe.customHeight - ); + // 4. Video bitrate at the target resolution (Mbps) + const videoBitrate = + videoBitrateFromCrf(recipe.quality) * + resolutionMultiplier(width, height) * + formatFactor(recipe.format); - const adjustedBitrate = - (baseBitrate * resolutionMultiplier) / - Math.max(recipe.speed, 0.25); + // 5. Total bitrate = video + audio + const totalBitrate = videoBitrate + AUDIO_BITRATE_MBPS; - return (adjustedBitrate * effectiveDuration) / 8; + // 6. Size in megabytes (Mbps × seconds / 8 = megabytes) + const sizeMb = (totalBitrate * outputDuration) / 8; + + return sizeMb; } +/** + * Format a megabyte value into a human-readable approximate string. + * Examples: "~320 KB", "~4.2 MB", "~1.3 GB" + */ export function formatEstimatedSize(sizeMb: number): string { if (sizeMb >= 1024) { return `~${(sizeMb / 1024).toFixed(1)} GB`; } - if (sizeMb < 1) { - return `~${(sizeMb * 1024).toFixed(0)} KB`; + return `~${Math.round(sizeMb * 1024)} KB`; } - return `~${sizeMb.toFixed(1)} MB`; } \ No newline at end of file diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts index beaeed1b..4f215075 100644 --- a/src/lib/ffmpeg.ts +++ b/src/lib/ffmpeg.ts @@ -131,12 +131,10 @@ function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: number): return filters.join(","); } -export function buildAudioFilter(speed: number): string { - if (speed === 1) return ""; - + export function buildAudioFilter(speed: number, normalizeAudio: boolean): string { const filters: string[] = []; - let remaining = speed; + let remaining = speed; while (remaining < 0.5) { filters.push("atempo=0.5"); remaining /= 0.5; @@ -147,10 +145,12 @@ export function buildAudioFilter(speed: number): string { remaining /= 2.0; } - if (Math.abs(remaining - 1.0) > 0.001) { + if (Math.abs(remaining - 1.0) > 0.001) { filters.push(`atempo=${Number(remaining.toFixed(4))}`); } + if (normalizeAudio) filters.push("loudnorm=I=-14:TP=-1.5:LRA=11"); + return filters.join(","); } @@ -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, @@ -177,7 +177,7 @@ function buildArguments( ): string[] { const vf = buildVideoFilter(recipe, targetW, targetH); const audioTrim = hasOriginalAudio ? buildAudioTrimFilter(recipe) : ""; - const audioSpeed = hasOriginalAudio ? buildAudioFilter(recipe.speed) : ""; +const audioSpeed = hasOriginalAudio ? buildAudioFilter(recipe.speed, recipe.normalizeAudio ?? false) : ""; const afParts = [audioTrim, audioSpeed].filter(Boolean); const af = afParts.join(","); @@ -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,15 +321,23 @@ 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))); }; + try { await ffmpeg.writeFile(inputName, await fetchFile(file), { signal }); + const vf = buildVideoFilter(recipe, targetW, targetH); + const audioTrim = buildAudioTrimFilter(recipe); + const audioSpeed = buildAudioFilter(recipe.speed, recipe.normalizeAudio ?? false); + + const afParts = [audioTrim, audioSpeed].filter(Boolean); + const af = afParts.join(","); const hasMusicTrack = !!(musicOptions?.file && recipe.keepAudio); const musicInputName = `music_input_${sessionId}.mp3`; if (hasMusicTrack) { @@ -345,6 +355,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/tests/ffmpeg.test.ts b/src/lib/tests/ffmpeg.test.ts index bdde5d41..c2e42e74 100644 --- a/src/lib/tests/ffmpeg.test.ts +++ b/src/lib/tests/ffmpeg.test.ts @@ -3,15 +3,15 @@ import { buildAudioFilter } from "../ffmpeg"; describe("buildAudioFilter", () => { it("should return an empty string for 1.0x speed", () => { - expect(buildAudioFilter(1)).toBe(""); + expect(buildAudioFilter(1, false)).toBe(""); }); it("should chain two 0.5x filters for 0.25x speed", () => { - expect(buildAudioFilter(0.25)).toBe("atempo=0.5,atempo=0.5"); + expect(buildAudioFilter(0.25, false)).toBe("atempo=0.5,atempo=0.5"); }); it("should chain two 2.0x filters for 4.0x speed", () => { - expect(buildAudioFilter(4)).toBe("atempo=2.0,atempo=2"); + expect(buildAudioFilter(4, false)).toBe("atempo=2.0,atempo=2"); }); it("should chain multiple 0.5x filters and a remainder for 0.1x speed", () => { @@ -19,25 +19,30 @@ describe("buildAudioFilter", () => { // 0.2 / 0.5 = 0.4 // 0.4 / 0.5 = 0.8 // Result should be three 0.5s and one 0.8 - expect(buildAudioFilter(0.1)).toBe("atempo=0.5,atempo=0.5,atempo=0.5,atempo=0.8"); + expect(buildAudioFilter(0.1, false)).toBe("atempo=0.5,atempo=0.5,atempo=0.5,atempo=0.8"); }); it("should chain multiple 2.0x filters and a remainder for 3.0x speed", () => { // 3.0 / 2.0 = 1.5 - expect(buildAudioFilter(3)).toBe("atempo=2.0,atempo=1.5"); + expect(buildAudioFilter(3, false)).toBe("atempo=2.0,atempo=1.5"); }); it("should handle boundary values inside the 0.5x-2.0x range without chaining", () => { - expect(buildAudioFilter(0.5)).toBe("atempo=0.5"); - expect(buildAudioFilter(2.0)).toBe("atempo=2"); // Note: Number(2.0.toFixed(4)) -> 2 - expect(buildAudioFilter(1.5)).toBe("atempo=1.5"); - expect(buildAudioFilter(0.75)).toBe("atempo=0.75"); + expect(buildAudioFilter(0.5, false)).toBe("atempo=0.5"); + expect(buildAudioFilter(2.0, false)).toBe("atempo=2"); // Note: Number(2.0.toFixed(4)) -> 2 + expect(buildAudioFilter(1.5, false)).toBe("atempo=1.5"); + expect(buildAudioFilter(0.75, false)).toBe("atempo=0.75"); }); it("should chain properly for very large speeds", () => { // 10 / 2.0 = 5 // 5 / 2.0 = 2.5 // 2.5 / 2.0 = 1.25 - expect(buildAudioFilter(10)).toBe("atempo=2.0,atempo=2.0,atempo=2.0,atempo=1.25"); + expect(buildAudioFilter(10, false)).toBe("atempo=2.0,atempo=2.0,atempo=2.0,atempo=1.25"); + }); + + it("should append loudnorm filter when normalizeAudio is true", () => { + const result = buildAudioFilter(1, true); + expect(result).toContain("loudnorm"); }); }); diff --git a/src/lib/types.ts b/src/lib/types.ts index bf167094..521a82c2 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -7,9 +7,10 @@ export interface EditRecipe { trimEnd: number | null; rotate: 0 | 90 | 180 | 270; keepAudio: boolean; + normalizeAudio: boolean; speed: number; quality: number; - format: "mp4" | "webm" | "mkv"; + format: "mp4" | "webm" | "mkv" | "gif"; stabilization: boolean; brightness: number; contrast: number; @@ -42,7 +43,7 @@ export interface ExportResult { size: number; width: number; height: number; - format: "mp4" | "webm" | "mkv"; + format: "mp4" | "webm" | "mkv" | "gif"; } export type ExportStatus = @@ -72,6 +73,7 @@ export const DEFAULT_RECIPE: EditRecipe = { trimEnd: null, rotate: 0, keepAudio: true, + normalizeAudio: false, speed: 1, quality: 23, format: "mp4", diff --git a/tsconfig.json b/tsconfig.json index c1334095..5310d2e1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ "isolatedModules": true, "jsx": "preserve", "incremental": true, + "types": ["bun-types"], "plugins": [ { "name": "next" @@ -23,5 +24,5 @@ } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] -} + "exclude": ["node_modules", "src/lib/exportEstimate.test.ts"] +} \ No newline at end of file