mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 23:35:43 +00:00
Merge pull request #747 from webadderallorg/fix/746-remove-background-export-parity
fix(export): preserve cropped native aspect ratio
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user