collective

Browse / github.com/storytold/artcraft / patch

feat(image-edit): keep the source image's aspect ratio by default

feat(image-edit): keep the source image's aspect ratio by default Related #1248 Add Auto to the 2D editor's aspect-ratio picker and make it the default. Resolve it to the selected model's supported auto option, or its nearest supported fixed ratio based on the source image dimensions. Models without ratio control omit the field. Forward the resolved ratio for both ordinary edits and masked edits. The credit quote uses the same canvas dimensions and supported ratios as generation, and updates when the source image changes. Validation: 92 frontend tests pass across model-list, pagedraw, promptbox, pricing-modal and the new desktop PageDraw adapter regressio

Status
open
Score
0.00
Adopters
0 other operators
Author
channelramble (imported from pull request #2241, open)
Base
d4e4ac69bbd93d237eaf5d0260bc6a5ff3a5e373
Size
+730 −15 in 15 files
Review
pending
Shared
2026-10-28
Id
db06d450e55a6032c5a2b200

How it went

No other operator has reported using it yet.

Diff

raw · frontend/apps/artcraft/app/src/pages/PageDraw/PageDraw.spec.tsx frontend/apps/artcraft/app/src/pages/PageDraw/PageDraw.tsx frontend/libs/components/pagedraw/src/lib/PageDraw.tsx frontend/libs/components/pagedraw/src/lib/adapter.ts frontend/libs/components/pagedraw/src/lib/utilities/editAspectRatio.spec.ts frontend/libs/components/pagedraw/src/lib/utilities/editAspectRatio.ts frontend/libs/components/pricing-modal/src/lib/useImageCostEstimate.spec.tsx frontend/libs/components/pricing-modal/src/lib/useImageCostEstimate.ts frontend/libs/components/promptbox/src/lib/PromptBox2D.spec.tsx frontend/libs/components/promptbox/src/lib/PromptBox2D.tsx frontend/libs/components/promptbox/src/lib/promptStore.ts frontend/libs/model-list/src/index.ts frontend/libs/model-list/src/lib/classes/matchSourceAspectRatio.spec.ts frontend/libs/model-list/src/lib/classes/matchSourceAspectRatio.ts netlify.toml

diff --git a/frontend/apps/artcraft/app/src/pages/PageDraw/PageDraw.spec.tsx b/frontend/apps/artcraft/app/src/pages/PageDraw/PageDraw.spec.tsxnew file mode 100644--- /dev/null+++ b/frontend/apps/artcraft/app/src/pages/PageDraw/PageDraw.spec.tsx@@ -0,0 +1,45 @@+import { render, cleanup } from "@testing-library/react";+import { afterEach, expect, it, vi } from "vitest";+import { CommonAspectRatio } from "@storyteller/model-list";+import type { PageDrawAdapter } from "@storyteller/ui-pagedraw";+import PageDraw from "./PageDraw";++const mocks = vi.hoisted(() => ({+  adapter: undefined as PageDrawAdapter | undefined,+  generate: vi.fn(async (_request: { aspect_ratio?: string }) => ({ status: "success" })),+}));+vi.mock("@storyteller/ui-pagedraw", () => ({+  PageDraw: ({ adapter }: { adapter: PageDrawAdapter }) => {+    mocks.adapter = adapter;+    return null;+  },+  useSceneStore: { getState: vi.fn() },+}));+vi.mock("@storyteller/tauri-api", () => ({+  GenerateImage: mocks.generate,+  EnqueueImageBgRemoval: vi.fn(),+  useCanvasBgRemovedEvent: vi.fn(),+}));+vi.mock("@storyteller/tauri-events", () => ({ useTextToImageGenerationCompleteEvent: vi.fn() }));+vi.mock("@storyteller/api", () => ({ UploadImageMedia: vi.fn() }));+vi.mock("./BaseImageSelector", () => ({ BaseImageSelector: () => null }));+afterEach(() => { cleanup(); mocks.generate.mockClear(); });++it.each(["enqueueEditImage", "enqueueInpaint"] as const)(+  "%s sends the resolved source ratio to generation", async (method) => {+    render(<PageDraw />);+    await mocks.adapter![method]({ commonAspectRatio: CommonAspectRatio.TallNineBySixteen });+    expect(mocks.generate).toHaveBeenCalledWith(expect.objectContaining({+      aspect_ratio: CommonAspectRatio.TallNineBySixteen,+    }));+  },+);+it.each(["enqueueEditImage", "enqueueInpaint"] as const)(+  "%s preserves explicit ratios and omits unsupported Auto", async (method) => {+    render(<PageDraw />);+    await mocks.adapter![method]({ aspectRatio: "square" });+    expect(mocks.generate.mock.calls.at(-1)![0]).toMatchObject({ aspect_ratio: CommonAspectRatio.Square });+    await mocks.adapter![method]({});+    expect(mocks.generate.mock.calls.at(-1)![0].aspect_ratio).toBeUndefined();+  },+);diff --git a/frontend/apps/artcraft/app/src/pages/PageDraw/PageDraw.tsx b/frontend/apps/artcraft/app/src/pages/PageDraw/PageDraw.tsx--- a/frontend/apps/artcraft/app/src/pages/PageDraw/PageDraw.tsx+++ b/frontend/apps/artcraft/app/src/pages/PageDraw/PageDraw.tsx@@ -105,7 +105,7 @@ const useTauriAdapter = (): PageDrawAdapter => {               ? !req.disableSystemPrompt               : undefined,           batch_size: req.imageCount,-          aspect_ratio: mapAspectRatio(req.aspectRatio),+          aspect_ratio: req.commonAspectRatio ?? mapAspectRatio(req.aspectRatio),           resolution: mapResolution(req.imageResolution),           frontend_caller: req.frontendCaller,           frontend_subscriber_id: req.frontendSubscriberId,@@ -119,6 +119,7 @@ const useTauriAdapter = (): PageDrawAdapter => {           model: req.model,           image_media_tokens: req.imageMediaToken ? [req.imageMediaToken] : undefined,           inpainting_mask_image_raw_bytes: req.maskImageRawBytes,+          aspect_ratio: req.commonAspectRatio ?? mapAspectRatio(req.aspectRatio),           prompt: req.prompt,           batch_size: req.imageCount,           frontend_caller: req.frontendCaller,diff --git a/frontend/libs/components/pagedraw/src/lib/PageDraw.tsx b/frontend/libs/components/pagedraw/src/lib/PageDraw.tsx--- a/frontend/libs/components/pagedraw/src/lib/PageDraw.tsx+++ b/frontend/libs/components/pagedraw/src/lib/PageDraw.tsx@@ -35,6 +35,7 @@ import { GenerationProvider } from "@storyteller/api-enums"; import { HistoryStack } from "./HistoryStack"; import { type BaseSelectorImage } from "./types"; import { EncodeImageBitmapToBase64 } from "./utilities/EncodeImageBitmapToBase64";+import { editAspectRatioFields } from "./utilities/editAspectRatio"; import { compositeInWorker, maskInWorker } from "./utilities/generatePipeline"; import { RefImage, usePrompt2DStore } from "@storyteller/ui-promptbox"; import { PromptsApi } from "@storyteller/api";@@ -406,6 +407,11 @@ const PageDraw = ({     redo,   } = useSceneStore(useShallow(selector)); +  const sourceAspectRatioType = useSceneStore((state) => state.aspectRatioType);+  useEffect(() => {+    usePrompt2DStore.setState({ sourceImageDimensions: getAspectRatioDimensions() });+  }, [baseImageBitmap, baseImageInfo, sourceAspectRatioType, getAspectRatioDimensions]);+   const promptStoreProvider = usePrompt2DStore;   const generationCount = promptStoreProvider((state) => state.generationCount);   const setGenerationCount = promptStoreProvider(@@ -869,6 +875,12 @@ const PageDraw = ({           timestamp: Date.now(),         }); +        const aspectRatioFields = editAspectRatioFields(+          options?.aspectRatio,+          selectedImageModel,+          getAspectRatioDimensions(),+        );+         let result;          if (selectedImageModel?.editingIsInpainting) {@@ -878,6 +890,7 @@ const PageDraw = ({             model: selectedImageModel,             imageMediaToken: editedImageToken,             maskImageRawBytes: arrayBuffer,+            ...aspectRatioFields,             prompt: prompt,             imageCount: generationCount,             frontendCaller: "image_editor",@@ -914,7 +927,7 @@ const PageDraw = ({             imageCount: generationCount,             frontendCaller: "image_editor",             frontendSubscriberId: subscriberId,-            aspectRatio: options?.aspectRatio,+            ...aspectRatioFields,             imageResolution: options?.resolution,             provider: options?.selectedProvider,           });@@ -948,7 +961,7 @@ const PageDraw = ({             imageCount: generationCount,             frontendCaller: "image_editor",             frontendSubscriberId: subscriberId,-            aspectRatio: options?.aspectRatio,+            ...aspectRatioFields,             imageResolution: options?.resolution,             disableSystemPrompt: !useSystemPrompt,             provider: options?.selectedProvider,@@ -966,6 +979,7 @@ const PageDraw = ({       generationCount,       getCompositeFile,       getMaskArrayBuffer,+      getAspectRatioDimensions,       selectedImageModel,       adapter,       baseImageBitmap,diff --git a/frontend/libs/components/pagedraw/src/lib/adapter.ts b/frontend/libs/components/pagedraw/src/lib/adapter.ts--- a/frontend/libs/components/pagedraw/src/lib/adapter.ts+++ b/frontend/libs/components/pagedraw/src/lib/adapter.ts@@ -1,5 +1,5 @@ import type { ReactNode } from "react";-import type { ImageModel } from "@storyteller/model-list";+import type { CommonAspectRatio, ImageModel } from "@storyteller/model-list"; import type { GenerationProvider } from "@storyteller/api-enums"; import type { UploadMediaFn } from "@storyteller/api"; import type { BaseSelectorImage } from "./types";@@ -12,15 +12,21 @@ export interface PageDrawEditRequest {   prompt?: string;   disableSystemPrompt?: boolean;   imageCount?: number;-  /** "auto" | "wide" | "tall" | "square" */+  /** "wide" | "tall" | "square". Unset when the picker is on "auto"; see `commonAspectRatio`. */   aspectRatio?: string;+  /**+   * Set instead of `aspectRatio` when the picker is on "auto": the value for+   * `model` that keeps the base image's aspect ratio. Hosts that do not read+   * it send no aspect ratio, which leaves the choice to the model.+   */+  commonAspectRatio?: CommonAspectRatio;   /** "1k" | "2k" | "4k" */   imageResolution?: string;   frontendCaller?: string;   frontendSubscriberId?: string; } -export interface PageDrawInpaintRequest {+export interface PageDrawInpaintRequest extends Pick<PageDrawEditRequest, "aspectRatio" | "commonAspectRatio"> {   model?: ImageModel;   provider?: GenerationProvider;   imageMediaToken?: string;diff --git a/frontend/libs/components/pagedraw/src/lib/utilities/editAspectRatio.spec.ts b/frontend/libs/components/pagedraw/src/lib/utilities/editAspectRatio.spec.tsnew file mode 100644--- /dev/null+++ b/frontend/libs/components/pagedraw/src/lib/utilities/editAspectRatio.spec.ts@@ -0,0 +1,72 @@+import { describe, expect, it } from "vitest";+import {+  buildImageModelsFromListing,+  CommonAspectRatio,+} from "@storyteller/model-list";+import { editAspectRatioFields } from "./editAspectRatio";++const LANDSCAPE = { width: 1600, height: 1200 };++const [NANO_BANANA_PRO, GPT_IMAGE_1P5, FUTURE_MODEL] = buildImageModelsFromListing(+  [],+  [+    {+      model: "nano_banana_pro",+      aspect_ratio_options: ["auto", "square", "wide_four_by_three"],+    },+    {+      model: "gpt_image_1p5",+      aspect_ratio_options: ["square", "wide", "tall"],+    },+    { model: "future_edit_model" },+  ],+);++describe("editAspectRatioFields", () => {+  describe("a ratio picked by the user", () => {+    it.each(["wide", "tall", "square"])("is sent as picked (%s)", (picked) => {+      const fields = editAspectRatioFields(picked, NANO_BANANA_PRO, LANDSCAPE);+      expect(fields.aspectRatio).toBe(picked);+      expect(fields.commonAspectRatio).toBeUndefined();+    });++    it("is left unset when nothing is picked", () => {+      const fields = editAspectRatioFields(undefined, NANO_BANANA_PRO, LANDSCAPE);+      expect(fields.aspectRatio).toBeUndefined();+      expect(fields.commonAspectRatio).toBeUndefined();+    });+  });++  describe("auto", () => {+    it("asks a model that has an auto option for it", () => {+      const fields = editAspectRatioFields("auto", NANO_BANANA_PRO, LANDSCAPE);+      expect(fields.commonAspectRatio).toBe(CommonAspectRatio.Auto);+    });++    it("asks other models for the listed ratio closest to the base image", () => {+      expect(+        editAspectRatioFields("auto", GPT_IMAGE_1P5, { width: 1920, height: 1080 })+          .commonAspectRatio,+      ).toBe(CommonAspectRatio.Wide);+      expect(+        editAspectRatioFields("auto", GPT_IMAGE_1P5, { width: 1080, height: 1920 })+          .commonAspectRatio,+      ).toBe(CommonAspectRatio.Tall);+    });++    it("never sends the word auto itself", () => {+      for (const model of [NANO_BANANA_PRO, GPT_IMAGE_1P5, FUTURE_MODEL, undefined]) {+        expect(editAspectRatioFields("auto", model, LANDSCAPE).aspectRatio).toBeUndefined();+      }+    });++    it("sends no ratio when the model has no aspect ratio control", () => {+      expect(+        editAspectRatioFields("auto", FUTURE_MODEL, LANDSCAPE).commonAspectRatio,+      ).toBeUndefined();+      expect(+        editAspectRatioFields("auto", undefined, LANDSCAPE).commonAspectRatio,+      ).toBeUndefined();+    });+  });+});diff --git a/frontend/libs/components/pagedraw/src/lib/utilities/editAspectRatio.ts b/frontend/libs/components/pagedraw/src/lib/utilities/editAspectRatio.tsnew file mode 100644--- /dev/null+++ b/frontend/libs/components/pagedraw/src/lib/utilities/editAspectRatio.ts@@ -0,0 +1,29 @@+import {+  matchSourceAspectRatio,+  type ImageModel,+} from "@storyteller/model-list";+import type { PageDrawEditRequest } from "../adapter";++/**+ * The aspect ratio fields of an edit request for the value picked in the+ * prompt box.+ *+ * "auto" means keep the base image's aspect ratio. Only the model's own+ * options can express that, so it is resolved here, where the base image size+ * is known, instead of being sent as the word "auto".+ */+export function editAspectRatioFields(+  picked: string | undefined,+  model: ImageModel | undefined,+  baseImage: { width: number; height: number },+): Pick<PageDrawEditRequest, "aspectRatio" | "commonAspectRatio"> {+  if (picked !== "auto") return { aspectRatio: picked };++  return {+    commonAspectRatio: matchSourceAspectRatio(+      model,+      baseImage.width,+      baseImage.height,+    ),+  };+}diff --git a/frontend/libs/components/pricing-modal/src/lib/useImageCostEstimate.spec.tsx b/frontend/libs/components/pricing-modal/src/lib/useImageCostEstimate.spec.tsxnew file mode 100644--- /dev/null+++ b/frontend/libs/components/pricing-modal/src/lib/useImageCostEstimate.spec.tsx@@ -0,0 +1,108 @@+import { renderHook, waitFor } from "@testing-library/react";+import { beforeEach, expect, it, vi } from "vitest";+import { ModelPage } from "@storyteller/ui-model-selector";+import {+  buildImageModelsFromListing,+  type ImageModel,+} from "@storyteller/model-list";+import { useImageCostEstimate } from "./useImageCostEstimate";++const mocks = vi.hoisted(() => ({+  estimate: vi.fn(),+  setCredits: vi.fn(),+  canvas2D: {+    sourceImageDimensions: undefined as { width: number; height: number } | undefined,+    aspectRatio: "auto",+    resolution: "1k",+    referenceImages: [],+    generationCount: 1,+  },+}));++vi.mock("@storyteller/ui-promptbox", () => ({+  usePromptImageStore: (select: any) =>+    select({ referenceImages: [], generationCount: 1 }),+  usePrompt2DStore: (select: any) => select(mocks.canvas2D),+  usePrompt3DStore: (select: any) => select({ resolution: "1k", referenceImages: [] }),+  usePromptEditStore: (select: any) =>+    select({ aspectRatio: "auto", resolution: "1k", referenceImages: [] }),+}));+vi.mock("@storyteller/ui-model-selector", () => ({+  ModelPage: {+    TextToImage: "text-to-image",+    Canvas2D: "canvas-2d",+    Stage3D: "stage-3d",+    ImageEditor: "image-editor",+    Angles: "angles",+  },+}));+vi.mock("@storyteller/tauri-api", () => ({+  EstimateImageCost: mocks.estimate,+  isEstimateImageCostSuccess: (result: any) => result.status === "success",+}));+vi.mock("./cost-breakdown-modal-store", () => ({+  useCostBreakdownModalStore: (select: any) =>+    select({ setEstimatedCreditsForPage: mocks.setCredits }),+}));++const [NANO_BANANA_PRO, SEEDREAM_4, GPT_IMAGE_1P5] = buildImageModelsFromListing([], [+  { model: "nano_banana_pro", aspect_ratio_options: ["auto", "square", "wide_sixteen_by_nine"] },+  { model: "seedream_4", aspect_ratio_options: ["auto_2k", "auto_4k", "square"] },+  { model: "gpt_image_1p5", aspect_ratio_options: ["square", "wide", "tall"] },+]);++beforeEach(() => {+  mocks.canvas2D.aspectRatio = "auto";+  mocks.canvas2D.sourceImageDimensions = undefined;+  mocks.estimate.mockReset();+  mocks.setCredits.mockReset();+  mocks.estimate.mockResolvedValue({+    status: "success",+    payload: { cost_in_credits: 12 },+  });+});++it.each([+  ["a model with an auto option", NANO_BANANA_PRO, "auto"],+  ["a model whose auto option also picks a resolution", SEEDREAM_4, "auto_2k"],+  ["a model with no auto option, which leaves it to the API", GPT_IMAGE_1P5, undefined],+])("quotes an edit that keeps the base image's shape for %s", async (_name, model, expected) => {+  await quoteCanvas2D(model);+  expect(mocks.estimate).toHaveBeenCalledWith(+    expect.objectContaining({ aspect_ratio: expected }),+  );+});++it.each([+  ["wide", "wide"],+  ["tall", "tall"],+  ["square", "square"],+])("quotes the %s ratio picked by the user as before", async (picked, expected) => {+  mocks.canvas2D.aspectRatio = picked;+  await quoteCanvas2D(GPT_IMAGE_1P5);+  expect(mocks.estimate).toHaveBeenCalledWith(+    expect.objectContaining({ aspect_ratio: expected }),+  );+});++async function quoteCanvas2D(model: ImageModel) {+  renderHook(() => useImageCostEstimate(ModelPage.Canvas2D, model, "artcraft"));+  await waitFor(() =>+    expect(mocks.setCredits).toHaveBeenLastCalledWith(ModelPage.Canvas2D, 12),+  );+}++it("quotes the same fixed ratio as generation for a portrait canvas", async () => {+  mocks.canvas2D.sourceImageDimensions = { width: 900, height: 1600 };+  await quoteCanvas2D(GPT_IMAGE_1P5);+  expect(mocks.estimate).toHaveBeenCalledWith(expect.objectContaining({ aspect_ratio: "tall" }));+});++it("updates the quote when the source canvas changes shape", async () => {+  mocks.canvas2D.sourceImageDimensions = { width: 900, height: 1600 };+  const { rerender } = renderHook(() => useImageCostEstimate(ModelPage.Canvas2D, GPT_IMAGE_1P5, "artcraft"));+  await waitFor(() => expect(mocks.estimate).toHaveBeenLastCalledWith(expect.objectContaining({ aspect_ratio: "tall" })));+  mocks.canvas2D.sourceImageDimensions = { width: 1600, height: 900 };+  rerender();+  await waitFor(() => expect(mocks.estimate).toHaveBeenLastCalledWith(expect.objectContaining({ aspect_ratio: "wide" })));+});diff --git a/frontend/libs/components/pricing-modal/src/lib/useImageCostEstimate.ts b/frontend/libs/components/pricing-modal/src/lib/useImageCostEstimate.ts--- a/frontend/libs/components/pricing-modal/src/lib/useImageCostEstimate.ts+++ b/frontend/libs/components/pricing-modal/src/lib/useImageCostEstimate.ts@@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { ModelPage } from "@storyteller/ui-model-selector";-import { Model, ImageModel } from "@storyteller/model-list";+import { Model, ImageModel, autoAspectRatio, matchSourceAspectRatio } from "@storyteller/model-list"; import { GenerationProvider } from "@storyteller/api-enums"; import {   usePromptImageStore,@@ -45,6 +45,7 @@ export function useImageCostEstimate(   const imageQuality = usePromptImageStore((s) => s.commonQuality);    // Canvas2D store+  const sourceImageDimensions = usePrompt2DStore((s) => s.sourceImageDimensions);   const prompt2DAspectRatio = usePrompt2DStore((s) => s.aspectRatio);   const prompt2DResolution = usePrompt2DStore((s) => s.resolution);   const prompt2DReferenceImages = usePrompt2DStore((s) => s.referenceImages);@@ -84,7 +85,14 @@ export function useImageCostEstimate(         generationCount = imageGenerationCount;         break;       case ModelPage.Canvas2D:-        legacyAspectRatioStr = prompt2DAspectRatio;+        if (prompt2DAspectRatio === "auto") {+          // Use the same source dimensions and model options as generation.+          aspectRatioStr = sourceImageDimensions+            ? matchSourceAspectRatio(imageModel, sourceImageDimensions.width, sourceImageDimensions.height)+            : autoAspectRatio(imageModel);+        } else {+          legacyAspectRatioStr = prompt2DAspectRatio;+        }         resolutionStr = prompt2DResolution;         referenceImageCount = prompt2DReferenceImages.length + 1;         generationCount = prompt2DGenerationCount;@@ -161,6 +169,7 @@ export function useImageCostEstimate(     imageReferenceImages.length,     imageGenerationCount,     imageQuality,+    sourceImageDimensions,     prompt2DAspectRatio,     prompt2DResolution,     prompt2DReferenceImages.length,diff --git a/frontend/libs/components/promptbox/src/lib/PromptBox2D.spec.tsx b/frontend/libs/components/promptbox/src/lib/PromptBox2D.spec.tsxnew file mode 100644--- /dev/null+++ b/frontend/libs/components/promptbox/src/lib/PromptBox2D.spec.tsx@@ -0,0 +1,104 @@+import { cleanup, render, screen, waitFor } from "@testing-library/react";+import userEvent from "@testing-library/user-event";+import { afterEach, beforeEach, expect, it, vi } from "vitest";+import { buildImageModelsFromListing } from "@storyteller/model-list";+import { PromptBox2D } from "./PromptBox2D";+import { usePrompt2DStore } from "./promptStore";++const PROMPT = "make the sky stormy";++const [MODEL] = buildImageModelsFromListing([], [+  {+    model: "nano_banana_pro",+    aspect_ratio_options: ["auto", "square", "wide_sixteen_by_nine"],+  },+]);++beforeEach(() => {+  vi.stubGlobal("ResizeObserver", class {+    observe() {}+    disconnect() {}+    unobserve() {}+  });+  // jsdom has no layout; give popovers a visible rect.+  vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(+    new DOMRect(100, 100, 280, 40),+  );+  usePrompt2DStore.setState(usePrompt2DStore.getInitialState(), true);+});++afterEach(() => {+  cleanup();+  vi.restoreAllMocks();+  vi.unstubAllGlobals();+});++it("keeps the base image's aspect ratio unless the user picks another", async () => {+  const { user, generate } = await renderPromptBox();++  await user.type(screen.getByPlaceholderText("Describe your image..."), PROMPT);+  await user.click(screen.getByRole("button", { name: /Generate/ }));++  await waitFor(() => expect(generate).toHaveBeenCalledTimes(1));+  expect(generate).toHaveBeenCalledWith(+    PROMPT,+    expect.objectContaining({ aspectRatio: "auto" }),+  );+});++it("offers auto next to wide, tall and square", async () => {+  const { user } = await renderPromptBox();++  await user.click(aspectRatioButton("Auto"));++  for (const label of ["Auto", "Wide", "Tall", "Square"]) {+    expect(await screen.findAllByText(label)).not.toHaveLength(0);+  }+});++it.each([+  ["Wide", "wide"],+  ["Tall", "tall"],+  ["Square", "square"],+])("sends %s once picked, and auto again when switched back", async (label, expected) => {+  const { user, generate } = await renderPromptBox();+  await user.type(screen.getByPlaceholderText("Describe your image..."), PROMPT);++  await user.click(aspectRatioButton("Auto"));+  await user.click(await screen.findByRole("button", { name: label }));+  await user.click(screen.getByRole("button", { name: /Generate/ }));+  await waitFor(() => expect(generate).toHaveBeenCalledTimes(1));+  expect(generate).toHaveBeenLastCalledWith(+    PROMPT,+    expect.objectContaining({ aspectRatio: expected }),+  );++  await user.click(aspectRatioButton(label));+  await user.click(await screen.findByRole("button", { name: "Auto" }));+  await user.click(screen.getByRole("button", { name: /Generate/ }));+  await waitFor(() => expect(generate).toHaveBeenCalledTimes(2));+  expect(generate).toHaveBeenLastCalledWith(+    PROMPT,+    expect.objectContaining({ aspectRatio: "auto" }),+  );+});++async function renderPromptBox() {+  const user = userEvent.setup();+  const generate = vi.fn();+  render(+    <PromptBox2D+      usePrompt2DStore={usePrompt2DStore}+      selectedImageModel={MODEL}+      EncodeImageBitmapToBase64={async () => ""}+      onGenerateClick={generate}+    />,+  );+  return { user, generate };+}++// The picker's trigger shows the selected ratio's name, as does that ratio's+// row in the open menu. Only the trigger reports whether the menu is expanded.+function aspectRatioButton(name: string) {+  return screen.getByRole("button", { name, expanded: false });+}diff --git a/frontend/libs/components/promptbox/src/lib/PromptBox2D.tsx b/frontend/libs/components/promptbox/src/lib/PromptBox2D.tsx--- a/frontend/libs/components/promptbox/src/lib/PromptBox2D.tsx+++ b/frontend/libs/components/promptbox/src/lib/PromptBox2D.tsx@@ -4,7 +4,7 @@ import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover"; import { Tooltip } from "@storyteller/ui-tooltip"; import { Button, GenerateButton } from "@storyteller/ui-button"; import { Modal } from "@storyteller/ui-modal";-import { ChevronDownIcon, ChevronUpIcon, FrameIcon, MaximizeIcon, RectangleHorizontalIcon, RectangleVerticalIcon, SquareIcon } from "lucide-react";+import { ChevronDownIcon, ChevronUpIcon, FrameIcon, MaximizeIcon, RectangleHorizontalIcon, RectangleVerticalIcon, SparklesIcon, SquareIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { DynamicIcon } from "@storyteller/icons"; @@ -27,7 +27,8 @@ import { PromptClearAllButton } from "./PromptClearAllButton"; import { StoreApi, UseBoundStore } from "zustand"; import { toast } from "@storyteller/ui-toaster"; -export type AspectRatio = "wide" | "tall" | "square";+// "auto" keeps the aspect ratio of the image being edited.+export type AspectRatio = "auto" | "wide" | "tall" | "square";  export interface PromptBox2DProps {   uploadImage?: UploadImageFn;@@ -106,6 +107,11 @@ export const PromptBox2D = ({   const enterToGenerate = useEnterToGenerateStore((s) => s.enabled);   const [showImagePrompts, setShowImagePrompts] = useState(false);   const [aspectRatioList, setAspectRatioList] = useState<PopoverItem[]>([+    {+      label: "Auto",+      selected: aspectRatio === "auto",+      icon: <SparklesIcon className="h-4 w-4" />,+    },     {       label: "Wide",       selected: aspectRatio === "wide",diff --git a/frontend/libs/components/promptbox/src/lib/promptStore.ts b/frontend/libs/components/promptbox/src/lib/promptStore.ts--- a/frontend/libs/components/promptbox/src/lib/promptStore.ts+++ b/frontend/libs/components/promptbox/src/lib/promptStore.ts@@ -32,17 +32,20 @@ export interface RefAudio {  // ----- 2D Prompt Box Store ----- type AspectRatio = "wide" | "tall" | "square";+// Editors can also keep the shape of the image being edited.+type EditAspectRatio = "auto" | AspectRatio; type Resolution = "1k" | "2k" | "4k";  export interface Prompt2DStore {+  sourceImageDimensions?: { width: number; height: number };   prompt: string;-  aspectRatio: AspectRatio;+  aspectRatio: EditAspectRatio;   resolution: Resolution;   useSystemPrompt: boolean;   referenceImages: RefImage[];   generationCount: number;   setPrompt: (prompt: string) => void;-  setAspectRatio: (ratio: AspectRatio) => void;+  setAspectRatio: (ratio: EditAspectRatio) => void;   setResolution: (resolution: Resolution) => void;   setUseSystemPrompt: (value: boolean) => void;   setReferenceImages: (images: RefImage[]) => void;@@ -51,7 +54,7 @@ export interface Prompt2DStore {  export const usePrompt2DStore = create<Prompt2DStore>()((set) => ({   prompt: "",-  aspectRatio: "wide",+  aspectRatio: "auto",   resolution: "1k",   useSystemPrompt: true,   referenceImages: [],@@ -285,8 +288,6 @@ export const usePromptAudioStore = create<PromptAudioStore>()((set) => ({ }));  // ----- Edit Prompt Box Store ------type EditAspectRatio = "auto" | "wide" | "tall" | "square";- interface PromptEditStore {   referenceImages: RefImage[];   aspectRatio: EditAspectRatio;diff --git a/frontend/libs/model-list/src/index.ts b/frontend/libs/model-list/src/index.ts--- a/frontend/libs/model-list/src/index.ts+++ b/frontend/libs/model-list/src/index.ts@@ -27,5 +27,6 @@ export * from "./lib/lists/Object3DModels.js"; export * from "./lib/loader/buildModelsFromListing.js";  export * from "./lib/classes/videoOptions.js";+export * from "./lib/classes/matchSourceAspectRatio.js";  export { imageModelForProvider } from "./lib/classes/imageModelForProvider.js";diff --git a/frontend/libs/model-list/src/lib/classes/matchSourceAspectRatio.spec.ts b/frontend/libs/model-list/src/lib/classes/matchSourceAspectRatio.spec.tsnew file mode 100644--- /dev/null+++ b/frontend/libs/model-list/src/lib/classes/matchSourceAspectRatio.spec.ts@@ -0,0 +1,202 @@+// @vitest-environment jsdom+import { describe, expect, it } from "vitest";+import { buildImageModelsFromListing } from "../loader/buildModelsFromListing.js";+import {+  autoAspectRatio,+  matchSourceAspectRatio,+} from "./matchSourceAspectRatio.js";+import { CommonAspectRatio as Ratio } from "./properties/CommonAspectRatio.js";++// Same lists the API serves for these models.+const NANO_BANANA_PRO = [+  Ratio.Auto,+  Ratio.Square,+  Ratio.WideFiveByFour,+  Ratio.WideFourByThree,+  Ratio.WideThreeByTwo,+  Ratio.WideSixteenByNine,+  Ratio.WideTwentyOneByNine,+  Ratio.TallFourByFive,+  Ratio.TallThreeByFour,+  Ratio.TallTwoByThree,+  Ratio.TallNineBySixteen,+];+const SEEDREAM_4 = [+  Ratio.Auto2k,+  Ratio.Auto4k,+  Ratio.Square,+  Ratio.SquareHd,+  Ratio.WideFourByThree,+  Ratio.WideSixteenByNine,+  Ratio.TallThreeByFour,+  Ratio.TallNineBySixteen,+];+const FLUX_PRO_1P1 = [+  Ratio.Square,+  Ratio.WideFourByThree,+  Ratio.WideSixteenByNine,+  Ratio.TallThreeByFour,+  Ratio.TallNineBySixteen,+];+const GPT_IMAGE_1P5 = [Ratio.Square, Ratio.Wide, Ratio.Tall];++describe("matchSourceAspectRatio", () => {+  describe("models with an auto option", () => {+    it.each([+      ["square", 1024, 1024],+      ["landscape", 1920, 1080],+      ["portrait", 1080, 1920],+      ["an unlisted shape", 2400, 1000],+    ])("asks for auto with a %s source", (_name, width, height) => {+      expect(matchSourceAspectRatio(model(NANO_BANANA_PRO), width, height)).toBe(+        Ratio.Auto,+      );+    });++    it("asks for auto when the source size is unknown", () => {+      expect(matchSourceAspectRatio(model(NANO_BANANA_PRO), 0, 0)).toBe(Ratio.Auto);+    });++    it("prefers plain auto over the auto variants that pick a resolution", () => {+      const options = [Ratio.Auto4k, Ratio.Auto2k, Ratio.Auto, Ratio.Square];+      expect(matchSourceAspectRatio(model(options), 1920, 1080)).toBe(Ratio.Auto);+    });++    it.each([+      [SEEDREAM_4, Ratio.Auto2k],+      [[Ratio.Auto4k, Ratio.Square], Ratio.Auto4k],+      [[Ratio.Square, Ratio.Auto3k, Ratio.Auto4k], Ratio.Auto3k],+    ])("falls back to the auto variant a model lists (%#)", (options, expected) => {+      expect(matchSourceAspectRatio(model(options), 1920, 1080)).toBe(expected);+    });+  });++  describe("models without an auto option", () => {+    it.each([+      // [options, width, height, expected]+      [FLUX_PRO_1P1, 1024, 1024, Ratio.Square],+      [FLUX_PRO_1P1, 1200, 900, Ratio.WideFourByThree],+      [FLUX_PRO_1P1, 900, 1200, Ratio.TallThreeByFour],+      [FLUX_PRO_1P1, 1920, 1080, Ratio.WideSixteenByNine],+      [FLUX_PRO_1P1, 1080, 1920, Ratio.TallNineBySixteen],+      // 3:2 sits between 4:3 and 16:9; 4:3 is nearer+      [FLUX_PRO_1P1, 3000, 2000, Ratio.WideFourByThree],+      [FLUX_PRO_1P1, 2000, 3000, Ratio.TallThreeByFour],+      // beyond the widest and tallest options+      [FLUX_PRO_1P1, 4000, 1000, Ratio.WideSixteenByNine],+      [FLUX_PRO_1P1, 1000, 4000, Ratio.TallNineBySixteen],+      // not quite square+      [FLUX_PRO_1P1, 1000, 1040, Ratio.Square],+      // shapes are compared as ratios, not differences: 1.16 is nearer 4:3 than 1:1+      [FLUX_PRO_1P1, 1160, 1000, Ratio.WideFourByThree],+      [FLUX_PRO_1P1, 1000, 1160, Ratio.TallThreeByFour],+    ])("picks the closest listed ratio (%#)", (options, width, height, expected) => {+      expect(matchSourceAspectRatio(model(options), width, height)).toBe(expected);+    });++    it("scales the source before comparing, so size does not matter", () => {+      const small = matchSourceAspectRatio(model(FLUX_PRO_1P1), 40, 30);+      const large = matchSourceAspectRatio(model(FLUX_PRO_1P1), 4000, 3000);+      expect(small).toBe(Ratio.WideFourByThree);+      expect(large).toBe(small);+    });++    it("breaks ties toward the option listed first", () => {+      expect(+        matchSourceAspectRatio(model([Ratio.Square, Ratio.SquareHd]), 512, 512),+      ).toBe(Ratio.Square);+      expect(+        matchSourceAspectRatio(model([Ratio.SquareHd, Ratio.Square]), 512, 512),+      ).toBe(Ratio.SquareHd);+    });++    it.each([+      [1024, 1024, Ratio.Square],+      [1920, 1080, Ratio.Wide],+      [1080, 1920, Ratio.Tall],+    ])("reads wide and tall as 3:2 and 2:3 (%ix%i)", (width, height, expected) => {+      expect(matchSourceAspectRatio(model(GPT_IMAGE_1P5), width, height)).toBe(+        expected,+      );+    });++    it("uses square, wide and tall for a model that lists no ratios", () => {+      const unlisted = model([]);+      expect(matchSourceAspectRatio(unlisted, 1000, 1000)).toBe(Ratio.Square);+      expect(matchSourceAspectRatio(unlisted, 1600, 900)).toBe(Ratio.Wide);+      expect(matchSourceAspectRatio(unlisted, 900, 1600)).toBe(Ratio.Tall);+    });++    it("skips options this build does not know", () => {+      const future = "future_aspect" as Ratio;+      expect(+        matchSourceAspectRatio(model([future, Ratio.WideFourByThree]), 1600, 900),+      ).toBe(Ratio.WideFourByThree);+      expect(matchSourceAspectRatio(model([future]), 1600, 900)).toBeUndefined();+    });++    it.each([+      [0, 1080],+      [1920, 0],+      [-1920, 1080],+      [Number.NaN, 1080],+      [Number.POSITIVE_INFINITY, 1080],+    ])("returns nothing for an unusable source size (%f x %f)", (width, height) => {+      expect(matchSourceAspectRatio(model(FLUX_PRO_1P1), width, height)).toBeUndefined();+    });+  });++  describe("models that cannot change aspect ratio", () => {+    it("leaves the choice to the model, even when ratios are listed", () => {+      const fixed = model(NANO_BANANA_PRO, { canChangeAspectRatio: false });+      expect(matchSourceAspectRatio(fixed, 1920, 1080)).toBeUndefined();+    });++    it("leaves the choice to the model when none is selected yet", () => {+      expect(matchSourceAspectRatio(undefined, 1920, 1080)).toBeUndefined();+    });+  });++  describe("models built from the API listing", () => {+    it.each([+      ["nano_banana_pro", ["auto", "square", "wide_sixteen_by_nine"], Ratio.Auto],+      ["seedream_4", ["auto_2k", "auto_4k", "square"], Ratio.Auto2k],+      ["flux_pro_1p1", ["square", "wide_four_by_three", "tall_three_by_four"], Ratio.WideFourByThree],+    ])("%s keeps the source shape using the ratios the API lists", (id, aspectRatioOptions, expected) => {+      const [hydrated] = buildImageModelsFromListing([], [+        { model: id, aspect_ratio_options: aspectRatioOptions },+      ]);+      expect(matchSourceAspectRatio(hydrated, 1200, 900)).toBe(expected);+    });++    it("sends nothing for a model the API gives no ratios", () => {+      const [hydrated] = buildImageModelsFromListing([], [{ model: "future_edit_model" }]);+      expect(matchSourceAspectRatio(hydrated, 1200, 900)).toBeUndefined();+    });+  });+});++describe("autoAspectRatio", () => {+  it.each([+    [NANO_BANANA_PRO, Ratio.Auto],+    [SEEDREAM_4, Ratio.Auto2k],+    [FLUX_PRO_1P1, undefined],+    [GPT_IMAGE_1P5, undefined],+  ])("finds the option that works the shape out from the source (%#)", (options, expected) => {+    expect(autoAspectRatio(model(options))).toBe(expected);+  });++  it("finds nothing for a model that cannot change aspect ratio", () => {+    expect(+      autoAspectRatio(model(NANO_BANANA_PRO, { canChangeAspectRatio: false })),+    ).toBeUndefined();+    expect(autoAspectRatio(undefined)).toBeUndefined();+  });+});++function model(+  aspectRatios: Ratio[],+  overrides: { canChangeAspectRatio?: boolean } = {},+) {+  return { canChangeAspectRatio: true, aspectRatios, ...overrides };+}diff --git a/frontend/libs/model-list/src/lib/classes/matchSourceAspectRatio.ts b/frontend/libs/model-list/src/lib/classes/matchSourceAspectRatio.tsnew file mode 100644--- /dev/null+++ b/frontend/libs/model-list/src/lib/classes/matchSourceAspectRatio.ts@@ -0,0 +1,110 @@+import type { ImageModel } from "./ImageModel.js";+import { CommonAspectRatio } from "./properties/CommonAspectRatio.js";++// Options that leave the shape to the model, which works it out from the input+// image. The `auto_*` variants also fix a resolution (Seedream), so plain+// `auto` wins when a model offers both.+const AUTO_ASPECT_RATIOS = [+  CommonAspectRatio.Auto,+  CommonAspectRatio.Auto2k,+  CommonAspectRatio.Auto3k,+  CommonAspectRatio.Auto4k,+];++// Models that can change aspect ratio but do not say which ones they accept.+// These are the values the image editor has always sent.+const LEGACY_ASPECT_RATIOS = [+  CommonAspectRatio.Square,+  CommonAspectRatio.Wide,+  CommonAspectRatio.Tall,+];++// width / height of every option that names a fixed shape. `auto*` and values+// from a newer server than this build have no entry, so they are never chosen+// by closeness.+const ASPECT_RATIO_SHAPES: Partial<Record<CommonAspectRatio, number>> = {+  [CommonAspectRatio.Square]: 1,+  [CommonAspectRatio.SquareHd]: 1,++  [CommonAspectRatio.WideFiveByFour]: 5 / 4,+  [CommonAspectRatio.WideFourByThree]: 4 / 3,+  [CommonAspectRatio.WideThreeByTwo]: 3 / 2,+  [CommonAspectRatio.WideSixteenByNine]: 16 / 9,+  [CommonAspectRatio.WideTwentyOneByNine]: 21 / 9,++  [CommonAspectRatio.TallFourByFive]: 4 / 5,+  [CommonAspectRatio.TallThreeByFour]: 3 / 4,+  [CommonAspectRatio.TallTwoByThree]: 2 / 3,+  [CommonAspectRatio.TallNineBySixteen]: 9 / 16,+  [CommonAspectRatio.TallNineByTwentyOne]: 9 / 21,++  // Imprecise labels whose exact shape varies by model (4:3 to 16:9); 3:2 and+  // 2:3 are the middle.+  [CommonAspectRatio.Wide]: 3 / 2,+  [CommonAspectRatio.Tall]: 2 / 3,+};++type ModelAspectRatios = Pick<ImageModel, "canChangeAspectRatio" | "aspectRatios">;++/**+ * The aspect ratio to request so an edit keeps the shape of its source image.+ *+ * That is the model's own auto option when it has one, since it reads the+ * source image itself. Otherwise it is the listed ratio closest to the+ * source's shape. Returns `undefined` when the model has no aspect ratio+ * control or lists nothing usable; leave the field out and the model decides.+ */+export function matchSourceAspectRatio(+  model: ModelAspectRatios | undefined,+  sourceWidth: number,+  sourceHeight: number,+): CommonAspectRatio | undefined {+  if (!model?.canChangeAspectRatio) return undefined;+  const options = listedOrLegacyAspectRatios(model);+  return (+    findAutoAspectRatio(options) ??+    closestAspectRatio(options, sourceWidth / sourceHeight)+  );+}++/** The model's "work it out from the input image" option, if it lists one. */+export function autoAspectRatio(+  model: ModelAspectRatios | undefined,+): CommonAspectRatio | undefined {+  if (!model?.canChangeAspectRatio) return undefined;+  return findAutoAspectRatio(listedOrLegacyAspectRatios(model));+}++function listedOrLegacyAspectRatios(+  model: ModelAspectRatios,+): readonly CommonAspectRatio[] {+  return model.aspectRatios.length > 0 ? model.aspectRatios : LEGACY_ASPECT_RATIOS;+}++function findAutoAspectRatio(+  options: readonly CommonAspectRatio[],+): CommonAspectRatio | undefined {+  return AUTO_ASPECT_RATIOS.find((ratio) => options.includes(ratio));+}++function closestAspectRatio(+  options: readonly CommonAspectRatio[],+  sourceShape: number,+): CommonAspectRatio | undefined {+  if (!Number.isFinite(sourceShape) || sourceShape <= 0) return undefined;++  let closest: CommonAspectRatio | undefined;+  let closestDistance = Infinity;+  for (const option of options) {+    const shape = ASPECT_RATIO_SHAPES[option];+    if (shape === undefined) continue;+    // Compare in log space so 2:1 and 1:2 are equally far from square.+    const distance = Math.abs(Math.log(sourceShape / shape));+    // Strictly closer: on a tie the option listed first wins.+    if (distance < closestDistance) {+      closest = option;+      closestDistance = distance;+    }+  }+  return closest;+}diff --git a/netlify.toml b/netlify.tomlnew file mode 100644--- /dev/null+++ b/netlify.toml@@ -0,0 +1,7 @@+# The `storyteller-docs` Netlify site still builds this repository, but its+# build command (./script/netlify_build_docs.sh) and the docs themselves now+# live in storytold/artcraft-services, so every deploy preview here fails.+# Skip those builds until the Netlify site is repointed or disconnected, then+# delete this file.+[build]+  ignore = "exit 0"