feat: refine candidate handling in generation commands and update UI interactions
This commit is contained in:
@@ -36,7 +36,15 @@ describe("generation commands", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("applies candidates as top-level layers", () => {
|
test("applies candidates as top-level layers", () => {
|
||||||
const state = generationAddCandidateCommand.execute({ state: documentWithSourceLayer() }, { candidate: generationCandidate("candidate-1") });
|
const withRemainingCandidate = generationAddCandidateCommand.execute(
|
||||||
|
{ state: documentWithSourceLayer() },
|
||||||
|
{ candidate: generationCandidate("candidate-2") },
|
||||||
|
);
|
||||||
|
const withSelectedCandidate = generationAddCandidateCommand.execute(
|
||||||
|
{ state: withRemainingCandidate },
|
||||||
|
{ candidate: generationCandidate("candidate-1") },
|
||||||
|
);
|
||||||
|
const state = generationSetCompareModeCommand.execute({ state: withSelectedCandidate }, { mode: "split" });
|
||||||
|
|
||||||
const next = generationApplyCandidateAsLayerCommand.execute(
|
const next = generationApplyCandidateAsLayerCommand.execute(
|
||||||
{ state },
|
{ state },
|
||||||
@@ -52,11 +60,23 @@ describe("generation commands", () => {
|
|||||||
});
|
});
|
||||||
expect(next.document.artboards[0]?.layers[0]?.id).toBe("generated-layer");
|
expect(next.document.artboards[0]?.layers[0]?.id).toBe("generated-layer");
|
||||||
expect(next.editor.selection).toEqual({ artboardId: "a1", layerIds: ["generated-layer"] });
|
expect(next.editor.selection).toEqual({ artboardId: "a1", layerIds: ["generated-layer"] });
|
||||||
expect(next.editor.generation).toEqual({ candidates: [], selectedCandidateId: undefined, compareMode: "result" });
|
expect(next.editor.generation).toEqual({
|
||||||
|
candidates: [generationCandidate("candidate-2")],
|
||||||
|
selectedCandidateId: "candidate-2",
|
||||||
|
compareMode: "split",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("replaces source asset pixels for inpaint candidates", () => {
|
test("replaces source asset pixels for inpaint candidates", () => {
|
||||||
const state = generationAddCandidateCommand.execute({ state: documentWithSourceLayer() }, { candidate: generationCandidate("candidate-1", true) });
|
const withRemainingCandidate = generationAddCandidateCommand.execute(
|
||||||
|
{ state: documentWithSourceLayer() },
|
||||||
|
{ candidate: generationCandidate("candidate-2", true) },
|
||||||
|
);
|
||||||
|
const withSelectedCandidate = generationAddCandidateCommand.execute(
|
||||||
|
{ state: withRemainingCandidate },
|
||||||
|
{ candidate: generationCandidate("candidate-1", true) },
|
||||||
|
);
|
||||||
|
const state = generationSetCompareModeCommand.execute({ state: withSelectedCandidate }, { mode: "before" });
|
||||||
|
|
||||||
const next = generationReplaceCandidatePixelsCommand.execute(
|
const next = generationReplaceCandidatePixelsCommand.execute(
|
||||||
{ state },
|
{ state },
|
||||||
@@ -75,7 +95,11 @@ describe("generation commands", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(next.editor.selection).toEqual({ artboardId: "a1", layerIds: ["source-layer"] });
|
expect(next.editor.selection).toEqual({ artboardId: "a1", layerIds: ["source-layer"] });
|
||||||
expect(next.editor.generation).toEqual({ candidates: [], selectedCandidateId: undefined, compareMode: "result" });
|
expect(next.editor.generation).toEqual({
|
||||||
|
candidates: [generationCandidate("candidate-2", true)],
|
||||||
|
selectedCandidateId: "candidate-2",
|
||||||
|
compareMode: "before",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ export type GenerationApplyCandidateAsLayerPayload = {
|
|||||||
candidateId: string;
|
candidateId: string;
|
||||||
assetId: AssetId;
|
assetId: AssetId;
|
||||||
layerId: LayerId;
|
layerId: LayerId;
|
||||||
variant?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GenerationReplaceCandidatePixelsPayload = {
|
export type GenerationReplaceCandidatePixelsPayload = {
|
||||||
@@ -105,18 +104,13 @@ export const generationRemoveCandidateCommand: Command<GenerationRemoveCandidate
|
|||||||
name: "Remove generation candidate",
|
name: "Remove generation candidate",
|
||||||
history: { mode: "ignore" },
|
history: { mode: "ignore" },
|
||||||
execute({ state }, payload) {
|
execute({ state }, payload) {
|
||||||
const candidates = state.editor.generation.candidates.filter((candidate) => candidate.id !== payload.candidateId);
|
const generation = removeGenerationCandidate(state.editor.generation, payload.candidateId);
|
||||||
if (candidates.length === state.editor.generation.candidates.length) return state;
|
if (generation === state.editor.generation) return state;
|
||||||
const selectedCandidateId = state.editor.generation.selectedCandidateId === payload.candidateId ? candidates[0]?.id : state.editor.generation.selectedCandidateId;
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
editor: {
|
editor: {
|
||||||
...state.editor,
|
...state.editor,
|
||||||
generation: {
|
generation,
|
||||||
candidates,
|
|
||||||
selectedCandidateId,
|
|
||||||
compareMode: candidates.length > 0 ? state.editor.generation.compareMode : "result",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -149,16 +143,16 @@ export const generationApplyCandidateAsLayerCommand: Command<GenerationApplyCand
|
|||||||
|
|
||||||
const asset: Asset = {
|
const asset: Asset = {
|
||||||
id: payload.assetId,
|
id: payload.assetId,
|
||||||
name: payload.variant ? `${candidate.placement.layerName} variant` : candidate.placement.layerName,
|
name: candidate.placement.layerName,
|
||||||
mimeType: candidate.mimeType,
|
mimeType: candidate.mimeType,
|
||||||
source: candidate.source,
|
source: candidate.source,
|
||||||
intrinsicSize: { ...candidate.intrinsicSize },
|
intrinsicSize: { ...candidate.intrinsicSize },
|
||||||
provenance: generationProvenance(candidate, payload.variant ? "variant-layer" : "layer"),
|
provenance: generationProvenance(candidate, "layer"),
|
||||||
};
|
};
|
||||||
const layer: ImageLayer = {
|
const layer: ImageLayer = {
|
||||||
id: payload.layerId,
|
id: payload.layerId,
|
||||||
type: "image",
|
type: "image",
|
||||||
name: payload.variant ? `${candidate.placement.layerName} variant` : candidate.placement.layerName,
|
name: candidate.placement.layerName,
|
||||||
visible: true,
|
visible: true,
|
||||||
locked: false,
|
locked: false,
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
@@ -175,7 +169,7 @@ export const generationApplyCandidateAsLayerCommand: Command<GenerationApplyCand
|
|||||||
document: insertLayerAtTop({ ...state.document, assets: [...state.document.assets, asset] }, candidate.placement.artboardId, layer),
|
document: insertLayerAtTop({ ...state.document, assets: [...state.document.assets, asset] }, candidate.placement.artboardId, layer),
|
||||||
editor: {
|
editor: {
|
||||||
...state.editor,
|
...state.editor,
|
||||||
generation: clearCommittedGenerationPreview(state.editor.generation),
|
generation: removeGenerationCandidate(state.editor.generation, candidate.id),
|
||||||
selection: { artboardId: candidate.placement.artboardId, layerIds: [layer.id] },
|
selection: { artboardId: candidate.placement.artboardId, layerIds: [layer.id] },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -209,7 +203,7 @@ export const generationReplaceCandidatePixelsCommand: Command<GenerationReplaceC
|
|||||||
},
|
},
|
||||||
editor: {
|
editor: {
|
||||||
...state.editor,
|
...state.editor,
|
||||||
generation: clearCommittedGenerationPreview(state.editor.generation),
|
generation: removeGenerationCandidate(state.editor.generation, candidate.id),
|
||||||
selection: { artboardId: targetLayerLocation.artboardId, layerIds: [candidate.inpaint.targetLayerId] },
|
selection: { artboardId: targetLayerLocation.artboardId, layerIds: [candidate.inpaint.targetLayerId] },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -257,9 +251,23 @@ function findLayerInTree(layers: readonly Layer[], layerId: LayerId): Layer | un
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearCommittedGenerationPreview(generation: GenerationState): GenerationState {
|
function removeGenerationCandidate(generation: GenerationState, candidateId: string): GenerationState {
|
||||||
if (generation.candidates.length === 0 && !generation.selectedCandidateId && generation.compareMode === "result") return generation;
|
const removedIndex = generation.candidates.findIndex((candidate) => candidate.id === candidateId);
|
||||||
return { candidates: [], selectedCandidateId: undefined, compareMode: "result" };
|
if (removedIndex < 0) return generation;
|
||||||
|
|
||||||
|
const candidates = generation.candidates.filter((candidate) => candidate.id !== candidateId);
|
||||||
|
const selectionStillExists = generation.selectedCandidateId
|
||||||
|
? candidates.some((candidate) => candidate.id === generation.selectedCandidateId)
|
||||||
|
: false;
|
||||||
|
const selectedCandidateId = selectionStillExists
|
||||||
|
? generation.selectedCandidateId
|
||||||
|
: candidates[Math.min(removedIndex, candidates.length - 1)]?.id;
|
||||||
|
|
||||||
|
return {
|
||||||
|
candidates,
|
||||||
|
selectedCandidateId,
|
||||||
|
compareMode: candidates.length > 0 ? generation.compareMode : "result",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function generationProvenance(candidate: GenerationCandidate, acceptance: GeneratedAssetAcceptance): AssetGenerationProvenance {
|
function generationProvenance(candidate: GenerationCandidate, acceptance: GeneratedAssetAcceptance): AssetGenerationProvenance {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { AssetId, LayerId } from "./id";
|
|||||||
|
|
||||||
export type GeneratedAssetMode = "text-to-image" | "image-to-image" | "inpaint" | "outpaint";
|
export type GeneratedAssetMode = "text-to-image" | "image-to-image" | "inpaint" | "outpaint";
|
||||||
|
|
||||||
export type GeneratedAssetAcceptance = "layer" | "variant-layer" | "replacement";
|
export type GeneratedAssetAcceptance = "layer" | "replacement";
|
||||||
|
|
||||||
export type AssetGenerationProvenance = {
|
export type AssetGenerationProvenance = {
|
||||||
kind: "generated";
|
kind: "generated";
|
||||||
|
|||||||
@@ -107,22 +107,28 @@ These surfaces do not form a legible sequence. “Generate” is closer to a wor
|
|||||||
|
|
||||||
Redesign implication: generation should open an operation workspace with a clear input stage and a result stage. The active canvas interaction inside that workspace can still be select, pan, paint-mask, or transform.
|
Redesign implication: generation should open an operation workspace with a clear input stage and a result stage. The active canvas interaction inside that workspace can still be select, pan, paint-mask, or transform.
|
||||||
|
|
||||||
### P1 — accepting one candidate destroys the entire candidate session
|
### P1 — accepting one candidate destroys the entire candidate session — resolved 2026-07-09
|
||||||
|
|
||||||
Both “accept as layer” and “replace pixels” call `clearCommittedGenerationPreview`, which clears all candidates, not only the accepted candidate. This conflicts with the core use case of combining multiple model outputs. A user who generates several alternatives and accepts one loses the remaining comparison set.
|
Both “accept as layer” and “replace pixels” call `clearCommittedGenerationPreview`, which clears all candidates, not only the accepted candidate. This conflicts with the core use case of combining multiple model outputs. A user who generates several alternatives and accepts one loses the remaining comparison set.
|
||||||
|
|
||||||
Redesign implication: accepting a candidate should mark or remove only that candidate by default. The result tray should support keeping, pinning, multi-selecting, and clearing the session explicitly.
|
Redesign implication: accepting a candidate should mark or remove only that candidate by default. The result tray should support keeping, pinning, multi-selecting, and clearing the session explicitly.
|
||||||
|
|
||||||
### P1 — up to twelve candidates are stored but only six are selectable
|
Resolution: both layer acceptance and masked-pixel replacement now remove only the committed candidate, select the nearest remaining candidate, and preserve the active comparison mode while results remain.
|
||||||
|
|
||||||
|
### P1 — up to twelve candidates are stored but only six are selectable — resolved 2026-07-09
|
||||||
|
|
||||||
Generation state retains twelve candidates, while `CandidatePicker` renders only `candidates.slice(0, 6)`. Candidates seven through twelve have no visible selection path. This is a concrete interaction bug, not merely a styling concern.
|
Generation state retains twelve candidates, while `CandidatePicker` renders only `candidates.slice(0, 6)`. Candidates seven through twelve have no visible selection path. This is a concrete interaction bug, not merely a styling concern.
|
||||||
|
|
||||||
### P1 — “variant” is only a renamed ordinary layer
|
Resolution: the picker now renders the complete bounded candidate set in a horizontally scrollable group.
|
||||||
|
|
||||||
|
### P1 — “variant” is only a renamed ordinary layer — resolved 2026-07-09
|
||||||
|
|
||||||
“Accept variant” creates a standard top-level layer and records `variant-layer` provenance. There is no document-level variant set, linked source, stack semantics, exclusive visibility, or comparison group. The label promises more structure than the product provides.
|
“Accept variant” creates a standard top-level layer and records `variant-layer` provenance. There is no document-level variant set, linked source, stack semantics, exclusive visibility, or comparison group. The label promises more structure than the product provides.
|
||||||
|
|
||||||
Redesign implication: either call this “Add as another layer” or introduce a real variant/result-set concept. A result tray can provide variant semantics without forcing them into the document tree prematurely.
|
Redesign implication: either call this “Add as another layer” or introduce a real variant/result-set concept. A result tray can provide variant semantics without forcing them into the document tree prematurely.
|
||||||
|
|
||||||
|
Resolution: the duplicate variant action and `variant-layer` provenance value were removed. The UI now describes the real operation as “Add as layer”; refinement layers use the same honest layer acceptance semantics.
|
||||||
|
|
||||||
### P1 — generation preconditions are not represented clearly
|
### P1 — generation preconditions are not represented clearly
|
||||||
|
|
||||||
The Generate button only requires a non-empty prompt. Image-to-image can proceed without a selected source image. Inpaint configuration is available without explaining or enforcing the required source layer and mask. Outpaint and mode-specific settings coexist regardless of current mode.
|
The Generate button only requires a non-empty prompt. Image-to-image can proceed without a selected source image. Inpaint configuration is available without explaining or enforcing the required source layer and mask. Outpaint and mode-specific settings coexist regardless of current mode.
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ export function GenerateActionControls({ document, selection, viewport, settings
|
|||||||
function CandidatePicker({ generation, dispatch }: { generation: GenerationState; dispatch: AppStore["dispatch"] }) {
|
function CandidatePicker({ generation, dispatch }: { generation: GenerationState; dispatch: AppStore["dispatch"] }) {
|
||||||
if (generation.candidates.length < 2) return null;
|
if (generation.candidates.length < 2) return null;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1 rounded-full bg-white/[0.04] px-1.5 py-1 ring-1 ring-white/[0.05]">
|
<div className="subtle-scrollbar flex max-w-[min(28rem,calc(100vw-2rem))] items-center gap-1 overflow-x-auto rounded-full bg-white/[0.04] px-1.5 py-1 ring-1 ring-white/[0.05]" role="group" aria-label="Generation candidates">
|
||||||
{generation.candidates.slice(0, 6).map((candidate) => {
|
{generation.candidates.map((candidate) => {
|
||||||
const selected = candidate.id === (generation.selectedCandidateId ?? generation.candidates[0]?.id);
|
const selected = candidate.id === (generation.selectedCandidateId ?? generation.candidates[0]?.id);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -142,11 +142,11 @@ function CandidateControls({
|
|||||||
/>
|
/>
|
||||||
<CandidateButton disabled={disabled} label="Reuse seed" title="Regenerate with the same seed" busy={busy === "Reuse seed"} onClick={() => rerun("Reuse seed", { ...candidate.settings, seed: candidate.seed })} />
|
<CandidateButton disabled={disabled} label="Reuse seed" title="Regenerate with the same seed" busy={busy === "Reuse seed"} onClick={() => rerun("Reuse seed", { ...candidate.settings, seed: candidate.seed })} />
|
||||||
<CandidateButton disabled={disabled} label="New seed" title="Regenerate with a new seed" busy={busy === "New seed"} onClick={() => rerun("New seed", { ...candidate.settings, seed: -1 })} />
|
<CandidateButton disabled={disabled} label="New seed" title="Regenerate with a new seed" busy={busy === "New seed"} onClick={() => rerun("New seed", { ...candidate.settings, seed: -1 })} />
|
||||||
<CandidateButton disabled={disabled} label="Accept layer" title="Accept candidate as a normal layer" onClick={() => applyCandidateAsLayer(candidate, false, dispatch)} />
|
<CandidateButton disabled={disabled} label="Add as layer" title="Add candidate to the document as a layer" onClick={() => applyCandidateAsLayer(candidate, dispatch)} />
|
||||||
<CandidateButton
|
<CandidateButton
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
label="Accept + mask"
|
label="Add + mask"
|
||||||
title="Accept candidate as a layer with a fresh refinement mask"
|
title="Add candidate as a layer with a fresh refinement mask"
|
||||||
busy={busy === "Refine"}
|
busy={busy === "Refine"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setBusy("Refine");
|
setBusy("Refine");
|
||||||
@@ -156,10 +156,9 @@ function CandidateControls({
|
|||||||
.finally(() => setBusy(undefined));
|
.finally(() => setBusy(undefined));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<CandidateButton disabled={disabled} label="Accept variant" title="Stack candidate as another variant layer" onClick={() => applyCandidateAsLayer(candidate, true, dispatch)} />
|
|
||||||
<CandidateButton
|
<CandidateButton
|
||||||
disabled={disabled || !candidate.inpaint}
|
disabled={disabled || !candidate.inpaint}
|
||||||
label="Accept replace"
|
label="Replace pixels"
|
||||||
title={candidate.inpaint ? "Replace masked pixels and preserve unmasked pixels" : "Only inpaint candidates can replace masked pixels"}
|
title={candidate.inpaint ? "Replace masked pixels and preserve unmasked pixels" : "Only inpaint candidates can replace masked pixels"}
|
||||||
busy={busy === "Replace"}
|
busy={busy === "Replace"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -249,15 +248,15 @@ function selectedCandidate(generation: GenerationState): GenerationCandidate | u
|
|||||||
return generation.candidates.find((candidate) => candidate.id === generation.selectedCandidateId) ?? generation.candidates[0];
|
return generation.candidates.find((candidate) => candidate.id === generation.selectedCandidateId) ?? generation.candidates[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyCandidateAsLayer(candidate: GenerationCandidate, variant: boolean, dispatch: AppStore["dispatch"]) {
|
function applyCandidateAsLayer(candidate: GenerationCandidate, dispatch: AppStore["dispatch"]) {
|
||||||
applyCandidateAsLayerWithIds(candidate, { layerId: crypto.randomUUID(), assetId: crypto.randomUUID() }, variant, dispatch);
|
applyCandidateAsLayerWithIds(candidate, { layerId: crypto.randomUUID(), assetId: crypto.randomUUID() }, dispatch);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyCandidateAsRefinementLayer(candidate: GenerationCandidate, dispatch: AppStore["dispatch"]) {
|
async function applyCandidateAsRefinementLayer(candidate: GenerationCandidate, dispatch: AppStore["dispatch"]) {
|
||||||
const layerId = crypto.randomUUID();
|
const layerId = crypto.randomUUID();
|
||||||
const maskLayerId = crypto.randomUUID();
|
const maskLayerId = crypto.randomUUID();
|
||||||
const maskAssetId = crypto.randomUUID();
|
const maskAssetId = crypto.randomUUID();
|
||||||
applyCandidateAsLayerWithIds(candidate, { layerId, assetId: crypto.randomUUID() }, true, dispatch);
|
applyCandidateAsLayerWithIds(candidate, { layerId, assetId: crypto.randomUUID() }, dispatch);
|
||||||
const width = Math.max(1, Math.round(candidate.intrinsicSize.w));
|
const width = Math.max(1, Math.round(candidate.intrinsicSize.w));
|
||||||
const height = Math.max(1, Math.round(candidate.intrinsicSize.h));
|
const height = Math.max(1, Math.round(candidate.intrinsicSize.h));
|
||||||
const source = await createSolidMaskSource(width, height, "white");
|
const source = await createSolidMaskSource(width, height, "white");
|
||||||
@@ -289,12 +288,11 @@ async function applyCandidateAsRefinementLayer(candidate: GenerationCandidate, d
|
|||||||
dispatch(commandIds.toolSetActive, { tool: "eraser" });
|
dispatch(commandIds.toolSetActive, { tool: "eraser" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyCandidateAsLayerWithIds(candidate: GenerationCandidate, ids: { layerId: string; assetId: string }, variant: boolean, dispatch: AppStore["dispatch"]) {
|
function applyCandidateAsLayerWithIds(candidate: GenerationCandidate, ids: { layerId: string; assetId: string }, dispatch: AppStore["dispatch"]) {
|
||||||
dispatch(commandIds.generationApplyCandidateAsLayer, {
|
dispatch(commandIds.generationApplyCandidateAsLayer, {
|
||||||
candidateId: candidate.id,
|
candidateId: candidate.id,
|
||||||
assetId: ids.assetId,
|
assetId: ids.assetId,
|
||||||
layerId: ids.layerId,
|
layerId: ids.layerId,
|
||||||
variant,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user