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 PostHogfaceted-photo-createflag. 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 workflowTerms
- 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) anddropdown(e.g. Model: likeness / realism / …). - Option — one selectable value of an axis (the Model axis has options
likenessandrealism). - 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.
- The family — and the resolved variant’s contract — is the single source of truth for what controls exist.
- 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.
- 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
WorkflowFamilymodel + theWorkflowFamilyMemberjoin 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)
- Go to Configs → Workflow Families → Create.
- Set the family key and display name.
- Add axes: each has a key, a display name, a control type (
toggleordropdown), options (for dropdowns), and a default value. - 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.