Skip to content

Commit b809227

Browse files
committed
fix(export): size a GIF from the source, not the hidden MP4 tier
A GIF started from whatever MP4 quality tier was last picked, a control the GIF settings do not show: a 640x360 clip gave an 852x480 README GIF straight away and a 640x360 one after Studio. It now starts from the "Source" size in both the export and the destination summary, so no preset upscales and Original means the source size. MP4 is unchanged.
1 parent 77bdb43 commit b809227

2 files changed

Lines changed: 71 additions & 28 deletions

File tree

‎src/components/ai-edition/ExportDialog.params.test.tsx‎

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,16 @@ const DOC: AxcutDocument = {
7878
legacyEditor: { aspectRatio: "16:9" },
7979
};
8080

81-
function renderDialog() {
81+
/** A 640x360 recording: both fixed tiers, 720p and 1080p, are bigger than it. */
82+
const SMALL_SOURCE_DOC: AxcutDocument = {
83+
...DOC,
84+
assets: [{ ...DOC.assets[0], video: { codec: "h264", width: 640, height: 360, fps: 60 } }],
85+
};
86+
87+
function renderDialog(document: AxcutDocument = DOC) {
8288
render(
8389
<I18nProvider>
84-
<ExportDialog open={true} onClose={noop} document={DOC} />
90+
<ExportDialog open={true} onClose={noop} document={document} />
8591
</I18nProvider>,
8692
);
8793
}
@@ -95,6 +101,15 @@ async function exportMp4() {
95101
return params;
96102
}
97103

104+
/** Same for a GIF, counting calls so a run of exports cannot read the previous one's params. */
105+
async function exportGif() {
106+
const calls = vi.mocked(exportGifNative).mock.calls.length;
107+
fireEvent.click(screen.getByRole("button", { name: /export gif/i }));
108+
await waitFor(() => expect(exportGifNative).toHaveBeenCalledTimes(calls + 1));
109+
await screen.findByTestId("export-show-in-folder");
110+
return vi.mocked(exportGifNative).mock.calls[calls][3];
111+
}
112+
98113
describe("ExportDialog MP4 params", () => {
99114
beforeEach(() => {
100115
window.electronAPI = {
@@ -190,4 +205,33 @@ describe("ExportDialog destinations", () => {
190205
loopCount: 0,
191206
});
192207
});
208+
209+
it("sizes README GIF from the source, whatever MP4 tier was picked before", async () => {
210+
// The report on #814: a 640x360 clip got an 852x480 GIF straight away, sized from the hidden
211+
// 1080p tier the dialog opens on, and a 640x360 one after Studio, whose tier is "Source".
212+
renderDialog(SMALL_SOURCE_DOC);
213+
fireEvent.click(destination(/README GIF/));
214+
expect(destination(/README GIF/)).toHaveTextContent("GIF · 640 × 360 · 15 fps");
215+
const direct = await exportGif();
216+
expect(direct).toMatchObject({ width: 640, height: 360 });
217+
218+
fireEvent.click(destination(/Studio/));
219+
fireEvent.click(destination(/README GIF/));
220+
expect(destination(/README GIF/)).toHaveTextContent("GIF · 640 × 360 · 15 fps");
221+
expect(await exportGif()).toEqual(direct);
222+
});
223+
224+
it("never exports a GIF bigger than its source, whatever the tier and size preset", async () => {
225+
renderDialog(SMALL_SOURCE_DOC);
226+
fireEvent.click(screen.getByRole("button", { name: "Advanced" }));
227+
for (const tier of [/^720p/, /^1080p/, /^Source/]) {
228+
fireEvent.click(screen.getByRole("button", { name: "MP4" }));
229+
fireEvent.click(screen.getByRole("button", { name: tier }));
230+
fireEvent.click(screen.getByRole("button", { name: "GIF" }));
231+
for (const size of ["Small (480p)", "Medium (720p)", "Large (1080p)", "Original"]) {
232+
fireEvent.click(screen.getByRole("button", { name: size }));
233+
expect(await exportGif()).toMatchObject({ width: 640, height: 360 });
234+
}
235+
}
236+
});
193237
});

‎src/components/ai-edition/ExportDialog.tsx‎

Lines changed: 25 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -248,8 +248,8 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) {
248248
);
249249
// (The "largest clip" pick lived here for the old renderer-side GIF path, which
250250
// sized to the best available footage independently of the quality tier. GIF now
251-
// goes through the same native exporter as MP4 and shares its sizing, so only the
252-
// smallest-clip pick below is still needed.)
251+
// goes through the same native exporter as MP4 and starts from its "Source" size, so
252+
// only the smallest-clip pick below is still needed.)
253253

254254
// Smallest clip's true (cropped) footprint on the timeline — a multiclip timeline can mix
255255
// crops/resolutions, so this is what "Source" quality actually targets: sizing to the
@@ -280,25 +280,6 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) {
280280
// quality actually uses these as its target size; 720p/1080p target a fixed short side
281281
// regardless (`calculateDimensionsForShortSide`), so this only changes what "Source"
282282
// resolves to.
283-
// GIF is 8-bit indexed and grows fast with area, so the size preset caps the
284-
// output height rather than following the quality tier. `original` keeps the
285-
// tier's dims; the native side falls back to its own defaults when undefined.
286-
const gifOutputDims = (
287-
preset: GifSizePreset,
288-
tierDims: { width: number; height: number } | null,
289-
): { width?: number; height?: number } => {
290-
if (!tierDims) return {};
291-
const maxHeight = GIF_SIZE_PRESETS[preset].maxHeight;
292-
if (!Number.isFinite(maxHeight) || tierDims.height <= maxHeight) {
293-
return { width: tierDims.width, height: tierDims.height };
294-
}
295-
const scale = maxHeight / tierDims.height;
296-
// Even dimensions: the compositor rasterises to this size and the readback
297-
// assumes a tightly-packed RGBA buffer.
298-
const even = (n: number) => Math.max(2, Math.round(n * scale) & ~1);
299-
return { width: even(tierDims.width), height: even(tierDims.height) };
300-
};
301-
302283
const tierOutputDims = (value: ExportQuality) =>
303284
smallestSource
304285
? calculateMp4ExportSettings({
@@ -310,6 +291,26 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) {
310291
})
311292
: null;
312293

294+
// GIF is 8-bit indexed and grows fast with area, so the size preset caps the output
295+
// height. It starts from the "Source" size, never from the quality tier: that control is
296+
// MP4-only and hidden while GIF is picked, yet the tier an MP4 choice left behind used to
297+
// size the GIF (a 640x360 clip gave 852x480 after Web / YouTube, 640x360 after Studio).
298+
// So no preset upscales and `original` is the source size. The native side falls back
299+
// to its own defaults when undefined.
300+
const gifOutputDims = (preset: GifSizePreset): { width?: number; height?: number } => {
301+
const source = tierOutputDims("source");
302+
if (!source) return {};
303+
const maxHeight = GIF_SIZE_PRESETS[preset].maxHeight;
304+
if (source.height <= maxHeight) {
305+
return { width: source.width, height: source.height };
306+
}
307+
const scale = maxHeight / source.height;
308+
// Even dimensions: the compositor rasterises to this size and the readback
309+
// assumes a tightly-packed RGBA buffer.
310+
const even = (n: number) => Math.max(2, Math.round(n * scale) & ~1);
311+
return { width: even(source.width), height: even(source.height) };
312+
};
313+
313314
useEffect(() => {
314315
if (!open) {
315316
pickerGeneration.current += 1;
@@ -456,9 +457,7 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) {
456457
pickedPath,
457458
sceneJson,
458459
{
459-
// GIF is 256-colour and grows fast; cap the long edge at the
460-
// chosen preset rather than exporting at source size.
461-
...gifOutputDims(gifSize, outDims),
460+
...gifOutputDims(gifSize),
462461
fps: gifFrameRate,
463462
// 0 = infinite, the historical GIF default; 1 = play once.
464463
loopCount: gifLoop ? 0 : 1,
@@ -536,12 +535,12 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) {
536535
};
537536
/** What a destination produces, in the numbers the Advanced settings show. */
538537
const destinationSummary = (set: Partial<ExportChoice>) => {
539-
const dims = tierOutputDims(set.quality ?? quality);
540538
if (set.format === "gif") {
541-
const gif = gifOutputDims(set.gifSize ?? gifSize, dims);
539+
const gif = gifOutputDims(set.gifSize ?? gifSize);
542540
const size = gif.width ? `${gif.width} × ${gif.height} · ` : "";
543541
return `GIF · ${size}${set.gifFrameRate ?? gifFrameRate} fps`;
544542
}
543+
const dims = tierOutputDims(set.quality ?? quality);
545544
const size = dims ? `${dims.width} × ${dims.height} · ` : "";
546545
return `MP4 · ${size}${set.fps ?? fps} fps`;
547546
};

0 commit comments

Comments
 (0)