14 KiB
Generation Presets
Overview
Users can save named snapshots of their generation settings as presets and load them back into the GenerationForm. Presets are private to the user in v1 — there is no sharing, browsing, or copying between users.
Requirements
- A preset captures the entire generation-graph output at save time (all current node values). No field picker — save everything.
- Every preset is scoped to a single ecosystem (auto-detected from the form's current ecosystem at save time)
- When a user is in the GenerationForm, they see the list of presets that apply to their current ecosystem — this includes:
- Presets saved directly for that ecosystem
- Presets from other ecosystems whose resources are cross-ecosystem compatible (see Cross-Ecosystem Querying)
- On apply: the full preset values are loaded into the form. The graph handles ecosystem switching when the preset's checkpoint belongs to a different ecosystem — checkpoints always denote which ecosystem to use.
- On apply: resource availability is validated via
getGenerationData— unavailable resources are flagged to the user - Applying a preset overwrites the current generator panel values (plain replace; no prompt merge modes)
- All presets are private in v1 (no
publictoggle, no sharing, no copy) - Users can reorder their own presets
- Images/video input nodes are excluded from presets
- Unlimited presets per user
Database Schema
New GenerationPreset table:
model GenerationPreset {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String @db.VarChar(100)
description String? @db.VarChar(500)
// Scoping — every preset is owned by exactly one ecosystem
// Stored as the ecosystem key string (e.g., 'SDXL', 'Flux1') — matches how ecosystems
// are persisted elsewhere. No FK because ecosystems aren't a DB-bound table yet.
// Cross-ecosystem visibility is computed at query time via basemodel.constants helpers.
ecosystem String
// The full generation-graph output at save time. Applied via graph.set(values) on load.
// Resource refs store { id, strength? } — see "Resource reference shape" below.
// Images/video input nodes are excluded at save time.
values Json
// User's personal ordering
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([userId, ecosystem])
}
Requires a Prisma migration.
Implementation Plan
Default ecosystem preferences (preferred ecosystem per output type) are not part of this PR — they'll be a separate follow-up.
Phase 1: Schema & API
-
Create Prisma migration for
GenerationPresettable- Add relation to
Usermodel inschema.full.prisma
- Add relation to
-
Add tRPC router for preset CRUD
generationPreset.getForEcosystem({ ecosystem })— list presets applicable to a given ecosystem (owner's presets only, expanded via cross-ecosystem rules)generationPreset.getOwn— list current user's presets (all ecosystems)generationPreset.getById({ id })— get a single preset (owner only)generationPreset.create({ name, description?, values })— server readsecosystemfromvalues(guaranteed present in the graph snapshot) and writes it to the column; rejects if missinggenerationPreset.update({ id, name?, description?, values? })— edit own preset (same ecosystem-from-values rule whenvaluesis supplied)generationPreset.delete({ id })— delete own presetgenerationPreset.reorder({ orderedIds: number[] })— bulk-assignsortOrderby array index
Phase 2: Client State & UI
-
Create
generation-preset.store(Zustand)- Session-only; not persisted to localStorage
- State:
activePresetId: number | null,activePresetValues: Record<string, unknown> | null,isDirty: boolean - Actions:
loadPreset(preset),closePreset(),markClean()(called after a successful save/update) isDirtyis derived by subscribing to the graph store snapshot and diffing againstactivePresetValuesusing the shared comparison set (see Dirty State Detection)- Rationale: preset orchestration spans the graph store (snapshot source) and the panel store (UI chrome), so it gets its own store rather than being squeezed into either
-
Panel header: preset control
- A preset button sits next to the help/tour icon at the top of the generation panel
- Clicking it opens a dropdown showing:
- The user's existing presets for the current ecosystem (from
generationPreset.getForEcosystem) - A "Save current values as new preset" action at the bottom that opens the save modal via
dialogStore.trigger
- The user's existing presets for the current ecosystem (from
- Selecting an existing preset calls
loadPreset(which runsgraph.set(preset.values)and populates the store)
-
Active-preset indicator
- When
activePresetIdis set, show the preset name above the workflow selectors - Next to the name: Save icon, Save as icon, Close (X) icon
- When
isDirtyis true, show a dirty indicator alongside the name; the Save icon becomes actionable - Save (dirty): opens a
PopConfirm→ on confirm, callsgenerationPreset.update({ id: activePresetId, values: currentSnapshot })→markClean() - Save as (any state): opens the save modal (same one as the panel-header dropdown's "Save as new") via
dialogStore.trigger - Close (X): clears the store via
closePreset()— does not touch the form values
- When
-
Save modal
- Triggered via
dialogStore.trigger - Fields: name (required) and description (optional)
- On submit: calls
generationPreset.createwith the current snapshot, thenloadPreseton the returned row so the new preset is active
- Triggered via
-
Load preset behavior
graph.set(preset.values)applies the full saved state- If the preset includes a checkpoint from a different ecosystem, the graph's existing ecosystem-switch behavior handles the switch (checkpoints denote ecosystem)
- If any resources are unavailable, surface the same warning pattern remix uses via
getGenerationData
-
Manage presets UI
- List view of user's presets with reorder, rename, edit description, delete
- Accessible from the generation form (e.g., a "Manage presets" entry in the panel-header dropdown)
Authorization
Every route is owner-scoped in v1 — there are no public presets.
| Procedure | Who can call | Ownership check |
|---|---|---|
getForEcosystem |
authenticated user | implicit userId = ctx.user.id |
getOwn |
authenticated user | implicit userId = ctx.user.id |
getById |
authenticated user | preset.userId === ctx.user.id |
create |
authenticated user | row created with ctx.user.id |
update |
authenticated user | preset.userId === ctx.user.id |
delete |
authenticated user | preset.userId === ctx.user.id |
reorder |
authenticated user | all orderedIds belong to the user |
Key Integration Points
Saving — graph.getSnapshot()
The DataGraph's getSnapshot() returns all current node values. At save time we take the whole snapshot (minus the excluded keys) and send it to generationPreset.create:
const snapshot = graph.getSnapshot();
const values = Object.fromEntries(
Object.entries(snapshot).filter(([k]) => !PRESET_EXCLUDED_KEYS.has(k))
);
The excluded keys are shared between save and dirty-detection — see Dirty State Detection.
Applying — graph.set(values)
// graph.set accepts partial updates; inactive/unknown nodes are silently ignored.
// The graph handles ecosystem switching when the checkpoint belongs to a different ecosystem.
graph.set(preset.values);
Resource reference shape
Resources inside values store the minimum needed to re-hydrate plus the user-tunable strength:
{
model: { id: number }, // checkpoint
resources: Array<{ id: number; strength?: number }>, // LoRAs and similar
vae: { id: number } // optional
}
On apply, full resource metadata is re-resolved via getGenerationData — same pattern remix uses. If a resource is unavailable, the user sees the standard unavailable-resource warning.
Values validation at the API boundary
The values column is Json. We rely on the generation graph's existing per-node validation to handle malformed or out-of-range data on apply — invalid nodes are dropped, valid ones are applied. The tRPC input validates the outer shape (values is an object with expected top-level keys), not the full per-node schema.
Dirty State Detection
Dirty state drives the active-preset UI (whether the Save icon is actionable, whether a dirty indicator is shown). It's computed in generation-preset.store by subscribing to the graph store snapshot and diffing against activePresetValues.
Excluded keys (ignored on both save and diff — a change to these never marks the preset dirty):
images— input references, not part of a presetvideo— input references, not part of a presetpriority— ephemeral generation-panel preferenceoutputFormat— ephemeral generation-panel preferencequantity— ephemeral generation-panel preference- Any computed keys emitted by the generation graph (e.g., derived fields that aren't user-authored inputs)
Both save and dirty-check use the same PRESET_EXCLUDED_KEYS constant to keep the two in sync — if a value is excluded on save, a change to it must not mark the preset dirty.
Diff strategy: shallow-stable deep compare on the remaining keys. Arrays of resources are compared by id + strength (order-sensitive, since order can matter).
Cross-Ecosystem Querying
Presets are saved against a single ecosystem, but a preset can still be applied in a different ecosystem when its resources are cross-compatible (e.g., an SDXL LoRA can run inside Pony/Illustrious/NoobAI), or when it's a settings-only preset shared within the same family. The generation form should surface all such presets to the user.
Helpers in basemodel.constants.ts
The compatibility layer already exposes everything we need:
crossEcosystemRules— the rule table keyed by(sourceEcosystemId, targetEcosystemId, supportType, modelTypes?)getGenerationSupport(checkpointEcosystemId, addonEcosystemId, modelType)— returns'full' | 'partial' | nullareResourcesCompatible(ecosystemId, resources)— returnstrueif every resource is compatible with the target ecosystemgetResourceEcosystemCompatibility(ecosystemId, baseModel, modelType)— single-resource variantgetRootEcosystem(ecosystemId)— used to bound settings-only preset visibility to a shared family
Query flow for generationPreset.getForEcosystem
Given the user's current ecosystem (and filtered to userId = ctx.user.id):
- Direct matches — presets where
preset.ecosystem === currentEcosystem(no compatibility check needed) - Cross-compatible matches — presets where
preset.ecosystem !== currentEcosystem:- If
preset.valuescontains resource refs (model/resources/vae) → visible iffareResourcesCompatible(currentEcosystem, <extracted resources>)returnstrue - If
preset.valueshas no resource refs (settings / prompt only) → visible iff the two ecosystems share a root viagetRootEcosystem(keeps SDXL-family settings visible within the family but avoids SDXL settings leaking into Flux)
- If
Because values is a single JSON bag, the server extracts resource refs on the fly when running compatibility checks. Cross-compatibility is a sparse rule table (not a parent-chain inference — see comment at basemodel.constants.ts:3049), so it's cheap to expand the user's current ecosystem into a set of "source ecosystems whose resources can run here" and pre-filter candidates by that set before the per-preset check.
Design Decisions
Single-ecosystem storage with cross-ecosystem query expansion
A preset is owned by one ecosystem (ecosystem). Broader applicability is derived at query time via the cross-ecosystem rules rather than stored on the row. This keeps the row shape simple and guarantees we use the same compatibility rules everywhere (a preset's reach automatically widens when a new cross-ecosystem rule is added to basemodel.constants.ts).
Save the full graph, not a user-picked subset
We considered a field picker in the create modal. Dropping it keeps v1 scope minimal and matches user intent ("save what I have"). Users can still delete a preset and save a new one if they want a different shape.
Checkpoint carries ecosystem context on apply
Because a checkpoint always denotes an ecosystem, applying a preset whose model belongs to a different ecosystem triggers the graph's existing ecosystem-switch behavior. We don't need special "apply settings only" logic at the API layer — the graph owns that transition.
Private-only in v1
All presets are private. No public toggle, no sharing, no copy. This defers the content-moderation surface entirely and keeps the row shape minimal. Sharing (with or without prompts) can be added later as an additive change — the schema won't need to change to support read-only public rows.
Resource references store { id, strength? }
Storing just id (plus strength where applicable) keeps rows small and ensures that model metadata (availability, baseModel, permissions) is always re-resolved at apply time. This matches the remix flow.
@dev - in the client, we will show a save button next to the help icon that starts the tour at the top of the generation panel. This will open the modal to save the preset. When a preset is selected, we will show the preset name above the generation form workflow selectors. When the form values have diverged from the preset values, we will show something that indicates that the preset is dirty. There will be a "save" button as well as a "save as" button when the preset is dirty. Save will simply update the preset, while "save as" will opent the preset modal so that the user can enter a different name for the preset.