Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 46 additions & 2 deletions src/components/ai-edition/ExportDialog.params.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,16 @@ const DOC: AxcutDocument = {
legacyEditor: { aspectRatio: "16:9" },
};

function renderDialog() {
/** A 640x360 recording: both fixed tiers, 720p and 1080p, are bigger than it. */
const SMALL_SOURCE_DOC: AxcutDocument = {
...DOC,
assets: [{ ...DOC.assets[0], video: { codec: "h264", width: 640, height: 360, fps: 60 } }],
};

function renderDialog(document: AxcutDocument = DOC) {
render(
<I18nProvider>
<ExportDialog open={true} onClose={noop} document={DOC} />
<ExportDialog open={true} onClose={noop} document={document} />
</I18nProvider>,
);
}
Expand All @@ -95,6 +101,15 @@ async function exportMp4() {
return params;
}

/** Same for a GIF, counting calls so a run of exports cannot read the previous one's params. */
async function exportGif() {
const calls = vi.mocked(exportGifNative).mock.calls.length;
fireEvent.click(screen.getByRole("button", { name: /export gif/i }));
await waitFor(() => expect(exportGifNative).toHaveBeenCalledTimes(calls + 1));
await screen.findByTestId("export-show-in-folder");
return vi.mocked(exportGifNative).mock.calls[calls][3];
}

describe("ExportDialog MP4 params", () => {
beforeEach(() => {
window.electronAPI = {
Expand Down Expand Up @@ -190,4 +205,33 @@ describe("ExportDialog destinations", () => {
loopCount: 0,
});
});

it("sizes README GIF from the source, whatever MP4 tier was picked before", async () => {
// The report on #814: a 640x360 clip got an 852x480 GIF straight away, sized from the hidden
// 1080p tier the dialog opens on, and a 640x360 one after Studio, whose tier is "Source".
renderDialog(SMALL_SOURCE_DOC);
fireEvent.click(destination(/README GIF/));
expect(destination(/README GIF/)).toHaveTextContent("GIF · 640 × 360 · 15 fps");
const direct = await exportGif();
expect(direct).toMatchObject({ width: 640, height: 360 });

fireEvent.click(destination(/Studio/));
fireEvent.click(destination(/README GIF/));
expect(destination(/README GIF/)).toHaveTextContent("GIF · 640 × 360 · 15 fps");
expect(await exportGif()).toEqual(direct);
});

it("never exports a GIF bigger than its source, whatever the tier and size preset", async () => {
renderDialog(SMALL_SOURCE_DOC);
fireEvent.click(screen.getByRole("button", { name: "Advanced" }));
for (const tier of [/^720p/, /^1080p/, /^Source/]) {
fireEvent.click(screen.getByRole("button", { name: "MP4" }));
fireEvent.click(screen.getByRole("button", { name: tier }));
fireEvent.click(screen.getByRole("button", { name: "GIF" }));
for (const size of ["Small (480p)", "Medium (720p)", "Large (1080p)", "Original"]) {
fireEvent.click(screen.getByRole("button", { name: size }));
expect(await exportGif()).toMatchObject({ width: 640, height: 360 });
}
}
});
});
51 changes: 25 additions & 26 deletions src/components/ai-edition/ExportDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -248,8 +248,8 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) {
);
// (The "largest clip" pick lived here for the old renderer-side GIF path, which
// sized to the best available footage independently of the quality tier. GIF now
// goes through the same native exporter as MP4 and shares its sizing, so only the
// smallest-clip pick below is still needed.)
// goes through the same native exporter as MP4 and starts from its "Source" size, so
// only the smallest-clip pick below is still needed.)

// Smallest clip's true (cropped) footprint on the timeline — a multiclip timeline can mix
// crops/resolutions, so this is what "Source" quality actually targets: sizing to the
Expand Down Expand Up @@ -280,25 +280,6 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) {
// quality actually uses these as its target size; 720p/1080p target a fixed short side
// regardless (`calculateDimensionsForShortSide`), so this only changes what "Source"
// resolves to.
// GIF is 8-bit indexed and grows fast with area, so the size preset caps the
// output height rather than following the quality tier. `original` keeps the
// tier's dims; the native side falls back to its own defaults when undefined.
const gifOutputDims = (
preset: GifSizePreset,
tierDims: { width: number; height: number } | null,
): { width?: number; height?: number } => {
if (!tierDims) return {};
const maxHeight = GIF_SIZE_PRESETS[preset].maxHeight;
if (!Number.isFinite(maxHeight) || tierDims.height <= maxHeight) {
return { width: tierDims.width, height: tierDims.height };
}
const scale = maxHeight / tierDims.height;
// Even dimensions: the compositor rasterises to this size and the readback
// assumes a tightly-packed RGBA buffer.
const even = (n: number) => Math.max(2, Math.round(n * scale) & ~1);
return { width: even(tierDims.width), height: even(tierDims.height) };
};

const tierOutputDims = (value: ExportQuality) =>
smallestSource
? calculateMp4ExportSettings({
Expand All @@ -310,6 +291,26 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) {
})
: null;

// GIF is 8-bit indexed and grows fast with area, so the size preset caps the output
// height. It starts from the "Source" size, never from the quality tier: that control is
// MP4-only and hidden while GIF is picked, yet the tier an MP4 choice left behind used to
// size the GIF (a 640x360 clip gave 852x480 after Web / YouTube, 640x360 after Studio).
// So no preset upscales and `original` is the source size. The native side falls back
// to its own defaults when undefined.
const gifOutputDims = (preset: GifSizePreset): { width?: number; height?: number } => {
const source = tierOutputDims("source");
if (!source) return {};
const maxHeight = GIF_SIZE_PRESETS[preset].maxHeight;
if (source.height <= maxHeight) {
return { width: source.width, height: source.height };
}
const scale = maxHeight / source.height;
// Even dimensions: the compositor rasterises to this size and the readback
// assumes a tightly-packed RGBA buffer.
const even = (n: number) => Math.max(2, Math.round(n * scale) & ~1);
return { width: even(source.width), height: even(source.height) };
};

useEffect(() => {
if (!open) {
pickerGeneration.current += 1;
Expand Down Expand Up @@ -456,9 +457,7 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) {
pickedPath,
sceneJson,
{
// GIF is 256-colour and grows fast; cap the long edge at the
// chosen preset rather than exporting at source size.
...gifOutputDims(gifSize, outDims),
...gifOutputDims(gifSize),
fps: gifFrameRate,
// 0 = infinite, the historical GIF default; 1 = play once.
loopCount: gifLoop ? 0 : 1,
Expand Down Expand Up @@ -536,12 +535,12 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) {
};
/** What a destination produces, in the numbers the Advanced settings show. */
const destinationSummary = (set: Partial<ExportChoice>) => {
const dims = tierOutputDims(set.quality ?? quality);
if (set.format === "gif") {
const gif = gifOutputDims(set.gifSize ?? gifSize, dims);
const gif = gifOutputDims(set.gifSize ?? gifSize);
const size = gif.width ? `${gif.width} × ${gif.height} · ` : "";
return `GIF · ${size}${set.gifFrameRate ?? gifFrameRate} fps`;
}
const dims = tierOutputDims(set.quality ?? quality);
const size = dims ? `${dims.width} × ${dims.height} · ` : "";
return `MP4 · ${size}${set.fps ?? fps} fps`;
};
Expand Down
Loading