From 5e761703077f035ad87352a9242cc272bf6f438a Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 16 May 2026 13:47:20 +0200 Subject: [PATCH] Clarify MP4 export resolution presets --- src/components/video-editor/SettingsPanel.tsx | 134 +++++++++++----- src/components/video-editor/VideoEditor.tsx | 115 ++++---------- src/i18n/locales/ar/settings.json | 8 +- src/i18n/locales/en/settings.json | 8 +- src/i18n/locales/es/settings.json | 8 +- src/i18n/locales/fr/settings.json | 8 +- src/i18n/locales/ja-JP/settings.json | 8 +- src/i18n/locales/ko-KR/settings.json | 8 +- src/i18n/locales/ru/settings.json | 8 +- src/i18n/locales/tr/settings.json | 8 +- src/i18n/locales/vi/settings.json | 8 +- src/i18n/locales/zh-CN/settings.json | 8 +- src/i18n/locales/zh-TW/settings.json | 8 +- src/lib/compositeLayout.test.ts | 30 ++++ src/lib/compositeLayout.ts | 16 +- src/lib/exporter/index.ts | 5 + src/lib/exporter/mp4ExportSettings.test.ts | 148 ++++++++++++++++++ src/lib/exporter/mp4ExportSettings.ts | 143 +++++++++++++++++ 18 files changed, 518 insertions(+), 161 deletions(-) create mode 100644 src/lib/exporter/mp4ExportSettings.test.ts create mode 100644 src/lib/exporter/mp4ExportSettings.ts diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index cae959c..19e10cf 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -40,7 +40,11 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useScopedT } from "@/contexts/I18nContext"; import { WEBCAM_LAYOUT_PRESETS } from "@/lib/compositeLayout"; import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter"; -import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter"; +import { + calculateEffectiveSourceDimensions, + GIF_FRAME_RATES, + GIF_SIZE_PRESETS, +} from "@/lib/exporter"; import { cn } from "@/lib/utils"; import { resolveImageWallpaperUrl, WALLPAPER_PATHS } from "@/lib/wallpaper"; import { type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils"; @@ -328,6 +332,23 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [ type SettingsPanelMode = "background" | "effects" | "layout" | "cursor" | "export"; +const MP4_EXPORT_SHORT_SIDES = { + medium: 720, + good: 1080, +} as const; + +function formatSourceDimensions(videoElement?: HTMLVideoElement | null, cropRegion?: CropRegion) { + const width = videoElement?.videoWidth ?? 0; + const height = videoElement?.videoHeight ?? 0; + + if (width <= 0 || height <= 0) { + return null; + } + + const dimensions = calculateEffectiveSourceDimensions(width, height, cropRegion); + return { ...dimensions, shortSide: Math.min(dimensions.width, dimensions.height) }; +} + export function SettingsPanel({ selected, onWallpaperChange, @@ -421,6 +442,7 @@ export function SettingsPanel({ }: SettingsPanelProps) { const t = useScopedT("settings"); const [activePanelMode, setActivePanelMode] = useState("background"); + const sourceDimensions = formatSourceDimensions(videoElement, cropRegion); // Resolved URLs are for DOM rendering only (backgroundImage). The canonical // `/wallpapers/wallpaperN.jpg` form in WALLPAPER_PATHS is what gets persisted // on click — never the machine-specific file:// URL. @@ -1776,40 +1798,82 @@ export function SettingsPanel({ {exportFormat === "mp4" && ( -
- - - +
+ {sourceDimensions && ( +
+ {t("exportQuality.title")} + + Source {sourceDimensions.width}x{sourceDimensions.height} + +
+ )} +
+ + + +
)} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 464370d..d4116f9 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -18,6 +18,8 @@ import { type Locale } from "@/i18n/config"; import { getAvailableLocales, getLocaleName } from "@/i18n/loader"; import { hasNativeCursorRecordingData } from "@/lib/cursor/nativeCursor"; import { + calculateEffectiveSourceDimensions, + calculateMp4ExportSettings, calculateOutputDimensions, type ExportFormat, type ExportProgress, @@ -1574,6 +1576,11 @@ export default function VideoEditor() { const sourceWidth = video.videoWidth || 1920; const sourceHeight = video.videoHeight || 1080; + const effectiveSourceDimensions = calculateEffectiveSourceDimensions( + sourceWidth, + sourceHeight, + cropRegion, + ); const aspectRatioValue = aspectRatio === "native" ? getNativeAspectRatioValue(sourceWidth, sourceHeight, cropRegion) @@ -1667,83 +1674,16 @@ export default function VideoEditor() { } else { // MP4 Export const quality = settings.quality || exportQuality; - let exportWidth: number; - let exportHeight: number; - let bitrate: number; - - if (quality === "source") { - exportWidth = sourceWidth; - exportHeight = sourceHeight; - - // Use the source's longer dimension as the long axis of the export so - // a landscape recording can still fill a portrait target (and vice versa). - const sourceLongDim = Math.max(sourceWidth, sourceHeight); - - if (aspectRatioValue === 1) { - const baseDimension = Math.floor(Math.min(sourceWidth, sourceHeight) / 2) * 2; - exportWidth = baseDimension; - exportHeight = baseDimension; - } else if (aspectRatioValue > 1) { - const baseWidth = Math.floor(sourceLongDim / 2) * 2; - let found = false; - for (let w = baseWidth; w >= 100 && !found; w -= 2) { - const h = Math.round(w / aspectRatioValue); - if (h % 2 === 0 && Math.abs(w / h - aspectRatioValue) < 0.0001) { - exportWidth = w; - exportHeight = h; - found = true; - } - } - if (!found) { - exportWidth = baseWidth; - exportHeight = Math.floor(baseWidth / aspectRatioValue / 2) * 2; - } - } else { - const baseHeight = Math.floor(sourceLongDim / 2) * 2; - let found = false; - for (let h = baseHeight; h >= 100 && !found; h -= 2) { - const w = Math.round(h * aspectRatioValue); - if (w % 2 === 0 && Math.abs(w / h - aspectRatioValue) < 0.0001) { - exportWidth = w; - exportHeight = h; - found = true; - } - } - if (!found) { - exportHeight = baseHeight; - exportWidth = Math.floor((baseHeight * aspectRatioValue) / 2) * 2; - } - } - - const totalPixels = exportWidth * exportHeight; - bitrate = 30_000_000; - if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) { - bitrate = 50_000_000; - } else if (totalPixels > 2560 * 1440) { - bitrate = 80_000_000; - } - } else { - // Quality presets target the SHORT side; the long side derives from the - // aspect ratio. This keeps 1080p portrait at 1080×1920 instead of 607×1080. - const targetShortDim = quality === "medium" ? 720 : 1080; - - if (aspectRatioValue >= 1) { - exportHeight = Math.floor(targetShortDim / 2) * 2; - exportWidth = Math.floor((exportHeight * aspectRatioValue) / 2) * 2; - } else { - exportWidth = Math.floor(targetShortDim / 2) * 2; - exportHeight = Math.floor(exportWidth / aspectRatioValue / 2) * 2; - } - - const totalPixels = exportWidth * exportHeight; - if (totalPixels <= 1280 * 720) { - bitrate = 10_000_000; - } else if (totalPixels <= 1920 * 1080) { - bitrate = 20_000_000; - } else { - bitrate = 30_000_000; - } - } + const { + width: exportWidth, + height: exportHeight, + bitrate, + } = calculateMp4ExportSettings({ + quality, + sourceWidth: effectiveSourceDimensions.width, + sourceHeight: effectiveSourceDimensions.height, + aspectRatioValue, + }); const exporter = new VideoExporter({ videoUrl: videoPath, @@ -1903,13 +1843,18 @@ export default function VideoEditor() { // Build export settings from current state const sourceWidth = video.videoWidth || 1920; const sourceHeight = video.videoHeight || 1080; + const effectiveSourceDimensions = calculateEffectiveSourceDimensions( + sourceWidth, + sourceHeight, + cropRegion, + ); const aspectRatioValue = aspectRatio === "native" ? getNativeAspectRatioValue(sourceWidth, sourceHeight, cropRegion) : getAspectRatioValue(aspectRatio); const gifDimensions = calculateOutputDimensions( - sourceWidth, - sourceHeight, + effectiveSourceDimensions.width, + effectiveSourceDimensions.height, gifSizePreset, GIF_SIZE_PRESETS, aspectRatioValue, @@ -2266,8 +2211,16 @@ export default function VideoEditor() { gifSizePreset={gifSizePreset} onGifSizePresetChange={setGifSizePreset} gifOutputDimensions={calculateOutputDimensions( - videoPlaybackRef.current?.video?.videoWidth || 1920, - videoPlaybackRef.current?.video?.videoHeight || 1080, + calculateEffectiveSourceDimensions( + videoPlaybackRef.current?.video?.videoWidth || 1920, + videoPlaybackRef.current?.video?.videoHeight || 1080, + cropRegion, + ).width, + calculateEffectiveSourceDimensions( + videoPlaybackRef.current?.video?.videoWidth || 1920, + videoPlaybackRef.current?.video?.videoHeight || 1080, + cropRegion, + ).height, gifSizePreset, GIF_SIZE_PRESETS, aspectRatio === "native" diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index 47fdca4..8c11cda 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -69,10 +69,10 @@ "gifDescription": "صورة متحركة للمشاركة" }, "exportQuality": { - "title": "جودة التصدير", - "low": "منخفضة", - "medium": "متوسطة", - "high": "عالية" + "title": "دقة التصدير", + "low": "720p", + "medium": "1080p", + "high": "Source" }, "gifSettings": { "frameRate": "معدل إطارات GIF", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 9d41736..c7af960 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -85,10 +85,10 @@ "gifDescription": "Animated image for sharing" }, "exportQuality": { - "title": "Export Quality", - "low": "Low", - "medium": "Medium", - "high": "High" + "title": "Export resolution", + "low": "720p", + "medium": "1080p", + "high": "Source" }, "gifSettings": { "frameRate": "GIF Frame Rate", diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 20a9ec3..189a309 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -76,10 +76,10 @@ "gifDescription": "Imagen animada para compartir" }, "exportQuality": { - "title": "Calidad de exportación", - "low": "Baja", - "medium": "Media", - "high": "Alta" + "title": "Resolución de exportación", + "low": "720p", + "medium": "1080p", + "high": "Source" }, "gifSettings": { "frameRate": "Velocidad de cuadros del GIF", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index 66a6d74..2c33340 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -83,10 +83,10 @@ "gifDescription": "Image animée pour le partage" }, "exportQuality": { - "title": "Qualité d'export", - "low": "Faible", - "medium": "Moyenne", - "high": "Haute" + "title": "Résolution d'export", + "low": "720p", + "medium": "1080p", + "high": "Source" }, "gifSettings": { "frameRate": "Fréquence d'images GIF", diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 1ccc381..9a9b7bd 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -76,10 +76,10 @@ "gifDescription": "共有用のアニメーション画像" }, "exportQuality": { - "title": "エクスポート品質", - "low": "低画質", - "medium": "中画質", - "high": "高画質" + "title": "書き出し解像度", + "low": "720p", + "medium": "1080p", + "high": "Source" }, "gifSettings": { "frameRate": "GIF フレームレート", diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index 268ba57..76e912c 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -76,10 +76,10 @@ "gifDescription": "공유용 애니메이션 이미지" }, "exportQuality": { - "title": "내보내기 품질", - "low": "낮음", - "medium": "보통", - "high": "높음" + "title": "내보내기 해상도", + "low": "720p", + "medium": "1080p", + "high": "Source" }, "gifSettings": { "frameRate": "GIF 프레임 속도", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index dc15c3f..708a9c9 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -77,10 +77,10 @@ "gifDescription": "Анимированное изображение для обмена" }, "exportQuality": { - "title": "Качество экспорта", - "low": "Низкое", - "medium": "Среднее", - "high": "Высокое" + "title": "Разрешение экспорта", + "low": "720p", + "medium": "1080p", + "high": "Source" }, "gifSettings": { "frameRate": "Частота кадров GIF", diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index f639558..af5a6f2 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -76,10 +76,10 @@ "gifDescription": "Paylaşım için hareketli görüntü" }, "exportQuality": { - "title": "Dışa Aktarım Kalitesi", - "low": "Düşük", - "medium": "Orta", - "high": "Yüksek" + "title": "Dışa aktarma çözünürlüğü", + "low": "720p", + "medium": "1080p", + "high": "Source" }, "gifSettings": { "frameRate": "GIF Kare Hızı", diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index e6a897d..149a385 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -66,10 +66,10 @@ "gifDescription": "Hình ảnh động để chia sẻ" }, "exportQuality": { - "title": "Chất lượng xuất", - "low": "Thấp", - "medium": "Trung bình", - "high": "Cao" + "title": "Độ phân giải xuất", + "low": "720p", + "medium": "1080p", + "high": "Source" }, "gifSettings": { "frameRate": "Tốc độ khung hình GIF", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index ff157dc..e9d729e 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -76,10 +76,10 @@ "gifDescription": "可分享的动态图片" }, "exportQuality": { - "title": "导出质量", - "low": "低", - "medium": "中", - "high": "高" + "title": "导出分辨率", + "low": "720p", + "medium": "1080p", + "high": "Source" }, "gifSettings": { "frameRate": "GIF 帧率", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 50ca00c..44b6422 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -83,10 +83,10 @@ "gifDescription": "可分享的動態圖片" }, "exportQuality": { - "title": "匯出品質", - "low": "低", - "medium": "中", - "high": "高" + "title": "匯出解析度", + "low": "720p", + "medium": "1080p", + "high": "Source" }, "gifSettings": { "frameRate": "GIF 影格率", diff --git a/src/lib/compositeLayout.test.ts b/src/lib/compositeLayout.test.ts index 596eb75..65cdfd5 100644 --- a/src/lib/compositeLayout.test.ts +++ b/src/lib/compositeLayout.test.ts @@ -17,6 +17,21 @@ describe("computeCompositeLayout", () => { expect(layout!.webcamRect!.y).toBeGreaterThan(1080 / 2); }); + it("scales small screen content up to the export canvas when no padding is applied", () => { + const layout = computeCompositeLayout({ + canvasSize: { width: 1280, height: 720 }, + screenSize: { width: 854, height: 480 }, + }); + + expect(layout).not.toBeNull(); + expect(layout!.screenRect).toEqual({ + x: 0, + y: 0, + width: 1280, + height: 720, + }); + }); + it("keeps the overlay within the configured stage fraction while preserving aspect ratio", () => { const layout = computeCompositeLayout({ canvasSize: { width: 1280, height: 720 }, @@ -128,6 +143,21 @@ describe("computeCompositeLayout", () => { expect(aboveMax!.webcamRect!.height).toBe(atMax!.webcamRect!.height); }); + it("snaps rounding-only source aspect gaps to the full canvas", () => { + const layout = computeCompositeLayout({ + canvasSize: { width: 319, height: 199 }, + maxContentSize: { width: 319, height: 199 }, + screenSize: { width: 1680, height: 1050 }, + }); + + expect(layout?.screenRect).toEqual({ + x: 0, + y: 0, + width: 319, + height: 199, + }); + }); + it("centers the combined screen and webcam stack in vertical stack mode", () => { const layout = computeCompositeLayout({ canvasSize: { width: 1920, height: 1080 }, diff --git a/src/lib/compositeLayout.ts b/src/lib/compositeLayout.ts index 93161c0..839a7e0 100644 --- a/src/lib/compositeLayout.ts +++ b/src/lib/compositeLayout.ts @@ -401,10 +401,24 @@ function centerRectInBounds(params: { bounds: RenderRect; size: Size; maxSize: S const { x: boundsX, y: boundsY, width: boundsWidth, height: boundsHeight } = bounds; const { width, height } = size; const { width: maxWidth, height: maxHeight } = maxSize; - const scale = Math.min(maxWidth / width, maxHeight / height, 1); + const scale = Math.min(maxWidth / width, maxHeight / height); const resolvedWidth = Math.round(width * scale); const resolvedHeight = Math.round(height * scale); + if ( + maxWidth >= boundsWidth && + maxHeight >= boundsHeight && + Math.abs(boundsWidth - resolvedWidth) <= 1 && + Math.abs(boundsHeight - resolvedHeight) <= 1 + ) { + return { + x: boundsX, + y: boundsY, + width: boundsWidth, + height: boundsHeight, + }; + } + return { x: boundsX + Math.max(0, Math.floor((boundsWidth - resolvedWidth) / 2)), y: boundsY + Math.max(0, Math.floor((boundsHeight - resolvedHeight) / 2)), diff --git a/src/lib/exporter/index.ts b/src/lib/exporter/index.ts index e93166c..8ac404f 100644 --- a/src/lib/exporter/index.ts +++ b/src/lib/exporter/index.ts @@ -1,5 +1,10 @@ export { FrameRenderer } from "./frameRenderer"; export { calculateOutputDimensions, GifExporter } from "./gifExporter"; +export { + calculateEffectiveSourceDimensions, + calculateMp4ExportSettings, + type Mp4ExportSettings, +} from "./mp4ExportSettings"; export { VideoMuxer } from "./muxer"; export { StreamingVideoDecoder } from "./streamingDecoder"; export type { diff --git a/src/lib/exporter/mp4ExportSettings.test.ts b/src/lib/exporter/mp4ExportSettings.test.ts new file mode 100644 index 0000000..2a94154 --- /dev/null +++ b/src/lib/exporter/mp4ExportSettings.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; +import { + calculateEffectiveSourceDimensions, + calculateMp4ExportSettings, +} from "./mp4ExportSettings"; + +describe("calculateMp4ExportSettings", () => { + it("keeps 1080p explicit even when it upscales short native captures", () => { + const aspectRatioValue = 1920 / 1032; + + expect( + calculateMp4ExportSettings({ + quality: "good", + sourceWidth: 1920, + sourceHeight: 1032, + aspectRatioValue, + }), + ).toMatchObject({ + width: 2008, + height: 1080, + bitrate: 30_000_000, + }); + + expect( + calculateMp4ExportSettings({ + quality: "source", + sourceWidth: 1920, + sourceHeight: 1032, + aspectRatioValue, + }), + ).toMatchObject({ + width: 1920, + height: 1032, + bitrate: 30_000_000, + }); + }); + + it("keeps lower quality presets below the source size when downscaling is useful", () => { + expect( + calculateMp4ExportSettings({ + quality: "medium", + sourceWidth: 1920, + sourceHeight: 1032, + aspectRatioValue: 1920 / 1032, + }), + ).toMatchObject({ + width: 1338, + height: 720, + bitrate: 20_000_000, + }); + }); + + it("keeps 1080p explicit even for 720p source dimensions", () => { + expect( + calculateMp4ExportSettings({ + quality: "good", + sourceWidth: 1280, + sourceHeight: 720, + aspectRatioValue: 16 / 9, + }), + ).toMatchObject({ + width: 1920, + height: 1080, + bitrate: 20_000_000, + }); + }); + + it("preserves source-sized High exports when the source is already 1080p or larger", () => { + expect( + calculateMp4ExportSettings({ + quality: "source", + sourceWidth: 1920, + sourceHeight: 1080, + aspectRatioValue: 16 / 9, + }), + ).toMatchObject({ + width: 1920, + height: 1080, + bitrate: 30_000_000, + }); + + expect( + calculateMp4ExportSettings({ + quality: "source", + sourceWidth: 3840, + sourceHeight: 2160, + aspectRatioValue: 16 / 9, + }), + ).toMatchObject({ + width: 3840, + height: 2160, + bitrate: 80_000_000, + }); + }); + + it("keeps portrait presets on the short side", () => { + expect( + calculateMp4ExportSettings({ + quality: "good", + sourceWidth: 1080, + sourceHeight: 1920, + aspectRatioValue: 9 / 16, + }), + ).toMatchObject({ + width: 1080, + height: 1920, + bitrate: 20_000_000, + }); + }); + + it("uses the cropped area as the effective source size", () => { + const effectiveSource = calculateEffectiveSourceDimensions(3840, 2160, { + width: 854 / 3840, + height: 480 / 2160, + }); + + expect(effectiveSource).toEqual({ + width: 854, + height: 480, + }); + + expect( + calculateMp4ExportSettings({ + quality: "source", + sourceWidth: effectiveSource.width, + sourceHeight: effectiveSource.height, + aspectRatioValue: effectiveSource.width / effectiveSource.height, + }), + ).toMatchObject({ + width: 854, + height: 480, + bitrate: 30_000_000, + }); + + expect( + calculateMp4ExportSettings({ + quality: "good", + sourceWidth: effectiveSource.width, + sourceHeight: effectiveSource.height, + aspectRatioValue: effectiveSource.width / effectiveSource.height, + }), + ).toMatchObject({ + width: 1920, + height: 1080, + bitrate: 20_000_000, + }); + }); +}); diff --git a/src/lib/exporter/mp4ExportSettings.ts b/src/lib/exporter/mp4ExportSettings.ts new file mode 100644 index 0000000..b75193f --- /dev/null +++ b/src/lib/exporter/mp4ExportSettings.ts @@ -0,0 +1,143 @@ +import type { ExportQuality } from "./types"; + +export interface Mp4ExportSettings { + width: number; + height: number; + bitrate: number; +} + +interface SourceCropRegion { + width: number; + height: number; +} + +const MEDIUM_SHORT_SIDE = 720; +const HIGH_SHORT_SIDE = 1080; + +function even(value: number) { + return Math.floor(value / 2) * 2; +} + +function atLeastEven(value: number) { + return Math.max(2, even(value)); +} + +export function calculateEffectiveSourceDimensions( + sourceWidth: number, + sourceHeight: number, + cropRegion?: SourceCropRegion, +) { + const cropWidth = cropRegion?.width ?? 1; + const cropHeight = cropRegion?.height ?? 1; + + return { + width: atLeastEven(Math.round(sourceWidth * cropWidth)), + height: atLeastEven(Math.round(sourceHeight * cropHeight)), + }; +} + +function calculateDimensionsForShortSide(targetShortSide: number, aspectRatioValue: number) { + if (aspectRatioValue >= 1) { + const height = even(targetShortSide); + return { + width: even(height * aspectRatioValue), + height, + }; + } + + const width = even(targetShortSide); + return { + width, + height: even(width / aspectRatioValue), + }; +} + +function calculateSourceDimensions( + sourceWidth: number, + sourceHeight: number, + aspectRatioValue: number, +) { + const sourceLongDim = Math.max(sourceWidth, sourceHeight); + + if (aspectRatioValue === 1) { + const baseDimension = even(Math.min(sourceWidth, sourceHeight)); + return { + width: baseDimension, + height: baseDimension, + }; + } + + if (aspectRatioValue > 1) { + const baseWidth = even(sourceLongDim); + for (let width = baseWidth; width >= 100; width -= 2) { + const height = Math.round(width / aspectRatioValue); + if (height % 2 === 0 && Math.abs(width / height - aspectRatioValue) < 0.0001) { + return { width, height }; + } + } + return { + width: baseWidth, + height: even(baseWidth / aspectRatioValue), + }; + } + + const baseHeight = even(sourceLongDim); + for (let height = baseHeight; height >= 100; height -= 2) { + const width = Math.round(height * aspectRatioValue); + if (width % 2 === 0 && Math.abs(width / height - aspectRatioValue) < 0.0001) { + return { width, height }; + } + } + return { + width: even(baseHeight * aspectRatioValue), + height: baseHeight, + }; +} + +function calculateBitrate(width: number, height: number, quality: ExportQuality) { + const totalPixels = width * height; + + if (quality === "source") { + if (totalPixels > 2560 * 1440) return 80_000_000; + if (totalPixels > 1920 * 1080) return 50_000_000; + return 30_000_000; + } + + if (totalPixels <= 1280 * 720) return 10_000_000; + if (totalPixels <= 1920 * 1080) return 20_000_000; + return 30_000_000; +} + +export function calculateMp4ExportSettings({ + quality, + sourceWidth, + sourceHeight, + aspectRatioValue, +}: { + quality: ExportQuality; + sourceWidth: number; + sourceHeight: number; + aspectRatioValue: number; +}): Mp4ExportSettings { + if (quality === "medium") { + const dimensions = calculateDimensionsForShortSide(MEDIUM_SHORT_SIDE, aspectRatioValue); + return { + ...dimensions, + bitrate: calculateBitrate(dimensions.width, dimensions.height, quality), + }; + } + + if (quality === "good") { + const dimensions = calculateDimensionsForShortSide(HIGH_SHORT_SIDE, aspectRatioValue); + return { + ...dimensions, + bitrate: calculateBitrate(dimensions.width, dimensions.height, quality), + }; + } + + const sourceDimensions = calculateSourceDimensions(sourceWidth, sourceHeight, aspectRatioValue); + return { + ...sourceDimensions, + bitrate: calculateBitrate(sourceDimensions.width, sourceDimensions.height, quality), + }; +}