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
76 changes: 76 additions & 0 deletions backend/alembic/versions/20261029_1200_flux_custom_size.py
Original file line number Diff line number Diff line change
@@ -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)
42 changes: 42 additions & 0 deletions backend/app/generation/catalogue.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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)

Expand Down
39 changes: 39 additions & 0 deletions backend/app/generation/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion backend/app/models/generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions backend/app/schemas_generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions backend/tests/fal_stub.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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",
),
Expand Down
43 changes: 43 additions & 0 deletions backend/tests/test_generation_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 31 additions & 1 deletion backend/tests/test_generation_catalogue.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"),
Expand All @@ -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):
Expand Down
3 changes: 3 additions & 0 deletions backend/tests/test_migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading
Loading