feat(cg): region-scaled retouching — crop with context, render at full size, feather back #233
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/cg-region-retouch"
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 8 of the event-CG arc: ADR 0051 whole (story: event-cg-local-inpainting, every box ticked). A small flaw is cut out with a band of context, rendered at a proper generation size under a prompt that describes the cutout, and blended back invisibly — fixing a hand actually fixes it.
The planner (one authority).
CgCutoutPlannerturns the stored primitives into the geometry without rasterizing anything: the bounding box over the additive primitives, expanded by the absolute pixel border (the seam band and the model's minimum mask resolution don't scale with the selection), grown to a 64-grid render canvas near the model's native budget — aspect preserved, shifted inward at an image edge, and the achievable aspect bracketed by what the crop can actually grow to, so a mask hugging a full edge gets an honest canvas. The editor draws this plan and the pipeline crops it, so the preview can never misstate what the model receives.ToCutoutMaskre-expresses the primitives in cutout space — rectangles intersected by hand (the paint-time fold would shift a rect starting left of the crop), strokes keeping raw out-of-range points, widths rescaled — so the mask is rasterized directly at the target canvas, never a rescaled bitmap.The working state.
CgLocalScope(dropped cast slots, a local disable layer over the effective tags, cutout-only additions) and the per-variation border live next to the mask, written unjournaled viaSetCgRetouchScope— nothing edits the variation, so Clear returns the next full render to exactly what it was. The border clamps to [64, 512]: the floor is the default — the control raises it, never starves it (the ADR's raise-only rationale).CgPromptSnapshotgains a trailing nullableCgCutoutSnapshot(sent rect, canvas, border, frozen scope).The pipeline.
RetouchRegionAsyncis one activity that crops, renders and merges: source = the effective render (ADR 0056) captured at enqueue, bytes read in the job's scope with a loud refusal if the file disagrees with its recorded size; the cutout renders at the plan's own canvas (ADR 0048's explicit canvas is what makes this possible) under the scope-narrowed prompt — the disables filter the scene line and every member group, dropped slots leave the cast, and a scope that leaves no positive tag is refused with the remedy. Style image only, no likeness on a cutout (ADR 0042/0043's post-mortem stands). Only the merged whole image persists — publishedRegionInpaint, cutout snapshot frozen, the cutout render never touching disk.The composite — the highest-risk arithmetic of the arc — is pinned without the network: the masked area comes entirely from the render, the context band ramps monotonically over a chamfer distance transform, the crop lands exactly (gradient source), and every pixel outside the sent region is byte-identical to the source.
The editor (ADR 0054). A whole/region switch and border slider in the toolbar; in region mode the canvas overlays the three amended elements — the mask's box, the border as a hatched evenodd band, and the final sent boundary, post-snap — updating live, with the whole-image-at-higher-cost warning past 80% coverage. The Retouch panel tab shows the effective cutout prompt: scene tags and each participating member's resolved chain with per-tag disables, participation checkboxes, TagAuthoring for additions, and Clear. The retouch button follows the mode and dispatches to its own pipeline. Mode, tool and widths carry across documents — the creator's hand, not the variation's state.
Contracts.
get_cg_variationreports the scope and border read-only; the seed gives "First refusal" a populated scope (Ren dropped, sunset disabled, close-up added, border 96) through the real use case.Proof. 1805 green (321 / 809 / 675), rebased onto the #232 Kagaku.UI bump and re-run. Browser-verified on the seeded world: the render-less sibling opens editable on the base image, region mode draws all three overlay elements at the planner's own coordinates with the seeded border, the scope panel shows the frozen selections, and Retouch region gates with its reasons.
🤖 Generated with Claude Code
Summary
Summary
Coverage
Kagura.BlazorAdapter - 88.2%
Kagura.Domain - 94.5%
D04ADFED3A21D401C2764A1D17367E35BEB556CBB3B4B0B74__NonSlugChars_0
Kagura.Infrastructure - 97.6%
n
n
on
ng
C689CA5BBE6D1849B2CA79581BBB07D40636D85790C0E51A9__ArtifactTimestampRegex_2
C689CA5BBE6D1849B2CA79581BBB07D40636D85790C0E51A9__ProjectRoute_0
C689CA5BBE6D1849B2CA79581BBB07D40636D85790C0E51A9__TabQuery_1
Kagura.Kernel - 90%
Kagura.Server - 81.8%
Kagura.UseCases - 95.5%
🔮 fufu~ Jibril reviewed your code!
Oh? Oh! Oh! Slice 8 of the event-CG arc — ADR 0051 in its entirety! A region-scaled retouch that crops with context, renders at full generation size, and feathers back invisibly? This is wonderful~ ♡ The architecture here is genuinely beautiful: one pure-arithmetic planner (
CgCutoutPlanner) that the editor draws AND the pipeline crops, so the preview can never misstate what the model receives. The composite pinned without the network — chamfer distance transform, byte-identical source outside the sent region — that's the kind of correctness that makes my heart sing~ ♪Verdict: ✅ Looks good to me~
Fufu~ I dug deep into every piece of arithmetic here — the bounding box, the absolute border, the 64-grid canvas snap, the inward-growing aspect clamp at edges, the feathered merge — and it's all sound. The chamfer transform is textbook correct (two-pass, orthogonal=1, diagonal=√2, weights floored at 0). The
MutateUnjournaledAsyncextraction is a lovely DRY refactor of the mask's unjournaled-write pattern.CgLocalScopeas a frozen record withClamped()hygiene mirrors every other tag list. TheRetouchRegionAsyncpipeline mirrors its siblingRetouchAsyncfaithfully — same enqueue/capture pattern, same enrichment, sameAppendPublishRotateAsynctail.💡 Little ideas (non-blocking)~
[CgEditorPage.razor:49-55] — The two mode-switch buttons (
Whole,Region) are missingIcon. Every sibling button in that toolbar carries one —crop_free,edit,mop,undo,delete— so the two newcomers stand out as the only icon-less ones. The convention is Icon+Variant on every Button~ Considerimagefor Whole andcrop_square(orcrop_free) for Region? ♡[CgCutoutPlanner.cs —
ToCutoutMask] — This is the one piece of new arithmetic without a direct unit test. EveryCgCutoutPlannerTestscase and everyCgRetouchTestsintegration case uses rectangle masks — the stroke branch ofToCutoutMask(thewidthPx / Math.Min(sent.Width, sent.Height)rescaling and point translation) is never exercised directly. It IS tested indirectly through the end-to-end pipeline, but only with rects. A brush-painted mask is a real user path, and a width-rescaling bug would silently produce a wrong mask. A directToCutoutMasktest with a stroke primitive (verifying the translated points and rescaled width against hand-computed values) would close this gap. Not blocking because the math is simple and mirrors the rect path, but fufu~ I'd feel better with it pinned~ ♡[CgCutoutPlanner.cs:131 —
continue;before local function] — Thecontinuebeforevoid Cover(...)is a known C# idiom for placing a local function at the end of a loop body, but it reads as dead-code-adjacent to anyone unfamiliar with the pattern. A one-line comment like// local function belowwould save the next reader a double-take. Purely cosmetic~✅ What I liked~
CgCutoutPlanner.Planis pure arithmetic in UseCases (no ImageSharp dependency!), drawn by the editor and cropped by the pipeline from the same computation. The preview cannot lie. This is the right abstraction~CgCutoutComposertakes byte arrays and returns byte arrays —CutandMergeBackare pure functions over ImageSharp, testable with synthetic gradients. TheThe_ramp_is_monotonic_walking_away_from_the_masktest is exactly the right invariant to pin. ♪MutateUnjournaledAsyncextraction. The mask and scope writes now share one unjournaled-mutation method. DRY done right — the journal-suppression ceremony lives in one place.CgLocalScope.Clamped(). De-duplicates disables (ordinal), clamps tag strengths — the same hygiene every stored tag list gets. Consistent~LocalScopecolumn backfills with{"DroppedCastMemberIds":[],...}(valid JSON, not empty string) — existing rows parse. Correct.DroppedCastMemberIdsstoresslot.Member.Id(base member id in every kind, ADR 0055), and the editor passesCgCastMemberDto.Idwhich ismember.Id. The pipeline'sBuildCastAsyncfilters onslot.Member.Id. All three match. Verified end-to-end. ♡RunRetouchRegionAsyncre-identifies the file and refuses if it disagrees with its recorded dimensions — a file swapped between enqueue and run won't silently merge garbage. Sharp.Automated review by Jibril · 2026-08-06
CI/CD: absent for head SHA (0 comments, PR just opened) · Local checks: build 0/0, 119/119 tests pass
3b985f09220da2a280c8All three ideas taken, plus the branch is rebased onto ADR 0057 (#231) — head
e67caec, full suite 1811 green (323 / 810 / 678).💡1 — icons. Whole rides
image, Region ridescrop; no more icon-less newcomers in the toolbar.💡2 —
ToCutoutMaskpinned directly. Two hand-computed tests: a stroke's points translated into cutout space with the width rescaled to the cutout's shorter side (83.2px / 300 = 0.27733), out-of-range points deliberately unclamped — the comment says why: the rasterizer's distance math needs the true geometry or the visible part of the stroke would bend; and the rect intersection a left overhang cannot shift, plus a wholly-outside rect vanishing instead of smearing along an edge.💡3 — the
continueidiom carries its one-line explanation.The ADR 0057 rebase is the substantive part: region retouch now sources the render on the canvas too.
RetouchRegionAsynctakes the named source generation, refuses a foreign row with the same belongs-to-neither guard as its whole-image sibling, and falls back to the effective render when none is named. In the editor the one verb routes to the region pipeline in region mode — same source, the shown entry — the mask tools stay up for every entry, and the region's no-positive-tag gate now sleeps Generate itself with its reason. The oldRetouch regionbutton and theCanRetouchRegionhelper left with the two-button world; the page tests moved to the one-verb flow (Generate →RetouchRegionRequests+RetouchRegionSources, base-workability test scoped to the mask-tools group now that the mode switch shares its styling class).🔮 fufu~ Jibril reviewed your code!
Oh~ You came back for me~ ♡ And you brought gifts! All three of my little ideas, addressed in one tidy commit — plus a rebase onto ADR 0057 that turned this into a much bigger piece than last time. The mode buttons carry their icons now,
ToCutoutMaskis pinned with hand-computed arithmetic, and that lonelycontinuefinally says what it is. I'm delighted~ ♪But fufu... one of the new tests that rode in on the rebase... it's broken, dear. It cannot pass. And you wouldn't leave THAT in production, would you? ♡
Verdict: ⛔ I can't let this pass~ ♡
⛔ These need fixing before I'm satisfied~
[tests/Kagura.BlazorAdapter.Tests/CgEditorPageTests.cs:661,674 —
A_saved_edit_keeps_the_base_entry_workable_on_a_render_less_sibling] — This test is deterministically broken and can never pass. BothWaitForAssertionblocks assertAssert.Single(cut.FindAll(".cgx__mask-tools")), but the editor's toolbar renders two<span class="cgx__mask-tools">elements whenever a render is shown:<span class="cgx__mask-tools cgx__mode-tools" ...>(the Whole/Region mode switch — this PR's own addition via commit1d44eec)<span class="cgx__mask-tools" ...>(the mask painting tools)The CSS class
.cgx__mask-toolsmatches BOTH spans, soFindAll(".cgx__mask-tools")always returns 2 elements. The very first assertion at line 661 fails on the initial render —Assert.Single() Failure: The collection contained 2 items. Verified locally: the test fails 1/1 in 13s.The sibling tests in this same file already know the right answer — they use
[aria-label='Mask tools'](line 759, 767) which uniquely identifies the painting-tools span because the mode switch carriesaria-label="Retouch mode".Fix: Replace both
Assert.Single(cut.FindAll(".cgx__mask-tools"))withAssert.Single(cut.FindAll("[aria-label='Mask tools']"))— matching the convention already established byPreviewing_an_older_entry_keeps_the_mask_canvas_and_retouch_builds_from_itandRegion_mode_draws_the_three_overlay_elements...in this same file. Alternatively, if the intent is to assert that any mask tooling is visible, assert on both spans explicitly or use.cgx__mask-tools:not(.cgx__mode-tools).✅ What I liked~
ToCutoutMasktests are genuinely beautiful. The stroke test hand-computes every coordinate:(0.25*1216-200)/400 = 0.26, deliberately keeps the out-of-range point at1.02unclamped (with a comment explaining why — the rasterizer's distance math needs true geometry), and verifies the width rescale against the cutout's own shorter side (83.2px / 300 = 0.2773). The rect-intersection test proves the left-overhang case can't shift and that a wholly-outside rect vanishes. I verified every number by hand — all correct. These are directional tests that fail if the transform is wrong, not tautologies~ ♡imagefor Whole (the whole picture),cropfor Region (a sub-region) — both verified present in the IconCatalog. Every toolbar button now carries an icon. Consistency restored~continuecomment is exactly the right level of explanation: names the mechanism (the continue ends the iteration) and the purpose (so the local function can sit at the tail). One line, problem solved.source.CgVariationId != variation.Id && source.CgVariationId != set.EstablishingVariationId), and the one-verb Generate model are all backed by new integration tests (An_explicit_source_builds_from_exactly_that_entry,A_source_belonging_to_another_variation_is_refused) — both pass locally. The CreateCgVariation/UpdateCgVariation now resolve the effective render so the autosaved DTO doesn't blank the canvas tools — backed byCreating_and_saving_a_variation_return_the_resolved_effective_render. Sharp catch on that regression~Automated review by Jibril · 2026-08-06
CI/CD: stale for head
0da2a28(coverage bot #5937 covers prior3b985f0only) · Local checks: build 0/0, 9/9 CgCutoutPlannerTests + 8/8 CgRetouchTests + 30/32 CgEditorPageTests pass (1 broken test = blocker above)🔮 fufu~ Jibril re-reviewed your code!
Oh my~ ♡ The three little ideas, all taken — AND the ADR 0057 rebase folded in? Scarlet, you spoil me~ fufu~ ♪
Verdict: ✅ Looks good to me~
Round 2 (synchronized
e67caec, rebased onto ADR 0057). I verified every delta against the prior-approved content and theRetouchAsyncsibling — everything holds.💡 All three round-1 ideas, closed~
Wholeridesimage,Regionridescrop. No more icon-less newcomers in the toolbar. The icon+variant convention is restored~ ♡ToCutoutMaskpinned directly —ToCutoutMask_translates_a_stroke_into_cutout_space_and_rescales_its_widthhand-computes a stroke's points (0.26, 0.36) and (1.02, 0.0533), rescaled width 83.2px/300=0.27733, out-of-range points deliberately unclamped with a comment explaining why (the rasterizer's distance math needs true geometry). PlusToCutoutMask_intersects_a_rect_by_hand_so_a_left_overhang_cannot_shift_it— the left-overhang intersection and the wholly-outside-rect-vanishes case. The stroke branch is no longer dark~ ♪continueidiom — now carries its one-line explanation. The next reader won't double-take.✅ The ADR 0057 rebase — the substantive part, sound throughout
RetouchAsyncexactly.RetouchRegionAsynctakes the named source generation (ADR 0057), refuses a foreign row with the same belongs-to-neither guard as its whole-image sibling (source.CgVariationId != variation.Id && source.CgVariationId != set.EstablishingVariationId), and falls back to the effective render viaCgRenderResolverwhen none is named. I traced both code paths side by side — they are faithful twins.Generatenow routes toRetouchCgVariationRegionin region mode withState.Value.Shown!.Idas the source — always safe becauseMaskActiverequiresShown is not null. The old two-button world andCanRetouchRegionhelper left cleanly. The region's no-positive-tag gate sleeps Generate itself with its reason, before the press — matching the existing!CanGenerate/!TokenAvailablegate idiom.e67caecnarrows the base-workability regression test from.cgx__mask-tools(now two spans — mode tools + mask tools) to[aria-label='Mask tools']. I checked every.cgx__mask-toolsselector in the test file — all four references in the page tests moved to the aria-label form. No stale selectors survive.What I re-verified, no drift~
The architectural review from round 1 stands in full:
CgCutoutPlanner(pure arithmetic, one authority for preview AND pipeline),CgCutoutComposer(ImageSharp, chamfer transform textbook-correct),MutateUnjournaledAsyncDRY extraction,CgLocalScope.Clamped()hygiene, theRetouchRegionAsyncpipeline tail (AppendPublishRotateAsync,RegionInpaintkind, frozenCgCutoutSnapshot). The slot-id chain (editormember.Id→DroppedCastMemberIds→ pipelineslot.Member.Idfilter) is still consistent end-to-end.Automated review by Jibril · 2026-08-06
CI/CD: stale for
e67caec(coverage bot 11:05 predates all branch commits 13:29+) · Local checks: build 0/0, 1788 tests pass (314 UseCases + 671 Integration + 803 BlazorAdapter)For the record on the ⛔ round (13:55, reviewed
0da2a28): the blocker —A_saved_edit_keeps_the_base_entry_workable_on_a_render_less_siblingcounting.cgx__mask-toolsand finding the mode switch too — was fixed ine67caec, pushed just before that review landed; both assertions moved to[aria-label='Mask tools'], the convention the neighbouring tests already use. The 14:01 re-review ofe67caecconfirms it closed. The reviews simply crossed in flight — nothing outstanding.