feat: add inpaint region functionality and related tools

- Enhanced cursor behavior for new tools: semantic select, mask lasso, and mask rectangle.
- Updated mask edit state to include mask asset ID and kind.
- Implemented inpaint region commands for adding, applying, and removing inpaint regions.
- Introduced new operations for lasso and semantic selection tools.
- Created UI components for candidate review and inpaint region management.
- Added tests for inpaint region commands to ensure functionality.
- Updated various components to support new inpaint features and improve user experience.
This commit is contained in:
syntaxbullet
2026-07-11 16:41:22 +02:00
parent f4e13b80e7
commit ff762b8f17
78 changed files with 1632 additions and 301 deletions

View File

@@ -23,6 +23,7 @@ export type GenerationSetCompareModePayload = {
export type GenerationRemoveCandidatePayload = {
candidateId: GenerationCandidateId;
};
export type GenerationToggleCandidateFavoritePayload = { candidateId: GenerationCandidateId };
export type GenerationReuseCandidateSettingsPayload = {
candidateId: GenerationCandidateId;
@@ -41,6 +42,7 @@ export type GenerationReplaceCandidatePixelsPayload = {
};
export type GenerationStartJobPayload = { jobId: GenerationJobId; kind: GenerationJobKind; label: string; startedAt: number };
export type GenerationUpdateJobPayload = { jobId: GenerationJobId; progress: number; detail: string };
export type GenerationSucceedJobPayload = { jobId: GenerationJobId; finishedAt: number };
export type GenerationFailJobPayload = { jobId: GenerationJobId; finishedAt: number; error: string };
export type GenerationCancelJobPayload = { jobId: GenerationJobId; finishedAt: number };
@@ -57,7 +59,8 @@ export const generationAddCandidateCommand: Command<GenerationAddCandidatePayloa
name: "Add generation candidate",
history: { mode: "ignore" },
execute({ state }, payload) {
const candidates = retainCandidateBudget([payload.candidate, ...state.editor.generation.candidates.filter((candidate) => candidate.id !== payload.candidate.id)]);
const existing = state.editor.generation.candidates.filter((candidate) => candidate.id !== payload.candidate.id);
const candidates = retainCandidateBudget([payload.candidate, ...existing.filter((candidate) => candidate.favorite), ...existing.filter((candidate) => !candidate.favorite)]);
return {
...state,
editor: {
@@ -130,6 +133,25 @@ export const generationRemoveCandidateCommand: Command<GenerationRemoveCandidate
},
};
export const generationToggleCandidateFavoriteCommand: Command<GenerationToggleCandidateFavoritePayload> = {
id: commandIds.generationToggleCandidateFavorite,
name: "Favorite generation candidate",
history: { mode: "ignore" },
execute({ state }, payload) {
if (!state.editor.generation.candidates.some((candidate) => candidate.id === payload.candidateId)) return state;
return {
...state,
editor: {
...state.editor,
generation: {
...state.editor.generation,
candidates: state.editor.generation.candidates.map((candidate) => candidate.id === payload.candidateId ? { ...candidate, favorite: !candidate.favorite } : candidate),
},
},
};
},
};
export const generationClearCandidatesCommand: Command = {
id: commandIds.generationClearCandidates,
name: "Clear generation candidates",
@@ -260,6 +282,17 @@ export const generationStartJobCommand: Command<GenerationStartJobPayload> = {
},
};
export const generationUpdateJobCommand: Command<GenerationUpdateJobPayload> = {
id: commandIds.generationUpdateJob,
name: "Update generation progress",
history: { mode: "ignore" },
execute({ state }, payload) {
const job = state.editor.generation.jobs.find((candidate) => candidate.id === payload.jobId);
if (!job || job.status !== "running" || !Number.isFinite(payload.progress) || !payload.detail.trim()) return state;
return updateJobs(state, state.editor.generation.jobs.map((candidate) => candidate.id === payload.jobId ? { ...candidate, progress: Math.max(0, Math.min(1, payload.progress)), detail: payload.detail.trim() } : candidate));
},
};
export const generationSucceedJobCommand: Command<GenerationSucceedJobPayload> = {
id: commandIds.generationSucceedJob,
name: "Complete generation job",
@@ -322,11 +355,13 @@ export const generationCommands = [
generationSelectCandidateCommand,
generationSetCompareModeCommand,
generationRemoveCandidateCommand,
generationToggleCandidateFavoriteCommand,
generationClearCandidatesCommand,
generationReuseCandidateSettingsCommand,
generationApplyCandidateAsLayerCommand,
generationReplaceCandidatePixelsCommand,
generationStartJobCommand,
generationUpdateJobCommand,
generationSucceedJobCommand,
generationFailJobCommand,
generationCancelJobCommand,
@@ -364,7 +399,7 @@ function retainCandidateBudget(candidates: GenerationCandidate[]): GenerationCan
}
function candidateRetainedBytes(candidate: GenerationCandidate): number {
return [candidate.source, candidate.inputImage, candidate.maskImage, candidate.inpaint?.inputImage, candidate.inpaint?.maskImage]
return [candidate.source, candidate.inputImage, candidate.maskImage, candidate.blendMaskImage, candidate.inpaint?.inputImage, candidate.inpaint?.maskImage, candidate.inpaint?.editMaskImage, candidate.inpaint?.blendMaskImage]
.reduce((total, source) => total + (source?.length ?? 0) * 2, 0);
}
@@ -441,11 +476,14 @@ function generationProvenance(candidate: GenerationCandidate, acceptance: Genera
scheduler: candidate.settings.scheduler,
width: candidate.settings.width,
height: candidate.settings.height,
batchSize: candidate.settings.batchSize,
refinePass: candidate.settings.refinePass,
refineStrength: candidate.settings.refineStrength,
},
inpaint: candidate.inpaint
? {
targetLayerId: candidate.inpaint.targetLayerId,
maskLayerId: candidate.inpaint.maskLayerId,
regionId: candidate.inpaint.regionId,
sourceAssetId: candidate.inpaint.sourceAssetId,
maskAssetId: candidate.inpaint.maskAssetId,
crop: {
@@ -465,6 +503,11 @@ function generationProvenance(candidate: GenerationCandidate, acceptance: Genera
maskFeather: candidate.inpaint.backend.maskFeather,
maskExpand: candidate.inpaint.backend.maskExpand,
cropPadding: candidate.inpaint.backend.cropPadding,
profile: candidate.settings.inpaint.profile,
structureControl: candidate.settings.inpaint.structureControl,
controlStrength: candidate.settings.inpaint.controlStrength,
controlModel: candidate.settings.inpaint.controlModel,
colorMatch: candidate.settings.inpaint.colorMatch,
},
}
: undefined,