diff --git a/backend/alembic/versions/20261029_1200_flux_custom_size.py b/backend/alembic/versions/20261029_1200_flux_custom_size.py new file mode 100644 index 0000000..386a9b0 --- /dev/null +++ b/backend/alembic/versions/20261029_1200_flux_custom_size.py @@ -0,0 +1,76 @@ +"""let the seeded FLUX rows take a custom width × height + +FLUX's `image_size` is a named preset *or* `{width, height}`, and the presets stop at +1024 on the long side: half the width of a 1080p frame. `options.custom_size` lets a row +say it takes any size within limits, so Video Creator can ask for a picture big enough +for the video it is going into. + +The limits are deliberately below what fal's schema allows. FLUX.1 was trained up to +about two megapixels, and well past that it starts repeating subjects across the +picture; five million pixels reaches 1.5× a 1080p frame (2880×1620) without going far +beyond. An admin can widen or narrow them per row. + +A row whose options already carry `custom_size` (an admin set it) is left alone, and +the downgrade removes the key only where it still holds what this revision wrote. + +Revision ID: f3a7d2c18e54 +Revises: e4b2c9d17a35 +Create Date: 2026-10-29 12:00:00.000000+00:00 +""" +import json +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'f3a7d2c18e54' +down_revision: Union[str, None] = 'e4b2c9d17a35' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +ENDPOINTS = ("fal-ai/flux/schnell", "fal-ai/flux/dev") +CUSTOM_SIZE = {"max_width": 3072, "max_height": 3072, "multiple_of": 16, "max_pixels": 5_000_000} + + +def _rows(conn): + return conn.execute( + sa.text("SELECT id, options FROM generationmodel WHERE endpoint_id IN :endpoints") + .bindparams(sa.bindparam("endpoints", expanding=True)), + {"endpoints": list(ENDPOINTS)}, + ).fetchall() + + +def _decoded(text): + try: + value = json.loads(text or "{}") + except ValueError: + return None + return value if isinstance(value, dict) else None + + +def _write(conn, row_id, options): + conn.execute( + sa.text("UPDATE generationmodel SET options = :options WHERE id = :id"), + {"options": json.dumps(options), "id": row_id}, + ) + + +def upgrade() -> None: + conn = op.get_bind() + for row_id, text in _rows(conn): + options = _decoded(text) + if options is None or options.get("custom_size") is not None: + continue + _write(conn, row_id, {**options, "custom_size": CUSTOM_SIZE}) + + +def downgrade() -> None: + conn = op.get_bind() + for row_id, text in _rows(conn): + options = _decoded(text) + if options is None or options.get("custom_size") != CUSTOM_SIZE: + continue + options.pop("custom_size") + _write(conn, row_id, options) diff --git a/backend/app/generation/catalogue.py b/backend/app/generation/catalogue.py index bfc4db6..ecb0a1b 100644 --- a/backend/app/generation/catalogue.py +++ b/backend/app/generation/catalogue.py @@ -32,10 +32,19 @@ OPTION_FLAGS = ("supports_seed", "supports_negative_prompt", "supports_audio") MAX_OUTPUTS = 4 +# A model that takes any width × height rather than only named sizes (FLUX's +# `image_size: {width, height}`) says so with these limits; null means it does not. It +# exists so a caller sizing a picture for a video frame can ask for the size it needs, +# which no preset list reaches: FLUX's largest is 1024 on the long side. +CUSTOM_SIZE_LIMITS = ("max_width", "max_height", "multiple_of") +CUSTOM_SIZE_KEYS = (*CUSTOM_SIZE_LIMITS, "max_pixels") +MAX_MULTIPLE_OF = 64 + DEFAULT_OPTIONS: dict[str, Any] = { **{key: [] for key in OPTION_LISTS}, **{flag: False for flag in OPTION_FLAGS}, "max_outputs": 1, + "custom_size": None, } # An endpoint id becomes a URL path, so it is held to what fal's ids look like: at @@ -66,6 +75,10 @@ def options_of(row: GenerationModel) -> dict[str, Any]: options[flag] = stored[flag] if _is_int(stored.get("max_outputs")): options["max_outputs"] = stored["max_outputs"] + try: + options["custom_size"] = _custom_size(stored.get("custom_size")) + except InvalidEntry: + pass return options @@ -107,6 +120,8 @@ def normalise_options(value: Any) -> dict[str, Any]: if not _is_int(item): raise InvalidEntry("options.max_outputs must be a whole number") options[key] = item + elif key == "custom_size": + options[key] = _custom_size(item) else: raise InvalidEntry(f"options has no setting called {key!r}") return options @@ -178,6 +193,8 @@ def validate(entry: dict[str, Any]) -> dict[str, Any]: ) elif not 1 <= max_outputs <= MAX_OUTPUTS: raise InvalidEntry(f"options.max_outputs must be between 1 and {MAX_OUTPUTS}") + if kind == KIND_IMAGE_TO_VIDEO and options["custom_size"] is not None: + raise InvalidEntry("A video model takes no custom picture size: options.custom_size must be null") if not isinstance(entry.get("extra_params", {}), dict): raise InvalidEntry("extra_params must be an object") @@ -192,6 +209,31 @@ def validate(entry: dict[str, Any]) -> dict[str, Any]: return {**entry, "options": options} +def _custom_size(value: Any) -> Optional[dict[str, Any]]: + """`options.custom_size`, checked: null, or every limit a positive whole number. + + `max_pixels` may be null, for a model bounded only per side. The keys are all + required, since a missing `multiple_of` would leave a caller guessing what the model + rounds to. + """ + if value is None: + return None + if not isinstance(value, dict): + raise InvalidEntry("options.custom_size must be an object or null") + unknown = sorted(set(value) - set(CUSTOM_SIZE_KEYS)) + if unknown: + raise InvalidEntry(f"options.custom_size has no setting called {unknown[0]!r}") + for key in CUSTOM_SIZE_LIMITS: + if not _is_int(value.get(key)) or value[key] < 1: + raise InvalidEntry(f"options.custom_size.{key} must be a whole number above 0") + if value["multiple_of"] > MAX_MULTIPLE_OF: + raise InvalidEntry(f"options.custom_size.multiple_of must be at most {MAX_MULTIPLE_OF}") + max_pixels = value.get("max_pixels") + if max_pixels is not None and (not _is_int(max_pixels) or max_pixels < 1): + raise InvalidEntry("options.custom_size.max_pixels must be a whole number above 0, or null") + return {**{key: value[key] for key in CUSTOM_SIZE_LIMITS}, "max_pixels": max_pixels} + + def _is_scalar(value: Any) -> bool: return isinstance(value, (str, int, float)) and not isinstance(value, bool) diff --git a/backend/app/generation/request.py b/backend/app/generation/request.py index 6cc3a53..e9daf84 100644 --- a/backend/app/generation/request.py +++ b/backend/app/generation/request.py @@ -125,6 +125,12 @@ def build_parameters(row: GenerationModel, options: dict, params: dict) -> dict: ) body[param] = match + size = custom_size(row, options["custom_size"], params.get("width"), params.get("height")) + if size is not None: + if params.get("image_size") is not None: + raise InvalidGeneration("Ask for a named image size or a width and height, not both") + body["image_size"] = size + if params.get("seed") is not None: if not options["supports_seed"]: raise InvalidGeneration(f"{row.label} does not take a seed") @@ -159,6 +165,39 @@ def build_parameters(row: GenerationModel, options: dict, params: dict) -> dict: return without_app_fields(body, row.image_field, row.end_image_field) +def custom_size(row: GenerationModel, limits: Optional[dict], width: Any, height: Any) -> Optional[dict]: + """`{width, height}` for a model that takes any size, or None when none was asked. + + Each side is rounded *down* to the model's multiple rather than refused for being + off it: a caller sizing a picture for a 1080p frame should not have to know FLUX + counts in sixteens. Over a limit is refused, not shrunk, since a smaller picture + than asked is exactly what the caller is trying to avoid and it should say so. + """ + if width is None and height is None: + return None + if not limits: + raise InvalidGeneration(f"{row.label} does not take a custom size") + if not (_whole(width) and _whole(height)): + raise InvalidGeneration("A custom size needs both a width and a height, in whole pixels") + step = limits["multiple_of"] + width, height = width - width % step, height - height % step + if width < step or height < step: + raise InvalidGeneration(f"A custom size must be at least {step} pixels on each side") + if width > limits["max_width"] or height > limits["max_height"]: + raise InvalidGeneration( + f"{row.label} makes pictures up to {limits['max_width']}×{limits['max_height']}" + ) + if limits["max_pixels"] is not None and width * height > limits["max_pixels"]: + raise InvalidGeneration( + f"{width}×{height} is over {row.label}'s {limits['max_pixels']:,} pixels" + ) + return {"width": width, "height": height} + + +def _whole(value: Any) -> bool: + return isinstance(value, int) and not isinstance(value, bool) + + def without_app_fields(body: dict, *image_fields: Optional[str]) -> dict: """The parameters with the fields the app owns taken out. diff --git a/backend/app/models/generation.py b/backend/app/models/generation.py index 20778c1..8314ac4 100644 --- a/backend/app/models/generation.py +++ b/backend/app/models/generation.py @@ -69,7 +69,8 @@ class GenerationModel(SQLModel, table=True): # JSON: the exact values the endpoint accepts for each user-facing choice — # {"aspect_ratios", "image_sizes", "durations", "resolutions"} — passed through # verbatim so `"5"`, `"8s"` and `6` all reach fal as the type it expects; plus - # {"supports_seed", "supports_negative_prompt", "supports_audio", "max_outputs"}. + # {"supports_seed", "supports_negative_prompt", "supports_audio", "max_outputs"}, + # and "custom_size" (null, or the limits of a model that takes any width × height). # The form offers only what a row declares, and the server refuses anything else. options: str = Field(default="{}") # JSON: merged into every request body first, so a user's choices and the app's own diff --git a/backend/app/schemas_generate.py b/backend/app/schemas_generate.py index 9ecd53e..387dfe1 100644 --- a/backend/app/schemas_generate.py +++ b/backend/app/schemas_generate.py @@ -90,6 +90,10 @@ class GenerationParams(BaseModel): image_size: Any = None duration: Any = None resolution: Any = None + # A size in pixels for a model whose catalogue row has `custom_size`; sent to fal as + # `image_size: {width, height}`. Both or neither. + width: Any = None + height: Any = None seed: Optional[int] = None negative_prompt: Optional[str] = None generate_audio: Optional[bool] = None diff --git a/backend/tests/fal_stub.py b/backend/tests/fal_stub.py index 790a399..a07faea 100644 --- a/backend/tests/fal_stub.py +++ b/backend/tests/fal_stub.py @@ -210,6 +210,8 @@ def fal(monkeypatch): I2V = "fal-ai/kling-video/v2.5-turbo/pro/image-to-video" VEO = "fal-ai/veo3.1/fast/image-to-video" +CUSTOM_SIZE = {"max_width": 3072, "max_height": 3072, "multiple_of": 16, "max_pixels": 5_000_000} + def seed_models(session) -> dict: """Four catalogue rows, shaped like the seeded ones they are named after. @@ -227,6 +229,7 @@ def seed_models(session) -> dict: "image_sizes": ["square_hd", "landscape_16_9"], "supports_seed": True, "max_outputs": 4, + "custom_size": CUSTOM_SIZE, }), unit_price=0.025, price_unit="megapixel", price_currency="USD", ), diff --git a/backend/tests/test_generation_api.py b/backend/tests/test_generation_api.py index 742543b..d4d385e 100644 --- a/backend/tests/test_generation_api.py +++ b/backend/tests/test_generation_api.py @@ -242,6 +242,49 @@ def test_a_feature_the_model_lacks_is_refused(library, keyed, model, params): ) +def test_a_custom_size_is_sent_as_flux_names_it(library, session, keyed): + response = _post(library, { + "model_id": keyed[T2I].id, "prompt": PROMPT, "params": {"width": 2880, "height": 1616}, + }) + assert response.status_code == 202, response.text + + # Stored like any other choice, so a regeneration makes the same size again. + parameters = _request_of(session, response.json()["data"]["id"])["parameters"] + assert parameters["image_size"] == {"width": 2880, "height": 1616} + + +def test_a_custom_size_is_rounded_down_to_the_models_multiple(library, session, keyed): + response = _post(library, { + "model_id": keyed[T2I].id, "prompt": PROMPT, "params": {"width": 1921, "height": 1087}, + }) + assert response.status_code == 202, response.text + parameters = _request_of(session, response.json()["data"]["id"])["parameters"] + assert parameters["image_size"] == {"width": 1920, "height": 1072} + + +@pytest.mark.parametrize( + "model, params, fragment", + [ + (I2I, {"width": 1024, "height": 1024}, "does not take a custom size"), + (T2I, {"width": 2880}, "both a width and a height"), + (T2I, {"width": "2880", "height": 1620}, "both a width and a height"), + (T2I, {"width": True, "height": 1620}, "both a width and a height"), + (T2I, {"width": 8, "height": 1620}, "at least 16"), + (T2I, {"width": 4096, "height": 1024}, "up to 3072×3072"), + (T2I, {"width": 3072, "height": 3072}, "pixels"), + (T2I, {"width": 1024, "height": 576, "image_size": "landscape_16_9"}, "not both"), + ], +) +def test_a_custom_size_must_fit_the_model(library, keyed, model, params, fragment): + photo = upload(library, "sample_image.jpg", "image/jpeg") + bases = [photo["id"]] if model != T2I else [] + message = _refused( + _post(library, {"model_id": keyed[model].id, "prompt": PROMPT, "base_asset_ids": bases, "params": params}), + 422, "invalid_generation", + ) + assert fragment in message + + def test_an_empty_negative_prompt_is_not_a_request_for_one(library, keyed): response = _post(library, {"model_id": keyed[T2I].id, "prompt": PROMPT, "params": {"negative_prompt": ""}}) assert response.status_code == 202 diff --git a/backend/tests/test_generation_catalogue.py b/backend/tests/test_generation_catalogue.py index 259f4ce..f0e2708 100644 --- a/backend/tests/test_generation_catalogue.py +++ b/backend/tests/test_generation_catalogue.py @@ -13,7 +13,7 @@ from app.models.setting import UserSetting from app.models.user import User from app.settings_store import FAL_API_KEY, get_setting -from tests.fal_stub import I2I, I2V, T2I, TEST_USER, VEO, seed_models +from tests.fal_stub import CUSTOM_SIZE, I2I, I2V, T2I, TEST_USER, VEO, seed_models from tests.test_auth import make_token NEW = { @@ -82,6 +82,7 @@ def test_a_row_reads_with_every_option_present(auth_client, models): "supports_negative_prompt": False, "supports_audio": False, "max_outputs": 4, + "custom_size": CUSTOM_SIZE, } assert flux["extra_params"] == {} assert flux["image_field"] is None and flux["max_images"] == 0 @@ -90,6 +91,18 @@ def test_a_row_reads_with_every_option_present(auth_client, models): veo = next(m for m in body if m["endpoint_id"] == VEO) assert veo["extra_params"] == {"generate_audio": False} + assert veo["options"]["custom_size"] is None + + +def test_a_malformed_stored_custom_size_reads_as_none(auth_client, session, models): + models[T2I].options = json.dumps({"image_sizes": ["square_hd"], "custom_size": {"max_width": "big"}}) + session.add(models[T2I]) + session.commit() + + body = auth_client.get("/api/generate/models").json()["data"] + flux = next(m for m in body if m["endpoint_id"] == T2I) + assert flux["options"]["custom_size"] is None + assert flux["options"]["image_sizes"] == ["square_hd"] def test_inactive_rows_are_for_admins_only(auth_client, session, models, monkeypatch): @@ -176,6 +189,15 @@ def test_an_admin_adds_a_model(auth_client, session, admin): assert "fal-ai/flux/schnell" in listed +def test_an_admin_adds_a_model_that_takes_any_size(auth_client, admin): + unbounded = {**CUSTOM_SIZE, "max_pixels": None} + response = auth_client.post( + "/api/generate/models", json={**NEW, "options": {**NEW["options"], "custom_size": unbounded}} + ) + assert response.status_code == 201, response.text + assert response.json()["data"]["options"]["custom_size"] == unbounded + + def test_a_video_model_with_an_end_frame(auth_client, admin): response = auth_client.post("/api/generate/models", json=VIDEO) assert response.status_code == 201, response.text @@ -206,6 +228,13 @@ def test_a_duplicate_endpoint_is_a_conflict(auth_client, admin, models): ({"options": {"max_outputs": 5}}, "max_outputs"), ({"options": {"max_outputs": 0}}, "max_outputs"), ({"options": {"supports_seed": "yes"}}, "supports_seed"), + ({"options": {"custom_size": "big"}}, "custom_size must be an object"), + ({"options": {"custom_size": {**CUSTOM_SIZE, "max_depth": 3}}}, "max_depth"), + ({"options": {"custom_size": {**CUSTOM_SIZE, "max_width": 0}}}, "custom_size.max_width"), + ({"options": {"custom_size": {**CUSTOM_SIZE, "max_height": "3072"}}}, "custom_size.max_height"), + ({"options": {"custom_size": {"max_width": 3072, "max_height": 3072}}}, "custom_size.multiple_of"), + ({"options": {"custom_size": {**CUSTOM_SIZE, "multiple_of": 128}}}, "at most 64"), + ({"options": {"custom_size": {**CUSTOM_SIZE, "max_pixels": -1}}}, "custom_size.max_pixels"), ({"extra_params": ["generate_audio"]}, "extra_params must be an object"), ({"price_unit": "token"}, "price_unit"), ({"unit_price": -1}, "unit_price"), @@ -226,6 +255,7 @@ def test_an_invalid_text_to_image_entry_is_refused(auth_client, admin, change, f ({"max_images": 2}, "image_field_is_list"), ({"options": {"max_outputs": 2}}, "max_outputs must be 1"), ({"image_field": "image url"}, "image_field"), + ({"options": {"custom_size": CUSTOM_SIZE}}, "custom_size must be null"), ], ) def test_an_invalid_video_entry_is_refused(auth_client, admin, change, fragment): diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py index 632b47a..c81bd33 100644 --- a/backend/tests/test_migrations.py +++ b/backend/tests/test_migrations.py @@ -284,6 +284,9 @@ def test_the_generation_catalogue_is_seeded(alembic_config): veo = by_endpoint["fal-ai/veo3.1/fast/image-to-video"] assert json.loads(veo["extra_params"]) == {"generate_audio": False} assert json.loads(veo["options"])["durations"] == ["4s", "6s", "8s"] + for endpoint in ("fal-ai/flux/schnell", "fal-ai/flux/dev"): + assert json.loads(by_endpoint[endpoint]["options"])["custom_size"]["multiple_of"] == 16 + assert "custom_size" not in json.loads(by_endpoint["fal-ai/nano-banana-2"]["options"]) def test_the_generation_downgrade_survives_real_foreign_key_references(alembic_config): diff --git a/docs/m8-ai-generation.md b/docs/m8-ai-generation.md index 6b978c4..15db4fd 100644 --- a/docs/m8-ai-generation.md +++ b/docs/m8-ai-generation.md @@ -105,7 +105,11 @@ Rather than code per model, each catalogue row carries: - `options` — the *exact values* the endpoint accepts for each user-facing choice (`aspect_ratios`, `image_sizes`, `durations`, `resolutions`), passed through verbatim so `"5"` and `"8s"` and `6` all work, plus `supports_seed`, `supports_negative_prompt`, - `supports_audio` and `max_outputs`; + `supports_audio` and `max_outputs`, and `custom_size` — null, or + `{max_width, max_height, multiple_of, max_pixels}` for a model that takes any width × + height (FLUX's `image_size: {width, height}`; its named presets stop at 1024 px). Only + for image kinds. Seeded on the two FLUX rows so Video Creator can size a picture for + its video; - `extra_params` — defaults merged into every request body first (e.g. `{"generate_audio": false}` for Veo, whose audio default raises the price by half). @@ -270,8 +274,12 @@ alone. The key is never returned, masked or otherwise. "base_asset_ids": ["…"], "end_frame_asset_id": null, "params": {"aspect_ratio": null, "image_size": null, "duration": null, "resolution": null, "seed": null, "negative_prompt": null, - "generate_audio": null, "num_outputs": 1}} + "generate_audio": null, "num_outputs": 1, + "width": null, "height": null}} ``` +`width` and `height` are optional, both or neither, and only for a row with +`custom_size`. Each is rounded down to `multiple_of` and sent as +`image_size: {width, height}`, which is how it is stored in `ai_parameters` too. Checked at submit time, in this order: - `400 fal_key_missing` — no key stored. - `404 model_not_found`; `409 model_inactive`. @@ -282,7 +290,9 @@ Checked at submit time, in this order: - an end frame on a model without `end_image_field`; - a choice outside the row's declared list; - `seed` / `negative_prompt` / `generate_audio` on a model that does not declare it; - - `num_outputs` outside 1–`max_outputs`. + - `num_outputs` outside 1–`max_outputs`; + - `width`/`height` on a row without `custom_size`, only one of them, not whole + numbers, over a limit, or together with `image_size`. - `404 asset_not_found` for a base the user does not own. - `422 invalid_base` for a base that is not an image, owns no file, or is not readable. diff --git a/frontend/src/api/generate.ts b/frontend/src/api/generate.ts index 50c0ae6..ed6c71c 100644 --- a/frontend/src/api/generate.ts +++ b/frontend/src/api/generate.ts @@ -45,6 +45,16 @@ export interface GenerationOptions { supports_audio: boolean /** 1–4 for images; always 1 for video. */ max_outputs: number + /** Set on a model that takes any width × height (FLUX), within these limits. */ + custom_size: CustomSize | null +} + +/** Each side is rounded down to `multiple_of` by the server; `max_pixels` null is no cap. */ +export interface CustomSize { + max_width: number + max_height: number + multiple_of: number + max_pixels: number | null } export interface GenerationModel { @@ -94,6 +104,10 @@ export interface GenerationParams { negative_prompt: string | null generate_audio: boolean | null num_outputs: number + /** A size in pixels, for a model with `custom_size`. Both or neither. This form never + * sets them; Video Creator does, to size a picture for its video. */ + width?: number | null + height?: number | null } export interface GenerationCreate { diff --git a/frontend/src/components/GenerationSettings.tsx b/frontend/src/components/GenerationSettings.tsx index 32b445a..0082bbd 100644 --- a/frontend/src/components/GenerationSettings.tsx +++ b/frontend/src/components/GenerationSettings.tsx @@ -6,6 +6,7 @@ import { GENERATION_KINDS, generateApi, KIND_LABELS, + type CustomSize, type GenerationKind, type GenerationModel, type GenerationModelInput, @@ -160,6 +161,7 @@ interface ModelForm { supports_negative_prompt: boolean supports_audio: boolean max_outputs: string + custom_size: string extra_params: string unit_price: string price_unit: string @@ -187,6 +189,7 @@ const emptyForm = (): ModelForm => ({ supports_negative_prompt: false, supports_audio: false, max_outputs: '1', + custom_size: '', extra_params: '', unit_price: '', price_unit: '', @@ -241,6 +244,11 @@ function fromModel(model: GenerationModel): ModelForm { supports_negative_prompt: model.options.supports_negative_prompt, supports_audio: model.options.supports_audio, max_outputs: String(model.options.max_outputs), + // Kept as JSON text rather than four inputs: few models take it, and an edit to an + // unrelated field must send it back unchanged, not drop it. + custom_size: model.options.custom_size + ? JSON.stringify(model.options.custom_size) + : '', extra_params: Object.keys(model.extra_params).length > 0 ? JSON.stringify(model.extra_params, null, 2) @@ -294,6 +302,21 @@ function toInput(form: ModelForm): GenerationModelInput | string { return 'Maximum outputs must be between 1 and 4' } + let custom_size: CustomSize | null = null + if (form.custom_size.trim()) { + if (video) return 'A video model takes no custom picture size' + try { + const parsed: unknown = JSON.parse(form.custom_size) + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('not an object') + } + // The server checks each limit and names the one that is wrong. + custom_size = parsed as CustomSize + } catch { + return 'Custom size must be a JSON object, like {"max_width": 3072, "max_height": 3072, "multiple_of": 16, "max_pixels": 5000000}' + } + } + let extra_params: Record = {} if (form.extra_params.trim()) { try { @@ -334,6 +357,7 @@ function toInput(form: ModelForm): GenerationModelInput | string { supports_negative_prompt: form.supports_negative_prompt, supports_audio: form.supports_audio, max_outputs, + custom_size, }, extra_params, unit_price, @@ -700,6 +724,27 @@ function ModelEditor({ video ? 'Always 1 for video.' : '1 to 4.' )} + {!video && ( +
+ + set('custom_size', e.target.value)} + /> +

+ For a model that takes any width × height, sent as{' '} + image_size: {'{width, height}'}. Video Creator uses it to make + pictures big enough for its video. Leave blank if the model only takes named + sizes. +

+
+ )} +