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:
@@ -1,16 +1,24 @@
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { GenerationCandidate } from "@editor/state";
|
||||
import { loadImageCanvas, maskValueFromRgba } from "@platform/browser/maskRaster";
|
||||
import { createContentRevision } from "./inpaintPrep";
|
||||
|
||||
export async function createMaskedPixelReplacementSource(document: ImageDocument, candidate: GenerationCandidate): Promise<string> {
|
||||
if (!candidate.inpaint) throw new Error("Only inpaint candidates can replace masked pixels.");
|
||||
|
||||
const targetAsset = document.assets.find((asset) => asset.id === candidate.inpaint?.sourceAssetId);
|
||||
if (!targetAsset) throw new Error("The source layer for this candidate no longer exists.");
|
||||
const region = document.inpaintRegions.find((item) => item.id === candidate.inpaint?.regionId);
|
||||
const maskAsset = region ? document.assets.find((asset) => asset.id === region.maskAssetId) : undefined;
|
||||
if (!region || !maskAsset) throw new Error("The AI edit region for this candidate no longer exists.");
|
||||
const [sourceRevision, maskRevision] = await Promise.all([createContentRevision(targetAsset.source), createContentRevision(maskAsset.source)]);
|
||||
if (sourceRevision !== candidate.inpaint.revision.source || maskRevision !== candidate.inpaint.revision.mask) {
|
||||
throw new Error("The source or AI edit region changed after generation. Rebuild candidates from the current edit region before replacing pixels.");
|
||||
}
|
||||
|
||||
const targetCanvas = await loadImageCanvas(targetAsset.source, targetAsset.intrinsicSize.w, targetAsset.intrinsicSize.h);
|
||||
const generatedCanvas = await loadImageCanvas(candidate.source, candidate.width, candidate.height);
|
||||
const maskCanvas = await loadImageCanvas(candidate.inpaint.maskImage, candidate.width, candidate.height);
|
||||
const maskCanvas = await loadImageCanvas(candidate.inpaint.blendMaskImage, candidate.width, candidate.height);
|
||||
|
||||
const targetContext = require2dContext(targetCanvas);
|
||||
const generatedContext = require2dContext(generatedCanvas);
|
||||
@@ -19,6 +27,7 @@ export async function createMaskedPixelReplacementSource(document: ImageDocument
|
||||
const generatedData = generatedContext.getImageData(0, 0, generatedCanvas.width, generatedCanvas.height);
|
||||
const maskData = maskContext.getImageData(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
const crop = candidate.inpaint.crop.assetBounds;
|
||||
const colorOffset = candidate.settings.inpaint.colorMatch ? boundaryColorOffset(targetData.data, generatedData.data, maskData.data, candidate.width, candidate.height, crop, targetCanvas.width, targetCanvas.height) : [0, 0, 0];
|
||||
|
||||
for (let y = 0; y < candidate.height; y += 1) {
|
||||
for (let x = 0; x < candidate.width; x += 1) {
|
||||
@@ -33,7 +42,8 @@ export async function createMaskedPixelReplacementSource(document: ImageDocument
|
||||
|
||||
for (let channel = 0; channel < 4; channel += 1) {
|
||||
const previous = targetData.data[targetIndex + channel] ?? 0;
|
||||
const next = generatedData.data[generatedIndex + channel] ?? previous;
|
||||
const rawNext = generatedData.data[generatedIndex + channel] ?? previous;
|
||||
const next = channel < 3 ? Math.max(0, Math.min(255, rawNext + (colorOffset[channel] ?? 0))) : rawNext;
|
||||
targetData.data[targetIndex + channel] = Math.round(previous * (1 - mask) + next * mask);
|
||||
}
|
||||
}
|
||||
@@ -43,6 +53,28 @@ export async function createMaskedPixelReplacementSource(document: ImageDocument
|
||||
return targetCanvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
function boundaryColorOffset(target: Uint8ClampedArray, generated: Uint8ClampedArray, mask: Uint8ClampedArray, width: number, height: number, crop: { x: number; y: number }, targetWidth: number, targetHeight: number): number[] {
|
||||
const targetTotal = [0, 0, 0];
|
||||
const generatedTotal = [0, 0, 0];
|
||||
let count = 0;
|
||||
for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) {
|
||||
const index = (y * width + x) * 4;
|
||||
const amount = maskValueFromRgba(mask, index) / 255;
|
||||
if (amount <= 0.05 || amount >= 0.65) continue;
|
||||
const targetX = Math.round(crop.x) + x;
|
||||
const targetY = Math.round(crop.y) + y;
|
||||
if (targetX < 0 || targetY < 0 || targetX >= targetWidth || targetY >= targetHeight) continue;
|
||||
const targetIndex = (targetY * targetWidth + targetX) * 4;
|
||||
for (let channel = 0; channel < 3; channel += 1) {
|
||||
targetTotal[channel] = (targetTotal[channel] ?? 0) + (target[targetIndex + channel] ?? 0);
|
||||
generatedTotal[channel] = (generatedTotal[channel] ?? 0) + (generated[index + channel] ?? 0);
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
if (count < 16) return [0, 0, 0];
|
||||
return targetTotal.map((total, channel) => Math.max(-32, Math.min(32, total / count - (generatedTotal[channel] ?? 0) / count)));
|
||||
}
|
||||
|
||||
function require2dContext(canvas: HTMLCanvasElement): CanvasRenderingContext2D {
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Unable to prepare generated candidate");
|
||||
|
||||
@@ -7,7 +7,7 @@ export async function runGenerationJob(options: {
|
||||
label: string;
|
||||
dispatch: AppStore["dispatch"];
|
||||
signal: AbortSignal;
|
||||
task: (signal: AbortSignal) => Promise<void>;
|
||||
task: (signal: AbortSignal, report: (progress: number, detail: string) => void) => Promise<void>;
|
||||
}): Promise<void> {
|
||||
const jobId = crypto.randomUUID();
|
||||
const startedAt = Date.now();
|
||||
@@ -15,7 +15,7 @@ export async function runGenerationJob(options: {
|
||||
if (!nextState.editor.generation.jobs.some((job) => job.id === jobId && job.status === "running")) return;
|
||||
|
||||
try {
|
||||
await options.task(options.signal);
|
||||
await options.task(options.signal, (progress, detail) => options.dispatch(commandIds.generationUpdateJob, { jobId, progress, detail }));
|
||||
options.dispatch(commandIds.generationSucceedJob, { jobId, finishedAt: Date.now() });
|
||||
} catch (reason: unknown) {
|
||||
if (options.signal.aborted) {
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
import type { Asset } from "@core/asset";
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { Rect } from "@core/geometry";
|
||||
import type { AssetId, InpaintRegionId, LayerId } from "@core/id";
|
||||
import type { Layer } from "@core/layer";
|
||||
import { getLayerMask } from "@core/layer-mask-utils";
|
||||
import type { SelectionState } from "@editor/state";
|
||||
import type { GenerateSettings } from "@editor/tools";
|
||||
import { createDocumentReadIndex, resolveIndexedLayerBounds } from "@editor/document-indexes";
|
||||
import { createNormalizedMaskSource, cropCanvas, cropMaskValuesToDataUrl, expandRectWithinBounds, loadImageCanvas } from "@platform/browser/maskRaster";
|
||||
import { createNormalizedMaskSource, cropCanvas, cropMaskValuesToDataUrl, expandRectWithinBounds, loadImageCanvas, sampleDocumentCanvasInLayerSpace } from "@platform/browser/maskRaster";
|
||||
import { renderArtboardCanvas } from "@platform/browser/exportArtboardPng";
|
||||
|
||||
export type InpaintBundle = {
|
||||
inputImage: string;
|
||||
sourceImage: string;
|
||||
contextImage: string;
|
||||
maskImage: string;
|
||||
editMaskImage: string;
|
||||
blendMaskImage: string;
|
||||
width: number;
|
||||
height: number;
|
||||
targetLayerId: string;
|
||||
maskLayerId: string;
|
||||
sourceAssetId: string;
|
||||
maskAssetId: string;
|
||||
targetLayerId: LayerId;
|
||||
regionId: InpaintRegionId;
|
||||
sourceAssetId: AssetId;
|
||||
maskAssetId: AssetId;
|
||||
revision: { source: string; mask: string };
|
||||
crop: {
|
||||
assetBounds: Rect;
|
||||
documentBounds: Rect;
|
||||
@@ -46,10 +52,8 @@ type InpaintTarget = {
|
||||
artboardId: string;
|
||||
layer: Extract<Layer, { type: "image" | "raster" }>;
|
||||
asset: Asset;
|
||||
bounds: Rect;
|
||||
maskLayer: Extract<Layer, { type: "image" | "raster" }>;
|
||||
maskAsset: Asset;
|
||||
maskBounds: Rect;
|
||||
regionId: InpaintRegionId;
|
||||
};
|
||||
|
||||
const modelMultiple = 8;
|
||||
@@ -62,41 +66,65 @@ export async function buildInpaintBundle(document: ImageDocument, selection: Sel
|
||||
|
||||
const width = Math.max(1, Math.round(target.asset.intrinsicSize.w));
|
||||
const height = Math.max(1, Math.round(target.asset.intrinsicSize.h));
|
||||
const normalizedMask = await createNormalizedMaskSource(target.maskAsset.source, width, height, {
|
||||
polarity: settings.inpaint.maskPolarity,
|
||||
const sourceLimit = target.layer.sourceRect ?? { x: 0, y: 0, w: width, h: height };
|
||||
const editMask = await createNormalizedMaskSource(target.maskAsset.source, width, height, {
|
||||
polarity: "revealed",
|
||||
despeckle: settings.inpaint.maskDespeckle,
|
||||
limit: sourceLimit,
|
||||
});
|
||||
const noiseMask = await createNormalizedMaskSource(target.maskAsset.source, width, height, {
|
||||
polarity: "revealed",
|
||||
expand: settings.inpaint.maskExpand,
|
||||
feather: settings.inpaint.maskFeather,
|
||||
blur: settings.inpaint.maskBlur,
|
||||
despeckle: settings.inpaint.maskDespeckle,
|
||||
limit: sourceLimit,
|
||||
});
|
||||
const blendMask = await createNormalizedMaskSource(target.maskAsset.source, width, height, {
|
||||
polarity: "revealed",
|
||||
feather: settings.inpaint.maskFeather,
|
||||
despeckle: settings.inpaint.maskDespeckle,
|
||||
limit: sourceLimit,
|
||||
});
|
||||
|
||||
if (!normalizedMask.bounds) throw new Error("The selected layer mask has no inpaint pixels.");
|
||||
if (!editMask.bounds || !noiseMask.bounds) throw new Error("Paint over the area you want AI to replace.");
|
||||
|
||||
const crop = settings.inpaint.maskedAreaOnly
|
||||
? expandRectWithinBounds(normalizedMask.bounds, settings.inpaint.cropPadding, { w: width, h: height }, modelMultiple, minModelSize)
|
||||
? expandRectWithinBounds(noiseMask.bounds, settings.inpaint.cropPadding, { w: width, h: height }, modelMultiple, minModelSize)
|
||||
: { x: 0, y: 0, w: width, h: height };
|
||||
const outputWidth = toModelSize(crop.w, "width");
|
||||
const outputHeight = toModelSize(crop.h, "height");
|
||||
|
||||
const inputCanvas = prepareMaskedContentInputCanvas(await loadImageCanvas(target.asset.source, width, height), normalizedMask.values, settings.inpaint.maskedContent);
|
||||
const inputImage = cropCanvas(inputCanvas, crop, outputWidth, outputHeight);
|
||||
const maskImage = cropMaskValuesToDataUrl(normalizedMask.values, width, height, crop, outputWidth, outputHeight);
|
||||
const scaleX = target.bounds.w / width;
|
||||
const scaleY = target.bounds.h / height;
|
||||
const sourceCanvas = await loadImageCanvas(target.asset.source, width, height);
|
||||
const sourceImage = cropCanvas(sourceCanvas, crop, outputWidth, outputHeight);
|
||||
const preparedCanvas = prepareMaskedContentInputCanvas(await loadImageCanvas(target.asset.source, width, height), editMask.values, settings.inpaint.maskedContent);
|
||||
const preparedSourceImage = cropCanvas(preparedCanvas, crop, outputWidth, outputHeight);
|
||||
const artboard = document.artboards.find((candidate) => candidate.id === target.artboardId);
|
||||
if (!artboard) throw new Error("The target artboard no longer exists.");
|
||||
const contextImage = sampleDocumentCanvasInLayerSpace(await renderArtboardCanvas(artboard, document.assets), artboard.bounds, target.layer, target.asset.intrinsicSize, crop, outputWidth, outputHeight);
|
||||
const inputImage = await mergePreparedRegionIntoContext(contextImage, preparedSourceImage, cropMaskValuesToDataUrl(editMask.values, width, height, crop, outputWidth, outputHeight), outputWidth, outputHeight);
|
||||
const maskImage = cropMaskValuesToDataUrl(noiseMask.values, width, height, crop, outputWidth, outputHeight);
|
||||
const editMaskImage = cropMaskValuesToDataUrl(editMask.values, width, height, crop, outputWidth, outputHeight);
|
||||
const blendMaskImage = cropMaskValuesToDataUrl(blendMask.values, width, height, crop, outputWidth, outputHeight);
|
||||
const scaleX = target.layer.transform.scale.x;
|
||||
const scaleY = target.layer.transform.scale.y;
|
||||
const documentBounds = {
|
||||
x: target.bounds.x + crop.x * scaleX,
|
||||
y: target.bounds.y + crop.y * scaleY,
|
||||
x: target.layer.transform.position.x + crop.x * scaleX,
|
||||
y: target.layer.transform.position.y + crop.y * scaleY,
|
||||
w: outputWidth * scaleX,
|
||||
h: outputHeight * scaleY,
|
||||
};
|
||||
|
||||
return {
|
||||
inputImage,
|
||||
sourceImage,
|
||||
contextImage,
|
||||
maskImage,
|
||||
editMaskImage,
|
||||
blendMaskImage,
|
||||
width: outputWidth,
|
||||
height: outputHeight,
|
||||
targetLayerId: target.layer.id,
|
||||
maskLayerId: target.maskLayer.id,
|
||||
regionId: target.regionId,
|
||||
sourceAssetId: target.asset.id,
|
||||
maskAssetId: target.maskAsset.id,
|
||||
crop: {
|
||||
@@ -106,14 +134,14 @@ export async function buildInpaintBundle(document: ImageDocument, selection: Sel
|
||||
maskedAreaOnly: settings.inpaint.maskedAreaOnly,
|
||||
},
|
||||
mask: {
|
||||
polarity: settings.inpaint.maskPolarity,
|
||||
activeBounds: normalizedMask.bounds,
|
||||
polarity: "revealed",
|
||||
activeBounds: editMask.bounds,
|
||||
},
|
||||
placement: {
|
||||
artboardId: target.artboardId,
|
||||
layerName: `${target.layer.name} inpaint`,
|
||||
transform: {
|
||||
position: { x: documentBounds.x, y: documentBounds.y },
|
||||
position: rotatedCropPosition(target.layer, target.asset, { ...crop, w: outputWidth, h: outputHeight }),
|
||||
scale: { x: documentBounds.w / outputWidth, y: documentBounds.h / outputHeight },
|
||||
rotation: target.layer.transform.rotation,
|
||||
},
|
||||
@@ -126,6 +154,10 @@ export async function buildInpaintBundle(document: ImageDocument, selection: Sel
|
||||
maskExpand: settings.inpaint.maskExpand,
|
||||
cropPadding: settings.inpaint.cropPadding,
|
||||
},
|
||||
revision: {
|
||||
source: await createContentRevision(target.asset.source),
|
||||
mask: await createContentRevision(target.maskAsset.source),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -138,20 +170,14 @@ function resolveInpaintTarget(document: ImageDocument, selection: SelectionState
|
||||
|
||||
const asset = documentIndex.assetById.get(layerInfo.layer.assetId);
|
||||
if (!asset) throw new Error("The selected layer is missing its source image.");
|
||||
const layerMask = getLayerMask(layerInfo.layer);
|
||||
if (!layerMask?.enabled) throw new Error("Add a layer mask before running inpaint.");
|
||||
const region = document.inpaintRegions.find((candidate) => candidate.targetLayerId === layerInfo.layer.id && candidate.enabled);
|
||||
if (!region) throw new Error("Add an AI edit region before running inpaint.");
|
||||
const maskAsset = documentIndex.assetById.get(region.maskAssetId);
|
||||
if (!maskAsset) throw new Error("The AI edit region is missing its mask data.");
|
||||
|
||||
const maskLayer = documentIndex.layerById.get(layerMask.maskLayerId);
|
||||
if (!maskLayer || (maskLayer.type !== "image" && maskLayer.type !== "raster")) throw new Error("The selected layer mask is missing.");
|
||||
if (!resolveIndexedLayerBounds(documentIndex, layerInfo.layer)) throw new Error("Unable to resolve the selected layer bounds.");
|
||||
|
||||
const maskAsset = documentIndex.assetById.get(maskLayer.assetId);
|
||||
if (!maskAsset) throw new Error("The selected layer mask is missing its image data.");
|
||||
|
||||
const bounds = resolveIndexedLayerBounds(documentIndex, layerInfo.layer);
|
||||
const maskBounds = resolveIndexedLayerBounds(documentIndex, maskLayer);
|
||||
if (!bounds || !maskBounds) throw new Error("Unable to resolve the selected layer and mask bounds.");
|
||||
|
||||
return { artboardId: layerInfo.artboardId, layer: layerInfo.layer, asset, bounds, maskLayer, maskAsset, maskBounds };
|
||||
return { artboardId: layerInfo.artboardId, layer: layerInfo.layer, asset, maskAsset, regionId: region.id };
|
||||
}
|
||||
|
||||
function validateInpaintTarget(target: InpaintTarget) {
|
||||
@@ -160,13 +186,6 @@ function validateInpaintTarget(target: InpaintTarget) {
|
||||
if (Math.round(target.asset.intrinsicSize.w) !== Math.round(target.maskAsset.intrinsicSize.w) || Math.round(target.asset.intrinsicSize.h) !== Math.round(target.maskAsset.intrinsicSize.h)) {
|
||||
throw new Error("The selected layer and mask image sizes do not match.");
|
||||
}
|
||||
if (!rectsAligned(target.bounds, target.maskBounds) || Math.abs(target.layer.transform.rotation - target.maskLayer.transform.rotation) > 0.001) {
|
||||
throw new Error("The selected layer and mask are not aligned.");
|
||||
}
|
||||
}
|
||||
|
||||
function rectsAligned(a: Rect, b: Rect): boolean {
|
||||
return Math.abs(a.x - b.x) <= 0.5 && Math.abs(a.y - b.y) <= 0.5 && Math.abs(a.w - b.w) <= 0.5 && Math.abs(a.h - b.h) <= 0.5;
|
||||
}
|
||||
|
||||
function toModelSize(value: number, axis: "width" | "height"): number {
|
||||
@@ -258,3 +277,52 @@ function blendChannel(previous: number, next: number, amount: number): number {
|
||||
function clampNumber(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
async function mergePreparedRegionIntoContext(contextSource: string, preparedSource: string, editMaskSource: string, width: number, height: number): Promise<string> {
|
||||
const [contextCanvas, preparedCanvas, maskCanvas] = await Promise.all([
|
||||
loadImageCanvas(contextSource, width, height),
|
||||
loadImageCanvas(preparedSource, width, height),
|
||||
loadImageCanvas(editMaskSource, width, height),
|
||||
]);
|
||||
const context = contextCanvas.getContext("2d");
|
||||
const prepared = preparedCanvas.getContext("2d");
|
||||
const mask = maskCanvas.getContext("2d");
|
||||
if (!context || !prepared || !mask) return contextSource;
|
||||
const contextData = context.getImageData(0, 0, width, height);
|
||||
const preparedData = prepared.getImageData(0, 0, width, height);
|
||||
const maskData = mask.getImageData(0, 0, width, height);
|
||||
for (let pixel = 0; pixel < width * height; pixel += 1) {
|
||||
const amount = (maskData.data[pixel * 4] ?? 0) / 255;
|
||||
if (amount <= 0) continue;
|
||||
for (let channel = 0; channel < 4; channel += 1) {
|
||||
const index = pixel * 4 + channel;
|
||||
contextData.data[index] = blendChannel(contextData.data[index] ?? 0, preparedData.data[index] ?? 0, amount);
|
||||
}
|
||||
}
|
||||
context.putImageData(contextData, 0, 0);
|
||||
return contextCanvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
function rotatedCropPosition(layer: InpaintTarget["layer"], asset: Asset, crop: Rect) {
|
||||
const source = layer.sourceRect ?? { x: 0, y: 0, ...asset.intrinsicSize };
|
||||
const scale = layer.transform.scale;
|
||||
const originalCenter = {
|
||||
x: layer.transform.position.x + (source.x + source.w / 2) * scale.x,
|
||||
y: layer.transform.position.y + (source.y + source.h / 2) * scale.y,
|
||||
};
|
||||
const cropCenter = {
|
||||
x: layer.transform.position.x + (crop.x + crop.w / 2) * scale.x,
|
||||
y: layer.transform.position.y + (crop.y + crop.h / 2) * scale.y,
|
||||
};
|
||||
const dx = cropCenter.x - originalCenter.x;
|
||||
const dy = cropCenter.y - originalCenter.y;
|
||||
const cos = Math.cos(layer.transform.rotation);
|
||||
const sin = Math.sin(layer.transform.rotation);
|
||||
const rotatedCenter = { x: originalCenter.x + dx * cos - dy * sin, y: originalCenter.y + dx * sin + dy * cos };
|
||||
return { x: rotatedCenter.x - crop.w * scale.x / 2, y: rotatedCenter.y - crop.h * scale.y / 2 };
|
||||
}
|
||||
|
||||
export async function createContentRevision(source: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(source));
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
@@ -42,8 +42,8 @@ describe("generated output placement", () => {
|
||||
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",
|
||||
inputImage: "input", sourceImage: "source", contextImage: "context", maskImage: "mask", editMaskImage: "edit", blendMaskImage: "blend", width: 256, height: 128,
|
||||
targetLayerId: "source", regionId: "region", sourceAssetId: "source-asset", maskAssetId: "mask-asset", revision: { source: "source-revision", mask: "mask-revision" },
|
||||
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 } },
|
||||
@@ -81,7 +81,7 @@ function selected() {
|
||||
|
||||
function document(): ImageDocument {
|
||||
return {
|
||||
id: "document", name: "Test", version: 1,
|
||||
id: "document", name: "Test", version: 1, inpaintRegions: [],
|
||||
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 } },
|
||||
|
||||
@@ -15,7 +15,7 @@ describe("generation preconditions", () => {
|
||||
});
|
||||
|
||||
test("requires an enabled mask for inpaint", () => {
|
||||
expect(checkGenerationPreconditions(document(), selected(), settings("inpaint"))).toEqual({ ready: false, message: "Paint a mask over the area you want AI to replace.", repair: "add-mask" });
|
||||
expect(checkGenerationPreconditions(document(), selected(), settings("inpaint"))).toEqual({ ready: false, message: "Paint an AI edit region over the area you want to replace.", repair: "add-mask" });
|
||||
});
|
||||
|
||||
test("allows inpaint when the selected source has an aligned enabled mask", () => {
|
||||
@@ -46,6 +46,7 @@ function document(masked = false): ImageDocument {
|
||||
{ id: "source-asset", name: "Source", mimeType: "image/png", source: "source", intrinsicSize: { w: 100, h: 100 } },
|
||||
{ id: "mask-asset", name: "Mask", mimeType: "image/png", source: "mask", intrinsicSize: { w: 100, h: 100 } },
|
||||
],
|
||||
inpaintRegions: masked ? [{ id: "region", name: "AI edit", targetLayerId: "source", maskAssetId: "mask-asset", enabled: true }] : [],
|
||||
artboards: [{
|
||||
id: "artboard",
|
||||
name: "Artboard",
|
||||
@@ -64,7 +65,6 @@ function document(masked = false): ImageDocument {
|
||||
opacity: 1,
|
||||
assetId: "source-asset",
|
||||
transform,
|
||||
layerMask: masked ? { kind: "raster", maskLayerId: "mask", enabled: true, inverted: false } : undefined,
|
||||
},
|
||||
],
|
||||
}],
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import { getLayerMask } from "@core/layer-mask-utils";
|
||||
import { createDocumentReadIndex, resolveIndexedLayerBounds } from "@editor/document-indexes";
|
||||
import type { SelectionState } from "@editor/state";
|
||||
import type { GenerateSettings } from "@editor/tools";
|
||||
@@ -44,21 +43,15 @@ export function checkGenerationPreconditions(
|
||||
|
||||
if (settings.mode !== "inpaint") return { ready: true };
|
||||
|
||||
const layerMask = getLayerMask(layerInfo.layer);
|
||||
if (!layerMask?.enabled) return missing("Paint a mask over the area you want AI to replace.", "add-mask");
|
||||
const maskLayer = index.layerById.get(layerMask.maskLayerId);
|
||||
if (!maskLayer || (maskLayer.type !== "image" && maskLayer.type !== "raster")) return missing("The selected layer mask is missing.");
|
||||
const maskAsset = index.assetById.get(maskLayer.assetId);
|
||||
if (!maskAsset) return missing("The selected layer mask is missing its image data.");
|
||||
const region = document.inpaintRegions.find((candidate) => candidate.targetLayerId === layerInfo.layer.id && candidate.enabled);
|
||||
if (!region) return missing("Paint an AI edit region over the area you want to replace.", "add-mask");
|
||||
const maskAsset = index.assetById.get(region.maskAssetId);
|
||||
if (!maskAsset) return missing("The AI edit region is missing its mask data.");
|
||||
if (Math.round(asset.intrinsicSize.w) !== Math.round(maskAsset.intrinsicSize.w) || Math.round(asset.intrinsicSize.h) !== Math.round(maskAsset.intrinsicSize.h)) {
|
||||
return missing("The selected layer and mask image sizes must match.");
|
||||
}
|
||||
|
||||
const layerBounds = resolveIndexedLayerBounds(index, layerInfo.layer);
|
||||
const maskBounds = resolveIndexedLayerBounds(index, maskLayer);
|
||||
if (!layerBounds || !maskBounds || !rectsAligned(layerBounds, maskBounds) || Math.abs(layerInfo.layer.transform.rotation - maskLayer.transform.rotation) > 0.001) {
|
||||
return missing("Align the selected layer and its mask before inpainting.");
|
||||
}
|
||||
if (!resolveIndexedLayerBounds(index, layerInfo.layer)) return missing("The selected layer has invalid geometry.");
|
||||
|
||||
return { ready: true };
|
||||
}
|
||||
@@ -72,7 +65,3 @@ function modeSelectionMessage(mode: GenerateSettings["mode"]): string {
|
||||
function missing(message: string, repair?: Extract<GenerationPrecondition, { ready: false }>["repair"]): GenerationPrecondition {
|
||||
return { ready: false, message, ...(repair ? { repair } : {}) };
|
||||
}
|
||||
|
||||
function rectsAligned(a: { x: number; y: number; w: number; h: number }, b: { x: number; y: number; w: number; h: number }): boolean {
|
||||
return Math.abs(a.x - b.x) <= 0.5 && Math.abs(a.y - b.y) <= 0.5 && Math.abs(a.w - b.w) <= 0.5 && Math.abs(a.h - b.h) <= 0.5;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export async function runGenerate(options: {
|
||||
settings: GenerateSettings;
|
||||
dispatch: AppStore["dispatch"];
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (progress: number, detail: string) => void;
|
||||
}) {
|
||||
const { document, selection, settings, dispatch } = options;
|
||||
const precondition = checkGenerationPreconditions(document, selection, settings);
|
||||
@@ -41,25 +42,27 @@ export async function runGenerate(options: {
|
||||
maskImage,
|
||||
inpaintBundle,
|
||||
signal: options.signal,
|
||||
onProgress: options.onProgress,
|
||||
});
|
||||
const intrinsicSize = await loadImageSize(generated.source);
|
||||
const placement = resolveGeneratedOutputPlacement({ document, selection, settings, intrinsicSize, inpaintBundle });
|
||||
|
||||
dispatch(commandIds.generationAddCandidate, {
|
||||
candidate: createGenerationCandidate({
|
||||
source: generated.source,
|
||||
mimeType: generated.mimeType,
|
||||
intrinsicSize,
|
||||
settings: requestSettings,
|
||||
seed,
|
||||
width,
|
||||
height,
|
||||
inputImage,
|
||||
maskImage,
|
||||
placement,
|
||||
inpaintBundle,
|
||||
}),
|
||||
});
|
||||
for (const result of generated.results) {
|
||||
const intrinsicSize = await loadImageSize(result.source);
|
||||
const placement = resolveGeneratedOutputPlacement({ document, selection, settings, intrinsicSize, inpaintBundle });
|
||||
dispatch(commandIds.generationAddCandidate, {
|
||||
candidate: createGenerationCandidate({
|
||||
source: result.source,
|
||||
mimeType: result.mimeType,
|
||||
intrinsicSize,
|
||||
settings: requestSettings,
|
||||
seed: result.seed || seed,
|
||||
width,
|
||||
height,
|
||||
inputImage,
|
||||
maskImage,
|
||||
placement,
|
||||
inpaintBundle,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function runGenerateFromCandidate(options: {
|
||||
@@ -67,6 +70,7 @@ export async function runGenerateFromCandidate(options: {
|
||||
settings?: GenerateSettings;
|
||||
dispatch: AppStore["dispatch"];
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (progress: number, detail: string) => void;
|
||||
}) {
|
||||
const settings = options.settings ?? options.candidate.settings;
|
||||
const seed = resolveSeed(settings.seed);
|
||||
@@ -79,20 +83,22 @@ export async function runGenerateFromCandidate(options: {
|
||||
maskImage: options.candidate.maskImage,
|
||||
inpaintCandidate: options.candidate,
|
||||
signal: options.signal,
|
||||
onProgress: options.onProgress,
|
||||
});
|
||||
const intrinsicSize = await loadImageSize(generated.source);
|
||||
|
||||
options.dispatch(commandIds.generationAddCandidate, {
|
||||
candidate: {
|
||||
...options.candidate,
|
||||
id: crypto.randomUUID(),
|
||||
source: generated.source,
|
||||
mimeType: generated.mimeType,
|
||||
intrinsicSize,
|
||||
settings: requestSettings,
|
||||
seed,
|
||||
},
|
||||
});
|
||||
for (const result of generated.results) {
|
||||
const intrinsicSize = await loadImageSize(result.source);
|
||||
options.dispatch(commandIds.generationAddCandidate, {
|
||||
candidate: {
|
||||
...options.candidate,
|
||||
id: crypto.randomUUID(),
|
||||
source: result.source,
|
||||
mimeType: result.mimeType,
|
||||
intrinsicSize,
|
||||
settings: requestSettings,
|
||||
seed: result.seed || seed,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function createGenerationCandidate(options: {
|
||||
@@ -120,15 +126,19 @@ function createGenerationCandidate(options: {
|
||||
height: options.height,
|
||||
inputImage: options.inputImage,
|
||||
maskImage: options.maskImage,
|
||||
blendMaskImage: options.inpaintBundle?.blendMaskImage,
|
||||
placement: options.placement,
|
||||
inpaint: options.inpaintBundle
|
||||
? {
|
||||
targetLayerId: options.inpaintBundle.targetLayerId,
|
||||
maskLayerId: options.inpaintBundle.maskLayerId,
|
||||
regionId: options.inpaintBundle.regionId,
|
||||
sourceAssetId: options.inpaintBundle.sourceAssetId,
|
||||
maskAssetId: options.inpaintBundle.maskAssetId,
|
||||
inputImage: options.inpaintBundle.inputImage,
|
||||
maskImage: options.inpaintBundle.maskImage,
|
||||
editMaskImage: options.inpaintBundle.editMaskImage,
|
||||
blendMaskImage: options.inpaintBundle.blendMaskImage,
|
||||
revision: options.inpaintBundle.revision,
|
||||
crop: options.inpaintBundle.crop,
|
||||
mask: options.inpaintBundle.mask,
|
||||
backend: options.inpaintBundle.backend,
|
||||
@@ -146,6 +156,7 @@ async function requestGenerate(options: {
|
||||
inpaintBundle?: InpaintBundle;
|
||||
inpaintCandidate?: GenerationCandidate;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (progress: number, detail: string) => void;
|
||||
}) {
|
||||
return requestGeneration({
|
||||
architecture: options.settings.architecture,
|
||||
@@ -163,11 +174,14 @@ async function requestGenerate(options: {
|
||||
scheduler: options.settings.scheduler,
|
||||
width: options.width,
|
||||
height: options.height,
|
||||
batchSize: options.settings.batchSize,
|
||||
refinePass: options.settings.refinePass,
|
||||
refineStrength: options.settings.refineStrength,
|
||||
outpaint: options.settings.outpaint,
|
||||
inpaint: resolveInpaintRequest(options.inpaintBundle, options.inpaintCandidate, options.settings),
|
||||
inputImage: options.inputImage,
|
||||
maskImage: options.maskImage,
|
||||
}, options.signal);
|
||||
}, options.signal, options.onProgress);
|
||||
}
|
||||
|
||||
function resolveInpaintRequest(inpaintBundle: InpaintBundle | undefined, inpaintCandidate: GenerationCandidate | undefined, settings: GenerateSettings) {
|
||||
@@ -182,6 +196,9 @@ function resolveInpaintRequest(inpaintBundle: InpaintBundle | undefined, inpaint
|
||||
maskPolarity: inpaintBundle.mask.polarity,
|
||||
crop: inpaintBundle.crop,
|
||||
placement: inpaintBundle.placement,
|
||||
structureControl: settings.inpaint.structureControl,
|
||||
controlStrength: settings.inpaint.controlStrength,
|
||||
controlModel: settings.inpaint.controlModel,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -196,6 +213,9 @@ function resolveInpaintRequest(inpaintBundle: InpaintBundle | undefined, inpaint
|
||||
maskPolarity: inpaintCandidate.inpaint.mask.polarity,
|
||||
crop: inpaintCandidate.inpaint.crop,
|
||||
placement: inpaintCandidate.placement,
|
||||
structureControl: settings.inpaint.structureControl,
|
||||
controlStrength: settings.inpaint.controlStrength,
|
||||
controlModel: settings.inpaint.controlModel,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { documentCommands } from "@commands/document";
|
||||
import { inpaintRegionCommands } from "@commands/inpaint-region";
|
||||
import { generationCommands } from "@commands/generation";
|
||||
import { commandIds } from "@commands/ids";
|
||||
import { createCommandRegistry } from "@commands/registry";
|
||||
@@ -46,14 +47,14 @@ describe("generation workflow", () => {
|
||||
state.document.assets.push({ id: "source-asset", name: "Source", mimeType: "image/png", source: "source", intrinsicSize: { w: 80, h: 60 } });
|
||||
state.document.artboards[0]!.layers.push({ id: "source", type: "raster", name: "Source", visible: true, locked: false, opacity: 1, assetId: "source-asset", transform: { position: { x: 4, y: 8 }, scale: { x: 2, y: 2 }, rotation: 0 } });
|
||||
state.editor.selection = { artboardId: "artboard", layerIds: ["source"] };
|
||||
const ids = ["mask-asset", "mask-layer"];
|
||||
const ids = ["mask-asset", "region"];
|
||||
const workflow = createGenerationWorkflow(app.store, dependencies({ createId: () => ids.shift() ?? "unused" }));
|
||||
|
||||
await workflow.prepareInpaintMask();
|
||||
|
||||
const next = app.store.getState();
|
||||
expect(next.document.artboards[0]?.layers[0]).toMatchObject({ id: "mask-layer", transform: { position: { x: 4, y: 8 }, scale: { x: 2, y: 2 }, rotation: 0 } });
|
||||
expect(next.editor.maskEdit).toEqual({ targetLayerId: "source", maskLayerId: "mask-layer" });
|
||||
expect(next.document.inpaintRegions).toContainEqual({ id: "region", name: "Source AI edit", targetLayerId: "source", maskAssetId: "mask-asset", enabled: true });
|
||||
expect(next.editor.maskEdit).toEqual({ kind: "inpaintRegion", targetLayerId: "source", inpaintRegionId: "region", maskAssetId: "mask-asset", viewMode: "overlay" });
|
||||
});
|
||||
|
||||
test("cancels the active operation and records a cancelled job", async () => {
|
||||
@@ -82,6 +83,7 @@ function dependencies(overrides: Partial<GenerationWorkflowDependencies>): Gener
|
||||
runGenerateFromCandidate: async () => undefined,
|
||||
createMaskedPixelReplacementSource: async () => "replacement",
|
||||
createRefinementMask: async () => "mask",
|
||||
createInpaintRegionMask: async () => "mask",
|
||||
loadGenerationResources: async () => undefined,
|
||||
createId: () => crypto.randomUUID(),
|
||||
...overrides,
|
||||
@@ -118,6 +120,6 @@ function createTestApp() {
|
||||
locked: false,
|
||||
layers: [],
|
||||
});
|
||||
const registry = createCommandRegistry([...documentCommands, ...toolCommands, ...generationCommands]);
|
||||
const registry = createCommandRegistry([...documentCommands, ...inpaintRegionCommands, ...toolCommands, ...generationCommands]);
|
||||
return { store: createAppStore(state, registry) };
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Layer } from "@core/layer";
|
||||
import type { GenerationCandidate, GenerationJobKind } from "@editor/state";
|
||||
import type { AppStore } from "@editor/store";
|
||||
import type { GenerateSettings } from "@editor/tools";
|
||||
import { createRefinementMask } from "@operations/masks/rasterActions";
|
||||
import { createInpaintRegionMask, createRefinementMask } from "@operations/masks/rasterActions";
|
||||
import { createMaskedPixelReplacementSource } from "./candidateActions";
|
||||
import { runGenerationJob } from "./generationJob";
|
||||
import { loadGenerationResources } from "./loadResources";
|
||||
@@ -17,6 +17,7 @@ export type GenerationWorkflowDependencies = {
|
||||
runGenerateFromCandidate: typeof runGenerateFromCandidate;
|
||||
createMaskedPixelReplacementSource: typeof createMaskedPixelReplacementSource;
|
||||
createRefinementMask: typeof createRefinementMask;
|
||||
createInpaintRegionMask: typeof createInpaintRegionMask;
|
||||
loadGenerationResources: typeof loadGenerationResources;
|
||||
createId(): string;
|
||||
};
|
||||
@@ -26,13 +27,14 @@ const defaultDependencies: GenerationWorkflowDependencies = {
|
||||
runGenerateFromCandidate,
|
||||
createMaskedPixelReplacementSource,
|
||||
createRefinementMask,
|
||||
createInpaintRegionMask,
|
||||
loadGenerationResources,
|
||||
createId: () => crypto.randomUUID(),
|
||||
};
|
||||
|
||||
export function createGenerationWorkflow(store: AppStore, dependencies: GenerationWorkflowDependencies = defaultDependencies) {
|
||||
let activeController: AbortController | undefined;
|
||||
const job = async (kind: GenerationJobKind, label: string, task: (signal: AbortSignal) => Promise<void>) => {
|
||||
const job = async (kind: GenerationJobKind, label: string, task: (signal: AbortSignal, report: (progress: number, detail: string) => void) => Promise<void>) => {
|
||||
if (store.getState().editor.generation.jobs.some((candidate) => candidate.status === "running")) return;
|
||||
const controller = new AbortController();
|
||||
activeController = controller;
|
||||
@@ -60,26 +62,22 @@ export function createGenerationWorkflow(store: AppStore, dependencies: Generati
|
||||
if (!layer || (layer.type !== "image" && layer.type !== "raster")) return;
|
||||
const asset = state.document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||
if (!asset) return;
|
||||
const source = await dependencies.createRefinementMask(asset.intrinsicSize.w, asset.intrinsicSize.h);
|
||||
const existing = state.document.inpaintRegions.find((region) => region.targetLayerId === layerId && region.enabled);
|
||||
if (existing) {
|
||||
store.dispatch(commandIds.toolEnterInpaintRegionEdit, { targetLayerId: layerId, regionId: existing.id });
|
||||
return;
|
||||
}
|
||||
const source = await dependencies.createInpaintRegionMask(asset.intrinsicSize.w, asset.intrinsicSize.h);
|
||||
const maskAssetId = dependencies.createId();
|
||||
const maskLayerId = dependencies.createId();
|
||||
store.dispatch(commandIds.documentAddLayerMask, {
|
||||
layerId,
|
||||
asset: { id: maskAssetId, name: `${layer.name} AI edit mask`, mimeType: "image/png", source, intrinsicSize: { ...asset.intrinsicSize } },
|
||||
maskLayer: {
|
||||
id: maskLayerId,
|
||||
type: "raster",
|
||||
name: `${layer.name} AI edit mask`,
|
||||
visible: true,
|
||||
locked: false,
|
||||
opacity: 1,
|
||||
assetId: maskAssetId,
|
||||
transform: { position: { ...layer.transform.position }, scale: { ...layer.transform.scale }, rotation: layer.transform.rotation },
|
||||
},
|
||||
const regionId = dependencies.createId();
|
||||
store.dispatch(commandIds.documentAddInpaintRegion, {
|
||||
region: { id: regionId, name: `${layer.name} AI edit`, targetLayerId: layer.id, maskAssetId, enabled: true },
|
||||
maskAsset: { id: maskAssetId, name: `${layer.name} AI edit mask`, mimeType: "image/png", source, intrinsicSize: { ...asset.intrinsicSize } },
|
||||
});
|
||||
store.dispatch(commandIds.toolEnterInpaintRegionEdit, { targetLayerId: layer.id, regionId });
|
||||
},
|
||||
|
||||
generate: () => job("generate", "Generating", async (signal) => {
|
||||
generate: () => job("generate", "Generating", async (signal, report) => {
|
||||
const state = store.getState();
|
||||
await dependencies.runGenerate({
|
||||
document: state.document,
|
||||
@@ -88,15 +86,34 @@ export function createGenerationWorkflow(store: AppStore, dependencies: Generati
|
||||
settings: state.editor.tools.generate,
|
||||
dispatch: store.dispatch,
|
||||
signal,
|
||||
onProgress: report,
|
||||
});
|
||||
}),
|
||||
|
||||
regenerate: (candidateId: string, settings?: GenerateSettings, label = "Regenerate") =>
|
||||
job("regenerate", label, async (signal) => {
|
||||
job("regenerate", label, async (signal, report) => {
|
||||
const candidate = findCandidate(store, candidateId);
|
||||
const nextSettings = settings ?? candidate.settings;
|
||||
store.dispatch(commandIds.toolSetGenerateSettings, nextSettings);
|
||||
await dependencies.runGenerateFromCandidate({ candidate, settings: nextSettings, dispatch: store.dispatch, signal });
|
||||
await dependencies.runGenerateFromCandidate({ candidate, settings: nextSettings, dispatch: store.dispatch, signal, onProgress: report });
|
||||
}),
|
||||
|
||||
rebuildFromCurrentRegion: (candidateId: string) =>
|
||||
job("regenerate", "Rebuilding from current edit region", async (signal, report) => {
|
||||
const candidate = findCandidate(store, candidateId);
|
||||
if (!candidate.inpaint) throw new Error("Only inpaint candidates can rebuild from an edit region.");
|
||||
store.dispatch(commandIds.selectionSet, { artboardId: candidate.placement.artboardId, layerIds: [candidate.inpaint.targetLayerId] });
|
||||
store.dispatch(commandIds.toolSetGenerateSettings, candidate.settings);
|
||||
const state = store.getState();
|
||||
await dependencies.runGenerate({
|
||||
document: state.document,
|
||||
selection: state.editor.selection,
|
||||
viewport: state.editor.viewport,
|
||||
settings: candidate.settings,
|
||||
dispatch: store.dispatch,
|
||||
signal,
|
||||
onProgress: report,
|
||||
});
|
||||
}),
|
||||
|
||||
applyCandidateAsLayer: (candidateId: string) => {
|
||||
|
||||
52
operations/masks/lasso.ts
Normal file
52
operations/masks/lasso.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { commandIds } from "@commands/ids";
|
||||
import type { Vec2D } from "@core/geometry";
|
||||
import type { Layer } from "@core/layer";
|
||||
import type { AppStore } from "@editor/store";
|
||||
import { applyPolygonMask } from "@platform/browser/maskRaster";
|
||||
|
||||
export async function commitInpaintLasso(store: AppStore) {
|
||||
const state = store.getState();
|
||||
const edit = state.editor.maskEdit;
|
||||
const session = state.editor.maskShapeSession;
|
||||
const minimumPoints = session?.shape === "rectangle" ? 2 : 3;
|
||||
if (edit?.kind !== "inpaintRegion" || !edit.inpaintRegionId || !session || session.points.length < minimumPoints) {
|
||||
store.dispatch(commandIds.toolClearMaskShape, undefined);
|
||||
return;
|
||||
}
|
||||
const region = state.document.inpaintRegions.find((candidate) => candidate.id === edit.inpaintRegionId);
|
||||
const target = findLayer(state.document.artboards.flatMap((artboard) => artboard.layers), edit.targetLayerId);
|
||||
const asset = region ? state.document.assets.find((candidate) => candidate.id === region.maskAssetId) : undefined;
|
||||
if (!region || !target || (target.type !== "image" && target.type !== "raster") || !asset) {
|
||||
store.dispatch(commandIds.toolClearMaskShape, undefined);
|
||||
return;
|
||||
}
|
||||
const documentPoints = session.shape === "rectangle" ? rectanglePoints(session.points[0]!, session.points[1]!) : session.points;
|
||||
const points = documentPoints.map((point) => documentPointToAssetPoint(point, target, asset.intrinsicSize));
|
||||
const source = await applyPolygonMask(asset.source, asset.intrinsicSize.w, asset.intrinsicSize.h, points, session.mode);
|
||||
store.dispatch(commandIds.documentApplyInpaintRegionMaskOperation, { regionId: region.id, source, mimeType: "image/png", operation: { type: "paint" } });
|
||||
store.dispatch(commandIds.toolClearMaskShape, undefined);
|
||||
}
|
||||
|
||||
function rectanglePoints(start: Vec2D, end: Vec2D): Vec2D[] {
|
||||
return [start, { x: end.x, y: start.y }, end, { x: start.x, y: end.y }];
|
||||
}
|
||||
|
||||
function documentPointToAssetPoint(point: Vec2D, layer: Extract<Layer, { type: "image" | "raster" }>, intrinsicSize: { w: number; h: number }): Vec2D {
|
||||
const source = layer.sourceRect ?? { x: 0, y: 0, ...intrinsicSize };
|
||||
const destination = { x: layer.transform.position.x + source.x * layer.transform.scale.x, y: layer.transform.position.y + source.y * layer.transform.scale.y, w: source.w * layer.transform.scale.x, h: source.h * layer.transform.scale.y };
|
||||
const center = { x: destination.x + destination.w / 2, y: destination.y + destination.h / 2 };
|
||||
const dx = point.x - center.x;
|
||||
const dy = point.y - center.y;
|
||||
const cos = Math.cos(-layer.transform.rotation);
|
||||
const sin = Math.sin(-layer.transform.rotation);
|
||||
const x = center.x + dx * cos - dy * sin;
|
||||
const y = center.y + dx * sin + dy * cos;
|
||||
return { x: source.x + (x - destination.x) / Math.max(0.0001, layer.transform.scale.x), y: source.y + (y - destination.y) / Math.max(0.0001, layer.transform.scale.y) };
|
||||
}
|
||||
|
||||
function findLayer(layers: readonly Layer[], id: string): Layer | undefined {
|
||||
for (const layer of layers) {
|
||||
if (layer.id === id) return layer;
|
||||
if (layer.type === "group") { const child = findLayer(layer.children, id); if (child) return child; }
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,17 @@ import { createWandMask } from "@platform/browser/magicWandRaster";
|
||||
export async function applyMagicWandAt(store: AppStore, point: Vec2D, modeOverride?: EditorState["tools"]["magicWand"]["mode"]) {
|
||||
const state = store.getState();
|
||||
if (state.editor.tools.activeTool !== "magicWand") return false;
|
||||
const target = resolveTarget(state.document, state.editor);
|
||||
const target = resolveMaskSelectionTarget(state.document, state.editor);
|
||||
if (!target) return true;
|
||||
const x = Math.floor((point.x - target.layer.transform.position.x) / Math.max(0.0001, target.layer.transform.scale.x));
|
||||
const y = Math.floor((point.y - target.layer.transform.position.y) / Math.max(0.0001, target.layer.transform.scale.y));
|
||||
const assetPoint = documentPointToAssetPoint(point, target.layer, target.asset.intrinsicSize);
|
||||
const x = Math.floor(assetPoint.x);
|
||||
const y = Math.floor(assetPoint.y);
|
||||
if (x < 0 || y < 0 || x >= target.asset.intrinsicSize.w || y >= target.asset.intrinsicSize.h) return true;
|
||||
const source = await createWandMask(target.asset.source, target.maskAsset?.source, Math.round(target.asset.intrinsicSize.w), Math.round(target.asset.intrinsicSize.h), x, y, { ...state.editor.tools.magicWand, mode: modeOverride ?? state.editor.tools.magicWand.mode });
|
||||
const source = await createWandMask(target.asset.source, target.maskAsset?.source, Math.round(target.asset.intrinsicSize.w), Math.round(target.asset.intrinsicSize.h), x, y, { ...state.editor.tools.magicWand, mode: modeOverride ?? state.editor.tools.magicWand.mode, target: target.inpaintRegion ? "inpaint" : "visibility" });
|
||||
if (target.inpaintRegion && target.maskAsset) {
|
||||
store.dispatch(commandIds.documentApplyInpaintRegionMaskOperation, { regionId: target.inpaintRegion.id, source, mimeType: "image/png", operation: { type: "magicWand" } });
|
||||
return true;
|
||||
}
|
||||
if (target.maskAsset && target.maskLayer && (target.maskLayer.type === "image" || target.maskLayer.type === "raster")) {
|
||||
store.dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId: target.maskLayer.id, source, mimeType: "image/png", operation: { type: "magicWand" } });
|
||||
return true;
|
||||
@@ -35,7 +40,7 @@ export async function applyMagicWandAt(store: AppStore, point: Vec2D, modeOverri
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveTarget(document: ImageDocument, editor: EditorState) {
|
||||
export function resolveMaskSelectionTarget(document: ImageDocument, editor: EditorState) {
|
||||
const layerId = editor.selection.layerIds[0];
|
||||
if (!layerId || editor.selection.layerIds.length !== 1) return undefined;
|
||||
const layer = findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
|
||||
@@ -43,9 +48,23 @@ function resolveTarget(document: ImageDocument, editor: EditorState) {
|
||||
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||
const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id });
|
||||
const layerMask = getLayerMask(layer);
|
||||
const inpaintRegion = editor.maskEdit?.kind === "inpaintRegion" ? document.inpaintRegions.find((candidate) => candidate.id === editor.maskEdit?.inpaintRegionId && candidate.targetLayerId === layer.id) : undefined;
|
||||
const maskLayer = layerMask?.enabled ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerMask.maskLayerId) : undefined;
|
||||
const maskAsset = maskLayer && (maskLayer.type === "image" || maskLayer.type === "raster") ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
|
||||
return asset && bounds ? { layer, asset, bounds, maskLayer, maskAsset } : undefined;
|
||||
const maskAsset = inpaintRegion ? document.assets.find((candidate) => candidate.id === inpaintRegion.maskAssetId) : maskLayer && (maskLayer.type === "image" || maskLayer.type === "raster") ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
|
||||
return asset && bounds ? { layer, asset, bounds, maskLayer, maskAsset, inpaintRegion } : undefined;
|
||||
}
|
||||
|
||||
export function documentPointToAssetPoint(point: Vec2D, layer: Extract<Layer, { type: "image" | "raster" }>, intrinsicSize: { w: number; h: number }): Vec2D {
|
||||
const source = layer.sourceRect ?? { x: 0, y: 0, ...intrinsicSize };
|
||||
const destination = { x: layer.transform.position.x + source.x * layer.transform.scale.x, y: layer.transform.position.y + source.y * layer.transform.scale.y, w: source.w * layer.transform.scale.x, h: source.h * layer.transform.scale.y };
|
||||
const center = { x: destination.x + destination.w / 2, y: destination.y + destination.h / 2 };
|
||||
const dx = point.x - center.x;
|
||||
const dy = point.y - center.y;
|
||||
const cos = Math.cos(-layer.transform.rotation);
|
||||
const sin = Math.sin(-layer.transform.rotation);
|
||||
const x = center.x + dx * cos - dy * sin;
|
||||
const y = center.y + dx * sin + dy * cos;
|
||||
return { x: source.x + (x - destination.x) / Math.max(0.0001, layer.transform.scale.x), y: source.y + (y - destination.y) / Math.max(0.0001, layer.transform.scale.y) };
|
||||
}
|
||||
|
||||
function findLayer(layers: readonly Layer[], layerId: string): Layer | undefined {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { commandIds } from "@commands/ids";
|
||||
import type { Asset } from "@core/asset";
|
||||
import type { LayerId } from "@core/id";
|
||||
import type { InpaintRegionId } from "@core/id";
|
||||
import type { AppStore } from "@editor/store";
|
||||
import { analyzeMaskSource, applyMaskRasterOperation, createSolidMaskSource, type MaskAnalysis, type MaskRasterOperation } from "@platform/browser/maskRaster";
|
||||
|
||||
export type { MaskAnalysis, MaskRasterOperation };
|
||||
export function analyzeMask(asset: Asset): Promise<MaskAnalysis> { return analyzeMaskSource(asset.source, asset.intrinsicSize.w, asset.intrinsicSize.h); }
|
||||
export async function runMaskOperation(maskLayerId: LayerId, asset: Asset, operation: MaskRasterOperation, dispatch: AppStore["dispatch"]) { const source = await applyMaskRasterOperation(asset.source, asset.intrinsicSize.w, asset.intrinsicSize.h, operation); dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId, source, mimeType: "image/png", operation }); }
|
||||
export async function runInpaintRegionOperation(regionId: InpaintRegionId, asset: Asset, operation: MaskRasterOperation, dispatch: AppStore["dispatch"]) { const source = await applyMaskRasterOperation(asset.source, asset.intrinsicSize.w, asset.intrinsicSize.h, operation); dispatch(commandIds.documentApplyInpaintRegionMaskOperation, { regionId, source, mimeType: "image/png", operation }); }
|
||||
export function createRefinementMask(width: number, height: number) { return createSolidMaskSource(width, height, "white"); }
|
||||
export function createInpaintRegionMask(width: number, height: number) { return createSolidMaskSource(width, height, "black"); }
|
||||
|
||||
34
operations/masks/semantic-select.ts
Normal file
34
operations/masks/semantic-select.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { commandIds } from "@commands/ids";
|
||||
import type { Vec2D } from "@core/geometry";
|
||||
import type { AppStore } from "@editor/store";
|
||||
import { mergeMaskSources } from "@platform/browser/maskRaster";
|
||||
import { requestSemanticSelection } from "@platform/comfy/generationClient";
|
||||
import { runGenerationJob } from "@operations/generation/generationJob";
|
||||
import { documentPointToAssetPoint, resolveMaskSelectionTarget } from "./magic-wand";
|
||||
|
||||
export async function applySemanticSelectionAt(store: AppStore, point: Vec2D, mode: "replace" | "add" | "subtract") {
|
||||
const state = store.getState();
|
||||
if (state.editor.tools.activeTool !== "semanticSelect") return false;
|
||||
const target = resolveMaskSelectionTarget(state.document, state.editor);
|
||||
if (!target?.inpaintRegion || !target.maskAsset) return true;
|
||||
const region = target.inpaintRegion;
|
||||
const maskAsset = target.maskAsset;
|
||||
const assetPoint = documentPointToAssetPoint(point, target.layer, target.asset.intrinsicSize);
|
||||
if (assetPoint.x < 0 || assetPoint.y < 0 || assetPoint.x >= target.asset.intrinsicSize.w || assetPoint.y >= target.asset.intrinsicSize.h) return true;
|
||||
const controller = new AbortController();
|
||||
await runGenerationJob({
|
||||
kind: "mask",
|
||||
label: "Selecting object",
|
||||
dispatch: store.dispatch,
|
||||
signal: controller.signal,
|
||||
task: async (signal, report) => {
|
||||
report(0.1, "Sending point to SAM3");
|
||||
const result = await requestSemanticSelection({ inputImage: target.asset.source, x: assetPoint.x, y: assetPoint.y }, signal);
|
||||
report(0.85, "Merging object mask");
|
||||
const source = await mergeMaskSources(maskAsset.source, result.source, Math.round(target.asset.intrinsicSize.w), Math.round(target.asset.intrinsicSize.h), mode);
|
||||
store.dispatch(commandIds.documentApplyInpaintRegionMaskOperation, { regionId: region.id, source, mimeType: "image/png", operation: { type: "magicWand" } });
|
||||
report(1, "Object selected");
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export type BrushSession = {
|
||||
previewInFlight?: boolean;
|
||||
previewFrame?: number;
|
||||
previewSource?: string;
|
||||
targetLayer: RasterLayer;
|
||||
};
|
||||
|
||||
export type BrushTargetEditorState = {
|
||||
@@ -34,11 +35,9 @@ export type BrushTargetEditorState = {
|
||||
};
|
||||
|
||||
export function beginBrushSession(document: ImageDocument, editor: BrushTargetEditorState, point: Vec2D): BrushSession | undefined {
|
||||
const layer = resolveBrushTargetLayer(document, editor);
|
||||
if (!layer || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
|
||||
|
||||
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||
if (!asset) return undefined;
|
||||
const target = resolveBrushTarget(document, editor);
|
||||
if (!target || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
|
||||
const { layer, asset } = target;
|
||||
|
||||
const surface = createBrushSurface(asset.intrinsicSize.w, asset.intrinsicSize.h, asset.source);
|
||||
if (!surface) return undefined;
|
||||
@@ -52,17 +51,18 @@ export function beginBrushSession(document: ImageDocument, editor: BrushTargetEd
|
||||
ready: surface.ready,
|
||||
previousPoint: point,
|
||||
mode: editor.tools.activeTool,
|
||||
targetLayer: layer,
|
||||
};
|
||||
return session;
|
||||
}
|
||||
|
||||
export function canPreviewBrush(document: ImageDocument, editor: BrushTargetEditorState): boolean {
|
||||
return Boolean(resolveBrushTargetLayer(document, editor));
|
||||
return Boolean(resolveBrushTarget(document, editor));
|
||||
}
|
||||
|
||||
export function brushUnavailableHint(document: ImageDocument, editor: BrushTargetEditorState): string | undefined {
|
||||
if (isPanInteractionMode(editor.tools.interactionMode) || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
|
||||
if (resolveBrushTargetLayer(document, editor)) return undefined;
|
||||
if (resolveBrushTarget(document, editor)) return undefined;
|
||||
|
||||
const layerId = editor.maskEdit?.maskLayerId ?? editor.selection.layerIds[0];
|
||||
if (!layerId) {
|
||||
@@ -79,14 +79,24 @@ export function brushUnavailableHint(document: ImageDocument, editor: BrushTarge
|
||||
return "Select a raster layer or layer mask to paint.";
|
||||
}
|
||||
|
||||
function resolveBrushTargetLayer(document: ImageDocument, editor: BrushTargetEditorState): RasterLayer | undefined {
|
||||
function resolveBrushTarget(document: ImageDocument, editor: BrushTargetEditorState): { layer: RasterLayer; asset: ImageDocument["assets"][number] } | undefined {
|
||||
if (isPanInteractionMode(editor.tools.interactionMode) || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
|
||||
if (editor.maskEdit?.kind === "inpaintRegion") {
|
||||
const target = findLayer(document.artboards.flatMap((artboard) => artboard.layers), editor.maskEdit.targetLayerId);
|
||||
const asset = document.assets.find((candidate) => candidate.id === editor.maskEdit?.maskAssetId);
|
||||
if (!target || (target.type !== "image" && target.type !== "raster") || target.locked || !asset) return undefined;
|
||||
return {
|
||||
layer: { ...target, type: "raster", assetId: asset.id },
|
||||
asset,
|
||||
};
|
||||
}
|
||||
const editingMask = Boolean(editor.maskEdit);
|
||||
const layerId = editor.maskEdit?.maskLayerId ?? editor.selection.layerIds[0];
|
||||
if (!layerId) return undefined;
|
||||
const layer = findRasterLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
|
||||
if (!layer || layer.locked || (!editingMask && !layer.visible)) return undefined;
|
||||
return layer;
|
||||
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||
return asset ? { layer, asset } : undefined;
|
||||
}
|
||||
|
||||
export function updateBrushSession(options: {
|
||||
@@ -96,13 +106,21 @@ export function updateBrushSession(options: {
|
||||
color: string;
|
||||
size: number;
|
||||
hardness: number;
|
||||
opacity: number;
|
||||
flow: number;
|
||||
smoothing: number;
|
||||
pressure: number;
|
||||
pressureSize: boolean;
|
||||
}): BrushSession {
|
||||
const state = options.store.getState();
|
||||
const layer = findRasterLayer(state.document.artboards.flatMap((artboard) => artboard.layers), options.session.layerId);
|
||||
if (!layer || layer.assetId !== options.session.assetId) return options.session;
|
||||
const asset = state.document.assets.find((candidate) => candidate.id === options.session.assetId);
|
||||
if (!asset) return options.session;
|
||||
const layer = options.session.targetLayer;
|
||||
|
||||
const from = options.session.previousPoint;
|
||||
const to = options.point;
|
||||
const smoothing = Math.max(0, Math.min(100, options.smoothing)) / 100;
|
||||
const follow = 1 - smoothing * 0.85;
|
||||
const to = { x: from.x + (options.point.x - from.x) * follow, y: from.y + (options.point.y - from.y) * follow };
|
||||
options.session.previousPoint = to;
|
||||
options.session.pending = (options.session.pending ?? Promise.resolve())
|
||||
.then(async () => {
|
||||
@@ -113,8 +131,10 @@ export function updateBrushSession(options: {
|
||||
from: documentPointToAssetPoint(from, layer, options.session.width, options.session.height),
|
||||
to: documentPointToAssetPoint(to, layer, options.session.width, options.session.height),
|
||||
color: state.editor.maskEdit ? "#ffffff" : options.color,
|
||||
size: options.size,
|
||||
size: options.size * (options.pressureSize ? Math.max(0.1, options.pressure) : 1),
|
||||
hardness: options.hardness,
|
||||
opacity: options.opacity,
|
||||
flow: options.flow,
|
||||
mode: options.session.mode,
|
||||
});
|
||||
|
||||
@@ -135,7 +155,9 @@ export async function commitBrushSession(options: { store: AppStore; session: Br
|
||||
if (source) {
|
||||
const state = options.store.getState();
|
||||
const maskEdit = state.editor.maskEdit;
|
||||
if (maskEdit?.maskLayerId === options.session.layerId) {
|
||||
if (maskEdit?.kind === "inpaintRegion" && maskEdit.inpaintRegionId && maskEdit.maskAssetId === options.session.assetId) {
|
||||
options.store.dispatch(commandIds.documentApplyInpaintRegionMaskOperation, { regionId: maskEdit.inpaintRegionId, source, mimeType: "image/png", operation: { type: "paint" } });
|
||||
} else if (maskEdit?.maskLayerId === options.session.layerId) {
|
||||
options.store.dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId: maskEdit.maskLayerId, source, mimeType: "image/png", operation: { type: "paint" } });
|
||||
} else {
|
||||
options.store.dispatch(commandIds.documentUpdateAssetSource, { assetId: options.session.assetId, source });
|
||||
@@ -152,9 +174,22 @@ export function cancelBrushSession(options: { store: AppStore; session: BrushSes
|
||||
}
|
||||
|
||||
function documentPointToAssetPoint(point: Vec2D, layer: RasterLayer, width: number, height: number): Vec2D {
|
||||
const source = layer.sourceRect ?? { x: 0, y: 0, w: width, h: height };
|
||||
const destination = {
|
||||
x: layer.transform.position.x + source.x * layer.transform.scale.x,
|
||||
y: layer.transform.position.y + source.y * layer.transform.scale.y,
|
||||
w: source.w * layer.transform.scale.x,
|
||||
h: source.h * layer.transform.scale.y,
|
||||
};
|
||||
const center = { x: destination.x + destination.w / 2, y: destination.y + destination.h / 2 };
|
||||
const cos = Math.cos(-layer.transform.rotation);
|
||||
const sin = Math.sin(-layer.transform.rotation);
|
||||
const dx = point.x - center.x;
|
||||
const dy = point.y - center.y;
|
||||
const unrotated = { x: center.x + dx * cos - dy * sin, y: center.y + dx * sin + dy * cos };
|
||||
return {
|
||||
x: ((point.x - layer.transform.position.x) / Math.max(0.0001, layer.transform.scale.x) / width) * width,
|
||||
y: ((point.y - layer.transform.position.y) / Math.max(0.0001, layer.transform.scale.y) / height) * height,
|
||||
x: source.x + (unrotated.x - destination.x) / Math.max(0.0001, layer.transform.scale.x),
|
||||
y: source.y + (unrotated.y - destination.y) / Math.max(0.0001, layer.transform.scale.y),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ describe("project format", () => {
|
||||
|
||||
test("migrates a legacy bare document", () => {
|
||||
const result = parseProject(JSON.stringify(projectDocument()));
|
||||
expect(result.version).toBe(1);
|
||||
expect(result.version).toBe(CURRENT_PROJECT_VERSION);
|
||||
expect(result.savedAt).toBe("1970-01-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
@@ -76,6 +76,7 @@ function projectDocument(): ImageDocument {
|
||||
name: "Test Project",
|
||||
version: 1,
|
||||
assets: [{ id: "asset-1", name: "pixels.png", mimeType: "image/png", source: "data:image/png;base64,AA==", intrinsicSize: { w: 10, h: 20 } }],
|
||||
inpaintRegions: [],
|
||||
artboards: [{
|
||||
id: "artboard-1",
|
||||
name: "Board",
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Rect } from "@core/geometry";
|
||||
import { isValidTextStyle, type TextStyle } from "@core/text-layer";
|
||||
|
||||
export const PROJECT_FORMAT = "image-studio-project";
|
||||
export const CURRENT_PROJECT_VERSION = 1;
|
||||
export const CURRENT_PROJECT_VERSION = 2;
|
||||
|
||||
export type ProjectFile = {
|
||||
format: typeof PROJECT_FORMAT;
|
||||
@@ -41,6 +41,14 @@ function migrateProject(value: unknown): ProjectFile {
|
||||
if (!isRecord(value)) throw new Error("The project file must contain an object.");
|
||||
|
||||
if (value.format === PROJECT_FORMAT) {
|
||||
if (value.version === 1 && isRecord(value.document)) {
|
||||
return {
|
||||
format: PROJECT_FORMAT,
|
||||
version: CURRENT_PROJECT_VERSION,
|
||||
savedAt: typeof value.savedAt === "string" ? value.savedAt : new Date(0).toISOString(),
|
||||
document: { ...value.document, inpaintRegions: [] } as unknown as ImageDocument,
|
||||
};
|
||||
}
|
||||
if (value.version !== CURRENT_PROJECT_VERSION) {
|
||||
throw new Error(`Unsupported project version: ${String(value.version)}.`);
|
||||
}
|
||||
@@ -48,13 +56,13 @@ function migrateProject(value: unknown): ProjectFile {
|
||||
return value as ProjectFile;
|
||||
}
|
||||
|
||||
// Legacy exports stored ImageDocument directly. Loading upgrades them to v1.
|
||||
// Legacy exports stored ImageDocument directly. Loading upgrades them to the current format.
|
||||
if (looksLikeDocument(value)) {
|
||||
return {
|
||||
format: PROJECT_FORMAT,
|
||||
version: CURRENT_PROJECT_VERSION,
|
||||
savedAt: new Date(0).toISOString(),
|
||||
document: value as ImageDocument,
|
||||
document: { ...value, inpaintRegions: Array.isArray(value.inpaintRegions) ? value.inpaintRegions : [] } as ImageDocument,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -65,7 +73,7 @@ function assertImageDocument(value: unknown): asserts value is ImageDocument {
|
||||
if (!isRecord(value) || typeof value.id !== "string" || typeof value.name !== "string" || typeof value.version !== "number") {
|
||||
throw new Error("The project contains an invalid document.");
|
||||
}
|
||||
if (!Array.isArray(value.artboards) || !Array.isArray(value.assets)) throw new Error("The project document is incomplete.");
|
||||
if (!Array.isArray(value.artboards) || !Array.isArray(value.assets) || !Array.isArray(value.inpaintRegions)) throw new Error("The project document is incomplete.");
|
||||
|
||||
for (const asset of value.assets) {
|
||||
if (!isRecord(asset) || typeof asset.id !== "string" || typeof asset.name !== "string" || typeof asset.mimeType !== "string" || typeof asset.source !== "string" || !isSize(asset.intrinsicSize)) {
|
||||
@@ -78,6 +86,11 @@ function assertImageDocument(value: unknown): asserts value is ImageDocument {
|
||||
}
|
||||
assertLayers(artboard.layers, false);
|
||||
}
|
||||
for (const region of value.inpaintRegions) {
|
||||
if (!isRecord(region) || typeof region.id !== "string" || typeof region.name !== "string" || typeof region.targetLayerId !== "string" || typeof region.maskAssetId !== "string" || typeof region.enabled !== "boolean") {
|
||||
throw new Error("The project contains an invalid inpaint region.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function assertDocumentAssetOwnership(document: ImageDocument): void {
|
||||
@@ -98,6 +111,10 @@ export function assertDocumentAssetOwnership(document: ImageDocument): void {
|
||||
}
|
||||
});
|
||||
}
|
||||
for (const region of document.inpaintRegions) {
|
||||
if (!assetIds.has(region.maskAssetId)) throw new Error(`Inpaint region ${region.name} references missing mask asset ${region.maskAssetId}.`);
|
||||
if (!layerIds.has(region.targetLayerId)) throw new Error(`Inpaint region ${region.name} references missing target layer ${region.targetLayerId}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function isOwnedAssetSource(source: string): boolean {
|
||||
|
||||
Reference in New Issue
Block a user