Skip to content

Character Dataset Editing

Character Dataset Editing

Purpose

Document how a user edits an existing character’s reference dataset images (the “Character photos” that define the character) after creation, through the Character Images modal in Brisket, and how Sirloin persists those edits.

A character’s dataset images are the reference photos every new generation is conditioned on. Before this flow they could only be set during onboarding; this flow lets a user add, switch, and remove them on a live character.

Scope And Gating

This flow is additive and feature-flagged. It does not replace or modify onboarding/creation — see Character Creation.

  • PostHog flag character-images-modal toggles it. The hook treats only an explicit true as enabled.
  • It renders only for RI (real-identity) charactersCharacterType.REAL and CharacterModelType.KITSUNE. With the flag on, the character settings “Character photos” row opens CharacterImagesModal; with it off, the legacy ImageEdit renders. VI (virtual-identity) and non-Kitsune characters never reach this branch and are unaffected.
  • All modal state lives in a scoped, resettable Jotai atom (characterImagesAtom, atomWithReset) that onboarding code never reads or writes, and is reset when the modal unmounts.

Participants

  • Brisket renders the modal, holds all edit state locally, and calls Sirloin through tRPC.
  • Sirloin owns the dataset image records, presigned URLs, validation scoring (via Brain), and persistence.
  • Brain scores each uploaded image during verification (face match, NSFW, demographics) — see Image Moderation And Age Scoring.
  • S3 stores the uploaded reference images.

Slot Model

A character’s dataset images are grouped into slots by image type (groupDatasetImagesBySlot). Each slot has one in-use hero (the image currently driving generation) plus any number of alternates.

  • The slot set depends on the character: NSFW characters use the nsfw Kitsune file types, everyone else the sfw set. The three displayed slots are labelled “Face & full chest area”, “Full body front”, and “Full body”.
  • Grouping is slot-precise: once an image is matched to a slot it is removed from the pool, so each image lands in exactly one slot. The first SELECTED image in a slot becomes the in-use hero; the rest become alternates.
  • A slot caps display at MAX_ALTERNATES (10) alternates.

Sequence

sequenceDiagram
participant User
participant Brisket as Brisket (modal)
participant Sirloin
participant Brain
participant S3
User->>Brisket: Open "Character photos" (RI + flag on)
Brisket->>Sirloin: getCharacter
Sirloin-->>Brisket: characterDatasetImages (deduped)
Brisket->>Brisket: groupDatasetImagesBySlot -> in-use hero + alternates
rect rgb(245,245,245)
note over User,S3: Add an alternate (optimistic)
User->>Brisket: Pick a file for a slot
Brisket->>Brisket: Local dedup (name+size), compress, show "checking" tile
Brisket->>Sirloin: getKitsuneUploadUrls
Sirloin-->>Brisket: presigned PUT url + path
Brisket->>S3: PUT file
Brisket->>Sirloin: verifyDatasetImage(path)
Sirloin->>Brain: score image
Brain-->>Sirloin: failureReasons / manualReviewEligible
Sirloin-->>Brisket: scored CharacterDatasetImage
Brisket->>Brisket: scoreVerifiedImage -> tile status
end
User->>Brisket: Switch an alternate to in-use (staged locally)
User->>Brisket: Tick consent, Save
Brisket->>Sirloin: updateCharacterReferenceDataset(changed slots only)
Sirloin-->>Brisket: ok
Brisket->>Brisket: invalidate getCharacter / getAllCharacters, close
  • apps/brisket/src/features/character-images-modal/index.tsx
  • apps/brisket/src/features/character-images-modal/components/modal-content/index.tsx
  • apps/brisket/src/features/character-images-modal/group-dataset-images.ts
  • apps/brisket/src/features/character-images-modal/hooks/use-add-image.ts
  • apps/brisket/src/features/character-images-modal/hooks/use-save-dataset.ts
  • apps/sirloin/internal/app/services/characters/listcharacters.go
  • apps/sirloin/internal/app/services/characters/deletereferencedatasetimage.go

Adding A Photo

Uploads are optimistic and per-slot (use-add-image.ts):

  1. Reject client-side duplicates (compared by file name + size) with a toast.
  2. Compress to a 512px preview; a compression failure lands an IMAGE_FAILED_TO_LOAD tile.
  3. Prepend a “checking” tile to the slot’s alternates immediately.
  4. Request a presigned upload URL (getKitsuneUploadUrls), PUT the file to S3, then verify (verifyDatasetImage).
  5. Map the verified result to a tile status (scoreVerifiedImage):
    • no failures → passed alternate (switchable to in-use);
    • exactly one FACE_NOT_MATCHING_REFERENCE failure and Sirloin marks it manual-review-eligible → review-eligible;
    • any other failure → failed tile (kept visible with its reasons);
    • missing path / failed verify → failed tile.

