Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions src/lib/exportEstimate.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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(/^~/);
});
});
});
145 changes: 117 additions & 28 deletions src/lib/exportEstimate.ts
Original file line number Diff line number Diff line change
@@ -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<string, { width: number; height: number }> = {
"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`;
}
5 changes: 3 additions & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"types": ["bun-types"],
"plugins": [
{
"name": "next"
Expand All @@ -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"]
}
Loading