feat: add ComfyUI integration for image generation
- Implemented ComfyUI API for generating images with various modes (text-to-image, image-to-image, inpaint, outpaint). - Created GenerateSheet and associated controls for user input on generation settings. - Added subtle scrollbar styles for improved UI experience. - Enhanced canvas input handling to ignore key events when focused on editable elements. - Optimized canvas resizing logic to prevent unnecessary dispatches. - Introduced error handling for generation failures and loading models. - Added functionality to upload images and masks for inpainting.
This commit is contained in:
164
app/comfy.ts
Normal file
164
app/comfy.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
type GenerateMode = "text-to-image" | "image-to-image" | "inpaint" | "outpaint";
|
||||||
|
|
||||||
|
type ComfyGenerateRequest = {
|
||||||
|
mode: GenerateMode;
|
||||||
|
model: string;
|
||||||
|
prompt: string;
|
||||||
|
negativePrompt?: string;
|
||||||
|
strength?: number;
|
||||||
|
steps?: number;
|
||||||
|
cfg?: number;
|
||||||
|
seed?: number;
|
||||||
|
sampler?: string;
|
||||||
|
scheduler?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
outpaint?: {
|
||||||
|
left?: number;
|
||||||
|
top?: number;
|
||||||
|
right?: number;
|
||||||
|
bottom?: number;
|
||||||
|
feathering?: number;
|
||||||
|
};
|
||||||
|
inputImage?: string;
|
||||||
|
maskImage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const comfyBaseUrl = process.env.COMFYUI_URL ?? "http://127.0.0.1:8188";
|
||||||
|
|
||||||
|
export async function handleComfyApi(request: Request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.pathname === "/api/comfy/models" && request.method === "GET") return json(await listGenerationOptions());
|
||||||
|
if (url.pathname === "/api/comfy/generate" && request.method === "POST") return json(await generate(await request.json() as ComfyGenerateRequest));
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listGenerationOptions() {
|
||||||
|
const response = await fetch(`${comfyBaseUrl}/object_info`);
|
||||||
|
if (!response.ok) throw new Error(`ComfyUI option lookup failed: ${response.status}`);
|
||||||
|
const info = await response.json() as {
|
||||||
|
CheckpointLoaderSimple?: { input?: { required?: { ckpt_name?: [string[]] } } };
|
||||||
|
KSampler?: { input?: { required?: { sampler_name?: [string[]]; scheduler?: [string[]] } } };
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
models: info.CheckpointLoaderSimple?.input?.required?.ckpt_name?.[0] ?? [],
|
||||||
|
samplers: info.KSampler?.input?.required?.sampler_name?.[0] ?? [],
|
||||||
|
schedulers: info.KSampler?.input?.required?.scheduler?.[0] ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listCheckpointModels() {
|
||||||
|
return (await listGenerationOptions()).models;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generate(request: ComfyGenerateRequest) {
|
||||||
|
if (!request.prompt?.trim()) throw new Error("Prompt is required");
|
||||||
|
if (!request.model || request.model === "auto") {
|
||||||
|
const models = await listCheckpointModels();
|
||||||
|
request.model = models[0] ?? "sd_xl_base_1.0.safetensors";
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = crypto.randomUUID();
|
||||||
|
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 prompt = buildSdxlWorkflow({ ...request, inputImage: uploaded, maskImage: mask });
|
||||||
|
|
||||||
|
const queued = await fetch(`${comfyBaseUrl}/prompt`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ client_id: clientId, prompt }),
|
||||||
|
});
|
||||||
|
if (!queued.ok) throw new Error(`ComfyUI prompt failed: ${queued.status} ${await queued.text()}`);
|
||||||
|
const { prompt_id } = await queued.json() as { prompt_id: string };
|
||||||
|
const history = await waitForHistory(prompt_id);
|
||||||
|
const image = firstOutputImage(history);
|
||||||
|
if (!image) throw new Error("ComfyUI did not return an image");
|
||||||
|
|
||||||
|
const imageResponse = await fetch(`${comfyBaseUrl}/view?${new URLSearchParams({ filename: image.filename, subfolder: image.subfolder ?? "", type: image.type ?? "output" })}`);
|
||||||
|
if (!imageResponse.ok) throw new Error(`ComfyUI image fetch failed: ${imageResponse.status}`);
|
||||||
|
const bytes = Buffer.from(await imageResponse.arrayBuffer());
|
||||||
|
return { source: `data:image/png;base64,${bytes.toString("base64")}`, mimeType: "image/png" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadDataUrl(dataUrl: string, filename: string) {
|
||||||
|
const match = /^data:([^;]+);base64,(.+)$/.exec(dataUrl);
|
||||||
|
if (!match) return undefined;
|
||||||
|
const mimeType = match[1] ?? "image/png";
|
||||||
|
const base64 = match[2] ?? "";
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("image", new File([new Uint8Array(Buffer.from(base64, "base64"))], filename, { type: mimeType }));
|
||||||
|
form.append("overwrite", "true");
|
||||||
|
const response = await fetch(`${comfyBaseUrl}/upload/image`, { method: "POST", body: form });
|
||||||
|
if (!response.ok) throw new Error(`ComfyUI upload failed: ${response.status}`);
|
||||||
|
const uploaded = await response.json() as { name: string };
|
||||||
|
return uploaded.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForHistory(promptId: string) {
|
||||||
|
for (let attempt = 0; attempt < 240; attempt++) {
|
||||||
|
const response = await fetch(`${comfyBaseUrl}/history/${promptId}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const history = await response.json() as Record<string, unknown>;
|
||||||
|
if (history[promptId]) return history[promptId];
|
||||||
|
}
|
||||||
|
await Bun.sleep(500);
|
||||||
|
}
|
||||||
|
throw new Error("Timed out waiting for ComfyUI");
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstOutputImage(history: unknown): { filename: string; subfolder?: string; type?: string } | undefined {
|
||||||
|
const outputs = (history as { outputs?: Record<string, { images?: { filename: string; subfolder?: string; type?: string }[] }> }).outputs ?? {};
|
||||||
|
for (const output of Object.values(outputs)) {
|
||||||
|
const image = output.images?.[0];
|
||||||
|
if (image) return image;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSdxlWorkflow(request: ComfyGenerateRequest) {
|
||||||
|
if (!request.width || !request.height) throw new Error("Generation width and height are required");
|
||||||
|
const width = Math.max(64, Math.round(request.width));
|
||||||
|
const height = Math.max(64, Math.round(request.height));
|
||||||
|
const denoise = Math.max(0, Math.min(1, (request.strength ?? 75) / 100));
|
||||||
|
const seed = request.seed === undefined || request.seed < 0 ? Math.floor(Math.random() * 2 ** 32) : Math.round(request.seed);
|
||||||
|
const steps = Math.max(1, Math.round(request.steps ?? 30));
|
||||||
|
const cfg = Math.max(0, request.cfg ?? 7);
|
||||||
|
const sampler = request.sampler ?? "euler";
|
||||||
|
const scheduler = request.scheduler ?? "normal";
|
||||||
|
const positive = request.prompt;
|
||||||
|
const negative = request.negativePrompt ?? "";
|
||||||
|
const workflow: Record<string, unknown> = {
|
||||||
|
"1": { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: request.model } },
|
||||||
|
"2": { class_type: "CLIPTextEncode", inputs: { text: positive, clip: ["1", 1] } },
|
||||||
|
"3": { class_type: "CLIPTextEncode", inputs: { text: negative, clip: ["1", 1] } },
|
||||||
|
"6": { class_type: "KSampler", inputs: { seed, steps, cfg, sampler_name: sampler, scheduler, denoise, model: ["1", 0], positive: ["2", 0], negative: ["3", 0], latent_image: ["5", 0] } },
|
||||||
|
"7": { class_type: "VAEDecode", inputs: { samples: ["6", 0], vae: ["1", 2] } },
|
||||||
|
"8": { class_type: "SaveImage", inputs: { filename_prefix: `image-studio-${request.mode}`, images: ["7", 0] } },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (request.mode === "text-to-image" || !request.inputImage) {
|
||||||
|
workflow["5"] = { class_type: "EmptyLatentImage", inputs: { width, height, batch_size: 1 } };
|
||||||
|
return workflow;
|
||||||
|
}
|
||||||
|
|
||||||
|
workflow["4"] = { class_type: "LoadImage", inputs: { image: request.inputImage } };
|
||||||
|
|
||||||
|
if (request.mode === "inpaint" && request.maskImage) {
|
||||||
|
workflow["9"] = { class_type: "LoadImage", inputs: { image: request.maskImage } };
|
||||||
|
workflow["5"] = { class_type: "VAEEncodeForInpaint", inputs: { pixels: ["4", 0], vae: ["1", 2], mask: ["9", 1], grow_mask_by: 6 } };
|
||||||
|
return workflow;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.mode === "outpaint") {
|
||||||
|
workflow["10"] = { class_type: "ImagePadForOutpaint", inputs: { image: ["4", 0], left: Math.round(request.outpaint?.left ?? 0), top: Math.round(request.outpaint?.top ?? 0), right: Math.round(request.outpaint?.right ?? 0), bottom: Math.round(request.outpaint?.bottom ?? 0), feathering: Math.round(request.outpaint?.feathering ?? 0) } };
|
||||||
|
workflow["5"] = { class_type: "VAEEncodeForInpaint", inputs: { pixels: ["10", 0], vae: ["1", 2], mask: ["10", 1], grow_mask_by: 6 } };
|
||||||
|
return workflow;
|
||||||
|
}
|
||||||
|
|
||||||
|
workflow["5"] = { class_type: "VAEEncode", inputs: { pixels: ["4", 0], vae: ["1", 2] } };
|
||||||
|
return workflow;
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(value: unknown) {
|
||||||
|
return new Response(JSON.stringify(value), { headers: { "content-type": "application/json" } });
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ export const commandIds = {
|
|||||||
selectionClear: "selection.clear",
|
selectionClear: "selection.clear",
|
||||||
selectionAddLayer: "selection.addLayer",
|
selectionAddLayer: "selection.addLayer",
|
||||||
toolSetActive: "tool.setActive",
|
toolSetActive: "tool.setActive",
|
||||||
|
toolSetGenerateSettings: "tool.setGenerateSettings",
|
||||||
toolSetBrushSettings: "tool.setBrushSettings",
|
toolSetBrushSettings: "tool.setBrushSettings",
|
||||||
toolSetChromaKeySettings: "tool.setChromaKeySettings",
|
toolSetChromaKeySettings: "tool.setChromaKeySettings",
|
||||||
toolSetMagicWandSettings: "tool.setMagicWandSettings",
|
toolSetMagicWandSettings: "tool.setMagicWandSettings",
|
||||||
|
|||||||
@@ -54,10 +54,10 @@ export type { CommandRegistry } from "./registry";
|
|||||||
export { createCommandRegistry } from "./registry";
|
export { createCommandRegistry } from "./registry";
|
||||||
export { selectionAddLayerCommand, selectionClearCommand, selectionCommands, selectionSetCommand } from "./selection";
|
export { selectionAddLayerCommand, selectionClearCommand, selectionCommands, selectionSetCommand } from "./selection";
|
||||||
export type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
export type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
||||||
export { toolCommands, toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetChromaKeySettingsCommand, toolSetMagicWandSettingsCommand, toolSetMaskViewModeCommand } from "./tool";
|
export { toolCommands, toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetChromaKeySettingsCommand, toolSetGenerateSettingsCommand, toolSetMagicWandSettingsCommand, toolSetMaskViewModeCommand } from "./tool";
|
||||||
export { transformBeginCommand, transformCommands, transformEndCommand, transformSetBoundsCommand, transformUpdateCommand } from "./transform";
|
export { transformBeginCommand, transformCommands, transformEndCommand, transformSetBoundsCommand, transformUpdateCommand } from "./transform";
|
||||||
export type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
|
export type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
|
||||||
export type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
|
export type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
|
||||||
export {
|
export {
|
||||||
viewportCommands,
|
viewportCommands,
|
||||||
viewportPanCommand,
|
viewportPanCommand,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import type {
|
|||||||
DocumentUngroupLayerPayload,
|
DocumentUngroupLayerPayload,
|
||||||
} from "./document";
|
} from "./document";
|
||||||
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
||||||
import type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
|
import type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
|
||||||
import type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
|
import type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
|
||||||
import type {
|
import type {
|
||||||
ViewportFitArtboardPayload,
|
ViewportFitArtboardPayload,
|
||||||
@@ -59,6 +59,7 @@ export type CommandPayloads = {
|
|||||||
[commandIds.selectionClear]: void;
|
[commandIds.selectionClear]: void;
|
||||||
[commandIds.selectionAddLayer]: SelectionAddLayerPayload;
|
[commandIds.selectionAddLayer]: SelectionAddLayerPayload;
|
||||||
[commandIds.toolSetActive]: ToolSetActivePayload;
|
[commandIds.toolSetActive]: ToolSetActivePayload;
|
||||||
|
[commandIds.toolSetGenerateSettings]: ToolSetGenerateSettingsPayload;
|
||||||
[commandIds.toolSetBrushSettings]: ToolSetBrushSettingsPayload;
|
[commandIds.toolSetBrushSettings]: ToolSetBrushSettingsPayload;
|
||||||
[commandIds.toolSetChromaKeySettings]: ToolSetChromaKeySettingsPayload;
|
[commandIds.toolSetChromaKeySettings]: ToolSetChromaKeySettingsPayload;
|
||||||
[commandIds.toolSetMagicWandSettings]: ToolSetMagicWandSettingsPayload;
|
[commandIds.toolSetMagicWandSettings]: ToolSetMagicWandSettingsPayload;
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ 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 } };
|
||||||
|
|
||||||
describe("tool commands", () => {
|
describe("tool commands", () => {
|
||||||
test("sets active tool", () => {
|
test("sets active tool", () => {
|
||||||
const next = toolSetActiveCommand.execute({ state: createInitialAppState("Test") }, { tool: "brush" });
|
const next = toolSetActiveCommand.execute({ state: createInitialAppState("Test") }, { tool: "brush" });
|
||||||
expect(next.editor.tools).toEqual({ activeTool: "brush", interactionMode: { type: "tool", tool: "brush" }, brush: defaultBrush, chromaKey: defaultChromaKey, magicWand: defaultMagicWand });
|
expect(next.editor.tools).toEqual({ activeTool: "brush", interactionMode: { type: "tool", tool: "brush" }, brush: defaultBrush, generate: defaultGenerate, chromaKey: defaultChromaKey, magicWand: defaultMagicWand });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("sets brush settings", () => {
|
test("sets brush settings", () => {
|
||||||
@@ -64,7 +65,7 @@ describe("tool commands", () => {
|
|||||||
const panning = toolEnterTemporaryPanCommand.execute({ state: initial }, undefined);
|
const panning = toolEnterTemporaryPanCommand.execute({ state: initial }, undefined);
|
||||||
const restored = toolExitTemporaryPanCommand.execute({ state: panning }, undefined);
|
const restored = toolExitTemporaryPanCommand.execute({ state: panning }, undefined);
|
||||||
|
|
||||||
expect(panning.editor.tools).toEqual({ activeTool: "select", interactionMode: { type: "temporary-pan", previousTool: "select" }, brush: defaultBrush, chromaKey: defaultChromaKey, magicWand: defaultMagicWand });
|
expect(panning.editor.tools).toEqual({ activeTool: "select", interactionMode: { type: "temporary-pan", previousTool: "select" }, brush: defaultBrush, generate: defaultGenerate, chromaKey: defaultChromaKey, magicWand: defaultMagicWand });
|
||||||
expect(restored.editor.tools).toEqual(initial.editor.tools);
|
expect(restored.editor.tools).toEqual(initial.editor.tools);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +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 type { BrushSettings, ChromaKeySettings, 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";
|
||||||
|
|
||||||
@@ -13,6 +13,8 @@ export type ToolSetActivePayload = {
|
|||||||
|
|
||||||
export type ToolSetBrushSettingsPayload = Partial<BrushSettings>;
|
export type ToolSetBrushSettingsPayload = Partial<BrushSettings>;
|
||||||
|
|
||||||
|
export type ToolSetGenerateSettingsPayload = Partial<GenerateSettings>;
|
||||||
|
|
||||||
export type ToolSetChromaKeySettingsPayload = Partial<ChromaKeySettings>;
|
export type ToolSetChromaKeySettingsPayload = Partial<ChromaKeySettings>;
|
||||||
|
|
||||||
export type ToolSetMagicWandSettingsPayload = Partial<MagicWandSettings>;
|
export type ToolSetMagicWandSettingsPayload = Partial<MagicWandSettings>;
|
||||||
@@ -56,6 +58,44 @@ export const toolSetActiveCommand: Command<ToolSetActivePayload> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const toolSetGenerateSettingsCommand: Command<ToolSetGenerateSettingsPayload> = {
|
||||||
|
id: commandIds.toolSetGenerateSettings,
|
||||||
|
name: "Set generate settings",
|
||||||
|
execute({ state }, payload) {
|
||||||
|
const mode = payload.mode ?? state.editor.tools.generate.mode;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
editor: {
|
||||||
|
...state.editor,
|
||||||
|
tools: {
|
||||||
|
...state.editor.tools,
|
||||||
|
generate: {
|
||||||
|
mode,
|
||||||
|
model: payload.model ?? state.editor.tools.generate.model,
|
||||||
|
prompt: payload.prompt ?? state.editor.tools.generate.prompt,
|
||||||
|
negativePrompt: payload.negativePrompt ?? state.editor.tools.generate.negativePrompt,
|
||||||
|
strength: clampNumber(payload.strength ?? state.editor.tools.generate.strength, 0, 100),
|
||||||
|
steps: Math.round(clampNumber(payload.steps ?? state.editor.tools.generate.steps, 1, 150)),
|
||||||
|
cfg: clampNumber(payload.cfg ?? state.editor.tools.generate.cfg, 0, 30),
|
||||||
|
seed: Math.round(clampNumber(payload.seed ?? state.editor.tools.generate.seed, -1, Number.MAX_SAFE_INTEGER)),
|
||||||
|
sampler: payload.sampler ?? state.editor.tools.generate.sampler,
|
||||||
|
scheduler: payload.scheduler ?? state.editor.tools.generate.scheduler,
|
||||||
|
width: Math.round(clampNumber(payload.width ?? state.editor.tools.generate.width, 64, 4096)),
|
||||||
|
height: Math.round(clampNumber(payload.height ?? state.editor.tools.generate.height, 64, 4096)),
|
||||||
|
outpaint: {
|
||||||
|
left: Math.round(clampNumber(payload.outpaint?.left ?? state.editor.tools.generate.outpaint.left, 0, 2048)),
|
||||||
|
top: Math.round(clampNumber(payload.outpaint?.top ?? state.editor.tools.generate.outpaint.top, 0, 2048)),
|
||||||
|
right: Math.round(clampNumber(payload.outpaint?.right ?? state.editor.tools.generate.outpaint.right, 0, 2048)),
|
||||||
|
bottom: Math.round(clampNumber(payload.outpaint?.bottom ?? state.editor.tools.generate.outpaint.bottom, 0, 2048)),
|
||||||
|
feathering: Math.round(clampNumber(payload.outpaint?.feathering ?? state.editor.tools.generate.outpaint.feathering, 0, 512)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const toolSetBrushSettingsCommand: Command<ToolSetBrushSettingsPayload> = {
|
export const toolSetBrushSettingsCommand: Command<ToolSetBrushSettingsPayload> = {
|
||||||
id: commandIds.toolSetBrushSettings,
|
id: commandIds.toolSetBrushSettings,
|
||||||
name: "Set brush settings",
|
name: "Set brush settings",
|
||||||
@@ -274,6 +314,7 @@ export const toolExitTemporaryPanCommand: Command = {
|
|||||||
|
|
||||||
export const toolCommands = [
|
export const toolCommands = [
|
||||||
toolSetActiveCommand,
|
toolSetActiveCommand,
|
||||||
|
toolSetGenerateSettingsCommand,
|
||||||
toolSetBrushSettingsCommand,
|
toolSetBrushSettingsCommand,
|
||||||
toolSetChromaKeySettingsCommand,
|
toolSetChromaKeySettingsCommand,
|
||||||
toolSetMagicWandSettingsCommand,
|
toolSetMagicWandSettingsCommand,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const availableToolIds = ["select", "brush", "eraser", "chromaKey", "magicWand", "pan"] as const;
|
export const availableToolIds = ["select", "generate", "brush", "eraser", "chromaKey", "magicWand", "pan"] as const;
|
||||||
|
|
||||||
export type ToolId = (typeof availableToolIds)[number];
|
export type ToolId = (typeof availableToolIds)[number];
|
||||||
|
|
||||||
@@ -24,6 +24,32 @@ export type ChromaKeySettings = {
|
|||||||
|
|
||||||
export type MagicWandMode = "replace" | "add" | "subtract";
|
export type MagicWandMode = "replace" | "add" | "subtract";
|
||||||
|
|
||||||
|
export type GenerateMode = "text-to-image" | "image-to-image" | "inpaint" | "outpaint";
|
||||||
|
|
||||||
|
export type GenerateModel = string;
|
||||||
|
|
||||||
|
export type GenerateSettings = {
|
||||||
|
mode: GenerateMode;
|
||||||
|
model: GenerateModel;
|
||||||
|
prompt: string;
|
||||||
|
negativePrompt: string;
|
||||||
|
strength: number;
|
||||||
|
steps: number;
|
||||||
|
cfg: number;
|
||||||
|
seed: number;
|
||||||
|
sampler: string;
|
||||||
|
scheduler: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
outpaint: {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
right: number;
|
||||||
|
bottom: number;
|
||||||
|
feathering: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type MagicWandSettings = {
|
export type MagicWandSettings = {
|
||||||
tolerance: number;
|
tolerance: number;
|
||||||
feather: number;
|
feather: number;
|
||||||
@@ -39,6 +65,7 @@ export type ToolState = {
|
|||||||
brush: BrushSettings;
|
brush: BrushSettings;
|
||||||
chromaKey: ChromaKeySettings;
|
chromaKey: ChromaKeySettings;
|
||||||
magicWand: MagicWandSettings;
|
magicWand: MagicWandSettings;
|
||||||
|
generate: GenerateSettings;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const initialToolState: ToolState = {
|
export const initialToolState: ToolState = {
|
||||||
@@ -47,6 +74,7 @@ export const initialToolState: ToolState = {
|
|||||||
brush: { color: "#111827", size: 8, hardness: 100 },
|
brush: { color: "#111827", size: 8, hardness: 100 },
|
||||||
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: { mode: "text-to-image", model: "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 } },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isPanInteractionMode(interactionMode: InteractionMode): boolean {
|
export function isPanInteractionMode(interactionMode: InteractionMode): boolean {
|
||||||
|
|||||||
3
index.ts
3
index.ts
@@ -1,8 +1,11 @@
|
|||||||
import { serve } from "bun";
|
import { serve } from "bun";
|
||||||
|
import { handleComfyApi } from "./app/comfy";
|
||||||
import index from "./view/index.html";
|
import index from "./view/index.html";
|
||||||
|
|
||||||
const server = serve({
|
const server = serve({
|
||||||
routes: {
|
routes: {
|
||||||
|
"/api/comfy/models": handleComfyApi,
|
||||||
|
"/api/comfy/generate": handleComfyApi,
|
||||||
"/*": index,
|
"/*": index,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Dispatch } from "@commands/dispatcher";
|
|||||||
import type { KeybindEvent } from "./keyboard";
|
import type { KeybindEvent } from "./keyboard";
|
||||||
|
|
||||||
const toolKeybinds = {
|
const toolKeybinds = {
|
||||||
|
g: "generate",
|
||||||
b: "brush",
|
b: "brush",
|
||||||
e: "eraser",
|
e: "eraser",
|
||||||
k: "chromaKey",
|
k: "chromaKey",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import type { PointerInputEvent } from "./pointer";
|
|||||||
|
|
||||||
type TransformHandle = "body" | "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w";
|
type TransformHandle = "body" | "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w";
|
||||||
|
|
||||||
type InputToolId = "select" | "brush" | "eraser" | "chromaKey" | "magicWand" | "pan";
|
type InputToolId = "select" | "generate" | "brush" | "eraser" | "chromaKey" | "magicWand" | "pan";
|
||||||
|
|
||||||
type InputInteractionMode =
|
type InputInteractionMode =
|
||||||
| { type: "tool"; tool: InputToolId }
|
| { type: "tool"; tool: InputToolId }
|
||||||
|
|||||||
58
view/App.tsx
58
view/App.tsx
@@ -1,14 +1,16 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { DownloadSimple, FolderOpen, Stack } from "@phosphor-icons/react";
|
import { DownloadSimple, FolderOpen, Sparkle, Stack } from "@phosphor-icons/react";
|
||||||
import type { ImageStudioApp } from "@app/app";
|
import type { ImageStudioApp } from "@app/app";
|
||||||
import { commandIds } from "@commands/ids";
|
import { commandIds } from "@commands/ids";
|
||||||
import { BottomControlsIsland } from "./BottomControlsIsland";
|
import { BottomControlsIsland } from "./BottomControlsIsland";
|
||||||
import { brushUnavailableHint } from "./canvas/brush";
|
import { brushUnavailableHint } from "./canvas/brush";
|
||||||
import { CanvasViewport } from "./CanvasViewport";
|
import { CanvasViewport } from "./CanvasViewport";
|
||||||
|
import { GenerateSheet } from "./GenerateSheet";
|
||||||
import { LayersSheet } from "./LayersSheet";
|
import { LayersSheet } from "./LayersSheet";
|
||||||
import { ShortcutsDisplay } from "./ShortcutsDisplay";
|
import { ShortcutsDisplay } from "./ShortcutsDisplay";
|
||||||
import { ToolOverlay } from "./ToolOverlay";
|
import { ToolOverlay } from "./ToolOverlay";
|
||||||
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
|
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
|
||||||
|
import type { ToolId } from "@editor/tools";
|
||||||
import { handleDeleteSelectionKey, handleHistoryKey, handleToolKey, keybindEventFromKeyboardEvent } from "@input/index";
|
import { handleDeleteSelectionKey, handleHistoryKey, handleToolKey, keybindEventFromKeyboardEvent } from "@input/index";
|
||||||
import { useAppState } from "./useAppState";
|
import { useAppState } from "./useAppState";
|
||||||
import { downloadArtboardPng } from "./exportArtboardPng";
|
import { downloadArtboardPng } from "./exportArtboardPng";
|
||||||
@@ -25,9 +27,31 @@ export function App({ app }: AppProps) {
|
|||||||
const viewportActivityIsland = useViewportActivityIsland(state.editor.viewport);
|
const viewportActivityIsland = useViewportActivityIsland(state.editor.viewport);
|
||||||
const imageImport = useImageImport(app.store);
|
const imageImport = useImageImport(app.store);
|
||||||
const [layersOpen, setLayersOpen] = useState(false);
|
const [layersOpen, setLayersOpen] = useState(false);
|
||||||
|
const [generateOpen, setGenerateOpen] = useState(false);
|
||||||
|
const previousGenerateTool = useRef<ToolId>("select");
|
||||||
const transformTarget = state.editor.transformSession?.target ?? selectedTransformTarget(state.document, state.editor.selection);
|
const transformTarget = state.editor.transformSession?.target ?? selectedTransformTarget(state.document, state.editor.selection);
|
||||||
const activeArtboard = state.document.artboards.find((artboard) => artboard.id === state.editor.selection.artboardId) ?? state.document.artboards[0];
|
const activeArtboard = state.document.artboards.find((artboard) => artboard.id === state.editor.selection.artboardId) ?? state.document.artboards[0];
|
||||||
|
|
||||||
|
const openGenerate = useCallback(() => {
|
||||||
|
const activeTool = app.store.getState().editor.tools.activeTool;
|
||||||
|
if (activeTool !== "generate") previousGenerateTool.current = activeTool;
|
||||||
|
app.store.dispatch(commandIds.toolSetActive, { tool: "generate" });
|
||||||
|
setLayersOpen(false);
|
||||||
|
setGenerateOpen(true);
|
||||||
|
}, [app.store]);
|
||||||
|
|
||||||
|
const closeGenerate = useCallback(() => {
|
||||||
|
setGenerateOpen(false);
|
||||||
|
if (app.store.getState().editor.tools.activeTool === "generate") {
|
||||||
|
app.store.dispatch(commandIds.toolSetActive, { tool: previousGenerateTool.current });
|
||||||
|
}
|
||||||
|
}, [app.store]);
|
||||||
|
|
||||||
|
const toggleGenerate = useCallback(() => {
|
||||||
|
if (generateOpen) closeGenerate();
|
||||||
|
else openGenerate();
|
||||||
|
}, [closeGenerate, generateOpen, openGenerate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
const target = event.target;
|
const target = event.target;
|
||||||
@@ -51,16 +75,23 @@ export function App({ app }: AppProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
const keybindEvent = keybindEventFromKeyboardEvent(event);
|
const keybindEvent = keybindEventFromKeyboardEvent(event);
|
||||||
|
|
||||||
|
if (key === "g") {
|
||||||
|
toggleGenerate();
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const toolConsumed = handleToolKey({ event: keybindEvent, dispatch: app.store.dispatch });
|
const toolConsumed = handleToolKey({ event: keybindEvent, dispatch: app.store.dispatch });
|
||||||
if (toolConsumed) {
|
if (toolConsumed) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = event.key.toLowerCase();
|
|
||||||
|
|
||||||
if (key === "l") {
|
if (key === "l") {
|
||||||
|
closeGenerate();
|
||||||
setLayersOpen((open) => !open);
|
setLayersOpen((open) => !open);
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
@@ -76,7 +107,7 @@ export function App({ app }: AppProps) {
|
|||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [app.store]);
|
}, [app.store, closeGenerate, toggleGenerate]);
|
||||||
const transformBounds = transformTarget ? resolveTransformTargetBounds(state.document, transformTarget) : undefined;
|
const transformBounds = transformTarget ? resolveTransformTargetBounds(state.document, transformTarget) : undefined;
|
||||||
const brushHint = brushUnavailableHint(state.document, state.editor);
|
const brushHint = brushUnavailableHint(state.document, state.editor);
|
||||||
|
|
||||||
@@ -88,10 +119,13 @@ export function App({ app }: AppProps) {
|
|||||||
<button type="button" className={topBarButtonClass()} onClick={imageImport.openFilePicker}>
|
<button type="button" className={topBarButtonClass()} onClick={imageImport.openFilePicker}>
|
||||||
<FolderOpen size={24} />
|
<FolderOpen size={24} />
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" className={topBarButtonClass(generateOpen)} aria-pressed={generateOpen} onClick={toggleGenerate}>
|
||||||
|
<Sparkle size={24} weight={generateOpen ? "fill" : "regular"} />
|
||||||
|
</button>
|
||||||
<button type="button" className={topBarButtonClass()} disabled={!activeArtboard} onClick={() => activeArtboard && void downloadArtboardPng(activeArtboard, state.document.assets)}>
|
<button type="button" className={topBarButtonClass()} disabled={!activeArtboard} onClick={() => activeArtboard && void downloadArtboardPng(activeArtboard, state.document.assets)}>
|
||||||
<DownloadSimple size={24} />
|
<DownloadSimple size={24} />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={topBarButtonClass(layersOpen)} aria-pressed={layersOpen} onClick={() => setLayersOpen((open) => !open)}>
|
<button type="button" className={topBarButtonClass(layersOpen)} aria-pressed={layersOpen} onClick={() => { closeGenerate(); setLayersOpen((open) => !open); }}>
|
||||||
<Stack size={24} weight={layersOpen ? "fill" : "regular"} />
|
<Stack size={24} weight={layersOpen ? "fill" : "regular"} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,6 +137,15 @@ export function App({ app }: AppProps) {
|
|||||||
dispatch={app.store.dispatch}
|
dispatch={app.store.dispatch}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<GenerateSheet
|
||||||
|
document={state.document}
|
||||||
|
selection={state.editor.selection}
|
||||||
|
viewport={state.editor.viewport}
|
||||||
|
settings={state.editor.tools.generate}
|
||||||
|
open={generateOpen}
|
||||||
|
onOpenChange={(open) => open ? openGenerate() : closeGenerate()}
|
||||||
|
dispatch={app.store.dispatch}
|
||||||
|
/>
|
||||||
<LayersSheet
|
<LayersSheet
|
||||||
document={state.document}
|
document={state.document}
|
||||||
selection={state.editor.selection}
|
selection={state.editor.selection}
|
||||||
@@ -115,10 +158,11 @@ export function App({ app }: AppProps) {
|
|||||||
document={state.document}
|
document={state.document}
|
||||||
selection={state.editor.selection}
|
selection={state.editor.selection}
|
||||||
viewport={state.editor.viewport}
|
viewport={state.editor.viewport}
|
||||||
visible={state.editor.tools.activeTool === "brush" || state.editor.tools.activeTool === "eraser" || state.editor.tools.activeTool === "chromaKey" || state.editor.tools.activeTool === "magicWand" || Boolean(transformBounds) || viewportActivityIsland.visible}
|
visible={state.editor.tools.activeTool === "generate" || state.editor.tools.activeTool === "brush" || state.editor.tools.activeTool === "eraser" || state.editor.tools.activeTool === "chromaKey" || state.editor.tools.activeTool === "magicWand" || Boolean(transformBounds) || viewportActivityIsland.visible}
|
||||||
action={viewportActivityIsland.action}
|
action={viewportActivityIsland.action}
|
||||||
activeTool={state.editor.tools.activeTool}
|
activeTool={state.editor.tools.activeTool}
|
||||||
brushSettings={state.editor.tools.brush}
|
brushSettings={state.editor.tools.brush}
|
||||||
|
generateSettings={state.editor.tools.generate}
|
||||||
chromaKeySettings={state.editor.tools.chromaKey}
|
chromaKeySettings={state.editor.tools.chromaKey}
|
||||||
magicWandSettings={state.editor.tools.magicWand}
|
magicWandSettings={state.editor.tools.magicWand}
|
||||||
editingMask={Boolean(state.editor.maskEdit)}
|
editingMask={Boolean(state.editor.maskEdit)}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { AppStore } from "@editor/store";
|
import type { AppStore } from "@editor/store";
|
||||||
import type { ImageDocument } from "@core/document";
|
import type { ImageDocument } from "@core/document";
|
||||||
import type { MaskViewMode, SelectionState, ViewportState } from "@editor/state";
|
import type { MaskViewMode, SelectionState, ViewportState } from "@editor/state";
|
||||||
import type { BrushSettings, ChromaKeySettings, MagicWandSettings, ToolId } from "@editor/tools";
|
import type { BrushSettings, ChromaKeySettings, GenerateSettings, MagicWandSettings, ToolId } from "@editor/tools";
|
||||||
import { BrushControls } from "./bottom-controls/BrushControls";
|
import { BrushControls } from "./bottom-controls/BrushControls";
|
||||||
import { ChromaKeyControls } from "./bottom-controls/ChromaKeyControls";
|
import { ChromaKeyControls } from "./bottom-controls/ChromaKeyControls";
|
||||||
import { MagicWandControls } from "./bottom-controls/MagicWandControls";
|
import { MagicWandControls } from "./bottom-controls/MagicWandControls";
|
||||||
|
import { GenerateActionControls } from "./bottom-controls/GenerateActionControls";
|
||||||
import { PanControls } from "./bottom-controls/PanControls";
|
import { PanControls } from "./bottom-controls/PanControls";
|
||||||
import { TransformControls } from "./bottom-controls/TransformControls";
|
import { TransformControls } from "./bottom-controls/TransformControls";
|
||||||
import { ZoomControls } from "./bottom-controls/ZoomControls";
|
import { ZoomControls } from "./bottom-controls/ZoomControls";
|
||||||
@@ -20,6 +21,7 @@ export type BottomControlsIslandProps = {
|
|||||||
action: BottomControlsAction;
|
action: BottomControlsAction;
|
||||||
activeTool: ToolId;
|
activeTool: ToolId;
|
||||||
brushSettings: BrushSettings;
|
brushSettings: BrushSettings;
|
||||||
|
generateSettings: GenerateSettings;
|
||||||
chromaKeySettings: ChromaKeySettings;
|
chromaKeySettings: ChromaKeySettings;
|
||||||
magicWandSettings: MagicWandSettings;
|
magicWandSettings: MagicWandSettings;
|
||||||
editingMask?: boolean;
|
editingMask?: boolean;
|
||||||
@@ -30,7 +32,7 @@ export type BottomControlsIslandProps = {
|
|||||||
dispatch: AppStore["dispatch"];
|
dispatch: AppStore["dispatch"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BottomControlsIsland({ document, selection, viewport, visible, action, activeTool, brushSettings, chromaKeySettings, magicWandSettings, editingMask = false, maskViewMode = "composite", transformBounds, transformTarget, brushHint, dispatch }: BottomControlsIslandProps) {
|
export function BottomControlsIsland({ document, selection, viewport, visible, action, activeTool, brushSettings, generateSettings, chromaKeySettings, magicWandSettings, editingMask = false, maskViewMode = "composite", transformBounds, transformTarget, brushHint, dispatch }: BottomControlsIslandProps) {
|
||||||
const zoomPercent = Math.round(viewport.zoom * 100);
|
const zoomPercent = Math.round(viewport.zoom * 100);
|
||||||
const x = Math.round(viewport.center.x);
|
const x = Math.round(viewport.center.x);
|
||||||
const y = Math.round(viewport.center.y);
|
const y = Math.round(viewport.center.y);
|
||||||
@@ -38,11 +40,13 @@ export function BottomControlsIsland({ document, selection, viewport, visible, a
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
aria-hidden={!visible}
|
aria-hidden={!visible}
|
||||||
className={`flex h-20 min-w-96 items-center justify-center gap-4 rounded-full px-4 py-2 text-sm text-white backdrop-blur transition-all duration-200 ${
|
className={`flex h-20 min-w-96 rounded-full px-4 py-2 items-center justify-center gap-4 text-sm text-white backdrop-blur transition-all duration-200 ${
|
||||||
visible ? "pointer-events-auto translate-y-0 opacity-100" : "pointer-events-none translate-y-3 opacity-0"
|
visible ? "pointer-events-auto translate-y-0 opacity-100" : "pointer-events-none translate-y-3 opacity-0"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{(activeTool === "brush" || activeTool === "eraser") && brushHint ? (
|
{activeTool === "generate" ? (
|
||||||
|
<GenerateActionControls document={document} selection={selection} viewport={viewport} settings={generateSettings} dispatch={dispatch} />
|
||||||
|
) : (activeTool === "brush" || activeTool === "eraser") && brushHint ? (
|
||||||
<BrushHint tool={activeTool} hint={brushHint} />
|
<BrushHint tool={activeTool} hint={brushHint} />
|
||||||
) : activeTool === "brush" || activeTool === "eraser" ? (
|
) : activeTool === "brush" || activeTool === "eraser" ? (
|
||||||
<BrushControls tool={activeTool} settings={brushSettings} editingMask={editingMask} maskViewMode={maskViewMode} dispatch={dispatch} />
|
<BrushControls tool={activeTool} settings={brushSettings} editingMask={editingMask} maskViewMode={maskViewMode} dispatch={dispatch} />
|
||||||
|
|||||||
32
view/GenerateSheet.tsx
Normal file
32
view/GenerateSheet.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { ImageDocument } from "@core/document";
|
||||||
|
import type { SelectionState, ViewportState } from "@editor/state";
|
||||||
|
import type { GenerateSettings } from "@editor/tools";
|
||||||
|
import type { AppStore } from "@editor/store";
|
||||||
|
import { GenerateControls } from "./bottom-controls/GenerateControls";
|
||||||
|
|
||||||
|
export type GenerateSheetProps = {
|
||||||
|
document: ImageDocument;
|
||||||
|
selection: SelectionState;
|
||||||
|
viewport: ViewportState;
|
||||||
|
settings: GenerateSettings;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
dispatch: AppStore["dispatch"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GenerateSheet({ document, selection, viewport, settings, open, onOpenChange, dispatch }: GenerateSheetProps) {
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
aria-hidden={!open}
|
||||||
|
aria-label="Generate settings"
|
||||||
|
className={`pointer-events-auto absolute bottom-6 right-4 top-24 z-20 flex w-[28rem] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-[2.5rem] px-4 text-sm text-white backdrop-blur-xl transition-all duration-200 ${
|
||||||
|
open ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-8 opacity-0"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="subtle-scrollbar min-h-0 flex-1 overflow-auto py-4">
|
||||||
|
<GenerateControls document={document} selection={selection} viewport={viewport} settings={settings} dispatch={dispatch} />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ type Shortcut = {
|
|||||||
|
|
||||||
const shortcuts: Shortcut[] = [
|
const shortcuts: Shortcut[] = [
|
||||||
{ keys: ["S"], label: "Select" },
|
{ keys: ["S"], label: "Select" },
|
||||||
|
{ keys: ["G"], label: "Generate" },
|
||||||
{ keys: ["B"], label: "Brush" },
|
{ keys: ["B"], label: "Brush" },
|
||||||
{ keys: ["E"], label: "Eraser" },
|
{ keys: ["E"], label: "Eraser" },
|
||||||
{ keys: ["K"], label: "Chroma key" },
|
{ keys: ["K"], label: "Chroma key" },
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Cursor, Eraser, Hand, PaintBrush, DropHalf, MagicWand } from "@phosphor-icons/react";
|
import { Cursor, Eraser, Hand, PaintBrush, DropHalf, MagicWand, Sparkle } 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 { InteractionMode, ToolId } from "@editor/tools";
|
import type { InteractionMode, ToolId } from "@editor/tools";
|
||||||
@@ -41,6 +41,8 @@ export function ToolOverlay({ activeTool, interactionMode, dispatch }: ToolOverl
|
|||||||
|
|
||||||
function iconForTool(tool: ToolId) {
|
function iconForTool(tool: ToolId) {
|
||||||
switch (tool) {
|
switch (tool) {
|
||||||
|
case "generate":
|
||||||
|
return Sparkle;
|
||||||
case "brush":
|
case "brush":
|
||||||
return PaintBrush;
|
return PaintBrush;
|
||||||
case "eraser":
|
case "eraser":
|
||||||
|
|||||||
40
view/bottom-controls/GenerateActionControls.tsx
Normal file
40
view/bottom-controls/GenerateActionControls.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { ImageDocument } from "@core/document";
|
||||||
|
import type { SelectionState, ViewportState } from "@editor/state";
|
||||||
|
import type { GenerateSettings } from "@editor/tools";
|
||||||
|
import type { AppStore } from "@editor/store";
|
||||||
|
import { runGenerate } from "../generate/runGenerate";
|
||||||
|
|
||||||
|
export type GenerateActionControlsProps = {
|
||||||
|
document: ImageDocument;
|
||||||
|
selection: SelectionState;
|
||||||
|
viewport: ViewportState;
|
||||||
|
settings: GenerateSettings;
|
||||||
|
dispatch: AppStore["dispatch"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GenerateActionControls({ document, selection, viewport, settings, dispatch }: GenerateActionControlsProps) {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
const canGenerate = Boolean(settings.prompt.trim()) && !busy;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center px-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canGenerate}
|
||||||
|
className="h-12 rounded-full bg-white px-7 text-base font-semibold !text-black transition hover:bg-white/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/40 disabled:pointer-events-none disabled:opacity-35"
|
||||||
|
title={error ?? "Generate with ComfyUI"}
|
||||||
|
onClick={() => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(undefined);
|
||||||
|
void runGenerate({ document, selection, viewport, settings, dispatch })
|
||||||
|
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : "Generation failed"))
|
||||||
|
.finally(() => setBusy(false));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{busy ? "Generating…" : "Generate"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
334
view/bottom-controls/GenerateControls.tsx
Normal file
334
view/bottom-controls/GenerateControls.tsx
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||||
|
import { CaretDown, CaretUp } from "@phosphor-icons/react";
|
||||||
|
import { commandIds } from "@commands/ids";
|
||||||
|
import type { ImageDocument } from "@core/document";
|
||||||
|
import type { Layer } from "@core/layer";
|
||||||
|
import type { AppStore } from "@editor/store";
|
||||||
|
import type { SelectionState, ViewportState } from "@editor/state";
|
||||||
|
import type { GenerateMode, GenerateModel, GenerateSettings } from "@editor/tools";
|
||||||
|
import { BottomControlSelectMenu, type BottomControlSelectOption } from "./SelectMenu";
|
||||||
|
import { BottomControlSlider } from "./Slider";
|
||||||
|
|
||||||
|
const modes = [
|
||||||
|
{ value: "text-to-image", label: "Text → image" },
|
||||||
|
{ value: "image-to-image", label: "Image → image" },
|
||||||
|
{ value: "inpaint", label: "Inpaint" },
|
||||||
|
{ value: "outpaint", label: "Outpaint" },
|
||||||
|
] satisfies readonly BottomControlSelectOption<GenerateMode>[];
|
||||||
|
|
||||||
|
const sizePresets = [
|
||||||
|
{ label: "1:1", w: 1024, h: 1024 },
|
||||||
|
{ label: "4:3", w: 1152, h: 896 },
|
||||||
|
{ label: "3:4", w: 896, h: 1152 },
|
||||||
|
{ label: "16:9", w: 1344, h: 768 },
|
||||||
|
{ label: "9:16", w: 768, h: 1344 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type GenerateControlsProps = {
|
||||||
|
document: ImageDocument;
|
||||||
|
selection: SelectionState;
|
||||||
|
viewport: ViewportState;
|
||||||
|
settings: GenerateSettings;
|
||||||
|
dispatch: AppStore["dispatch"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GenerateControls({ settings, dispatch }: GenerateControlsProps) {
|
||||||
|
const [models, setModels] = useState<readonly BottomControlSelectOption<GenerateModel>[]>([{ value: "auto", label: "Auto" }]);
|
||||||
|
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 [outpaintOpen, setOutpaintOpen] = useState(false);
|
||||||
|
const [sizeOpen, setSizeOpen] = useState(false);
|
||||||
|
const sizeRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void fetch("/api/comfy/models")
|
||||||
|
.then((response) => response.ok ? response.json() : Promise.reject(new Error("Unable to load ComfyUI models")))
|
||||||
|
.then((body: { models?: string[]; samplers?: string[]; schedulers?: string[] }) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setModels([{ value: "auto", label: "Auto" }, ...(body.models ?? []).map((model) => ({ value: model, label: model }))]);
|
||||||
|
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) => {
|
||||||
|
if (!cancelled) setError(reason instanceof Error ? reason.message : "Unable to load ComfyUI models");
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sizeOpen) return;
|
||||||
|
const close = (event: PointerEvent) => {
|
||||||
|
if (!sizeRef.current?.contains(event.target as Node)) setSizeOpen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener("pointerdown", close);
|
||||||
|
return () => window.removeEventListener("pointerdown", close);
|
||||||
|
}, [sizeOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-5 pb-2 tabular-nums">
|
||||||
|
{error ? <p className="rounded-full bg-red-500/10 px-3 py-2 text-xs text-red-200">{error}</p> : null}
|
||||||
|
|
||||||
|
<section className={panelSectionClass()}>
|
||||||
|
<label className="grid gap-2">
|
||||||
|
<span className={panelLabelClass()}>Prompt</span>
|
||||||
|
<textarea
|
||||||
|
aria-label="Generate prompt"
|
||||||
|
value={settings.prompt}
|
||||||
|
rows={6}
|
||||||
|
placeholder="Describe the image you want to create…"
|
||||||
|
onChange={(event) => dispatch(commandIds.toolSetGenerateSettings, { prompt: event.currentTarget.value })}
|
||||||
|
className={panelTextAreaClass("min-h-36")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="grid gap-2">
|
||||||
|
<span className={panelLabelClass()}>Negative prompt</span>
|
||||||
|
<textarea
|
||||||
|
aria-label="Negative prompt"
|
||||||
|
value={settings.negativePrompt}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Things to avoid, e.g. bad quality, artifacts…"
|
||||||
|
onChange={(event) => dispatch(commandIds.toolSetGenerateSettings, { negativePrompt: event.currentTarget.value })}
|
||||||
|
className={panelTextAreaClass("min-h-20")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={panelSectionClass()}>
|
||||||
|
<SectionTitle title="Essentials" />
|
||||||
|
<PanelSelect label="Model" value={settings.model} options={models} ariaLabel="Generate model" onValueChange={(model) => dispatch(commandIds.toolSetGenerateSettings, { model })} />
|
||||||
|
<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 })} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={panelSectionClass()}>
|
||||||
|
<button type="button" className={sectionToggleClass()} aria-expanded={advancedOpen} aria-controls="generate-advanced-controls" onClick={() => setAdvancedOpen((open) => !open)}>
|
||||||
|
<span>
|
||||||
|
<span className="block text-sm font-semibold text-white/85">Advanced</span>
|
||||||
|
<span className="block text-xs text-white/40">Mode, sampler, scheduler, steps, CFG, strength</span>
|
||||||
|
</span>
|
||||||
|
{advancedOpen ? <CaretUp size={18} weight="bold" /> : <CaretDown size={18} weight="bold" />}
|
||||||
|
</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"}`}>
|
||||||
|
<PanelSelect label="Mode" value={settings.mode} options={modes} 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="Scheduler" value={settings.scheduler} options={schedulers} ariaLabel="Generate scheduler" onValueChange={(scheduler) => dispatch(commandIds.toolSetGenerateSettings, { scheduler })} />
|
||||||
|
<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="CFG" aria-label="Generate CFG" value={settings.cfg} onValueChange={(cfg) => dispatch(commandIds.toolSetGenerateSettings, { cfg })} />
|
||||||
|
</div>
|
||||||
|
<div className={panelRowClass()}>
|
||||||
|
<span className={panelLabelClass()}>Strength</span>
|
||||||
|
<BottomControlSlider min={0} max={100} value={settings.strength} className="w-32" aria-label="Generate strength" onValueChange={(strength) => dispatch(commandIds.toolSetGenerateSettings, { strength })} />
|
||||||
|
<span className="w-10 text-right text-sm text-white">{Math.round(settings.strength)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={panelSectionClass()}>
|
||||||
|
<button type="button" className={sectionToggleClass()} aria-expanded={outpaintOpen} aria-controls="generate-outpaint-controls" onClick={() => setOutpaintOpen((open) => !open)}>
|
||||||
|
<span>
|
||||||
|
<span className="block text-sm font-semibold text-white/85">Outpaint</span>
|
||||||
|
<span className="block text-xs text-white/40">Padding and feathering for outpaint mode</span>
|
||||||
|
</span>
|
||||||
|
{outpaintOpen ? <CaretUp size={18} weight="bold" /> : <CaretDown size={18} weight="bold" />}
|
||||||
|
</button>
|
||||||
|
<div id="generate-outpaint-controls" className={`grid gap-2 overflow-hidden transition-all duration-200 ${outpaintOpen ? "max-h-72 pt-2 opacity-100" : "max-h-0 opacity-0"}`}>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<PanelNumber label="Left" aria-label="Outpaint left padding" value={settings.outpaint.left} onValueChange={(left) => dispatch(commandIds.toolSetGenerateSettings, { outpaint: { ...settings.outpaint, left } })} />
|
||||||
|
<PanelNumber label="Top" aria-label="Outpaint top padding" value={settings.outpaint.top} onValueChange={(top) => dispatch(commandIds.toolSetGenerateSettings, { outpaint: { ...settings.outpaint, top } })} />
|
||||||
|
<PanelNumber label="Right" aria-label="Outpaint right padding" value={settings.outpaint.right} onValueChange={(right) => dispatch(commandIds.toolSetGenerateSettings, { outpaint: { ...settings.outpaint, right } })} />
|
||||||
|
<PanelNumber label="Bottom" aria-label="Outpaint bottom padding" value={settings.outpaint.bottom} onValueChange={(bottom) => dispatch(commandIds.toolSetGenerateSettings, { outpaint: { ...settings.outpaint, bottom } })} />
|
||||||
|
</div>
|
||||||
|
<PanelNumber label="Feather" aria-label="Outpaint feathering" value={settings.outpaint.feathering} onValueChange={(feathering) => dispatch(commandIds.toolSetGenerateSettings, { outpaint: { ...settings.outpaint, feathering } })} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionTitle({ title }: { title: string }) {
|
||||||
|
return <div className="px-1 text-xs font-semibold uppercase tracking-[0.18em] text-white/35">{title}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function panelSectionClass() {
|
||||||
|
return "grid gap-3 rounded-[1.75rem] bg-white/[0.035] p-3 ring-1 ring-white/[0.04]";
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionToggleClass() {
|
||||||
|
return "flex w-full items-center justify-between gap-3 rounded-[1.25rem] px-2 py-1 text-left transition hover:bg-white/[0.04] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30";
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanelSelect<TValue extends string>({ label, value, options, ariaLabel, onValueChange }: { label: string; value: TValue; options: readonly BottomControlSelectOption<TValue>[]; ariaLabel: string; onValueChange: (value: TValue) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[1.25rem] bg-white/[0.04] p-1">
|
||||||
|
<BottomControlSelectMenu label={label} value={value} options={options} aria-label={ariaLabel} placement="inline" onValueChange={onValueChange} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanelNumber({ label, value, onValueChange, min = 0, max = 4096, ...props }: { label: string; "aria-label": string; value: number; min?: number; max?: number; onValueChange: (value: number) => void }) {
|
||||||
|
return (
|
||||||
|
<label className={panelRowClass()}>
|
||||||
|
<span className={panelLabelClass()}>{label}</span>
|
||||||
|
<NumberInput {...props} min={min} max={max} value={value} onValueChange={onValueChange} />
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SizeControl({ refRoot, open, setOpen, settings, dispatch }: { refRoot: RefObject<HTMLDivElement | null>; open: boolean; setOpen: (open: boolean) => void; settings: GenerateSettings; dispatch: AppStore["dispatch"] }) {
|
||||||
|
return (
|
||||||
|
<div ref={refRoot} className="relative">
|
||||||
|
<button type="button" className={compactRowButtonClass()} aria-label={`Generate size ${settings.width} by ${settings.height}`} aria-expanded={open} onClick={() => setOpen(!open)}>
|
||||||
|
<span className={panelLabelClass()}>Size</span>
|
||||||
|
<span className="min-w-0 flex-1 text-right text-white">{settings.width} × {settings.height}</span>
|
||||||
|
<CaretDown size={16} weight="bold" />
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<div className="absolute right-0 top-full z-30 mt-2 grid w-full gap-3 rounded-[1.5rem] bg-slate-950/90 p-3 text-white shadow-2xl ring-1 ring-white/10 backdrop-blur-xl">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<PanelNumber label="W" aria-label="Generate width" value={settings.width} onValueChange={(width) => dispatch(commandIds.toolSetGenerateSettings, { width })} />
|
||||||
|
<PanelNumber label="H" aria-label="Generate height" value={settings.height} onValueChange={(height) => dispatch(commandIds.toolSetGenerateSettings, { height })} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-5 gap-2">
|
||||||
|
{sizePresets.map((preset) => (
|
||||||
|
<button key={preset.label} type="button" className="rounded-full bg-white/5 px-2 py-2 text-xs text-white/75 transition hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30" onClick={() => dispatch(commandIds.toolSetGenerateSettings, { width: preset.w, height: preset.h })}>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberInput({ value, onValueChange, min = 0, max = 4096, ...props }: { "aria-label": string; value: number; min?: number; max?: number; onValueChange: (value: number) => void }) {
|
||||||
|
return <input {...props} type="number" min={min} max={max} step={1} value={Math.round(value)} className="h-9 w-16 rounded-full bg-white/5 px-2 text-right text-sm text-white outline-none transition hover:bg-white/10 focus:bg-white/10 focus:ring-2 focus:ring-white/30" onChange={(event) => onValueChange(Number(event.currentTarget.value))} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function panelRowClass() {
|
||||||
|
return "flex min-h-11 items-center justify-between gap-3 rounded-full bg-white/[0.04] px-4";
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactRowButtonClass() {
|
||||||
|
return `${panelRowClass()} w-full text-left transition hover:bg-white/[0.07] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function panelLabelClass() {
|
||||||
|
return "shrink-0 text-sm font-medium text-white/55";
|
||||||
|
}
|
||||||
|
|
||||||
|
function panelTextAreaClass(extra = "") {
|
||||||
|
return `${extra} resize-none rounded-[1.25rem] bg-white/5 px-4 py-3 text-sm text-white outline-none transition placeholder:text-white/25 focus:bg-white/[0.07] focus:ring-2 focus:ring-white/30`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateImage(options: GenerateControlsProps & { setBusy: (busy: boolean) => void; setError: (error: string | undefined) => void }) {
|
||||||
|
const { document, selection, viewport, settings, dispatch, setBusy, setError } = options;
|
||||||
|
const artboard = selection.artboardId ? document.artboards.find((candidate) => candidate.id === selection.artboardId) : document.artboards[0];
|
||||||
|
if (!artboard) return;
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
const target = resolveSelectedImage(document, selection);
|
||||||
|
const inputImage = target && settings.mode !== "text-to-image" ? await imageSourceToDataUrl(target.asset.source) : undefined;
|
||||||
|
const maskImage = target?.maskAsset && settings.mode === "inpaint" ? await imageSourceToDataUrl(target.maskAsset.source) : undefined;
|
||||||
|
const response = await fetch("/api/comfy/generate", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
mode: settings.mode,
|
||||||
|
model: settings.model,
|
||||||
|
prompt: settings.prompt,
|
||||||
|
negativePrompt: settings.negativePrompt,
|
||||||
|
strength: settings.strength,
|
||||||
|
steps: settings.steps,
|
||||||
|
cfg: settings.cfg,
|
||||||
|
seed: settings.seed,
|
||||||
|
sampler: settings.sampler,
|
||||||
|
scheduler: settings.scheduler,
|
||||||
|
width: settings.width,
|
||||||
|
height: settings.height,
|
||||||
|
outpaint: settings.outpaint,
|
||||||
|
inputImage,
|
||||||
|
maskImage,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(await response.text());
|
||||||
|
const generated = await response.json() as { source: string; mimeType: string };
|
||||||
|
const intrinsicSize = await loadImageSize(generated.source);
|
||||||
|
const assetId = crypto.randomUUID();
|
||||||
|
const layerId = crypto.randomUUID();
|
||||||
|
dispatch(commandIds.documentAddAsset, { asset: { id: assetId, name: "Generated image", mimeType: generated.mimeType, source: generated.source, intrinsicSize } });
|
||||||
|
dispatch(commandIds.documentAddImageLayer, {
|
||||||
|
artboardId: artboard.id,
|
||||||
|
layer: {
|
||||||
|
id: layerId,
|
||||||
|
type: "image",
|
||||||
|
name: "Generated image",
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
opacity: 1,
|
||||||
|
assetId,
|
||||||
|
transform: { position: { x: viewport.center.x - intrinsicSize.w / 2, y: viewport.center.y - intrinsicSize.h / 2 }, scale: { x: 1, y: 1 }, rotation: 0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
dispatch(commandIds.selectionSet, { artboardId: artboard.id, layerIds: [layerId] });
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : "Generation failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSelectedImage(document: ImageDocument, selection: SelectionState) {
|
||||||
|
const layerId = selection.layerIds[0];
|
||||||
|
if (!layerId) return undefined;
|
||||||
|
const layer = findLayer(document.artboards.find((artboard) => artboard.id === selection.artboardId)?.layers ?? [], layerId);
|
||||||
|
if (!layer || layer.type === "group") return undefined;
|
||||||
|
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||||
|
const maskLayer = layer.clippingMask ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layer.clippingMask.maskLayerId) : undefined;
|
||||||
|
const maskAsset = maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
|
||||||
|
return asset ? { layer, asset, maskAsset } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLayer(layers: readonly Layer[], layerId: string): Layer | undefined {
|
||||||
|
for (const layer of layers) {
|
||||||
|
if (layer.id === layerId) return layer;
|
||||||
|
if (layer.type === "group") {
|
||||||
|
const found = findLayer(layer.children, layerId);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function imageSourceToDataUrl(source: string) {
|
||||||
|
if (source.startsWith("data:")) return source;
|
||||||
|
const image = await loadImage(source);
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = image.naturalWidth;
|
||||||
|
canvas.height = image.naturalHeight;
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) throw new Error("Unable to read selected image");
|
||||||
|
context.drawImage(image, 0, 0);
|
||||||
|
return canvas.toDataURL("image/png");
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImageSize(source: string): Promise<{ w: number; h: number }> {
|
||||||
|
return loadImage(source).then((image) => ({ w: image.naturalWidth, h: image.naturalHeight }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImage(source: string): Promise<HTMLImageElement> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => resolve(image);
|
||||||
|
image.onerror = () => reject(new Error("Failed to load image"));
|
||||||
|
image.src = source;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Check, CaretDown } from "@phosphor-icons/react";
|
import { Check, CaretDown } from "@phosphor-icons/react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { forwardRef, useEffect, useRef, useState, type CSSProperties, type ForwardedRef } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
export type BottomControlSelectOption<TValue extends string> = {
|
export type BottomControlSelectOption<TValue extends string> = {
|
||||||
value: TValue;
|
value: TValue;
|
||||||
@@ -10,19 +11,43 @@ export type BottomControlSelectMenuProps<TValue extends string> = {
|
|||||||
value: TValue;
|
value: TValue;
|
||||||
options: readonly BottomControlSelectOption<TValue>[];
|
options: readonly BottomControlSelectOption<TValue>[];
|
||||||
"aria-label": string;
|
"aria-label": string;
|
||||||
|
label?: string;
|
||||||
|
placement?: "top" | "bottom" | "inline";
|
||||||
onValueChange: (value: TValue) => void;
|
onValueChange: (value: TValue) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BottomControlSelectMenu<TValue extends string>({ value, options, onValueChange, ...props }: BottomControlSelectMenuProps<TValue>) {
|
export function BottomControlSelectMenu<TValue extends string>({ value, options, label, placement = "top", onValueChange, ...props }: BottomControlSelectMenuProps<TValue>) {
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [menuStyle, setMenuStyle] = useState<CSSProperties>();
|
||||||
const selectedOption = options.find((option) => option.value === value) ?? options[0];
|
const selectedOption = options.find((option) => option.value === value) ?? options[0];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
||||||
|
const updateMenuPosition = () => {
|
||||||
|
if (placement === "inline") return;
|
||||||
|
const rect = buttonRef.current?.getBoundingClientRect();
|
||||||
|
if (!rect) return;
|
||||||
|
const gap = 8;
|
||||||
|
const maxHeight = Math.max(160, placement === "bottom" ? window.innerHeight - rect.bottom - gap * 2 : rect.top - gap * 2);
|
||||||
|
setMenuStyle({
|
||||||
|
position: "fixed",
|
||||||
|
left: rect.left,
|
||||||
|
top: placement === "bottom" ? rect.bottom + gap : undefined,
|
||||||
|
bottom: placement === "top" ? window.innerHeight - rect.top + gap : undefined,
|
||||||
|
width: Math.max(rect.width, 192),
|
||||||
|
maxHeight,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
updateMenuPosition();
|
||||||
|
|
||||||
const handlePointerDown = (event: PointerEvent) => {
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
|
const target = event.target as Node;
|
||||||
|
if (!rootRef.current?.contains(target) && !menuRef.current?.contains(target)) setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -31,50 +56,97 @@ export function BottomControlSelectMenu<TValue extends string>({ value, options,
|
|||||||
|
|
||||||
window.addEventListener("pointerdown", handlePointerDown);
|
window.addEventListener("pointerdown", handlePointerDown);
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
window.addEventListener("resize", updateMenuPosition);
|
||||||
|
window.addEventListener("scroll", updateMenuPosition, true);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("pointerdown", handlePointerDown);
|
window.removeEventListener("pointerdown", handlePointerDown);
|
||||||
window.removeEventListener("keydown", handleKeyDown);
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
window.removeEventListener("resize", updateMenuPosition);
|
||||||
|
window.removeEventListener("scroll", updateMenuPosition, true);
|
||||||
};
|
};
|
||||||
}, [open]);
|
}, [open, placement]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={rootRef} className="relative">
|
<div ref={rootRef} className={`relative min-w-0 ${placement === "inline" ? "w-full" : ""}`}>
|
||||||
<button
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex h-10 min-w-32 items-center rounded-full text-base text-white/85 transition hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30"
|
className={`inline-flex h-10 min-w-32 max-w-full items-center rounded-full text-base text-white/85 transition hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30 ${placement === "inline" ? "w-full px-4" : ""}`}
|
||||||
aria-label={props["aria-label"]}
|
aria-label={props["aria-label"]}
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
onClick={() => setOpen((current) => !current)}
|
onClick={() => setOpen((current) => !current)}
|
||||||
>
|
>
|
||||||
<span className="min-w-0 flex-1 px-4 text-left">{selectedOption?.label}</span>
|
{placement === "inline" && label ? <span className="shrink-0 pr-3 text-sm font-medium text-white/55">{label}</span> : null}
|
||||||
|
<span className={`min-w-0 flex-1 truncate text-left ${placement === "inline" ? "" : "px-4"}`}>{selectedOption?.label}</span>
|
||||||
<span className="grid size-10 flex-none place-items-center text-white/60">
|
<span className="grid size-10 flex-none place-items-center text-white/60">
|
||||||
<CaretDown size={18} weight="bold" />
|
<CaretDown size={18} weight="bold" />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{open ? (
|
{open && placement === "inline" ? (
|
||||||
<div className="absolute bottom-full left-0 z-30 mb-3 min-w-full rounded-[1.5rem] p-1 text-white backdrop-blur-xl" role="listbox" aria-label={props["aria-label"]}>
|
<SelectOptions
|
||||||
{options.map((option) => {
|
ref={menuRef}
|
||||||
const selected = option.value === value;
|
options={options}
|
||||||
return (
|
value={value}
|
||||||
<button
|
onValueChange={onValueChange}
|
||||||
key={option.value}
|
setOpen={setOpen}
|
||||||
type="button"
|
className="subtle-scrollbar mt-2 max-h-56 w-full overflow-auto rounded-[1.25rem] bg-white/[0.04] p-1 text-white ring-1 ring-white/10"
|
||||||
className={`flex h-10 w-full items-center gap-3 rounded-full px-3 text-left text-sm transition ${selected ? "bg-white text-black" : "text-white/80 hover:bg-white/10 hover:text-white"}`}
|
aria-label={props["aria-label"]}
|
||||||
role="option"
|
/>
|
||||||
aria-selected={selected}
|
) : open ? (
|
||||||
onClick={() => {
|
createPortal(
|
||||||
onValueChange(option.value);
|
<SelectOptions
|
||||||
setOpen(false);
|
ref={menuRef}
|
||||||
}}
|
options={options}
|
||||||
>
|
value={value}
|
||||||
<span className="grid size-5 place-items-center">{selected ? <Check size={16} weight="bold" /> : null}</span>
|
onValueChange={onValueChange}
|
||||||
<span className="min-w-0 flex-1 whitespace-nowrap">{option.label}</span>
|
setOpen={setOpen}
|
||||||
</button>
|
className="subtle-scrollbar z-50 overflow-auto rounded-[1.5rem] bg-slate-950/90 p-1 text-white shadow-2xl ring-1 ring-white/10 backdrop-blur-xl"
|
||||||
);
|
style={menuStyle}
|
||||||
})}
|
aria-label={props["aria-label"]}
|
||||||
</div>
|
/>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SelectOptionsProps<TValue extends string> = {
|
||||||
|
options: readonly BottomControlSelectOption<TValue>[];
|
||||||
|
value: TValue;
|
||||||
|
className: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
"aria-label": string;
|
||||||
|
onValueChange: (value: TValue) => void;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SelectOptions = forwardRef(function SelectOptions<TValue extends string>(
|
||||||
|
{ options, value, className, style, onValueChange, setOpen, ...props }: SelectOptionsProps<TValue>,
|
||||||
|
ref: ForwardedRef<HTMLDivElement>,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={className} style={style} role="listbox" aria-label={props["aria-label"]}>
|
||||||
|
{options.map((option) => {
|
||||||
|
const selected = option.value === value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`flex h-10 w-full items-center gap-3 rounded-full px-3 text-left text-sm transition ${selected ? "bg-white !text-black" : "text-white/80 hover:bg-white/10 hover:text-white"}`}
|
||||||
|
role="option"
|
||||||
|
aria-selected={selected}
|
||||||
|
onClick={() => {
|
||||||
|
onValueChange(option.value);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="grid size-5 place-items-center">{selected ? <Check size={16} weight="bold" /> : null}</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{option.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ export function useCanvasInput(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (isEditableKeyboardTarget(event.target)) return;
|
||||||
|
|
||||||
const consumed = panHandler.keyDown(keybindEventFromKeyboardEvent(event));
|
const consumed = panHandler.keyDown(keybindEventFromKeyboardEvent(event));
|
||||||
if (consumed) {
|
if (consumed) {
|
||||||
clearBrushPreview();
|
clearBrushPreview();
|
||||||
@@ -80,6 +82,8 @@ export function useCanvasInput(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyUp = (event: KeyboardEvent) => {
|
const handleKeyUp = (event: KeyboardEvent) => {
|
||||||
|
if (isEditableKeyboardTarget(event.target)) return;
|
||||||
|
|
||||||
const consumed = panHandler.keyUp(keybindEventFromKeyboardEvent(event));
|
const consumed = panHandler.keyUp(keybindEventFromKeyboardEvent(event));
|
||||||
if (consumed) event.preventDefault();
|
if (consumed) event.preventDefault();
|
||||||
};
|
};
|
||||||
@@ -236,6 +240,13 @@ export function useCanvasInput(
|
|||||||
return { isPanning };
|
return { isPanning };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isEditableKeyboardTarget(target: EventTarget | null) {
|
||||||
|
return (
|
||||||
|
target instanceof HTMLElement &&
|
||||||
|
(target.isContentEditable || target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function pointInsideCanvas(point: { x: number; y: number }, canvas: HTMLCanvasElement) {
|
function pointInsideCanvas(point: { x: number; y: number }, canvas: HTMLCanvasElement) {
|
||||||
return point.x >= 0 && point.y >= 0 && point.x <= canvas.width && point.y <= canvas.height;
|
return point.x >= 0 && point.y >= 0 && point.x <= canvas.width && point.y <= canvas.height;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,34 @@
|
|||||||
import { useEffect, type RefObject } from "react";
|
import { useEffect, useRef, type RefObject } from "react";
|
||||||
import type { Dispatch } from "@commands/dispatcher";
|
import type { Dispatch } from "@commands/dispatcher";
|
||||||
import { commandIds } from "@commands/ids";
|
import { commandIds } from "@commands/ids";
|
||||||
|
|
||||||
export function useCanvasResize(canvasRef: RefObject<HTMLCanvasElement | null>, dispatch: Dispatch) {
|
export function useCanvasResize(canvasRef: RefObject<HTMLCanvasElement | null>, dispatch: Dispatch) {
|
||||||
|
const lastSize = useRef<{ w: number; h: number }>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
|
|
||||||
|
let animationFrame: number | undefined;
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(([entry]) => {
|
const resizeObserver = new ResizeObserver(([entry]) => {
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
|
|
||||||
const width = Math.floor(entry.contentRect.width);
|
const width = Math.floor(entry.contentRect.width);
|
||||||
const height = Math.floor(entry.contentRect.height);
|
const height = Math.floor(entry.contentRect.height);
|
||||||
dispatch(commandIds.viewportSetSize, { w: width, h: height });
|
if (lastSize.current?.w === width && lastSize.current.h === height) return;
|
||||||
|
|
||||||
|
if (animationFrame !== undefined) cancelAnimationFrame(animationFrame);
|
||||||
|
animationFrame = requestAnimationFrame(() => {
|
||||||
|
lastSize.current = { w: width, h: height };
|
||||||
|
dispatch(commandIds.viewportSetSize, { w: width, h: height });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
resizeObserver.observe(canvas);
|
resizeObserver.observe(canvas);
|
||||||
return () => resizeObserver.disconnect();
|
return () => {
|
||||||
|
if (animationFrame !== undefined) cancelAnimationFrame(animationFrame);
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
};
|
||||||
}, [canvasRef, dispatch]);
|
}, [canvasRef, dispatch]);
|
||||||
}
|
}
|
||||||
|
|||||||
111
view/generate/runGenerate.ts
Normal file
111
view/generate/runGenerate.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { commandIds } from "@commands/ids";
|
||||||
|
import type { ImageDocument } from "@core/document";
|
||||||
|
import type { Layer } from "@core/layer";
|
||||||
|
import type { AppStore } from "@editor/store";
|
||||||
|
import type { SelectionState, ViewportState } from "@editor/state";
|
||||||
|
import type { GenerateSettings } from "@editor/tools";
|
||||||
|
|
||||||
|
export async function runGenerate(options: {
|
||||||
|
document: ImageDocument;
|
||||||
|
selection: SelectionState;
|
||||||
|
viewport: ViewportState;
|
||||||
|
settings: GenerateSettings;
|
||||||
|
dispatch: AppStore["dispatch"];
|
||||||
|
}) {
|
||||||
|
const { document, selection, viewport, settings, dispatch } = options;
|
||||||
|
const artboard = selection.artboardId ? document.artboards.find((candidate) => candidate.id === selection.artboardId) : document.artboards[0];
|
||||||
|
if (!artboard) return;
|
||||||
|
|
||||||
|
const target = resolveSelectedImage(document, selection);
|
||||||
|
const inputImage = target && settings.mode !== "text-to-image" ? await imageSourceToDataUrl(target.asset.source) : undefined;
|
||||||
|
const maskImage = target?.maskAsset && settings.mode === "inpaint" ? await imageSourceToDataUrl(target.maskAsset.source) : undefined;
|
||||||
|
const response = await fetch("/api/comfy/generate", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
mode: settings.mode,
|
||||||
|
model: settings.model,
|
||||||
|
prompt: settings.prompt,
|
||||||
|
negativePrompt: settings.negativePrompt,
|
||||||
|
strength: settings.strength,
|
||||||
|
steps: settings.steps,
|
||||||
|
cfg: settings.cfg,
|
||||||
|
seed: settings.seed,
|
||||||
|
sampler: settings.sampler,
|
||||||
|
scheduler: settings.scheduler,
|
||||||
|
width: settings.width,
|
||||||
|
height: settings.height,
|
||||||
|
outpaint: settings.outpaint,
|
||||||
|
inputImage,
|
||||||
|
maskImage,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(await response.text());
|
||||||
|
const generated = await response.json() as { source: string; mimeType: string };
|
||||||
|
const intrinsicSize = await loadImageSize(generated.source);
|
||||||
|
const assetId = crypto.randomUUID();
|
||||||
|
const layerId = crypto.randomUUID();
|
||||||
|
dispatch(commandIds.documentAddAsset, { asset: { id: assetId, name: "Generated image", mimeType: generated.mimeType, source: generated.source, intrinsicSize } });
|
||||||
|
dispatch(commandIds.documentAddImageLayer, {
|
||||||
|
artboardId: artboard.id,
|
||||||
|
layer: {
|
||||||
|
id: layerId,
|
||||||
|
type: "image",
|
||||||
|
name: "Generated image",
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
opacity: 1,
|
||||||
|
assetId,
|
||||||
|
transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
dispatch(commandIds.documentMoveLayer, { layerId, toArtboardId: artboard.id, toIndex: 0 });
|
||||||
|
dispatch(commandIds.selectionSet, { artboardId: artboard.id, layerIds: [layerId] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSelectedImage(document: ImageDocument, selection: SelectionState) {
|
||||||
|
const layerId = selection.layerIds[0];
|
||||||
|
if (!layerId) return undefined;
|
||||||
|
const layer = findLayer(document.artboards.find((artboard) => artboard.id === selection.artboardId)?.layers ?? [], layerId);
|
||||||
|
if (!layer || layer.type === "group") return undefined;
|
||||||
|
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||||
|
const maskLayer = layer.clippingMask ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layer.clippingMask.maskLayerId) : undefined;
|
||||||
|
const maskAsset = maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
|
||||||
|
return asset ? { layer, asset, maskAsset } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLayer(layers: readonly Layer[], layerId: string): Layer | undefined {
|
||||||
|
for (const layer of layers) {
|
||||||
|
if (layer.id === layerId) return layer;
|
||||||
|
if (layer.type === "group") {
|
||||||
|
const found = findLayer(layer.children, layerId);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function imageSourceToDataUrl(source: string) {
|
||||||
|
if (source.startsWith("data:")) return source;
|
||||||
|
const image = await loadImage(source);
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = image.naturalWidth;
|
||||||
|
canvas.height = image.naturalHeight;
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) throw new Error("Unable to read selected image");
|
||||||
|
context.drawImage(image, 0, 0);
|
||||||
|
return canvas.toDataURL("image/png");
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImageSize(source: string): Promise<{ w: number; h: number }> {
|
||||||
|
return loadImage(source).then((image) => ({ w: image.naturalWidth, h: image.naturalHeight }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImage(source: string): Promise<HTMLImageElement> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => resolve(image);
|
||||||
|
image.onerror = () => reject(new Error("Failed to load image"));
|
||||||
|
image.src = source;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -66,4 +66,27 @@
|
|||||||
outline: 1px solid rgb(255 255 255);
|
outline: 1px solid rgb(255 255 255);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subtle-scrollbar {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgb(255 255 255 / 0.16) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtle-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 0.375rem;
|
||||||
|
height: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtle-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtle-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: rgb(255 255 255 / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtle-scrollbar:hover::-webkit-scrollbar-thumb {
|
||||||
|
background: rgb(255 255 255 / 0.22);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import type { ToolId } from "@editor/tools";
|
|||||||
|
|
||||||
export function labelForTool(tool: ToolId): string {
|
export function labelForTool(tool: ToolId): string {
|
||||||
switch (tool) {
|
switch (tool) {
|
||||||
|
case "generate":
|
||||||
|
return "Generate";
|
||||||
case "brush":
|
case "brush":
|
||||||
return "Brush";
|
return "Brush";
|
||||||
case "chromaKey":
|
case "chromaKey":
|
||||||
|
|||||||
Reference in New Issue
Block a user