feat: implement resolveGeneratedOutputPlacement function and add tests for output placement logic
This commit is contained in:
94
operations/generation/outputPlacement.test.ts
Normal file
94
operations/generation/outputPlacement.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { GenerateSettings } from "@editor/tools";
|
||||
import { createInitialAppState } from "@editor/initial-state";
|
||||
import { resolveGeneratedOutputPlacement } from "./outputPlacement";
|
||||
|
||||
describe("generated output placement", () => {
|
||||
test("centers text-to-image output in the active artboard", () => {
|
||||
const placement = resolveGeneratedOutputPlacement({
|
||||
document: document(),
|
||||
selection: { artboardId: "artboard", layerIds: [] },
|
||||
settings: settings("text-to-image"),
|
||||
intrinsicSize: { w: 400, h: 200 },
|
||||
});
|
||||
|
||||
expect(placement).toEqual({
|
||||
artboardId: "artboard",
|
||||
layerName: "Generated image",
|
||||
transform: { position: { x: 310, y: 270 }, scale: { x: 1, y: 1 }, rotation: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
test("matches image-to-image output to the selected layer bounds", () => {
|
||||
const placement = resolveGeneratedOutputPlacement({
|
||||
document: document(),
|
||||
selection: selected(),
|
||||
settings: settings("image-to-image"),
|
||||
intrinsicSize: { w: 200, h: 250 },
|
||||
});
|
||||
|
||||
expect(placement.transform).toEqual({
|
||||
position: { x: 80, y: 90 },
|
||||
scale: { x: 0.75, y: 1 },
|
||||
rotation: 12,
|
||||
});
|
||||
});
|
||||
|
||||
test("places an inpaint crop at its recorded document bounds", () => {
|
||||
const placement = resolveGeneratedOutputPlacement({
|
||||
document: document(),
|
||||
selection: selected(),
|
||||
settings: settings("inpaint"),
|
||||
intrinsicSize: { w: 128, h: 64 },
|
||||
inpaintBundle: {
|
||||
inputImage: "input", maskImage: "mask", width: 256, height: 128,
|
||||
targetLayerId: "source", maskLayerId: "mask", sourceAssetId: "source-asset", maskAssetId: "mask-asset",
|
||||
crop: { assetBounds: { x: 0, y: 0, w: 256, h: 128 }, documentBounds: { x: 140, y: 150, w: 384, h: 64 }, padding: 16, maskedAreaOnly: true },
|
||||
mask: { polarity: "hidden", activeBounds: { x: 20, y: 20, w: 40, h: 40 } },
|
||||
placement: { artboardId: "artboard", layerName: "Source inpaint", transform: { position: { x: 140, y: 150 }, scale: { x: 1.5, y: 0.5 }, rotation: 12 } },
|
||||
backend: { growMaskBy: 6, maskedContent: "neutral", maskBlur: 0, maskFeather: 0, maskExpand: 0, cropPadding: 16 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(placement.transform).toEqual({ position: { x: 140, y: 150 }, scale: { x: 3, y: 1 }, rotation: 12 });
|
||||
});
|
||||
|
||||
test("extends outpaint output left and top while preserving source pixel scale", () => {
|
||||
const outpaint = { ...settings("outpaint"), outpaint: { left: 40, top: 20, right: 10, bottom: 30, feathering: 8 } };
|
||||
const placement = resolveGeneratedOutputPlacement({
|
||||
document: document(),
|
||||
selection: selected(),
|
||||
settings: outpaint,
|
||||
intrinsicSize: { w: 150, h: 550 },
|
||||
});
|
||||
|
||||
expect(placement.transform).toEqual({
|
||||
position: { x: 20, y: 80 },
|
||||
scale: { x: 1.5, y: 0.5 },
|
||||
rotation: 12,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function settings(mode: GenerateSettings["mode"]): GenerateSettings {
|
||||
return { ...createInitialAppState("Test").editor.tools.generate, mode };
|
||||
}
|
||||
|
||||
function selected() {
|
||||
return { artboardId: "artboard", layerIds: ["source"] };
|
||||
}
|
||||
|
||||
function document(): ImageDocument {
|
||||
return {
|
||||
id: "document", name: "Test", version: 1,
|
||||
assets: [
|
||||
{ id: "source-asset", name: "Source", mimeType: "image/png", source: "source", intrinsicSize: { w: 100, h: 500 } },
|
||||
{ id: "mask-asset", name: "Mask", mimeType: "image/png", source: "mask", intrinsicSize: { w: 100, h: 500 } },
|
||||
],
|
||||
artboards: [{
|
||||
id: "artboard", name: "Artboard", bounds: { x: 10, y: 20, w: 1000, h: 700 }, backgroundColor: "transparent", visible: true, locked: false,
|
||||
layers: [{ id: "source", type: "raster", name: "Source", visible: true, locked: false, opacity: 1, assetId: "source-asset", transform: { position: { x: 80, y: 90 }, scale: { x: 1.5, y: 0.5 }, rotation: 12 } }],
|
||||
}],
|
||||
};
|
||||
}
|
||||
105
operations/generation/outputPlacement.ts
Normal file
105
operations/generation/outputPlacement.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { Size, Transform } from "@core/geometry";
|
||||
import type { ArtboardId } from "@core/id";
|
||||
import { createDocumentReadIndex } from "@editor/document-indexes";
|
||||
import type { GenerationCandidate, SelectionState } from "@editor/state";
|
||||
import type { GenerateSettings } from "@editor/tools";
|
||||
import type { InpaintBundle } from "./inpaintPrep";
|
||||
|
||||
export type GeneratedOutputPlacement = GenerationCandidate["placement"];
|
||||
|
||||
export function resolveGeneratedOutputPlacement(options: {
|
||||
document: ImageDocument;
|
||||
selection: SelectionState;
|
||||
settings: GenerateSettings;
|
||||
intrinsicSize: Size;
|
||||
inpaintBundle?: InpaintBundle;
|
||||
}): GeneratedOutputPlacement {
|
||||
const { document, selection, settings, intrinsicSize, inpaintBundle } = options;
|
||||
if (inpaintBundle) {
|
||||
return {
|
||||
artboardId: inpaintBundle.placement.artboardId,
|
||||
layerName: inpaintBundle.placement.layerName,
|
||||
transform: scaleInpaintPlacement(inpaintBundle, intrinsicSize),
|
||||
};
|
||||
}
|
||||
|
||||
const artboard = selection.artboardId
|
||||
? document.artboards.find((candidate) => candidate.id === selection.artboardId)
|
||||
: document.artboards[0];
|
||||
if (!artboard) throw new Error("Create an artboard before placing generated output.");
|
||||
|
||||
if (settings.mode === "text-to-image") {
|
||||
return {
|
||||
artboardId: artboard.id,
|
||||
layerName: "Generated image",
|
||||
transform: {
|
||||
position: {
|
||||
x: artboard.bounds.x + (artboard.bounds.w - intrinsicSize.w) / 2,
|
||||
y: artboard.bounds.y + (artboard.bounds.h - intrinsicSize.h) / 2,
|
||||
},
|
||||
scale: { x: 1, y: 1 },
|
||||
rotation: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const target = resolveTarget(document, selection, artboard.id);
|
||||
const sourceScale = {
|
||||
x: target.layer.transform.scale.x * target.asset.intrinsicSize.w / Math.max(1, intrinsicSize.w),
|
||||
y: target.layer.transform.scale.y * target.asset.intrinsicSize.h / Math.max(1, intrinsicSize.h),
|
||||
};
|
||||
|
||||
if (settings.mode === "image-to-image") {
|
||||
return {
|
||||
artboardId: artboard.id,
|
||||
layerName: "Generated image",
|
||||
transform: {
|
||||
position: { ...target.layer.transform.position },
|
||||
scale: sourceScale,
|
||||
rotation: target.layer.transform.rotation,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (settings.mode === "outpaint") {
|
||||
const pixelScale = target.layer.transform.scale;
|
||||
return {
|
||||
artboardId: artboard.id,
|
||||
layerName: "Generated image",
|
||||
transform: {
|
||||
position: {
|
||||
x: target.layer.transform.position.x - settings.outpaint.left * pixelScale.x,
|
||||
y: target.layer.transform.position.y - settings.outpaint.top * pixelScale.y,
|
||||
},
|
||||
scale: { ...pixelScale },
|
||||
rotation: target.layer.transform.rotation,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("Inpaint output requires an inpaint placement bundle.");
|
||||
}
|
||||
|
||||
function resolveTarget(document: ImageDocument, selection: SelectionState, artboardId: ArtboardId) {
|
||||
const layerId = selection.layerIds[0];
|
||||
const index = createDocumentReadIndex(document);
|
||||
const layerInfo = layerId ? index.layerInfoById.get(layerId) : undefined;
|
||||
if (!layerInfo || layerInfo.artboardId !== artboardId || layerInfo.layer.type === "group") {
|
||||
throw new Error("Select one image or raster layer before placing generated output.");
|
||||
}
|
||||
const asset = index.assetById.get(layerInfo.layer.assetId);
|
||||
if (!asset) throw new Error("The selected layer is missing its source image.");
|
||||
return { layer: layerInfo.layer, asset };
|
||||
}
|
||||
|
||||
function scaleInpaintPlacement(bundle: InpaintBundle, intrinsicSize: Size): Transform {
|
||||
return {
|
||||
position: { ...bundle.placement.transform.position },
|
||||
scale: {
|
||||
x: bundle.placement.transform.scale.x * bundle.width / Math.max(1, intrinsicSize.w),
|
||||
y: bundle.placement.transform.scale.y * bundle.height / Math.max(1, intrinsicSize.h),
|
||||
},
|
||||
rotation: bundle.placement.transform.rotation,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { commandIds } from "@commands/ids";
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { Transform } from "@core/geometry";
|
||||
import type { Layer } from "@core/layer";
|
||||
import { getLayerMask } from "@core/layer-mask-utils";
|
||||
import type { AppStore } from "@editor/store";
|
||||
@@ -10,6 +9,7 @@ import { buildInpaintBundle, type InpaintBundle } from "./inpaintPrep";
|
||||
import { imageSourceToDataUrl, loadImageSize } from "@platform/browser/imageRaster";
|
||||
import { requestGeneration } from "@platform/comfy/generationClient";
|
||||
import { checkGenerationPreconditions } from "./preconditions";
|
||||
import { resolveGeneratedOutputPlacement } from "./outputPlacement";
|
||||
|
||||
export async function runGenerate(options: {
|
||||
document: ImageDocument;
|
||||
@@ -41,12 +41,7 @@ export async function runGenerate(options: {
|
||||
inpaintBundle,
|
||||
});
|
||||
const intrinsicSize = await loadImageSize(generated.source);
|
||||
const targetArtboardId = inpaintBundle?.placement.artboardId ?? artboard.id;
|
||||
const placement = {
|
||||
artboardId: targetArtboardId,
|
||||
layerName: inpaintBundle?.placement.layerName ?? "Generated image",
|
||||
transform: generatedLayerTransform(inpaintBundle, intrinsicSize),
|
||||
};
|
||||
const placement = resolveGeneratedOutputPlacement({ document, selection, settings, intrinsicSize, inpaintBundle });
|
||||
|
||||
dispatch(commandIds.generationAddCandidate, {
|
||||
candidate: createGenerationCandidate({
|
||||
@@ -202,18 +197,6 @@ function resolveInpaintRequest(inpaintBundle: InpaintBundle | undefined, inpaint
|
||||
return settings.inpaint;
|
||||
}
|
||||
|
||||
function generatedLayerTransform(inpaintBundle: InpaintBundle | undefined, intrinsicSize: { w: number; h: number }): Transform {
|
||||
if (!inpaintBundle) return { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 };
|
||||
return {
|
||||
position: { ...inpaintBundle.placement.transform.position },
|
||||
scale: {
|
||||
x: (inpaintBundle.placement.transform.scale.x * inpaintBundle.width) / Math.max(1, intrinsicSize.w),
|
||||
y: (inpaintBundle.placement.transform.scale.y * inpaintBundle.height) / Math.max(1, intrinsicSize.h),
|
||||
},
|
||||
rotation: inpaintBundle.placement.transform.rotation,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSeed(seed: number): number {
|
||||
return seed < 0 ? Math.floor(Math.random() * 2 ** 32) : Math.round(seed);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user