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"
|
||||
|
||||
Reference in New Issue
Block a user