From 6d652c326429f1dcddccada50c72b1b79c016782 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 10 Jul 2026 23:29:03 +0200 Subject: [PATCH] feat: implement resolveGeneratedOutputPlacement function and add tests for output placement logic --- operations/generation/outputPlacement.test.ts | 94 ++++++++++++++++ operations/generation/outputPlacement.ts | 105 ++++++++++++++++++ operations/generation/runGenerate.ts | 21 +--- 3 files changed, 201 insertions(+), 19 deletions(-) create mode 100644 operations/generation/outputPlacement.test.ts create mode 100644 operations/generation/outputPlacement.ts diff --git a/operations/generation/outputPlacement.test.ts b/operations/generation/outputPlacement.test.ts new file mode 100644 index 0000000..06506f9 --- /dev/null +++ b/operations/generation/outputPlacement.test.ts @@ -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 } }], + }], + }; +} diff --git a/operations/generation/outputPlacement.ts b/operations/generation/outputPlacement.ts new file mode 100644 index 0000000..dceb1f1 --- /dev/null +++ b/operations/generation/outputPlacement.ts @@ -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, + }; +} diff --git a/operations/generation/runGenerate.ts b/operations/generation/runGenerate.ts index 2836849..f074ab4 100644 --- a/operations/generation/runGenerate.ts +++ b/operations/generation/runGenerate.ts @@ -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); }