feat: enhance generate settings to support multiple architectures and their defaults

This commit is contained in:
syntaxbullet
2026-07-05 11:21:56 +02:00
parent dd0c1df730
commit 0b6b064085
7 changed files with 435 additions and 52 deletions

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { buildSdxlWorkflow, selectGeneratedOutputImage } from "./comfy"; import { buildAnimaWorkflow, buildSdxlWorkflow, buildZImageTurboWorkflow, buildZImageWorkflow, selectGeneratedOutputImage } from "./comfy";
describe("Comfy adapter", () => { describe("Comfy adapter", () => {
test("selects SaveImage output instead of uploaded input or mask images", () => { test("selects SaveImage output instead of uploaded input or mask images", () => {
@@ -43,6 +43,48 @@ describe("Comfy adapter", () => {
expect(workflow["13"]?.inputs).toMatchObject({ samples: ["5", 0], mask: ["12", 0] }); expect(workflow["13"]?.inputs).toMatchObject({ samples: ["5", 0], mask: ["12", 0] });
expect(workflow["6"]?.inputs.latent_image).toEqual(["13", 0]); expect(workflow["6"]?.inputs.latent_image).toEqual(["13", 0]);
}); });
test("builds Z-Image text-to-image with separated model loaders", () => {
const workflow = buildZImageWorkflow(textRequest({ architecture: "z-image", model: "z_image_bf16.safetensors", steps: 30, cfg: 4 }));
expect(workflow["1"]).toMatchObject({ class_type: "UNETLoader", inputs: { unet_name: "z_image_bf16.safetensors", weight_dtype: "default" } });
expect(workflow["2"]).toMatchObject({ class_type: "CLIPLoader", inputs: { clip_name: "qwen_3_4b.safetensors", type: "lumina2" } });
expect(workflow["6"]?.class_type).toBe("EmptySD3LatentImage");
expect(workflow["7"]).toMatchObject({ class_type: "ModelSamplingAuraFlow", inputs: { shift: 3 } });
expect(workflow["8"]?.inputs).toMatchObject({ steps: 30, cfg: 4, sampler_name: "res_multistep", scheduler: "simple", model: ["7", 0] });
expect(workflow["10"]?.class_type).toBe("SaveImage");
});
test("builds Z-Image Turbo with zeroed negative conditioning", () => {
const workflow = buildZImageTurboWorkflow(textRequest({ architecture: "z-image-turbo", model: "z_image_turbo_bf16.safetensors", negativePrompt: "ignored" }));
expect(workflow["1"]?.inputs.unet_name).toBe("z_image_turbo_bf16.safetensors");
expect(workflow["5"]).toMatchObject({ class_type: "ConditioningZeroOut", inputs: { conditioning: ["4", 0] } });
expect(workflow["8"]?.inputs).toMatchObject({ steps: 8, cfg: 1, sampler_name: "res_multistep", scheduler: "simple" });
});
test("builds Anima text-to-image workflow", () => {
const workflow = buildAnimaWorkflow(textRequest({ architecture: "anima", model: "anima-base-v1.0.safetensors" }));
expect(workflow["1"]).toMatchObject({ class_type: "UNETLoader", inputs: { unet_name: "anima-base-v1.0.safetensors" } });
expect(workflow["2"]).toMatchObject({ class_type: "CLIPLoader", inputs: { clip_name: "qwen_3_06b_base.safetensors", type: "stable_diffusion" } });
expect(workflow["3"]).toMatchObject({ class_type: "VAELoader", inputs: { vae_name: "qwen_image_vae.safetensors" } });
expect(workflow["6"]?.class_type).toBe("EmptyLatentImage");
expect(workflow["8"]?.inputs).toMatchObject({ steps: 30, cfg: 4, sampler_name: "er_sde", scheduler: "simple", model: ["1", 0] });
});
test("builds Anima with model-specific text encoder and VAE", () => {
const workflow = buildAnimaWorkflow(textRequest({
architecture: "anima",
model: "miaomiaoHarem_anima13.safetensors",
textEncoder: "miaomiaoHarem_anima13_txt.safetensors",
vae: "qwen_image_vae.safetensors",
}));
expect(workflow["1"]?.inputs.unet_name).toBe("miaomiaoHarem_anima13.safetensors");
expect(workflow["2"]?.inputs.clip_name).toBe("miaomiaoHarem_anima13_txt.safetensors");
expect(workflow["3"]?.inputs.vae_name).toBe("qwen_image_vae.safetensors");
});
}); });
function inpaintRequest(inpaint: { maskedContent: "neutral" | "original"; growMaskBy?: number }) { function inpaintRequest(inpaint: { maskedContent: "neutral" | "original"; growMaskBy?: number }) {
@@ -57,3 +99,17 @@ function inpaintRequest(inpaint: { maskedContent: "neutral" | "original"; growMa
inpaint, inpaint,
}; };
} }
function textRequest(overrides: Partial<Parameters<typeof buildSdxlWorkflow>[0]> = {}) {
return {
architecture: "sdxl" as const,
mode: "text-to-image" as const,
model: "model.safetensors",
prompt: "a studio portrait",
negativePrompt: "low quality",
width: 1024,
height: 1024,
seed: 123,
...overrides,
};
}

View File

@@ -1,8 +1,13 @@
type GenerateArchitecture = "sdxl" | "z-image" | "z-image-turbo" | "anima";
type GenerateMode = "text-to-image" | "image-to-image" | "inpaint" | "outpaint"; type GenerateMode = "text-to-image" | "image-to-image" | "inpaint" | "outpaint";
type Workflow = Record<string, { class_type: string; inputs: Record<string, unknown> }>;
type ComfyGenerateRequest = { type ComfyGenerateRequest = {
architecture?: GenerateArchitecture;
mode: GenerateMode; mode: GenerateMode;
model: string; model: string;
textEncoder?: string;
vae?: string;
prompt: string; prompt: string;
negativePrompt?: string; negativePrompt?: string;
strength?: number; strength?: number;
@@ -35,7 +40,21 @@ type ComfyGenerateRequest = {
maskImage?: string; maskImage?: string;
}; };
type ComfyObjectInfo = {
CheckpointLoaderSimple?: { input?: { required?: { ckpt_name?: [string[]] } } };
KSampler?: { input?: { required?: { sampler_name?: [string[]]; scheduler?: [string[]] } } };
UNETLoader?: { input?: { required?: { unet_name?: [string[]] } } };
CLIPLoader?: { input?: { required?: { clip_name?: [string[]] } } };
VAELoader?: { input?: { required?: { vae_name?: [string[]] } } };
};
const comfyBaseUrl = process.env.COMFYUI_URL ?? "http://127.0.0.1:8188"; const comfyBaseUrl = process.env.COMFYUI_URL ?? "http://127.0.0.1:8188";
const defaultModels: Record<GenerateArchitecture, string> = {
sdxl: "sd_xl_base_1.0.safetensors",
"z-image": "z_image_bf16.safetensors",
"z-image-turbo": "z_image_turbo_bf16.safetensors",
anima: "anima-base-v1.0.safetensors",
};
export async function handleComfyApi(request: Request) { export async function handleComfyApi(request: Request) {
try { try {
@@ -51,14 +70,49 @@ export async function handleComfyApi(request: Request) {
async function listGenerationOptions() { async function listGenerationOptions() {
const response = await fetch(`${comfyBaseUrl}/object_info`); const response = await fetch(`${comfyBaseUrl}/object_info`);
if (!response.ok) throw new Error(`ComfyUI option lookup failed: ${response.status}`); if (!response.ok) throw new Error(`ComfyUI option lookup failed: ${response.status}`);
const info = await response.json() as { const info = await response.json() as ComfyObjectInfo;
CheckpointLoaderSimple?: { input?: { required?: { ckpt_name?: [string[]] } } }; const checkpointModels = info.CheckpointLoaderSimple?.input?.required?.ckpt_name?.[0] ?? [];
KSampler?: { input?: { required?: { sampler_name?: [string[]]; scheduler?: [string[]] } } }; const diffusionModels = info.UNETLoader?.input?.required?.unet_name?.[0] ?? [];
}; const textEncoders = info.CLIPLoader?.input?.required?.clip_name?.[0] ?? [];
const vaes = info.VAELoader?.input?.required?.vae_name?.[0] ?? [];
return { return {
models: info.CheckpointLoaderSimple?.input?.required?.ckpt_name?.[0] ?? [], models: checkpointModels,
samplers: info.KSampler?.input?.required?.sampler_name?.[0] ?? [], samplers: info.KSampler?.input?.required?.sampler_name?.[0] ?? [],
schedulers: info.KSampler?.input?.required?.scheduler?.[0] ?? [], schedulers: info.KSampler?.input?.required?.scheduler?.[0] ?? [],
diffusionModels,
textEncoders,
vaes,
architectures: [
{
value: "sdxl",
label: "SDXL",
defaultModel: checkpointModels[0] ?? defaultModels.sdxl,
models: checkpointModels,
supportedModes: ["text-to-image", "image-to-image", "inpaint", "outpaint"],
},
{
value: "z-image",
label: "Z-Image",
defaultModel: defaultModels["z-image"],
models: modelsForArchitecture(diffusionModels, defaultModels["z-image"], [/z[_-]?image(?!.*turbo)/i]),
supportedModes: ["text-to-image"],
},
{
value: "z-image-turbo",
label: "Z-Image Turbo",
defaultModel: defaultModels["z-image-turbo"],
models: modelsForArchitecture(diffusionModels, defaultModels["z-image-turbo"], [/z[_-]?image.*turbo/i]),
supportedModes: ["text-to-image"],
},
{
value: "anima",
label: "Anima",
defaultModel: defaultModels.anima,
models: modelsForArchitecture(diffusionModels, defaultModels.anima, [/anima/i]),
supportedModes: ["text-to-image"],
},
],
}; };
} }
@@ -68,16 +122,15 @@ async function listCheckpointModels() {
async function generate(request: ComfyGenerateRequest) { async function generate(request: ComfyGenerateRequest) {
if (!request.prompt?.trim()) throw new Error("Prompt is required"); if (!request.prompt?.trim()) throw new Error("Prompt is required");
if (!request.model || request.model === "auto") { const architecture = normalizeArchitecture(request.architecture);
const models = await listCheckpointModels(); if (request.mode !== "text-to-image" && architecture !== "sdxl") throw new Error(`${architectureLabel(architecture)} currently supports text-to-image only`);
request.model = models[0] ?? "sd_xl_base_1.0.safetensors"; if (!request.model || request.model === "auto") request.model = await defaultModelForArchitecture(architecture);
}
const clientId = crypto.randomUUID(); const clientId = crypto.randomUUID();
const uploaded = request.inputImage ? await uploadDataUrl(request.inputImage, `image-studio-${crypto.randomUUID()}.png`) : undefined; const uploaded = request.inputImage ? await uploadDataUrl(request.inputImage, `image-studio-${crypto.randomUUID()}.png`) : undefined;
const mask = request.maskImage ? await uploadDataUrl(request.maskImage, `image-studio-mask-${crypto.randomUUID()}.png`) : undefined; const mask = request.maskImage ? await uploadDataUrl(request.maskImage, `image-studio-mask-${crypto.randomUUID()}.png`) : undefined;
if (request.mode === "inpaint" && (!uploaded || !mask)) throw new Error("Inpaint requires normalized input and mask images"); if (request.mode === "inpaint" && (!uploaded || !mask)) throw new Error("Inpaint requires normalized input and mask images");
const prompt = buildSdxlWorkflow({ ...request, inputImage: uploaded, maskImage: mask }); const prompt = buildComfyWorkflow({ ...request, architecture, inputImage: uploaded, maskImage: mask });
const queued = await fetch(`${comfyBaseUrl}/prompt`, { const queued = await fetch(`${comfyBaseUrl}/prompt`, {
method: "POST", method: "POST",
@@ -143,7 +196,21 @@ function isGeneratedImage(image: { filename: string; subfolder?: string; type?:
return image.type === undefined || image.type === "output"; return image.type === undefined || image.type === "output";
} }
export function buildSdxlWorkflow(request: ComfyGenerateRequest) { export function buildComfyWorkflow(request: ComfyGenerateRequest): Workflow {
switch (normalizeArchitecture(request.architecture)) {
case "z-image":
return buildZImageWorkflow(request);
case "z-image-turbo":
return buildZImageTurboWorkflow(request);
case "anima":
return buildAnimaWorkflow(request);
case "sdxl":
default:
return buildSdxlWorkflow(request);
}
}
export function buildSdxlWorkflow(request: ComfyGenerateRequest): Workflow {
if (!request.width || !request.height) throw new Error("Generation width and height are required"); if (!request.width || !request.height) throw new Error("Generation width and height are required");
const width = Math.max(64, Math.round(request.width)); const width = Math.max(64, Math.round(request.width));
const height = Math.max(64, Math.round(request.height)); const height = Math.max(64, Math.round(request.height));
@@ -155,7 +222,7 @@ export function buildSdxlWorkflow(request: ComfyGenerateRequest) {
const scheduler = request.scheduler ?? "normal"; const scheduler = request.scheduler ?? "normal";
const positive = request.prompt; const positive = request.prompt;
const negative = request.negativePrompt ?? ""; const negative = request.negativePrompt ?? "";
const workflow: Record<string, { class_type: string; inputs: Record<string, unknown> }> = { const workflow: Workflow = {
"1": { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: request.model } }, "1": { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: request.model } },
"2": { class_type: "CLIPTextEncode", inputs: { text: positive, clip: ["1", 1] } }, "2": { class_type: "CLIPTextEncode", inputs: { text: positive, clip: ["1", 1] } },
"3": { class_type: "CLIPTextEncode", inputs: { text: negative, clip: ["1", 1] } }, "3": { class_type: "CLIPTextEncode", inputs: { text: negative, clip: ["1", 1] } },
@@ -195,6 +262,114 @@ export function buildSdxlWorkflow(request: ComfyGenerateRequest) {
return workflow; return workflow;
} }
export function buildZImageWorkflow(request: ComfyGenerateRequest): Workflow {
return buildSeparatedTextToImageWorkflow(request, {
architecture: "z-image",
filenamePrefix: "image-studio-z-image",
model: defaultModels["z-image"],
textEncoder: "qwen_3_4b.safetensors",
vae: "ae.safetensors",
clipType: "lumina2",
latentNode: "EmptySD3LatentImage",
modelSamplingAuraFlow: true,
negativeMode: "prompt",
steps: 30,
cfg: 4,
sampler: "res_multistep",
scheduler: "simple",
});
}
export function buildZImageTurboWorkflow(request: ComfyGenerateRequest): Workflow {
return buildSeparatedTextToImageWorkflow(request, {
architecture: "z-image-turbo",
filenamePrefix: "image-studio-z-image-turbo",
model: defaultModels["z-image-turbo"],
textEncoder: "qwen_3_4b.safetensors",
vae: "ae.safetensors",
clipType: "lumina2",
latentNode: "EmptySD3LatentImage",
modelSamplingAuraFlow: true,
negativeMode: "zero",
steps: 8,
cfg: 1,
sampler: "res_multistep",
scheduler: "simple",
});
}
export function buildAnimaWorkflow(request: ComfyGenerateRequest): Workflow {
return buildSeparatedTextToImageWorkflow(request, {
architecture: "anima",
filenamePrefix: "image-studio-anima",
model: defaultModels.anima,
textEncoder: "qwen_3_06b_base.safetensors",
vae: "qwen_image_vae.safetensors",
clipType: "stable_diffusion",
latentNode: "EmptyLatentImage",
modelSamplingAuraFlow: false,
negativeMode: "prompt",
steps: 30,
cfg: 4,
sampler: "er_sde",
scheduler: "simple",
});
}
function buildSeparatedTextToImageWorkflow(request: ComfyGenerateRequest, config: {
architecture: GenerateArchitecture;
filenamePrefix: string;
model: string;
textEncoder: string;
vae: string;
clipType: string;
latentNode: "EmptyLatentImage" | "EmptySD3LatentImage";
modelSamplingAuraFlow: boolean;
negativeMode: "prompt" | "zero";
steps: number;
cfg: number;
sampler: string;
scheduler: string;
}): Workflow {
if (request.mode !== "text-to-image") throw new Error(`${architectureLabel(config.architecture)} currently supports text-to-image only`);
const options = resolveSamplerOptions(request, config);
const modelOutput: [string, number] = config.modelSamplingAuraFlow ? ["7", 0] : ["1", 0];
const workflow: Workflow = {
"1": { class_type: "UNETLoader", inputs: { unet_name: request.model && request.model !== "auto" ? request.model : config.model, weight_dtype: "default" } },
"2": { class_type: "CLIPLoader", inputs: { clip_name: resolveSupportModelName(request.textEncoder, config.textEncoder), type: config.clipType, device: "default" } },
"3": { class_type: "VAELoader", inputs: { vae_name: resolveSupportModelName(request.vae, config.vae) } },
"4": { class_type: "CLIPTextEncode", inputs: { text: request.prompt, clip: ["2", 0] } },
"6": { class_type: config.latentNode, inputs: { width: options.width, height: options.height, batch_size: 1 } },
"8": { class_type: "KSampler", inputs: { seed: options.seed, steps: options.steps, cfg: options.cfg, sampler_name: options.sampler, scheduler: options.scheduler, denoise: 1, model: modelOutput, positive: ["4", 0], negative: ["5", 0], latent_image: ["6", 0] } },
"9": { class_type: "VAEDecode", inputs: { samples: ["8", 0], vae: ["3", 0] } },
"10": { class_type: "SaveImage", inputs: { filename_prefix: config.filenamePrefix, images: ["9", 0] } },
};
if (config.modelSamplingAuraFlow) workflow["7"] = { class_type: "ModelSamplingAuraFlow", inputs: { model: ["1", 0], shift: 3 } };
workflow["5"] = config.negativeMode === "zero"
? { class_type: "ConditioningZeroOut", inputs: { conditioning: ["4", 0] } }
: { class_type: "CLIPTextEncode", inputs: { text: request.negativePrompt ?? "", clip: ["2", 0] } };
return workflow;
}
function resolveSamplerOptions(request: ComfyGenerateRequest, defaults: { steps: number; cfg: number; sampler: string; scheduler: string }) {
if (!request.width || !request.height) throw new Error("Generation width and height are required");
return {
width: Math.max(64, Math.round(request.width)),
height: Math.max(64, Math.round(request.height)),
seed: request.seed === undefined || request.seed < 0 ? Math.floor(Math.random() * 2 ** 32) : Math.round(request.seed),
steps: Math.max(1, Math.round(request.steps ?? defaults.steps)),
cfg: Math.max(0, request.cfg ?? defaults.cfg),
sampler: request.sampler ?? defaults.sampler,
scheduler: request.scheduler ?? defaults.scheduler,
};
}
function resolveSupportModelName(value: string | undefined, fallback: string) {
return value && value !== "auto" ? value : fallback;
}
function resolveGrowMaskBy(request: ComfyGenerateRequest): number { function resolveGrowMaskBy(request: ComfyGenerateRequest): number {
const value = request.inpaint?.growMaskBy ?? 6; const value = request.inpaint?.growMaskBy ?? 6;
if (!Number.isFinite(value)) return 6; if (!Number.isFinite(value)) return 6;
@@ -205,6 +380,39 @@ function usesOriginalLatentContent(request: ComfyGenerateRequest): boolean {
return request.inpaint?.maskedContent === "original" || request.inpaint?.maskedContent === "originalColor" || request.inpaint?.maskedContent === "edges"; return request.inpaint?.maskedContent === "original" || request.inpaint?.maskedContent === "originalColor" || request.inpaint?.maskedContent === "edges";
} }
async function defaultModelForArchitecture(architecture: GenerateArchitecture) {
if (architecture !== "sdxl") return defaultModels[architecture];
const models = await listCheckpointModels();
return models[0] ?? defaultModels.sdxl;
}
function normalizeArchitecture(architecture: ComfyGenerateRequest["architecture"]): GenerateArchitecture {
if (architecture === "z-image" || architecture === "z-image-turbo" || architecture === "anima") return architecture;
return "sdxl";
}
function architectureLabel(architecture: GenerateArchitecture): string {
switch (architecture) {
case "z-image":
return "Z-Image";
case "z-image-turbo":
return "Z-Image Turbo";
case "anima":
return "Anima";
case "sdxl":
default:
return "SDXL";
}
}
function modelsForArchitecture(models: string[], defaultModel: string, matchers: RegExp[]) {
return unique([defaultModel, ...models.filter((model) => model === defaultModel || matchers.some((matcher) => matcher.test(model)))]);
}
function unique<T>(values: T[]) {
return Array.from(new Set(values));
}
function nodeErrorsMessage(nodeErrors: unknown): string | undefined { function nodeErrorsMessage(nodeErrors: unknown): string | undefined {
if (!nodeErrors) return undefined; if (!nodeErrors) return undefined;
if (Array.isArray(nodeErrors) && nodeErrors.length === 0) return undefined; if (Array.isArray(nodeErrors) && nodeErrors.length === 0) return undefined;

View File

@@ -5,7 +5,7 @@ import { toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEdi
const defaultBrush = { color: "#111827", size: 8, hardness: 100 }; const defaultBrush = { color: "#111827", size: 8, hardness: 100 };
const defaultChromaKey = { color: "#00ff00", tolerance: 32, softness: 24, feather: 0, choke: 0, despeckle: 0, spill: 50 }; const defaultChromaKey = { color: "#00ff00", tolerance: 32, softness: 24, feather: 0, choke: 0, despeckle: 0, spill: 50 };
const defaultMagicWand = { tolerance: 32, feather: 0, choke: 0, despeckle: 0, contiguous: true, mode: "replace" as const }; const defaultMagicWand = { tolerance: 32, feather: 0, choke: 0, despeckle: 0, contiguous: true, mode: "replace" as const };
const defaultGenerate = { mode: "text-to-image" as const, model: "auto" as const, prompt: "", negativePrompt: "", strength: 75, steps: 30, cfg: 7, seed: -1, sampler: "euler", scheduler: "normal", width: 1024, height: 1024, outpaint: { left: 128, top: 128, right: 128, bottom: 128, feathering: 32 }, inpaint: { maskedAreaOnly: true, cropPadding: 96, maskPolarity: "hidden" as const, maskedContent: "neutral" as const, growMaskBy: 6, maskExpand: 0, maskFeather: 0, maskBlur: 0, maskDespeckle: 0 } }; const defaultGenerate = { architecture: "sdxl" as const, mode: "text-to-image" as const, model: "auto" as const, textEncoder: "auto", vae: "auto", prompt: "", negativePrompt: "", strength: 75, steps: 30, cfg: 7, seed: -1, sampler: "euler", scheduler: "normal", width: 1024, height: 1024, outpaint: { left: 128, top: 128, right: 128, bottom: 128, feathering: 32 }, inpaint: { maskedAreaOnly: true, cropPadding: 96, maskPolarity: "hidden" as const, maskedContent: "neutral" as const, growMaskBy: 6, maskExpand: 0, maskFeather: 0, maskBlur: 0, maskDespeckle: 0 } };
describe("tool commands", () => { describe("tool commands", () => {
test("sets active tool", () => { test("sets active tool", () => {
@@ -35,6 +35,23 @@ describe("tool commands", () => {
expect(next.editor.tools.generate.inpaint).toEqual({ ...defaultGenerate.inpaint, cropPadding: 2048, growMaskBy: 0, maskExpand: -256, maskBlur: 256, maskPolarity: "revealed", maskedContent: "original" }); expect(next.editor.tools.generate.inpaint).toEqual({ ...defaultGenerate.inpaint, cropPadding: 2048, growMaskBy: 0, maskExpand: -256, maskBlur: 256, maskPolarity: "revealed", maskedContent: "original" });
}); });
test("applies architecture defaults and filters unsupported modes", () => {
const initial = toolSetGenerateSettingsCommand.execute({ state: createInitialAppState("Test") }, { mode: "inpaint" });
const next = toolSetGenerateSettingsCommand.execute({ state: initial }, { architecture: "z-image-turbo" });
expect(next.editor.tools.generate).toMatchObject({
architecture: "z-image-turbo",
mode: "text-to-image",
model: "auto",
textEncoder: "qwen_3_4b.safetensors",
vae: "ae.safetensors",
steps: 8,
cfg: 1,
sampler: "res_multistep",
scheduler: "simple",
});
});
test("sets and clears brush preview", () => { test("sets and clears brush preview", () => {
const showing = toolSetBrushPreviewCommand.execute({ state: createInitialAppState("Test") }, { position: { x: 10, y: 20 } }); const showing = toolSetBrushPreviewCommand.execute({ state: createInitialAppState("Test") }, { position: { x: 10, y: 20 } });
const cleared = toolSetBrushPreviewCommand.execute({ state: showing }, undefined); const cleared = toolSetBrushPreviewCommand.execute({ state: showing }, undefined);

View File

@@ -3,6 +3,7 @@ import type { Vec2D } from "@core/geometry";
import type { LayerId, ArtboardId, AssetId } from "@core/id"; import type { LayerId, ArtboardId, AssetId } from "@core/id";
import type { Layer } from "@core/layer"; import type { Layer } from "@core/layer";
import type { MaskViewMode } from "@editor/state"; import type { MaskViewMode } from "@editor/state";
import { generateArchitectureDefaults } from "@editor/tools";
import type { BrushSettings, ChromaKeySettings, GenerateSettings, MagicWandSettings, ToolId } from "@editor/tools"; import type { BrushSettings, ChromaKeySettings, GenerateSettings, MagicWandSettings, ToolId } from "@editor/tools";
import type { Command } from "./command"; import type { Command } from "./command";
import { commandIds } from "./ids"; import { commandIds } from "./ids";
@@ -62,7 +63,12 @@ export const toolSetGenerateSettingsCommand: Command<ToolSetGenerateSettingsPayl
id: commandIds.toolSetGenerateSettings, id: commandIds.toolSetGenerateSettings,
name: "Set generate settings", name: "Set generate settings",
execute({ state }, payload) { execute({ state }, payload) {
const mode = payload.mode ?? state.editor.tools.generate.mode; const current = state.editor.tools.generate;
const architecture = payload.architecture ?? current.architecture;
const architectureChanged = architecture !== current.architecture;
const defaults = generateArchitectureDefaults[architecture];
const requestedMode = payload.mode ?? current.mode;
const mode = defaults.supportedModes.includes(requestedMode) ? requestedMode : defaults.supportedModes[0] ?? "text-to-image";
return { return {
...state, ...state,
editor: { editor: {
@@ -70,35 +76,38 @@ export const toolSetGenerateSettingsCommand: Command<ToolSetGenerateSettingsPayl
tools: { tools: {
...state.editor.tools, ...state.editor.tools,
generate: { generate: {
architecture,
mode, mode,
model: payload.model ?? state.editor.tools.generate.model, model: payload.model ?? (architectureChanged ? defaults.model : current.model),
prompt: payload.prompt ?? state.editor.tools.generate.prompt, textEncoder: payload.textEncoder ?? (architectureChanged ? defaults.textEncoder : current.textEncoder),
negativePrompt: payload.negativePrompt ?? state.editor.tools.generate.negativePrompt, vae: payload.vae ?? (architectureChanged ? defaults.vae : current.vae),
strength: clampNumber(payload.strength ?? state.editor.tools.generate.strength, 0, 100), prompt: payload.prompt ?? current.prompt,
steps: Math.round(clampNumber(payload.steps ?? state.editor.tools.generate.steps, 1, 150)), negativePrompt: payload.negativePrompt ?? current.negativePrompt,
cfg: clampNumber(payload.cfg ?? state.editor.tools.generate.cfg, 0, 30), strength: clampNumber(payload.strength ?? current.strength, 0, 100),
seed: Math.round(clampNumber(payload.seed ?? state.editor.tools.generate.seed, -1, Number.MAX_SAFE_INTEGER)), steps: Math.round(clampNumber(payload.steps ?? (architectureChanged ? defaults.steps : current.steps), 1, 150)),
sampler: payload.sampler ?? state.editor.tools.generate.sampler, cfg: clampNumber(payload.cfg ?? (architectureChanged ? defaults.cfg : current.cfg), 0, 30),
scheduler: payload.scheduler ?? state.editor.tools.generate.scheduler, seed: Math.round(clampNumber(payload.seed ?? current.seed, -1, Number.MAX_SAFE_INTEGER)),
width: Math.round(clampNumber(payload.width ?? state.editor.tools.generate.width, 64, 4096)), sampler: payload.sampler ?? (architectureChanged ? defaults.sampler : current.sampler),
height: Math.round(clampNumber(payload.height ?? state.editor.tools.generate.height, 64, 4096)), scheduler: payload.scheduler ?? (architectureChanged ? defaults.scheduler : current.scheduler),
width: Math.round(clampNumber(payload.width ?? current.width, 64, 4096)),
height: Math.round(clampNumber(payload.height ?? current.height, 64, 4096)),
outpaint: { outpaint: {
left: Math.round(clampNumber(payload.outpaint?.left ?? state.editor.tools.generate.outpaint.left, 0, 2048)), left: Math.round(clampNumber(payload.outpaint?.left ?? current.outpaint.left, 0, 2048)),
top: Math.round(clampNumber(payload.outpaint?.top ?? state.editor.tools.generate.outpaint.top, 0, 2048)), top: Math.round(clampNumber(payload.outpaint?.top ?? current.outpaint.top, 0, 2048)),
right: Math.round(clampNumber(payload.outpaint?.right ?? state.editor.tools.generate.outpaint.right, 0, 2048)), right: Math.round(clampNumber(payload.outpaint?.right ?? current.outpaint.right, 0, 2048)),
bottom: Math.round(clampNumber(payload.outpaint?.bottom ?? state.editor.tools.generate.outpaint.bottom, 0, 2048)), bottom: Math.round(clampNumber(payload.outpaint?.bottom ?? current.outpaint.bottom, 0, 2048)),
feathering: Math.round(clampNumber(payload.outpaint?.feathering ?? state.editor.tools.generate.outpaint.feathering, 0, 512)), feathering: Math.round(clampNumber(payload.outpaint?.feathering ?? current.outpaint.feathering, 0, 512)),
}, },
inpaint: { inpaint: {
maskedAreaOnly: payload.inpaint?.maskedAreaOnly ?? state.editor.tools.generate.inpaint.maskedAreaOnly, maskedAreaOnly: payload.inpaint?.maskedAreaOnly ?? current.inpaint.maskedAreaOnly,
cropPadding: Math.round(clampNumber(payload.inpaint?.cropPadding ?? state.editor.tools.generate.inpaint.cropPadding, 0, 2048)), cropPadding: Math.round(clampNumber(payload.inpaint?.cropPadding ?? current.inpaint.cropPadding, 0, 2048)),
maskPolarity: payload.inpaint?.maskPolarity ?? state.editor.tools.generate.inpaint.maskPolarity, maskPolarity: payload.inpaint?.maskPolarity ?? current.inpaint.maskPolarity,
maskedContent: payload.inpaint?.maskedContent ?? state.editor.tools.generate.inpaint.maskedContent, maskedContent: payload.inpaint?.maskedContent ?? current.inpaint.maskedContent,
growMaskBy: Math.round(clampNumber(payload.inpaint?.growMaskBy ?? state.editor.tools.generate.inpaint.growMaskBy, 0, 256)), growMaskBy: Math.round(clampNumber(payload.inpaint?.growMaskBy ?? current.inpaint.growMaskBy, 0, 256)),
maskExpand: Math.round(clampNumber(payload.inpaint?.maskExpand ?? state.editor.tools.generate.inpaint.maskExpand, -256, 256)), maskExpand: Math.round(clampNumber(payload.inpaint?.maskExpand ?? current.inpaint.maskExpand, -256, 256)),
maskFeather: Math.round(clampNumber(payload.inpaint?.maskFeather ?? state.editor.tools.generate.inpaint.maskFeather, 0, 256)), maskFeather: Math.round(clampNumber(payload.inpaint?.maskFeather ?? current.inpaint.maskFeather, 0, 256)),
maskBlur: Math.round(clampNumber(payload.inpaint?.maskBlur ?? state.editor.tools.generate.inpaint.maskBlur, 0, 256)), maskBlur: Math.round(clampNumber(payload.inpaint?.maskBlur ?? current.inpaint.maskBlur, 0, 256)),
maskDespeckle: Math.round(clampNumber(payload.inpaint?.maskDespeckle ?? state.editor.tools.generate.inpaint.maskDespeckle, 0, 64)), maskDespeckle: Math.round(clampNumber(payload.inpaint?.maskDespeckle ?? current.inpaint.maskDespeckle, 0, 64)),
}, },
}, },
}, },

View File

@@ -26,12 +26,18 @@ export type MagicWandMode = "replace" | "add" | "subtract";
export type GenerateMode = "text-to-image" | "image-to-image" | "inpaint" | "outpaint"; export type GenerateMode = "text-to-image" | "image-to-image" | "inpaint" | "outpaint";
export const generateArchitectures = ["sdxl", "z-image", "z-image-turbo", "anima"] as const;
export type GenerateArchitecture = (typeof generateArchitectures)[number];
export type GenerateModel = string; export type GenerateModel = string;
export type InpaintMaskedContent = "neutral" | "original" | "originalColor" | "edges"; export type InpaintMaskedContent = "neutral" | "original" | "originalColor" | "edges";
export type GenerateSettings = { export type GenerateSettings = {
architecture: GenerateArchitecture;
mode: GenerateMode; mode: GenerateMode;
model: GenerateModel; model: GenerateModel;
textEncoder: string;
vae: string;
prompt: string; prompt: string;
negativePrompt: string; negativePrompt: string;
strength: number; strength: number;
@@ -62,6 +68,22 @@ export type GenerateSettings = {
}; };
}; };
export const generateArchitectureDefaults: Record<GenerateArchitecture, {
model: GenerateModel;
textEncoder: string;
vae: string;
steps: number;
cfg: number;
sampler: string;
scheduler: string;
supportedModes: readonly GenerateMode[];
}> = {
sdxl: { model: "auto", textEncoder: "auto", vae: "auto", steps: 30, cfg: 7, sampler: "euler", scheduler: "normal", supportedModes: ["text-to-image", "image-to-image", "inpaint", "outpaint"] },
"z-image": { model: "auto", textEncoder: "qwen_3_4b.safetensors", vae: "ae.safetensors", steps: 30, cfg: 4, sampler: "res_multistep", scheduler: "simple", supportedModes: ["text-to-image"] },
"z-image-turbo": { model: "auto", textEncoder: "qwen_3_4b.safetensors", vae: "ae.safetensors", steps: 8, cfg: 1, sampler: "res_multistep", scheduler: "simple", supportedModes: ["text-to-image"] },
anima: { model: "auto", textEncoder: "qwen_3_06b_base.safetensors", vae: "qwen_image_vae.safetensors", steps: 30, cfg: 4, sampler: "er_sde", scheduler: "simple", supportedModes: ["text-to-image"] },
};
export type MagicWandSettings = { export type MagicWandSettings = {
tolerance: number; tolerance: number;
feather: number; feather: number;
@@ -87,8 +109,11 @@ export const initialToolState: ToolState = {
chromaKey: { color: "#00ff00", tolerance: 32, softness: 24, feather: 0, choke: 0, despeckle: 0, spill: 50 }, chromaKey: { color: "#00ff00", tolerance: 32, softness: 24, feather: 0, choke: 0, despeckle: 0, spill: 50 },
magicWand: { tolerance: 32, feather: 0, choke: 0, despeckle: 0, contiguous: true, mode: "replace" }, magicWand: { tolerance: 32, feather: 0, choke: 0, despeckle: 0, contiguous: true, mode: "replace" },
generate: { generate: {
architecture: "sdxl",
mode: "text-to-image", mode: "text-to-image",
model: "auto", model: "auto",
textEncoder: "auto",
vae: "auto",
prompt: "", prompt: "",
negativePrompt: "", negativePrompt: "",
strength: 75, strength: 75,

View File

@@ -2,10 +2,18 @@ import { useEffect, useRef, useState, type RefObject } from "react";
import { CaretDown, CaretUp } from "@phosphor-icons/react"; import { CaretDown, CaretUp } from "@phosphor-icons/react";
import { commandIds } from "@commands/ids"; import { commandIds } from "@commands/ids";
import type { AppStore } from "@editor/store"; import type { AppStore } from "@editor/store";
import type { GenerateMode, GenerateModel, GenerateSettings } from "@editor/tools"; import { generateArchitectureDefaults } from "@editor/tools";
import type { GenerateArchitecture, GenerateMode, GenerateModel, GenerateSettings } from "@editor/tools";
import { BottomControlSelectMenu, type BottomControlSelectOption } from "./SelectMenu"; import { BottomControlSelectMenu, type BottomControlSelectOption } from "./SelectMenu";
import { BottomControlSlider } from "./Slider"; import { BottomControlSlider } from "./Slider";
const architectures = [
{ value: "sdxl", label: "SDXL" },
{ value: "z-image", label: "Z-Image" },
{ value: "z-image-turbo", label: "Z-Image Turbo" },
{ value: "anima", label: "Anima" },
] satisfies readonly BottomControlSelectOption<GenerateArchitecture>[];
const modes = [ const modes = [
{ value: "text-to-image", label: "Text → image" }, { value: "text-to-image", label: "Text → image" },
{ value: "image-to-image", label: "Image → image" }, { value: "image-to-image", label: "Image → image" },
@@ -38,26 +46,44 @@ export type GenerateControlsProps = {
dispatch: AppStore["dispatch"]; dispatch: AppStore["dispatch"];
}; };
type ComfyArchitectureOption = {
value: GenerateArchitecture;
label: string;
defaultModel: string;
models: string[];
supportedModes: GenerateMode[];
};
type ComfyOptionsResponse = {
architectures?: ComfyArchitectureOption[];
models?: string[];
textEncoders?: string[];
vaes?: string[];
samplers?: string[];
schedulers?: string[];
};
export function GenerateControls({ settings, dispatch }: GenerateControlsProps) { export function GenerateControls({ settings, dispatch }: GenerateControlsProps) {
const [models, setModels] = useState<readonly BottomControlSelectOption<GenerateModel>[]>([{ value: "auto", label: "Auto" }]); const [comfyOptions, setComfyOptions] = useState<ComfyOptionsResponse>();
const [samplers, setSamplers] = useState<readonly BottomControlSelectOption<string>[]>([{ value: settings.sampler, label: settings.sampler }]);
const [schedulers, setSchedulers] = useState<readonly BottomControlSelectOption<string>[]>([{ value: settings.scheduler, label: settings.scheduler }]);
const [advancedOpen, setAdvancedOpen] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false);
const [outpaintOpen, setOutpaintOpen] = useState(false); const [outpaintOpen, setOutpaintOpen] = useState(false);
const [inpaintOpen, setInpaintOpen] = useState(false); const [inpaintOpen, setInpaintOpen] = useState(false);
const [sizeOpen, setSizeOpen] = useState(false); const [sizeOpen, setSizeOpen] = useState(false);
const sizeRef = useRef<HTMLDivElement>(null); const sizeRef = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const modelOptions = resolveModelOptions(settings, comfyOptions);
const supportOptions = resolveSupportOptions(settings, comfyOptions);
const samplerOptions = resolveStringOptions(comfyOptions?.samplers, settings.sampler);
const schedulerOptions = resolveStringOptions(comfyOptions?.schedulers, settings.scheduler);
const modeOptions = resolveModeOptions(settings, comfyOptions);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
void fetch("/api/comfy/models") void fetch("/api/comfy/models")
.then((response) => response.ok ? response.json() : Promise.reject(new Error("Unable to load ComfyUI models"))) .then((response) => response.ok ? response.json() : Promise.reject(new Error("Unable to load ComfyUI models")))
.then((body: { models?: string[]; samplers?: string[]; schedulers?: string[] }) => { .then((body: ComfyOptionsResponse) => {
if (cancelled) return; if (cancelled) return;
setModels([{ value: "auto", label: "Auto" }, ...(body.models ?? []).map((model) => ({ value: model, label: model }))]); setComfyOptions(body);
if (body.samplers?.length) setSamplers(body.samplers.map((sampler) => ({ value: sampler, label: sampler })));
if (body.schedulers?.length) setSchedulers(body.schedulers.map((scheduler) => ({ value: scheduler, label: scheduler })));
}) })
.catch((reason: unknown) => { .catch((reason: unknown) => {
if (!cancelled) setError(reason instanceof Error ? reason.message : "Unable to load ComfyUI models"); if (!cancelled) setError(reason instanceof Error ? reason.message : "Unable to load ComfyUI models");
@@ -107,7 +133,14 @@ export function GenerateControls({ settings, dispatch }: GenerateControlsProps)
<section className={panelSectionClass()}> <section className={panelSectionClass()}>
<SectionTitle title="Essentials" /> <SectionTitle title="Essentials" />
<PanelSelect label="Model" value={settings.model} options={models} ariaLabel="Generate model" onValueChange={(model) => dispatch(commandIds.toolSetGenerateSettings, { model })} /> <PanelSelect label="Backend" value={settings.architecture} options={architectures} ariaLabel="Generate backend" onValueChange={(architecture) => dispatch(commandIds.toolSetGenerateSettings, { architecture })} />
<PanelSelect label="Model" value={settings.model} options={modelOptions} ariaLabel="Generate model" onValueChange={(model) => dispatch(commandIds.toolSetGenerateSettings, { model })} />
{settings.architecture !== "sdxl" ? (
<>
<PanelSelect label="Text enc." value={settings.textEncoder} options={supportOptions.textEncoders} ariaLabel="Generate text encoder" onValueChange={(textEncoder) => dispatch(commandIds.toolSetGenerateSettings, { textEncoder })} />
<PanelSelect label="VAE" value={settings.vae} options={supportOptions.vaes} ariaLabel="Generate VAE" onValueChange={(vae) => dispatch(commandIds.toolSetGenerateSettings, { vae })} />
</>
) : null}
<SizeControl refRoot={sizeRef} open={sizeOpen} setOpen={setSizeOpen} settings={settings} dispatch={dispatch} /> <SizeControl refRoot={sizeRef} open={sizeOpen} setOpen={setSizeOpen} settings={settings} dispatch={dispatch} />
<PanelNumber label="Seed" aria-label="Generate seed" min={-1} max={Number.MAX_SAFE_INTEGER} value={settings.seed} onValueChange={(seed) => dispatch(commandIds.toolSetGenerateSettings, { seed })} /> <PanelNumber label="Seed" aria-label="Generate seed" min={-1} max={Number.MAX_SAFE_INTEGER} value={settings.seed} onValueChange={(seed) => dispatch(commandIds.toolSetGenerateSettings, { seed })} />
</section> </section>
@@ -121,9 +154,9 @@ export function GenerateControls({ settings, dispatch }: GenerateControlsProps)
{advancedOpen ? <CaretUp size={18} weight="bold" /> : <CaretDown size={18} weight="bold" />} {advancedOpen ? <CaretUp size={18} weight="bold" /> : <CaretDown size={18} weight="bold" />}
</button> </button>
<div id="generate-advanced-controls" className={`grid gap-2 overflow-hidden transition-all duration-200 ${advancedOpen ? "max-h-[32rem] pt-2 opacity-100" : "max-h-0 opacity-0"}`}> <div id="generate-advanced-controls" className={`grid gap-2 overflow-hidden transition-all duration-200 ${advancedOpen ? "max-h-[32rem] pt-2 opacity-100" : "max-h-0 opacity-0"}`}>
<PanelSelect label="Mode" value={settings.mode} options={modes} ariaLabel="Generate mode" onValueChange={(mode) => dispatch(commandIds.toolSetGenerateSettings, { mode })} /> <PanelSelect label="Mode" value={settings.mode} options={modeOptions} ariaLabel="Generate mode" onValueChange={(mode) => dispatch(commandIds.toolSetGenerateSettings, { mode })} />
<PanelSelect label="Sampler" value={settings.sampler} options={samplers} ariaLabel="Generate sampler" onValueChange={(sampler) => dispatch(commandIds.toolSetGenerateSettings, { sampler })} /> <PanelSelect label="Sampler" value={settings.sampler} options={samplerOptions} ariaLabel="Generate sampler" onValueChange={(sampler) => dispatch(commandIds.toolSetGenerateSettings, { sampler })} />
<PanelSelect label="Scheduler" value={settings.scheduler} options={schedulers} ariaLabel="Generate scheduler" onValueChange={(scheduler) => dispatch(commandIds.toolSetGenerateSettings, { scheduler })} /> <PanelSelect label="Scheduler" value={settings.scheduler} options={schedulerOptions} ariaLabel="Generate scheduler" onValueChange={(scheduler) => dispatch(commandIds.toolSetGenerateSettings, { scheduler })} />
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<PanelNumber label="Steps" aria-label="Generate steps" value={settings.steps} onValueChange={(steps) => dispatch(commandIds.toolSetGenerateSettings, { steps })} /> <PanelNumber label="Steps" aria-label="Generate steps" value={settings.steps} onValueChange={(steps) => dispatch(commandIds.toolSetGenerateSettings, { steps })} />
<PanelNumber label="CFG" aria-label="Generate CFG" value={settings.cfg} onValueChange={(cfg) => dispatch(commandIds.toolSetGenerateSettings, { cfg })} /> <PanelNumber label="CFG" aria-label="Generate CFG" value={settings.cfg} onValueChange={(cfg) => dispatch(commandIds.toolSetGenerateSettings, { cfg })} />
@@ -196,6 +229,38 @@ export function GenerateControls({ settings, dispatch }: GenerateControlsProps)
); );
} }
function resolveModelOptions(settings: GenerateSettings, comfyOptions: ComfyOptionsResponse | undefined): readonly BottomControlSelectOption<GenerateModel>[] {
const architecture = comfyOptions?.architectures?.find((option) => option.value === settings.architecture);
const models = architecture?.models ?? (settings.architecture === "sdxl" ? comfyOptions?.models : undefined) ?? [];
const fallbackModel = architecture?.defaultModel ?? generateArchitectureDefaults[settings.architecture].model;
const values = unique(["auto", ...models, ...(models.length === 0 && fallbackModel !== "auto" ? [fallbackModel] : []), settings.model]);
return values.map((model) => ({ value: model, label: model === "auto" ? "Auto" : model }));
}
function resolveSupportOptions(settings: GenerateSettings, comfyOptions: ComfyOptionsResponse | undefined) {
const defaults = generateArchitectureDefaults[settings.architecture];
return {
textEncoders: resolveStringOptions([...(comfyOptions?.textEncoders ?? []), defaults.textEncoder].filter((value) => value !== "auto"), settings.textEncoder),
vaes: resolveStringOptions([...(comfyOptions?.vaes ?? []), defaults.vae].filter((value) => value !== "auto"), settings.vae),
};
}
function resolveStringOptions(values: string[] | undefined, current: string): readonly BottomControlSelectOption<string>[] {
return unique([...(values ?? []), current]).map((value) => ({ value, label: value }));
}
function resolveModeOptions(settings: GenerateSettings, comfyOptions: ComfyOptionsResponse | undefined): readonly BottomControlSelectOption<GenerateMode>[] {
const architecture = comfyOptions?.architectures?.find((option) => option.value === settings.architecture);
const supportedModes = architecture?.supportedModes?.length ? architecture.supportedModes : generateArchitectureDefaults[settings.architecture].supportedModes;
const availableModes = modes.filter((mode) => supportedModes.includes(mode.value));
if (availableModes.some((mode) => mode.value === settings.mode)) return availableModes;
return [modes.find((mode) => mode.value === settings.mode), ...availableModes].filter((mode): mode is BottomControlSelectOption<GenerateMode> => Boolean(mode));
}
function unique<T>(values: T[]): T[] {
return Array.from(new Set(values));
}
function SectionTitle({ title }: { title: string }) { function SectionTitle({ title }: { title: string }) {
return <div className="px-1 text-xs font-semibold uppercase tracking-[0.18em] text-white/35">{title}</div>; return <div className="px-1 text-xs font-semibold uppercase tracking-[0.18em] text-white/35">{title}</div>;
} }

View File

@@ -145,8 +145,11 @@ async function requestGenerate(options: {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
architecture: options.settings.architecture,
mode: options.settings.mode, mode: options.settings.mode,
model: options.settings.model, model: options.settings.model,
textEncoder: options.settings.textEncoder,
vae: options.settings.vae,
prompt: options.settings.prompt, prompt: options.settings.prompt,
negativePrompt: options.settings.negativePrompt, negativePrompt: options.settings.negativePrompt,
strength: options.settings.strength, strength: options.settings.strength,