Merge pull request #598 from EtienneLescot/codex/fix-high-quality-export
Clarify export resolution presets
This commit is contained in:
@@ -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<SettingsPanelMode>("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({
|
||||
</div>
|
||||
|
||||
{exportFormat === "mp4" && (
|
||||
<div className="mb-3 bg-white/5 border border-white/5 p-0.5 w-full grid grid-cols-3 h-7 rounded-lg">
|
||||
<button
|
||||
onClick={() => onExportQualityChange?.("medium")}
|
||||
className={cn(
|
||||
"rounded-md transition-all text-[10px] font-medium",
|
||||
exportQuality === "medium"
|
||||
? "bg-white text-black"
|
||||
: "text-slate-400 hover:text-slate-200",
|
||||
)}
|
||||
>
|
||||
{t("exportQuality.low")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onExportQualityChange?.("good")}
|
||||
className={cn(
|
||||
"rounded-md transition-all text-[10px] font-medium",
|
||||
exportQuality === "good"
|
||||
? "bg-white text-black"
|
||||
: "text-slate-400 hover:text-slate-200",
|
||||
)}
|
||||
>
|
||||
{t("exportQuality.medium")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onExportQualityChange?.("source")}
|
||||
className={cn(
|
||||
"rounded-md transition-all text-[10px] font-medium",
|
||||
exportQuality === "source"
|
||||
? "bg-white text-black"
|
||||
: "text-slate-400 hover:text-slate-200",
|
||||
)}
|
||||
>
|
||||
{t("exportQuality.high")}
|
||||
</button>
|
||||
<div className="mb-3 space-y-1.5">
|
||||
{sourceDimensions && (
|
||||
<div className="flex items-center justify-between px-0.5 text-[10px] leading-none text-slate-500">
|
||||
<span>{t("exportQuality.title")}</span>
|
||||
<span>
|
||||
Source {sourceDimensions.width}x{sourceDimensions.height}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-white/5 border border-white/5 p-0.5 w-full grid grid-cols-3 h-9 rounded-lg">
|
||||
<button
|
||||
onClick={() => onExportQualityChange?.("medium")}
|
||||
className={cn(
|
||||
"rounded-md transition-all text-[10px] font-medium flex flex-col items-center justify-center leading-none gap-0.5",
|
||||
exportQuality === "medium"
|
||||
? "bg-white text-black"
|
||||
: "text-slate-400 hover:text-slate-200",
|
||||
)}
|
||||
>
|
||||
<span>{t("exportQuality.low")}</span>
|
||||
{sourceDimensions &&
|
||||
sourceDimensions.shortSide < MP4_EXPORT_SHORT_SIDES.medium && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[8px] font-medium",
|
||||
exportQuality === "medium" ? "text-black/55" : "text-amber-300/80",
|
||||
)}
|
||||
>
|
||||
Upscale
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onExportQualityChange?.("good")}
|
||||
className={cn(
|
||||
"rounded-md transition-all text-[10px] font-medium flex flex-col items-center justify-center leading-none gap-0.5",
|
||||
exportQuality === "good"
|
||||
? "bg-white text-black"
|
||||
: "text-slate-400 hover:text-slate-200",
|
||||
)}
|
||||
>
|
||||
<span>{t("exportQuality.medium")}</span>
|
||||
{sourceDimensions &&
|
||||
sourceDimensions.shortSide < MP4_EXPORT_SHORT_SIDES.good && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[8px] font-medium",
|
||||
exportQuality === "good" ? "text-black/55" : "text-amber-300/80",
|
||||
)}
|
||||
>
|
||||
Upscale
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onExportQualityChange?.("source")}
|
||||
className={cn(
|
||||
"rounded-md transition-all text-[10px] font-medium flex flex-col items-center justify-center leading-none gap-0.5",
|
||||
exportQuality === "source"
|
||||
? "bg-white text-black"
|
||||
: "text-slate-400 hover:text-slate-200",
|
||||
)}
|
||||
>
|
||||
<span>{t("exportQuality.high")}</span>
|
||||
{sourceDimensions && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[8px] font-medium",
|
||||
exportQuality === "source" ? "text-black/55" : "text-slate-500",
|
||||
)}
|
||||
>
|
||||
{sourceDimensions.shortSide}p
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -69,10 +69,10 @@
|
||||
"gifDescription": "صورة متحركة للمشاركة"
|
||||
},
|
||||
"exportQuality": {
|
||||
"title": "جودة التصدير",
|
||||
"low": "منخفضة",
|
||||
"medium": "متوسطة",
|
||||
"high": "عالية"
|
||||
"title": "دقة التصدير",
|
||||
"low": "720p",
|
||||
"medium": "1080p",
|
||||
"high": "Source"
|
||||
},
|
||||
"gifSettings": {
|
||||
"frameRate": "معدل إطارات GIF",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -76,10 +76,10 @@
|
||||
"gifDescription": "共有用のアニメーション画像"
|
||||
},
|
||||
"exportQuality": {
|
||||
"title": "エクスポート品質",
|
||||
"low": "低画質",
|
||||
"medium": "中画質",
|
||||
"high": "高画質"
|
||||
"title": "書き出し解像度",
|
||||
"low": "720p",
|
||||
"medium": "1080p",
|
||||
"high": "Source"
|
||||
},
|
||||
"gifSettings": {
|
||||
"frameRate": "GIF フレームレート",
|
||||
|
||||
@@ -76,10 +76,10 @@
|
||||
"gifDescription": "공유용 애니메이션 이미지"
|
||||
},
|
||||
"exportQuality": {
|
||||
"title": "내보내기 품질",
|
||||
"low": "낮음",
|
||||
"medium": "보통",
|
||||
"high": "높음"
|
||||
"title": "내보내기 해상도",
|
||||
"low": "720p",
|
||||
"medium": "1080p",
|
||||
"high": "Source"
|
||||
},
|
||||
"gifSettings": {
|
||||
"frameRate": "GIF 프레임 속도",
|
||||
|
||||
@@ -77,10 +77,10 @@
|
||||
"gifDescription": "Анимированное изображение для обмена"
|
||||
},
|
||||
"exportQuality": {
|
||||
"title": "Качество экспорта",
|
||||
"low": "Низкое",
|
||||
"medium": "Среднее",
|
||||
"high": "Высокое"
|
||||
"title": "Разрешение экспорта",
|
||||
"low": "720p",
|
||||
"medium": "1080p",
|
||||
"high": "Source"
|
||||
},
|
||||
"gifSettings": {
|
||||
"frameRate": "Частота кадров GIF",
|
||||
|
||||
@@ -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ı",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -76,10 +76,10 @@
|
||||
"gifDescription": "可分享的动态图片"
|
||||
},
|
||||
"exportQuality": {
|
||||
"title": "导出质量",
|
||||
"low": "低",
|
||||
"medium": "中",
|
||||
"high": "高"
|
||||
"title": "导出分辨率",
|
||||
"low": "720p",
|
||||
"medium": "1080p",
|
||||
"high": "Source"
|
||||
},
|
||||
"gifSettings": {
|
||||
"frameRate": "GIF 帧率",
|
||||
|
||||
@@ -83,10 +83,10 @@
|
||||
"gifDescription": "可分享的動態圖片"
|
||||
},
|
||||
"exportQuality": {
|
||||
"title": "匯出品質",
|
||||
"low": "低",
|
||||
"medium": "中",
|
||||
"high": "高"
|
||||
"title": "匯出解析度",
|
||||
"low": "720p",
|
||||
"medium": "1080p",
|
||||
"high": "Source"
|
||||
},
|
||||
"gifSettings": {
|
||||
"frameRate": "GIF 影格率",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user