Skip to content
C.W.K.
Lesson 03 of 06 · published

The Shape a Generator Can Draw

~13 min · aspect ratio, catalog, validation, references

Level 0Sketch
0 XP0/43 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

A vocabulary and some narrower rows

Ember's generator catalog carries a list of aspect ratios at the top level, each an id with the sentence Ember adds to the prompt. Today the vocabulary is four shapes: 1:1, 3:2, 4:3 and 16:9. A row may also carry its own aspect_ratios. ElevenLabs rows do: Ember intersects what the model declares with its own vocabulary, and a model that offers none of the four gets no row at all. A row without its own list offers the whole vocabulary.

A request in Avatars has exactly one aspect_ratio, 1:1 unless the request says otherwise, and it covers the whole batch. So when a batch asks three generators for 16:9, all three must be able to draw 16:9.

The bug that taught it

On 2026-09-30 the studio offered every shape for every generator, and plan() checked only the top-level vocabulary. Some ElevenLabs rows are narrower than that. The commit that fixed it names them: GPT Image 1 and 1.5 offer 1:1 and 3:2, and both Seedream rows lack 3:2. A 16:9 request to one of those passed the plan, the batch was created, the tasks were queued and submitted, and Ember failed each job when it ran, because Ember's ElevenLabs path checks the row's list at generation time. Every one of those failures was knowable before the first task existed.

That is the shape of most avoidable failures in a pipeline like this one. The fact needed to refuse was published by the owner all along; the client simply asked a coarser question than the one the owner could answer.

Refuse by name, before anything is queued

The fix is two small module-level functions in backend/studio.py. aspect_ids reads both shapes Ember uses (plain ids on a row, {id, prompt} entries in the vocabulary). require_aspect refuses a ratio a row's own list leaves out, and its message names the generator, the shape asked for and the shapes it does offer. The plan now checks three things in order:

  1. the ratio is in Ember's vocabulary at all (This aspect ratio is not available in Ember.);
  2. every selected generator's row offers it;
  3. the tier the automatic route resolved offers it too.

The third check is a second guard. Avatars already passes the ratio to Ember's route preview, which skips tiers that lack it; require_aspect holds the resolved row to its list anyway, so a route decision can never be the reason a bad shape gets through.

The test for this builds a catalog where one row, labelled Narrow Credits, offers only 1:1 and 3:2. A 16:9 request gets exactly Narrow Credits does not offer 16:9 output; it offers 1:1, 3:2. Choose one of those, or another generator. A second test sends the same body to the preview route, the submit route and Pippa's internal gateway, expects that same sentence as a 422 from all three, and then checks that the batch list is still empty. The studio and every brain see the same refusal because they reach the same function.

The picker never changes your choice

The web studio uses the same facts to help before the server has to refuse. narrowAspects tags a generator whose list is narrower than the vocabulary, so the row reads, for example, credits · 1:1, 3:2 only. aspectConflicts disables, in the aspect menu, every shape a selected generator lacks. If the chosen shape still conflicts (say Dad picked 16:9 first and then ticked a narrow generator), a warning names the generator and what it offers, and the Review button stays disabled until he changes the shape or deselects the generator.

What the studio never does is change the ratio for him. Substituting 3:2 for 16:9 would quietly produce a batch of the wrong shape for a thumbnail or a diary cover. The architecture doc says it in one line: it never changes the chosen aspect ratio itself. The automatic row carries no list of its own, since the tier is decided later, so the picker cannot predict that case; the server check covers it.

References: the model's limit, or none

The same rule applies to how many reference images a generator accepts. Until 2026-10-06 the plan had a fallback ceiling of ten for rows that declared nothing. That ceiling was removed: Avatars now uses only the limit a model declares. Ember's own wording is that a missing limit is unknown, not an invented application-wide ceiling. Today Ember declares 14 for Nano Banana Pro, 16 for the OpenAI rows, none for Codex, 10 for its local instruction family, and whatever each ElevenLabs model lists.

The check runs per task, because a task's inputs are the frame's mapped original (for remix and regenerate) plus the request's references, with duplicates removed. Over the limit, the plan refuses with the row's label, the limit and the actual count. A test keeps all 24 references for a model with no declared limit, then sets that model's limit to 14 and expects a refusal naming both numbers. Remix adds one more refusal of the same kind: a frame with no mapped original stops the plan with Remix needs an original mapped to and the frame's name, because the architecture rules that a missing mapping is shown before submission, never guessed from the default.

Code

cwkAvatars, backend/studio.py: read Ember's aspect lists and refuse by name (verbatim, lines 56-68)·python
def aspect_ids(entries):
    """Ember names aspects as plain IDs on a catalog row and as {id, prompt}
    entries in the catalog's vocabulary."""
    return [entry["id"] if isinstance(entry, dict) else entry for entry in entries or []]


def require_aspect(choice, aspect_ratio):
    """A catalog row that lists its own aspect ratios produces only those; a row
    without a list offers the whole vocabulary. Ember accepts such a job and
    fails it when it runs, so refuse here, by name, before anything is queued."""
    offered = aspect_ids(choice.get("aspect_ratios"))
    if offered and aspect_ratio not in offered:
        raise StudioError(f"{choice['label']} does not offer {aspect_ratio} output; it offers {', '.join(offered)}. Choose one of those, or another generator.")
cwkAvatars, frontend/src/domain.ts: the picker's two helpers (verbatim, lines 294-309)·typescript
/** Generators whose own aspect list leaves `ratio` out. A generator without a
 *  list offers the catalog's whole vocabulary, so it never conflicts. */
export const aspectConflicts = (generators: Provider[], ratio: string) =>
  generators.filter(
    (p) => !!p.aspect_ratios?.length && !p.aspect_ratios.includes(ratio),
  );
/** A generator's own aspect list, when it leaves out part of the vocabulary. */
export const narrowAspects = (
  provider: Provider,
  catalog: Catalog,
): string[] | null => {
  const own = provider.aspect_ratios;
  return own?.length && catalog.aspect_ratios.some((a) => !own.includes(a.id))
    ? own
    : null;
};
cwkAvatars, backend/studio.py: the per-task reference limit, only when the model declares one (verbatim, lines 439-442)·python
                    inputs = list({a["id"]: a for a in ([matched] if matched else []) + refs}.values())
                    reference_limit = choice.get("max_references")
                    if reference_limit is not None and len(inputs) > reference_limit:
                        raise StudioError(f"{choice['label']} accepts at most {reference_limit} references; {frame} has {len(inputs)}.")

External links

Exercise

Find a setting in your own software where the global list of allowed values is wider than what some backends accept: file formats, video resolutions, languages, regions. Write a server-side check that refuses a request when any selected backend lacks the value, with a message that names the backend, the value asked for and the values it offers. In the UI, disable the values a selected backend lacks and show a warning on conflict, but never change the user's chosen value. Write one test that proves the refusal leaves nothing queued.
Hint
Read each backend's own list from its owner at request time; a copied list is the bug waiting for the next model release. Treat a backend with no list as offering the whole vocabulary, and a missing numeric limit as unknown rather than as a default you invent.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.