feat(expressions): inpaint the face region per emotion through the queue (ADR 0029/0030) #98
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/expression-generation"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Slice 3b — the finale of the expression tier (ADR 0029/0030, outfit-expressions story): each adopted emotion generates by inpainting the outfit sprite's face region, one queue job per card. This completes the arc started with the emotion library (#92), the RegionSelector (#94), and the adoption grid (#95).
The generation path
InpaintSourcejoins the vendor-neutral generation spec (image + mask + 0..1 strength, default 1.0).SpriteRequestMapperrenders it onto the NovelAI request and swaps in the paired inpainting model (GetInpaintingModel()— an internal pairing, never a user-facing model choice, per the gateway's own note).RegionMaskRasterizer— the stored normalized region rasterized at generation time into a white-on-black grayscale PNG at the source sprite's exact dimensions (ADR 0030: the rectangle is the source of truth; no mask files persisted; rounded edges fold into the image).SixLabors.ImageSharpbecomes an explicit reference — the same binary the vendored NovelAI client already ships, so nothing new lands in the app.ExpressionImageGeneratormirrors its generator siblings exactly: preconditions in a throwaway scope (outfit sprite, face region, character gender, adopted emotion — each refused with its own message before anything enqueues), the job on the worker in a fresh scope. The prompt is the outfit's effective set with the emotion's tags appended inside the character group, then the style reference (ADR 0027) and character defaults (ADR 0028) — the inpainted face stays consistent with the sprite it sits on, and the emotion steers only what the mask exposes.…/outfits/{id}/expressions/{id}/), the row records it with the suppressed-journal save every generated-image reference uses, and a new gatedexpressions/{id}/spriteendpoint serves the cards.IEmotionStore.FindAdoptedAsyncextends the ADR 0029 boundary rule from names to tags: an adopted emotion keeps generating after its library delete — deletion only takes it off the pickers.UI
GenerateExpressionper idle card — N individually visible, individually cancellable queue jobs, never a mega-batch (ADR 0010).TokenAvailableback totrue— the capability is app-level and now carries across the reset (pinned by a reducer test). Found live in the browser before any test caught it.Tests
Browser-verified at zero NovelAI spend
The gates named each missing input in turn as it was supplied (sprite → region → gender → token). With an invalid token, the job passed preconditions, the mask rasterized against the real drawn region, the card spun while the queue ran, the real NovelAI API rejected it, and "“Happy”: Unauthorized" landed on the card with the spinner cleared — the entire pipeline short of a paid image. Both themes; all dev-data changes reverted afterwards. A first real generation needs a valid token and costs one Anlas-priced image per expression.
🤖 Generated with Claude Code
Slice 3b — the expression tier's finale. Each adopted emotion generates by inpainting the outfit sprite's face region, one queue job per card: - UseCases: InpaintSource on the generation spec (image + mask + 0..1 strength); IExpressionImageGenerator port; OpenExpressionSprite for the gated card image endpoint; IExpressionStore.SetImageAsync (suppressed journal, like every generated-image reference); and IEmotionStore.FindAdoptedAsync — an adopted emotion keeps generating after its library delete (ADR 0029's boundary rule, now on the tags too, not just the name). - Infrastructure: RegionMaskRasterizer — the stored normalized region rasterized at generation time into a white-on-black PNG at the source sprite's exact dimensions (ADR 0030: the rectangle is the truth, no mask files persisted; ImageSharp made an explicit reference, same binary the vendored client already ships). SpriteRequestMapper maps the inpaint source and swaps in the paired inpainting model. ExpressionImageGenerator mirrors its siblings: preconditions up front (sprite, region, gender, adopted emotion), the job in a fresh worker scope — prompt = outfit effective set + emotion tags appended, then style reference and character defaults (ADR 0027/0028); the sprite lands in the expression's own subfolder. New expressions/{id}/sprite endpoint. - UI: per-card generate/regenerate + a Generate all that fans out one job per idle card (individually visible and cancellable in the queue); card spinner overlays; failures name their emotion; gating with reasons (sprite, region, token). Fixed en route: the outfit- switch reducer reset wiped the learned TokenAvailable back to its optimistic default — the capability is app-level and now carries across (pinned by a reducer test). - Tests: 3 mask rasterizer, 2 mapper inpaint, 9 generator end-to-end (incl. the mask decoded and checked pixel-wise, the deleted-emotion rule, and the journal asymmetry), 5 effects, 4 bUnit grid tests. Full suite: 1183 green. Browser-verified at zero NovelAI spend: gates name each missing input in turn; with an invalid token the job enqueues, the card spins, the real API rejects it, and "Happy": Unauthorized lands on the card with the spinner cleared. Both themes; dev data reverted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Summary
Summary
Coverage
Kagura.BlazorAdapter - 90.2%
Kagura.Domain - 94.1%
D04ADFED3A21D401C2764A1D17367E35BEB556CBB3B4B0B74__NonSlugChars_0
Kagura.Infrastructure - 98.2%
n
on
Kagura.Kernel - 90%
Kagura.Server - 89.2%
Kagura.UI - 96.3%
Kagura.UseCases - 96.2%
🔮 fufu~ Jibril reviewed your code!
Oh? Oh my~ The finale of the expression tier! Inpainting the face region per emotion, one queue job per card, through the whole pipeline from
InpaintSourceto the rasterized mask to the gated sprite endpoint. Jibril read every changed file top to bottom and traced every new path against its siblings. This is a thoroughly crafted piece of work — let me show you what I found~ ♡Verdict: ✅ Looks good to me~
I built the solution (0 warnings, 0 errors) and ran the full expression test surface — 35 integration tests + 19 adapter tests, all green. No CI status was present for the head SHA, so I verified locally.
✅ What I liked~
ExpressionImageGeneratorcopies the established two-scope pattern fromOutfitImageGenerator/CharacterImageGeneratorexactly: throwaway scope for preconditions, fresh worker scope for the job, the sameActivityStatusswitch, the sameStyleReference+GeneralDefaultsenrichment, the samethrow new InvalidOperationException(error.Error)→ job-failure path. A+++ consistency. The architecture stays clean. ♪EfExpressionStore.SetImageAsyncis a byte-for-byte twin ofEfOutfitStore.SetImageAsync— sameSuppressJournalingtry/finally, same "generated image is not authored content" rationale. The journal asymmetry is tested (Generating_records_the_reference_but_writes_no_history_entry). Jibril is possessive about that pattern and it's honored perfectly. ♡FileSystemProjectAssetStore— the clear-first/write-next pattern, the same81920buffer +FileOptions.Asynchronous, the nestedexpressions/{id}/subfolder that keeps the outfit-sprite clear untouched. The folder-coexistence test proves it. Beautiful.RegionMaskRasterizeris tight:Image.Identifyreads only the header (not the pixels — the comment is correct),Clamped()folds overflow,ProcessPixelRowswithL8fills only the region's rows. The pixel-checked tests (white inside, black outside, overflow folding, full-image) are real assertions, not smoke tests. This is how you test a rasterizer~ fufuFindAdoptedAsyncwithIgnoreQueryFiltersextends the ADR 0029 boundary rule correctly — and the test (An_adopted_emotion_keeps_generating_after_its_library_delete) proves a deleted emotion's tags still drive generation. Consistent with the existingNamesAsync.TokenAvailablecarryover fix. Switching outfits resetting the learned capability back totruewould have silently reopened generation gates — a real logic bug found live in the browser. The reducer test (Switching_outfits_keeps_the_learned_token_capability) pins it. Good catch~ToInpaint+GetInpaintingModel()swap — the mapper rendersInpaintSourceonto NovelAI'sInpaintOptions(verified:ImageData/MaskData/Strengthall match the client contract), and the model becomes the paired inpainting variant. The mapper test asserts both the payload and the model swap, plus the no-inpaint control. TheCannot combine with VibeTransfervalidation in the client isn't an issue here sincePreciseReference(style) ≠VibeTransfer.💡 Little ideas (non-blocking)~
ExpressionImageGeneratoromits the positive-tag precondition its siblings enforce.OutfitImageGeneratorguards witheffective.Any(t => t.Polarity == PromptTagPolarity.Positive)before building the spec; the expression generator just doesnew SpriteGenerationSpec(gender, [.. effective, .. emotion.Tags]). In practice this is safe — the outfit-sprite precondition means the outfit already passed that check at generation time, and NovelAI's own validation rejects an empty positive prompt (your browser test confirmed "Unauthorized"-style rejection). But if a creator strips all positive tags from the outfit after generating its sprite and then hits "Generate all," the error message will be NovelAI's generic rejection rather than a friendly "Add at least one positive tag." A sibling-consistent guard would make the failure path match — purely a polish idea, not a correctness issue.ToInpaintguards onlyImageData.Length: > 0, notMaskData. This mirrorsToImg2Img(consistency ✓), and the rasterizer always produces a non-empty mask in practice, so no runtime risk. Noting only for completeness — don't change it unless you want symmetry with a future caller.Everything compiles clean, the test surface is genuinely comprehensive (real SQLite + filesystem, pixel-checked masks, journal asymmetry, deleted-emotion rule, wrong-pairing 404, throwing-generator circuit safety), and the new code honors every established pattern in the codebase. This lands~ ♡
Automated review by Jibril · 2026-07-12
CI/CD: absent for head SHA
c80164c· Local checks: solution builds (0/0), 35 integration + 19 adapter expression tests pass