Skip to content

Generation Families

Generation Families

A family is one user-facing generation product (e.g. “Custom Image”) that groups several workflows behind one set of controls. The user sees one product and picks a few controls; the server uses the combination of those picks to resolve exactly one workflow to run.

Status: the authoring half (brain + fennec) shipped first. The consumer half is landing now — the custom photo page (/create-generate/photo) reads the catalog and builds its controls from it, behind the PostHog faceted-photo-create flag. Still gated; not yet on for end users.

The 30-second version

A family is one product the user sees. Behind it sit several workflows. The user picks a few controls (axes), and the server uses those picks to choose exactly one workflow to run — so adding another variant is data, not code.

sequenceDiagram
participant Fennec as Fennec (admin)
participant Brain
participant Brisket as Brisket (user)
participant Sirloin
Fennec->>Brain: Create family + axes; assign members with a full tuple
Note over Brain: Enforces the rules (full tuple, uniqueness, axis-in-use)
Brisket->>Sirloin: List catalog; render axes as controls
Brisket->>Sirloin: Generate(family, axis picks)
Note over Sirloin: Resolve: exact tuple match -> workflow
Sirloin->>Brain: Run the resolved workflow

Terms

  • Family — a single user-facing product that groups several workflows behind one set of controls. The user sees one thing; their choices route to the right workflow underneath.
  • Axis — one control the user picks. The combination of all axis picks selects the workflow, not any single axis alone. Two kinds today: toggle (e.g. 18+, on/off) and dropdown (e.g. Model: likeness / realism / …).
  • Option — one selectable value of an axis (the Model axis has options likeness and realism).
  • Tuple (called variant in code/API) — the set of axis values that tags a member, e.g. {Model: likeness, 18+: off}. It’s how a workflow says “I’m the one for this combination.”
  • Member — a workflow added to a family and tagged with a tuple. A family has many members, and a workflow can be a member of more than one family at the same time — each membership carries its own full tuple.

How a pick becomes a workflow (resolution)

Every member sets a value for every axis, and no two members share a tuple. So when the user submits their picks, the server (sirloin, at generate time) finds the one member whose tuple equals the pick exactly — a direct lookup that always lands on a single workflow. (Workflow name is the defensive final tie-break, but with unique full tuples there’s nothing to break.)

(brisket also runs a preview-only version of this for pricing; the authoritative match is server-side.)

How the controls get built (descriptors)

The design is centralised and contract-driven: there is one definition of a family and its controls, and every page reads from it instead of hand-coding its own form.

  1. The family — and the resolved variant’s contract — is the single source of truth for what controls exist.
  2. The app turns that contract plus the family’s axes into a list of small, typed descriptors, one per control. Each descriptor carries what kind of control it is (toggle, dropdown, aspect-ratio, number, text, character, media), its key, its options, its default, and its current value.
  3. A shared renderer reads those descriptors and, through a field-type → component registry, decides which component to render for each control and where to place it.

So a generation page assembles itself from the data — adding a new control type, or a whole new product, is mostly a matter of the descriptors, with no bespoke form code per page. The same engine (a useWorkflowForm-style hook producing descriptors, rendered by a shared controls layer) drives the dev demo page today and will drive the real photo/video pages later.

Reserved keys (brisket)

Most controls render generically from their descriptor through the field-type → component registry. Three keys are the exception: brisket gives them bespoke controls, so an author gets the right UI just by naming the key.

  • is_nsfw → the 18+ toggle. Gated behind KYC, and the rating always comes from the resolved workflow, never the toggle.
  • input_model → the model picker (“Choose your flavour”), docked in the prompt composer.
  • aspect_ratio → the ratio-pill format selector. Works whether the family models it as an axis (the pick re-resolves the workflow) or as a plain contract input.

Every other axis or input renders through the generic control with no page change. Fennec recognises the same three keys: type one as an axis key and it prefills the option values brisket expects (likeness / realism, the ratio list, 18+ / SFW).

The rules

  • A tuple must set every axis — no blanks. Each member pins one value per axis.
  • Each member’s tuple is unique within the family. A duplicate tuple is rejected (it would be ambiguous about which workflow to run).
  • The 18+ rating comes from the resolved workflow itself, never the client toggle — so the toggle can’t make an SFW workflow serve NSFW or vice-versa.
  • You can’t delete an axis (or option) a member still uses — re-tuple or unassign that member first. Brain blocks it; fennec surfaces a toast.
  • Each axis has a default value; together they form the family’s starting selection (kept SFW, not 18+).
  • A workflow can belong to several families at once — membership is many-to-many, stored per (workflow, family) with that family’s own full tuple. Removing it from one family leaves the others untouched; a family holds a given workflow at most once.

Availability (why a workflow can show as unavailable)

A member is only selectable if it’s a published workflow that actually produces media — its graph must expose an image or video output. A subworkflow, or a draft with no output mapping, has no media output, so it’s marked unavailable (“does not expose image or video outputs”). A family stays usable as long as at least one of its members is.

There’s a second way a member silently drops out: adding a new axis to a family makes every existing member incomplete until each is re-tupled to set a value for it. An unset axis matches no pick, so those members resolve to nothing and show unavailable (and the controls disable the options that lead nowhere). Re-tuple every member whenever you add an axis.

Who owns what

  • fennec (admin) — author families, axes, options, members. Entry: WorkflowFamiliesPage (Configs → Workflow Families) + the axes/members editors.
  • brain — stores families and the membership of each workflow (its per-family full tuple); enforces all the rules above. The WorkflowFamily model + the WorkflowFamilyMember join table (workflow name → family id, carrying the tuple) live here.
  • sirloin — at generate time, resolves the picked tuple → one workflow (ResolveVariant), and aggregates the generation catalog. (Ships with the consumer work.)
  • brisket — renders the axes as controls from the family’s contract and sends the pick. (Ships with the consumer work.)

Authoring a family (fennec)

  1. Go to Configs → Workflow FamiliesCreate.
  2. Set the family key and display name.
  3. Add axes: each has a key, a display name, a control type (toggle or dropdown), options (for dropdowns), and a default value.
  4. Assign members: for each workflow, pick a value for every axis via the dropdowns. The workflow must be published and produce media to be selectable.

The rules are enforced on save: an incomplete tuple, a duplicate tuple, or deleting an in-use axis are all rejected with a toast.