diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx
index 6e297539..76b7ed3f 100644
--- a/src/components/VideoEditor.tsx
+++ b/src/components/VideoEditor.tsx
@@ -194,10 +194,10 @@ export default function VideoEditor() {
{!file && (
-
-
Upload a video to get started
-
-
+
+
Upload a video to get started
+
Supports MP4, MOV, WebM and more
+
)}
{file && (
diff --git a/src/lib/exportEstimate.ts b/src/lib/exportEstimate.ts
index 7336f327..77c22573 100644
--- a/src/lib/exportEstimate.ts
+++ b/src/lib/exportEstimate.ts
@@ -5,12 +5,12 @@ import { EditRecipe } from "./types";
// 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 },
+ "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 },
@@ -29,21 +29,14 @@ function getOutputDimensions(recipe: EditRecipe): { width: number; height: numbe
const dims = PRESET_DIMENSIONS[recipe.preset];
if (dims) return dims;
}
- return { width: recipe.customWidth, height: recipe.customHeight };
+ return {
+ width: recipe.customWidth || 1920,
+ height: recipe.customHeight || 1080
+ };
}
// ---------------------------------------------------------------------------
// 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;
@@ -54,16 +47,11 @@ function videoBitrateFromCrf(crf: 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 refPixels = 1920 * 1080;
const ratio = pixels / refPixels;
- // sqrt damping: 4K (4×pixels) → ~2× bitrate, not 4×
return Math.max(Math.sqrt(ratio), 0.1);
}
@@ -103,27 +91,40 @@ export function estimateExportSize(recipe: EditRecipe, duration: number): number
const trimEnd = recipe.trimEnd ?? duration;
const trimmedDuration = Math.max(trimEnd - recipe.trimStart, 1); // seconds
- // 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.
+ // 2. Speed affects wall-clock output length
const outputDuration = trimmedDuration / Math.max(recipe.speed, 0.25);
- // 3. Resolve pixel dimensions from preset or custom fields
+ // 3. Resolve pixel dimensions from preset or custom fields safely
const { width, height } = getOutputDimensions(recipe);
- // 4. Video bitrate at the target resolution (Mbps)
+ // 4. Handle high-quality adaptive GIF estimation separately
+ if (recipe.format === "gif") {
+ const GIF_FPS = 15;
+
+ // Set base compression scaling factor for maximum quality (CRF 18)
+ const BASE_COMPRESSION = 0.85;
+
+ // Linearly reduce compression ratio as CRF slider increases toward 30
+ const qualityLossModifier = (recipe.quality - 18) * 0.035;
+ const effectiveCompression = Math.max(BASE_COMPRESSION - qualityLossModifier, 0.35);
+
+ const frames = outputDuration * GIF_FPS;
+
+ // Uncompressed raw/palette-mapped payload calculation (size in MB)
+ return (width * height * frames * effectiveCompression) / (1024 * 1024);
+ }
+
+ // 5. Standard Video bitrate at the target resolution (Mbps)
const videoBitrate =
videoBitrateFromCrf(recipe.quality) *
resolutionMultiplier(width, height) *
formatFactor(recipe.format);
- // 5. Total bitrate = video + audio
- const totalBitrate = videoBitrate + AUDIO_BITRATE_MBPS;
-
- // 6. Size in megabytes (Mbps × seconds / 8 = megabytes)
- const sizeMb = (totalBitrate * outputDuration) / 8;
+ // 6. Total bitrate = video + audio (only if keepAudio is checked)
+ const totalBitrate = videoBitrate + (recipe.keepAudio ? AUDIO_BITRATE_MBPS : 0);
- return sizeMb;
+ // 7. Size in megabytes (Mbps × seconds / 8 = megabytes)
+ return (totalBitrate * outputDuration) / 8;
}
/**