Merge pull request #598 from EtienneLescot/codex/fix-high-quality-export

Clarify export resolution presets
This commit is contained in:
Sid
2026-05-16 12:34:33 -07:00
committed by GitHub
18 changed files with 518 additions and 161 deletions
+99 -35
View File
@@ -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>
)}
+34 -81
View File
@@ -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"