feat: enhance generate settings to support multiple architectures and their defaults
This commit is contained in:
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
232
app/comfy.ts
232
app/comfy.ts
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user