Merge pull request #747 from webadderallorg/fix/746-remove-background-export-parity

fix(export): preserve cropped native aspect ratio
This commit is contained in:
PTMH-NMH
2026-07-11 12:27:07 +07:00
committed by GitHub
3 changed files with 182 additions and 46 deletions
+69 -42
View File
@@ -87,7 +87,12 @@ import {
} from "@/utils/aspectRatioUtils";
import { planClipSpeedChange } from "./clipSpeedChange";
import { ExtensionIcon } from "./ExtensionIcon";
import { calculateMp4ExportDimensions, calculateMp4SourceDimensions } from "./exportDimensions";
import {
calculateMp4ExportDimensions,
calculateMp4SourceDimensions,
type Mp4SupportProbeSnapshot,
shouldDebounceMp4SupportProbe,
} from "./exportDimensions";
import { resolveSavingExportProgress } from "./exportProgressState";
import { resolveExportStartSettings } from "./exportStartSettings";
import { resolveExportStatusModel } from "./exportStatusModel";
@@ -256,6 +261,7 @@ type CancelableExporter = {
};
const EXPORT_BLOB_STREAM_CHUNK_BYTES = 16 * 1024 * 1024;
const MP4_CROP_PROBE_DEBOUNCE_MS = 200;
async function streamExportBlobToTempFile(blob: Blob, extension: string): Promise<string | null> {
if (
@@ -701,6 +707,7 @@ export default function VideoEditor() {
const pendingFreshRecordingAutoSuggestTelemetryCountRef = useRef(0);
const cropSnapshotRef = useRef<CropRegion | null>(null);
const mp4SupportRequestRef = useRef(0);
const previousMp4SupportProbeRef = useRef<Mp4SupportProbeSnapshot | null>(null);
const smokeExportStartedRef = useRef(false);
const projectAutosaveTimeoutRef = useRef<number | null>(null);
const pendingProjectSaveDialogRef = useRef<PendingProjectSaveDialog | null>(null);
@@ -1485,15 +1492,22 @@ export default function VideoEditor() {
[gifSizePreset],
);
const desiredMp4SourceDimensions = useMemo(
() =>
calculateMp4SourceDimensions(
videoPlaybackRef.current?.video?.videoWidth || 1920,
videoPlaybackRef.current?.video?.videoHeight || 1080,
aspectRatio,
),
[aspectRatio],
);
const mp4SourceDimensions = useMemo(() => {
const sourceVideo = isPreviewReady ? videoPlaybackRef.current?.video : null;
return {
width: sourceVideo?.videoWidth || 1920,
height: sourceVideo?.videoHeight || 1080,
};
}, [isPreviewReady]);
const desiredMp4SourceDimensions = useMemo(() => {
return calculateMp4SourceDimensions(
mp4SourceDimensions.width,
mp4SourceDimensions.height,
aspectRatio,
cropRegion,
);
}, [aspectRatio, cropRegion, mp4SourceDimensions.height, mp4SourceDimensions.width]);
const mp4OutputDimensions = useMemo(() => {
const baseWidth = supportedMp4SourceDimensions.encoderPath
@@ -1533,21 +1547,6 @@ export default function VideoEditor() {
);
}
setSupportedMp4SourceDimensions((current) => {
if (
current.width === result.width &&
current.height === result.height &&
current.capped === result.capped &&
current.encoderPath?.codec === result.encoderPath?.codec &&
current.encoderPath?.hardwareAcceleration ===
result.encoderPath?.hardwareAcceleration
) {
return current;
}
return result;
});
return result;
},
[desiredMp4SourceDimensions.height, desiredMp4SourceDimensions.width],
@@ -1557,6 +1556,19 @@ export default function VideoEditor() {
let cancelled = false;
const requestId = mp4SupportRequestRef.current + 1;
mp4SupportRequestRef.current = requestId;
const probeSnapshot: Mp4SupportProbeSnapshot = {
sourceWidth: mp4SourceDimensions.width,
sourceHeight: mp4SourceDimensions.height,
targetWidth: desiredMp4SourceDimensions.width,
targetHeight: desiredMp4SourceDimensions.height,
aspectRatio,
frameRate: mp4FrameRate,
};
const shouldDebounce = shouldDebounceMp4SupportProbe(
previousMp4SupportProbeRef.current,
probeSnapshot,
);
previousMp4SupportProbeRef.current = probeSnapshot;
setSupportedMp4SourceDimensions({
width: desiredMp4SourceDimensions.width,
height: desiredMp4SourceDimensions.height,
@@ -1564,33 +1576,48 @@ export default function VideoEditor() {
encoderPath: null,
});
void ensureSupportedMp4SourceDimensions(mp4FrameRate)
.then((result) => {
if (cancelled || requestId !== mp4SupportRequestRef.current) {
return;
}
setSupportedMp4SourceDimensions(result);
})
.catch(() => {
if (cancelled || requestId !== mp4SupportRequestRef.current) {
return;
}
setSupportedMp4SourceDimensions({
width: desiredMp4SourceDimensions.width,
height: desiredMp4SourceDimensions.height,
capped: false,
encoderPath: null,
const runProbe = () => {
void ensureSupportedMp4SourceDimensions(mp4FrameRate)
.then((result) => {
if (cancelled || requestId !== mp4SupportRequestRef.current) {
return;
}
setSupportedMp4SourceDimensions(result);
})
.catch(() => {
if (cancelled || requestId !== mp4SupportRequestRef.current) {
return;
}
setSupportedMp4SourceDimensions({
width: desiredMp4SourceDimensions.width,
height: desiredMp4SourceDimensions.height,
capped: false,
encoderPath: null,
});
});
});
};
const timeoutId = shouldDebounce
? window.setTimeout(runProbe, MP4_CROP_PROBE_DEBOUNCE_MS)
: null;
if (timeoutId === null) {
runProbe();
}
return () => {
cancelled = true;
if (timeoutId !== null) {
window.clearTimeout(timeoutId);
}
};
}, [
aspectRatio,
desiredMp4SourceDimensions.height,
desiredMp4SourceDimensions.width,
ensureSupportedMp4SourceDimensions,
mp4FrameRate,
mp4SourceDimensions.height,
mp4SourceDimensions.width,
]);
// Extension-contributed standalone section pages (no parentSection)
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { calculateMp4ExportDimensions, calculateMp4SourceDimensions } from "./exportDimensions";
import {
calculateMp4ExportDimensions,
calculateMp4SourceDimensions,
shouldDebounceMp4SupportProbe,
} from "./exportDimensions";
describe("calculateMp4SourceDimensions", () => {
it("keeps native exports at the source dimensions", () => {
@@ -9,6 +13,18 @@ describe("calculateMp4SourceDimensions", () => {
});
});
it("uses the cropped source bounds for native exports", () => {
expect(
calculateMp4SourceDimensions(320, 180, "native", {
width: 1,
height: 0.8,
}),
).toEqual({
width: 320,
height: 144,
});
});
it("uses the rotated source bounds for 9:16 original exports", () => {
expect(calculateMp4SourceDimensions(1920, 1080, "9:16")).toEqual({
width: 1080,
@@ -16,6 +32,18 @@ describe("calculateMp4SourceDimensions", () => {
});
});
it("ignores crop bounds for fixed-aspect exports", () => {
expect(
calculateMp4SourceDimensions(1920, 1080, "9:16", {
width: 0.5,
height: 0.5,
}),
).toEqual({
width: 1080,
height: 1920,
});
});
it("uses the rotated source bounds for portrait social ratios", () => {
expect(calculateMp4SourceDimensions(1920, 1080, "4:5")).toEqual({
width: 1080,
@@ -70,3 +98,48 @@ describe("calculateMp4ExportDimensions", () => {
});
});
});
describe("shouldDebounceMp4SupportProbe", () => {
const baseSnapshot = {
sourceWidth: 1920,
sourceHeight: 1080,
targetWidth: 1920,
targetHeight: 1080,
aspectRatio: "native" as const,
frameRate: 30 as const,
};
it("debounces only native crop-driven target changes", () => {
expect(
shouldDebounceMp4SupportProbe(baseSnapshot, {
...baseSnapshot,
targetHeight: 864,
}),
).toBe(true);
});
it("keeps non-crop probe changes immediate", () => {
expect(shouldDebounceMp4SupportProbe(null, baseSnapshot)).toBe(false);
expect(
shouldDebounceMp4SupportProbe(baseSnapshot, {
...baseSnapshot,
frameRate: 60,
}),
).toBe(false);
expect(
shouldDebounceMp4SupportProbe(baseSnapshot, {
...baseSnapshot,
sourceWidth: 1280,
sourceHeight: 720,
targetWidth: 1280,
targetHeight: 720,
}),
).toBe(false);
expect(
shouldDebounceMp4SupportProbe(baseSnapshot, {
...baseSnapshot,
aspectRatio: "16:9",
}),
).toBe(false);
});
});
@@ -1,6 +1,36 @@
import type { ExportQuality } from "@/lib/exporter";
import type { ExportMp4FrameRate, ExportQuality } from "@/lib/exporter";
import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils";
export type Mp4SupportProbeSnapshot = {
sourceWidth: number;
sourceHeight: number;
targetWidth: number;
targetHeight: number;
aspectRatio: AspectRatio;
frameRate: ExportMp4FrameRate;
};
export function shouldDebounceMp4SupportProbe(
previous: Mp4SupportProbeSnapshot | null,
current: Mp4SupportProbeSnapshot,
): boolean {
if (
!previous ||
current.aspectRatio !== "native" ||
previous.aspectRatio !== current.aspectRatio ||
previous.frameRate !== current.frameRate ||
previous.sourceWidth !== current.sourceWidth ||
previous.sourceHeight !== current.sourceHeight
) {
return false;
}
return (
previous.targetWidth !== current.targetWidth ||
previous.targetHeight !== current.targetHeight
);
}
function normalizeEvenDimension(value: number): number {
return Math.max(2, Math.floor(value / 2) * 2);
}
@@ -30,9 +60,15 @@ export function calculateMp4SourceDimensions(
sourceWidth: number,
sourceHeight: number,
aspectRatio: AspectRatio,
cropRegion?: { width: number; height: number },
): { width: number; height: number } {
const safeSourceWidth = normalizeEvenDimension(sourceWidth);
const safeSourceHeight = normalizeEvenDimension(sourceHeight);
const useCroppedBounds = aspectRatio === "native";
const safeSourceWidth = normalizeEvenDimension(
sourceWidth * (useCroppedBounds ? (cropRegion?.width ?? 1) : 1),
);
const safeSourceHeight = normalizeEvenDimension(
sourceHeight * (useCroppedBounds ? (cropRegion?.height ?? 1) : 1),
);
const sourceAspectRatio = safeSourceHeight > 0 ? safeSourceWidth / safeSourceHeight : 16 / 9;
const aspectRatioValue = getAspectRatioValue(aspectRatio, sourceAspectRatio);