The in-use hero is never touched by an upload — new photos always arrive as alternates.

Switching The In-Use Photo

Promoting an alternate to in-use (promoteAlternateByPath) is staged locally only. The promoted alternate becomes the hero and the previous hero drops back into the alternates list. Nothing is sent to Sirloin until Save.

Saving

Saving is the only step that mutates the in-use selection server-side (use-save-dataset.ts).

  • Save is enabled only when all hold: consent checkbox ticked, there are changes vs. the loaded baseline, nothing is uploading, and no save is in flight.
  • Only changed slots are sent. Each becomes { imageType, pathFinal, pathRaw, validatedManually: false }. Sirloin infers in-use ordering from the newly-created reference rows, so sending only changed slots avoids duplicating untouched ones.
  • On success Brisket invalidates getCharacter and getAllCharacters and closes the modal; on failure it toasts and stays open.

Closing with unsaved changes (a pending switch, an in-flight upload, or an unsaved switchable alternate) raises an “unsaved changes” dialog. The modal cannot be closed mid-save.

Removing A Photo

Removal routes through a small decision tree (modal-content + use-remove-image.ts):

  • Save-before-delete guard — if the target is the slot’s baseline in-use image and the user has already switched the in-use to something else unsaved, deletion is blocked with a “Save your new photo first” dialog so the slot never loses its in-use image.
  • Immediate delete, no confirm — failed tiles (other than a face-mismatch) are dropped without a dialog.
  • Confirm dialog — everything else asks “Delete photo?” first.

The delete itself is optimistic: the tile disappears at once. A tile that was never scored server-side (the upload PUT failed, so it has no path) is dropped purely locally. Otherwise Brisket calls removeDatasetImage; on error it restores the tile and toasts.

Note: there is no timed “Undo” on delete in the shipped flow — removal is a confirm dialog plus optimistic delete with rollback on server error. (The original PR description mentioned a 5s Undo; that was replaced during review.)

Submitting For Manual Likeness Review

A photo that failed validation only on likeness (FACE_NOT_MATCHING_REFERENCE) and that Sirloin flags as manual-review-eligible can be submitted for human review (use-review-actions.ts). This is the NSFW likeness path.

  • The footer surfaces a single batch action for all review-eligible tiles in the modal.
  • Accepted submissions flip the tile to pending review and the dataset row to REVIEW.
  • Cancelling a review removes the row (removeDatasetImage).

Resolution (ops approve/reject, audit, promotion to SELECTED) is not part of this flow — see Contested Likeness Review.

Sirloin Behavior

Two server-side changes back this flow.

List-time dedup (listcharacters.go). ListCharacters now dedupes a character’s dataset images by an identity key of path|pathFinal, keeping the first occurrence. Because rows are returned newest-first, this stops the same image rendering twice when multiple rows exist for one path (for example a SELECTED row plus its SUPERSEDED predecessor after a switch). In plain terms: the modal sees one tile per real photo, not one per database row.

Immediate alternate delete (deletereferencedatasetimage.go). For a live character (status AVAILABLE), DeleteCharacterReferenceDatasetImage now applies the delete immediately rather than only discarding a manual-review attempt. This is what lets the modal remove an alternate and have it stick without forcing a Save. If the removed row was in REVIEW or REJECTED, the custom-VI creation source is reset when all rows are rejected.

Invariants

  • Only RI characters (REAL + KITSUNE) with the character-images-modal flag enabled reach this flow.
  • Onboarding/creation is never degraded: modal state is isolated in characterImagesAtom, and the two shared ImageUploadTips / ExamplePreview prop additions are default-preserving.
  • A slot never loses its in-use image as a side effect of editing (save-before-delete guard).
  • Switching the in-use photo is local until Save; Save requires explicit consent.
  • New uploads always arrive as alternates; the in-use hero only changes on an explicit switch or save.

Error Paths

  • Upload URL request, S3 PUT, or verify failure → the tile lands in a failed state and a toast explains it; the rest of the slot is unaffected.
  • Save failure → toast; the modal stays open with edits intact.
  • Delete failure → the optimistically-removed tile is restored and a toast is shown.
  • Review submission failure → toast; tiles are unchanged.

Tests And Verification

  • Brisket: pnpm --filter brisket exec vitest run src/features/character-images-modal
  • Sirloin: cd apps/sirloin && go test ./internal/app/services/characters/... (covers listcharacters dedup and deletereferencedatasetimage)
  • Manual: enable the character-images-modal flag, open an RI character’s “Character photos”, then exercise upload → switch → save and the delete/review paths.