feat: add generation cancel job functionality and improve job handling
- Introduced `generationCancelJob` command ID in `commands/ids.ts`. - Added `GenerationCancelJobPayload` type in `commands/payloads.ts`. - Enhanced job status to include "cancelled" in `editor/state.ts`. - Updated `runGenerationJob` to accept an `AbortSignal` and handle cancellation. - Implemented cancellation logic in `runGenerate` and related functions. - Added tests for job cancellation in `operations/generation/workflow.test.ts`. - Improved layer rendering logic to prevent stack overflow in `editor/document-indexes.ts`. - Added raster size assertions in `platform/browser/rasterLimits.ts` for image processing limits. - Enhanced image file handling to check for size limits in `platform/browser/imageFiles.ts`. - Updated UI components to reflect job cancellation state in `view/GenerationJobStatus.tsx` and `view/bottom-controls/GenerateActionControls.tsx`.
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
|||||||
generationFailJobCommand,
|
generationFailJobCommand,
|
||||||
generationStartJobCommand,
|
generationStartJobCommand,
|
||||||
generationSucceedJobCommand,
|
generationSucceedJobCommand,
|
||||||
|
generationCancelJobCommand,
|
||||||
} from "./generation";
|
} from "./generation";
|
||||||
|
|
||||||
describe("generation commands", () => {
|
describe("generation commands", () => {
|
||||||
@@ -40,6 +41,16 @@ describe("generation commands", () => {
|
|||||||
expect(next.editor.generation.compareMode).toBe("split");
|
expect(next.editor.generation.compareMode).toBe("split");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("bounds retained candidate source memory as well as candidate count", () => {
|
||||||
|
const largeSource = "x".repeat(34 * 1024 * 1024);
|
||||||
|
const first = { ...generationCandidate("candidate-1"), source: largeSource };
|
||||||
|
const second = { ...generationCandidate("candidate-2"), source: largeSource };
|
||||||
|
const withFirst = generationAddCandidateCommand.execute({ state: createInitialAppState("Test") }, { candidate: first });
|
||||||
|
const withSecond = generationAddCandidateCommand.execute({ state: withFirst }, { candidate: second });
|
||||||
|
|
||||||
|
expect(withSecond.editor.generation.candidates.map((candidate) => candidate.id)).toEqual(["candidate-2"]);
|
||||||
|
});
|
||||||
|
|
||||||
test("clears the candidate session and resets comparison", () => {
|
test("clears the candidate session and resets comparison", () => {
|
||||||
const withCandidate = generationAddCandidateCommand.execute(
|
const withCandidate = generationAddCandidateCommand.execute(
|
||||||
{ state: createInitialAppState("Test") },
|
{ state: createInitialAppState("Test") },
|
||||||
@@ -159,6 +170,13 @@ describe("generation commands", () => {
|
|||||||
|
|
||||||
expect(failed.editor.generation.jobs[0]).toMatchObject({ id: "job-1", status: "failed", error: "Backend unavailable", finishedAt: 125 });
|
expect(failed.editor.generation.jobs[0]).toMatchObject({ id: "job-1", status: "failed", error: "Backend unavailable", finishedAt: 125 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("records explicit cancellation separately from failure", () => {
|
||||||
|
const running = generationStartJobCommand.execute({ state: createInitialAppState("Test") }, { jobId: "job-1", kind: "generate", label: "Generating", startedAt: 100 });
|
||||||
|
const cancelled = generationCancelJobCommand.execute({ state: running }, { jobId: "job-1", finishedAt: 110 });
|
||||||
|
|
||||||
|
expect(cancelled.editor.generation.jobs[0]).toMatchObject({ id: "job-1", status: "cancelled", finishedAt: 110 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function documentWithSourceLayer() {
|
function documentWithSourceLayer() {
|
||||||
|
|||||||
@@ -43,10 +43,12 @@ export type GenerationReplaceCandidatePixelsPayload = {
|
|||||||
export type GenerationStartJobPayload = { jobId: GenerationJobId; kind: GenerationJobKind; label: string; startedAt: number };
|
export type GenerationStartJobPayload = { jobId: GenerationJobId; kind: GenerationJobKind; label: string; startedAt: number };
|
||||||
export type GenerationSucceedJobPayload = { jobId: GenerationJobId; finishedAt: number };
|
export type GenerationSucceedJobPayload = { jobId: GenerationJobId; finishedAt: number };
|
||||||
export type GenerationFailJobPayload = { jobId: GenerationJobId; finishedAt: number; error: string };
|
export type GenerationFailJobPayload = { jobId: GenerationJobId; finishedAt: number; error: string };
|
||||||
|
export type GenerationCancelJobPayload = { jobId: GenerationJobId; finishedAt: number };
|
||||||
export type GenerationSetResourcesPayload = { options: GenerationOptions };
|
export type GenerationSetResourcesPayload = { options: GenerationOptions };
|
||||||
export type GenerationFailResourcesPayload = { error: string };
|
export type GenerationFailResourcesPayload = { error: string };
|
||||||
|
|
||||||
const maxCandidates = 12;
|
const maxCandidates = 12;
|
||||||
|
const maxCandidateSourceBytes = 96 * 1024 * 1024;
|
||||||
const maxJobs = 20;
|
const maxJobs = 20;
|
||||||
const generationCompareModes = new Set<GenerationCompareMode>(["result", "before", "split"]);
|
const generationCompareModes = new Set<GenerationCompareMode>(["result", "before", "split"]);
|
||||||
|
|
||||||
@@ -55,7 +57,7 @@ export const generationAddCandidateCommand: Command<GenerationAddCandidatePayloa
|
|||||||
name: "Add generation candidate",
|
name: "Add generation candidate",
|
||||||
history: { mode: "ignore" },
|
history: { mode: "ignore" },
|
||||||
execute({ state }, payload) {
|
execute({ state }, payload) {
|
||||||
const candidates = [payload.candidate, ...state.editor.generation.candidates.filter((candidate) => candidate.id !== payload.candidate.id)].slice(0, maxCandidates);
|
const candidates = retainCandidateBudget([payload.candidate, ...state.editor.generation.candidates.filter((candidate) => candidate.id !== payload.candidate.id)]);
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
editor: {
|
editor: {
|
||||||
@@ -277,6 +279,15 @@ export const generationFailJobCommand: Command<GenerationFailJobPayload> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const generationCancelJobCommand: Command<GenerationCancelJobPayload> = {
|
||||||
|
id: commandIds.generationCancelJob,
|
||||||
|
name: "Cancel generation job",
|
||||||
|
history: { mode: "ignore" },
|
||||||
|
execute({ state }, payload) {
|
||||||
|
return settleJob(state, payload.jobId, payload.finishedAt, "cancelled");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const generationLoadResourcesCommand: Command = {
|
export const generationLoadResourcesCommand: Command = {
|
||||||
id: commandIds.generationLoadResources,
|
id: commandIds.generationLoadResources,
|
||||||
name: "Load generation resources",
|
name: "Load generation resources",
|
||||||
@@ -318,6 +329,7 @@ export const generationCommands = [
|
|||||||
generationStartJobCommand,
|
generationStartJobCommand,
|
||||||
generationSucceedJobCommand,
|
generationSucceedJobCommand,
|
||||||
generationFailJobCommand,
|
generationFailJobCommand,
|
||||||
|
generationCancelJobCommand,
|
||||||
generationLoadResourcesCommand,
|
generationLoadResourcesCommand,
|
||||||
generationSetResourcesCommand,
|
generationSetResourcesCommand,
|
||||||
generationFailResourcesCommand,
|
generationFailResourcesCommand,
|
||||||
@@ -331,13 +343,31 @@ function updateResources(state: AppState, resources: GenerationState["resources"
|
|||||||
return { ...state, editor: { ...state.editor, generation: { ...state.editor.generation, resources } } };
|
return { ...state, editor: { ...state.editor, generation: { ...state.editor.generation, resources } } };
|
||||||
}
|
}
|
||||||
|
|
||||||
function settleJob(state: AppState, jobId: GenerationJobId, finishedAt: number, status: "succeeded" | "failed", error?: string): AppState {
|
function settleJob(state: AppState, jobId: GenerationJobId, finishedAt: number, status: "succeeded" | "failed" | "cancelled", error?: string): AppState {
|
||||||
if (!Number.isFinite(finishedAt)) return state;
|
if (!Number.isFinite(finishedAt)) return state;
|
||||||
const job = state.editor.generation.jobs.find((candidate) => candidate.id === jobId);
|
const job = state.editor.generation.jobs.find((candidate) => candidate.id === jobId);
|
||||||
if (!job || job.status !== "running" || finishedAt < job.startedAt) return state;
|
if (!job || job.status !== "running" || finishedAt < job.startedAt) return state;
|
||||||
return updateJobs(state, state.editor.generation.jobs.map((candidate) => candidate.id === jobId ? { ...candidate, status, finishedAt, error } : candidate));
|
return updateJobs(state, state.editor.generation.jobs.map((candidate) => candidate.id === jobId ? { ...candidate, status, finishedAt, error } : candidate));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function retainCandidateBudget(candidates: GenerationCandidate[]): GenerationCandidate[] {
|
||||||
|
const retained: GenerationCandidate[] = [];
|
||||||
|
let bytes = 0;
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const candidateBytes = candidateRetainedBytes(candidate);
|
||||||
|
if (retained.length > 0 && bytes + candidateBytes > maxCandidateSourceBytes) continue;
|
||||||
|
retained.push(candidate);
|
||||||
|
bytes += candidateBytes;
|
||||||
|
if (retained.length >= maxCandidates) break;
|
||||||
|
}
|
||||||
|
return retained;
|
||||||
|
}
|
||||||
|
|
||||||
|
function candidateRetainedBytes(candidate: GenerationCandidate): number {
|
||||||
|
return [candidate.source, candidate.inputImage, candidate.maskImage, candidate.inpaint?.inputImage, candidate.inpaint?.maskImage]
|
||||||
|
.reduce((total, source) => total + (source?.length ?? 0) * 2, 0);
|
||||||
|
}
|
||||||
|
|
||||||
type LayerLocation = {
|
type LayerLocation = {
|
||||||
artboardId: ArtboardId;
|
artboardId: ArtboardId;
|
||||||
layer: Layer;
|
layer: Layer;
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export const commandIds = {
|
|||||||
generationStartJob: "generation.startJob",
|
generationStartJob: "generation.startJob",
|
||||||
generationSucceedJob: "generation.succeedJob",
|
generationSucceedJob: "generation.succeedJob",
|
||||||
generationFailJob: "generation.failJob",
|
generationFailJob: "generation.failJob",
|
||||||
|
generationCancelJob: "generation.cancelJob",
|
||||||
generationLoadResources: "generation.loadResources",
|
generationLoadResources: "generation.loadResources",
|
||||||
generationSetResources: "generation.setResources",
|
generationSetResources: "generation.setResources",
|
||||||
generationFailResources: "generation.failResources",
|
generationFailResources: "generation.failResources",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import type {
|
|||||||
GenerationStartJobPayload,
|
GenerationStartJobPayload,
|
||||||
GenerationSucceedJobPayload,
|
GenerationSucceedJobPayload,
|
||||||
GenerationFailJobPayload,
|
GenerationFailJobPayload,
|
||||||
|
GenerationCancelJobPayload,
|
||||||
GenerationSetResourcesPayload,
|
GenerationSetResourcesPayload,
|
||||||
GenerationFailResourcesPayload,
|
GenerationFailResourcesPayload,
|
||||||
} from "./generation";
|
} from "./generation";
|
||||||
@@ -106,6 +107,7 @@ export type CommandPayloads = {
|
|||||||
[commandIds.generationStartJob]: GenerationStartJobPayload;
|
[commandIds.generationStartJob]: GenerationStartJobPayload;
|
||||||
[commandIds.generationSucceedJob]: GenerationSucceedJobPayload;
|
[commandIds.generationSucceedJob]: GenerationSucceedJobPayload;
|
||||||
[commandIds.generationFailJob]: GenerationFailJobPayload;
|
[commandIds.generationFailJob]: GenerationFailJobPayload;
|
||||||
|
[commandIds.generationCancelJob]: GenerationCancelJobPayload;
|
||||||
[commandIds.generationLoadResources]: void;
|
[commandIds.generationLoadResources]: void;
|
||||||
[commandIds.generationSetResources]: GenerationSetResourcesPayload;
|
[commandIds.generationSetResources]: GenerationSetResourcesPayload;
|
||||||
[commandIds.generationFailResources]: GenerationFailResourcesPayload;
|
[commandIds.generationFailResources]: GenerationFailResourcesPayload;
|
||||||
|
|||||||
@@ -67,6 +67,17 @@ describe("document read indexes", () => {
|
|||||||
expect(visited).toEqual(["group", "target", "mask"]);
|
expect(visited).toEqual(["group", "target", "mask"]);
|
||||||
expect(layers.map((layer) => layer.id)).toEqual(["mask", "target", "group"]);
|
expect(layers.map((layer) => layer.id)).toEqual(["mask", "target", "group"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("indexes pathological nesting without recursive stack overflow", () => {
|
||||||
|
let layers: Layer[] = [raster("leaf", "Leaf", "asset-nested", { x: 80, y: 10 }, { x: 2, y: 3 })];
|
||||||
|
for (let depth = 0; depth < 10_000; depth += 1) layers = [group(`group-${depth}`, `Group ${depth}`, layers)];
|
||||||
|
const deepDocument = { ...document, artboards: [{ ...document.artboards[0]!, layers }] };
|
||||||
|
|
||||||
|
const index = createDocumentReadIndex(deepDocument);
|
||||||
|
|
||||||
|
expect(index.layerById.size).toBe(10_001);
|
||||||
|
expect(resolveIndexedLayerBounds(index, layers[0]!)).toEqual({ x: 80, y: 10, w: 40, h: 30 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function raster(
|
function raster(
|
||||||
|
|||||||
@@ -99,55 +99,58 @@ function indexLayerTree(options: {
|
|||||||
documentMaskLayerIds: Set<LayerId>;
|
documentMaskLayerIds: Set<LayerId>;
|
||||||
maskLayerIdsByLayerList: Map<readonly Layer[], ReadonlySet<LayerId>>;
|
maskLayerIdsByLayerList: Map<readonly Layer[], ReadonlySet<LayerId>>;
|
||||||
}): Set<LayerId> {
|
}): Set<LayerId> {
|
||||||
const layerListMaskLayerIds = new Set<LayerId>();
|
type Frame = { layers: readonly Layer[]; parentGroupId?: LayerId; visited: boolean };
|
||||||
|
const stack: Frame[] = [{ layers: options.layers, parentGroupId: options.parentGroupId, visited: false }];
|
||||||
for (let index = 0; index < options.layers.length; index += 1) {
|
while (stack.length > 0) {
|
||||||
const layer = options.layers[index];
|
const frame = stack.pop();
|
||||||
if (!layer) continue;
|
if (!frame) continue;
|
||||||
|
if (frame.visited) {
|
||||||
options.layerById.set(layer.id, layer);
|
const ids = new Set<LayerId>();
|
||||||
options.layerInfoById.set(layer.id, {
|
for (const layer of frame.layers) {
|
||||||
artboardId: options.artboardId,
|
const mask = getLayerMask(layer);
|
||||||
parentGroupId: options.parentGroupId,
|
if (mask) ids.add(mask.maskLayerId);
|
||||||
layer,
|
if (layer.type === "group") for (const id of options.maskLayerIdsByLayerList.get(layer.children) ?? []) ids.add(id);
|
||||||
siblings: options.layers,
|
}
|
||||||
index,
|
options.maskLayerIdsByLayerList.set(frame.layers, ids);
|
||||||
});
|
continue;
|
||||||
|
|
||||||
const layerMask = getLayerMask(layer);
|
|
||||||
if (layerMask) {
|
|
||||||
options.documentMaskLayerIds.add(layerMask.maskLayerId);
|
|
||||||
layerListMaskLayerIds.add(layerMask.maskLayerId);
|
|
||||||
}
|
}
|
||||||
|
stack.push({ ...frame, visited: true });
|
||||||
if (layer.type === "group") {
|
for (let index = frame.layers.length - 1; index >= 0; index -= 1) {
|
||||||
const childMaskLayerIds = indexLayerTree({
|
const layer = frame.layers[index];
|
||||||
...options,
|
if (!layer) continue;
|
||||||
layers: layer.children,
|
options.layerById.set(layer.id, layer);
|
||||||
parentGroupId: layer.id,
|
options.layerInfoById.set(layer.id, { artboardId: options.artboardId, parentGroupId: frame.parentGroupId, layer, siblings: frame.layers, index });
|
||||||
});
|
const mask = getLayerMask(layer);
|
||||||
for (const maskLayerId of childMaskLayerIds) layerListMaskLayerIds.add(maskLayerId);
|
if (mask) options.documentMaskLayerIds.add(mask.maskLayerId);
|
||||||
|
if (layer.type === "group") stack.push({ layers: layer.children, parentGroupId: layer.id, visited: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return new Set(options.maskLayerIdsByLayerList.get(options.layers) ?? []);
|
||||||
options.maskLayerIdsByLayerList.set(options.layers, layerListMaskLayerIds);
|
|
||||||
return layerListMaskLayerIds;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function countDisplayLayers(layers: readonly Layer[], maskLayerIds: ReadonlySet<LayerId>): number {
|
function countDisplayLayers(layers: readonly Layer[], maskLayerIds: ReadonlySet<LayerId>): number {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const layer of layers) {
|
const stack = [...layers];
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const layer = stack.pop();
|
||||||
|
if (!layer) continue;
|
||||||
if (maskLayerIds.has(layer.id)) continue;
|
if (maskLayerIds.has(layer.id)) continue;
|
||||||
count += 1;
|
count += 1;
|
||||||
if (layer.type === "group") count += countDisplayLayers(layer.children, maskLayerIds);
|
if (layer.type === "group") stack.push(...layer.children);
|
||||||
}
|
}
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
function unionLayerBounds(index: DocumentReadIndex, layers: readonly Layer[]): Rect | undefined {
|
function unionLayerBounds(index: DocumentReadIndex, layers: readonly Layer[]): Rect | undefined {
|
||||||
let bounds: Rect | undefined;
|
let bounds: Rect | undefined;
|
||||||
|
const stack = [...layers];
|
||||||
for (const layer of layers) {
|
while (stack.length > 0) {
|
||||||
|
const layer = stack.pop();
|
||||||
|
if (!layer) continue;
|
||||||
|
if (layer.type === "group") {
|
||||||
|
stack.push(...layer.children);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const layerBounds = resolveIndexedLayerBounds(index, layer);
|
const layerBounds = resolveIndexedLayerBounds(index, layer);
|
||||||
if (!layerBounds) continue;
|
if (!layerBounds) continue;
|
||||||
bounds = bounds ? unionRects(bounds, layerBounds) : layerBounds;
|
bounds = bounds ? unionRects(bounds, layerBounds) : layerBounds;
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export type GenerationCandidate = {
|
|||||||
export type GenerationCompareMode = "result" | "before" | "split";
|
export type GenerationCompareMode = "result" | "before" | "split";
|
||||||
|
|
||||||
export type GenerationJobKind = "generate" | "regenerate" | "refine" | "replace";
|
export type GenerationJobKind = "generate" | "regenerate" | "refine" | "replace";
|
||||||
export type GenerationJobStatus = "running" | "succeeded" | "failed";
|
export type GenerationJobStatus = "running" | "succeeded" | "failed" | "cancelled";
|
||||||
|
|
||||||
export type GenerationJob = {
|
export type GenerationJob = {
|
||||||
id: GenerationJobId;
|
id: GenerationJobId;
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ export async function runGenerationJob(options: {
|
|||||||
kind: GenerationJobKind;
|
kind: GenerationJobKind;
|
||||||
label: string;
|
label: string;
|
||||||
dispatch: AppStore["dispatch"];
|
dispatch: AppStore["dispatch"];
|
||||||
task: () => Promise<void>;
|
signal: AbortSignal;
|
||||||
|
task: (signal: AbortSignal) => Promise<void>;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const jobId = crypto.randomUUID();
|
const jobId = crypto.randomUUID();
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
@@ -14,9 +15,13 @@ export async function runGenerationJob(options: {
|
|||||||
if (!nextState.editor.generation.jobs.some((job) => job.id === jobId && job.status === "running")) return;
|
if (!nextState.editor.generation.jobs.some((job) => job.id === jobId && job.status === "running")) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await options.task();
|
await options.task(options.signal);
|
||||||
options.dispatch(commandIds.generationSucceedJob, { jobId, finishedAt: Date.now() });
|
options.dispatch(commandIds.generationSucceedJob, { jobId, finishedAt: Date.now() });
|
||||||
} catch (reason: unknown) {
|
} catch (reason: unknown) {
|
||||||
|
if (options.signal.aborted) {
|
||||||
|
options.dispatch(commandIds.generationCancelJob, { jobId, finishedAt: Date.now() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
options.dispatch(commandIds.generationFailJob, {
|
options.dispatch(commandIds.generationFailJob, {
|
||||||
jobId,
|
jobId,
|
||||||
finishedAt: Date.now(),
|
finishedAt: Date.now(),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export async function runGenerate(options: {
|
|||||||
viewport: ViewportState;
|
viewport: ViewportState;
|
||||||
settings: GenerateSettings;
|
settings: GenerateSettings;
|
||||||
dispatch: AppStore["dispatch"];
|
dispatch: AppStore["dispatch"];
|
||||||
|
signal?: AbortSignal;
|
||||||
}) {
|
}) {
|
||||||
const { document, selection, settings, dispatch } = options;
|
const { document, selection, settings, dispatch } = options;
|
||||||
const precondition = checkGenerationPreconditions(document, selection, settings);
|
const precondition = checkGenerationPreconditions(document, selection, settings);
|
||||||
@@ -39,6 +40,7 @@ export async function runGenerate(options: {
|
|||||||
inputImage,
|
inputImage,
|
||||||
maskImage,
|
maskImage,
|
||||||
inpaintBundle,
|
inpaintBundle,
|
||||||
|
signal: options.signal,
|
||||||
});
|
});
|
||||||
const intrinsicSize = await loadImageSize(generated.source);
|
const intrinsicSize = await loadImageSize(generated.source);
|
||||||
const placement = resolveGeneratedOutputPlacement({ document, selection, settings, intrinsicSize, inpaintBundle });
|
const placement = resolveGeneratedOutputPlacement({ document, selection, settings, intrinsicSize, inpaintBundle });
|
||||||
@@ -64,6 +66,7 @@ export async function runGenerateFromCandidate(options: {
|
|||||||
candidate: GenerationCandidate;
|
candidate: GenerationCandidate;
|
||||||
settings?: GenerateSettings;
|
settings?: GenerateSettings;
|
||||||
dispatch: AppStore["dispatch"];
|
dispatch: AppStore["dispatch"];
|
||||||
|
signal?: AbortSignal;
|
||||||
}) {
|
}) {
|
||||||
const settings = options.settings ?? options.candidate.settings;
|
const settings = options.settings ?? options.candidate.settings;
|
||||||
const seed = resolveSeed(settings.seed);
|
const seed = resolveSeed(settings.seed);
|
||||||
@@ -75,6 +78,7 @@ export async function runGenerateFromCandidate(options: {
|
|||||||
inputImage: options.candidate.inputImage,
|
inputImage: options.candidate.inputImage,
|
||||||
maskImage: options.candidate.maskImage,
|
maskImage: options.candidate.maskImage,
|
||||||
inpaintCandidate: options.candidate,
|
inpaintCandidate: options.candidate,
|
||||||
|
signal: options.signal,
|
||||||
});
|
});
|
||||||
const intrinsicSize = await loadImageSize(generated.source);
|
const intrinsicSize = await loadImageSize(generated.source);
|
||||||
|
|
||||||
@@ -141,6 +145,7 @@ async function requestGenerate(options: {
|
|||||||
maskImage?: string;
|
maskImage?: string;
|
||||||
inpaintBundle?: InpaintBundle;
|
inpaintBundle?: InpaintBundle;
|
||||||
inpaintCandidate?: GenerationCandidate;
|
inpaintCandidate?: GenerationCandidate;
|
||||||
|
signal?: AbortSignal;
|
||||||
}) {
|
}) {
|
||||||
return requestGeneration({
|
return requestGeneration({
|
||||||
architecture: options.settings.architecture,
|
architecture: options.settings.architecture,
|
||||||
@@ -162,7 +167,7 @@ async function requestGenerate(options: {
|
|||||||
inpaint: resolveInpaintRequest(options.inpaintBundle, options.inpaintCandidate, options.settings),
|
inpaint: resolveInpaintRequest(options.inpaintBundle, options.inpaintCandidate, options.settings),
|
||||||
inputImage: options.inputImage,
|
inputImage: options.inputImage,
|
||||||
maskImage: options.maskImage,
|
maskImage: options.maskImage,
|
||||||
});
|
}, options.signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveInpaintRequest(inpaintBundle: InpaintBundle | undefined, inpaintCandidate: GenerationCandidate | undefined, settings: GenerateSettings) {
|
function resolveInpaintRequest(inpaintBundle: InpaintBundle | undefined, inpaintCandidate: GenerationCandidate | undefined, settings: GenerateSettings) {
|
||||||
|
|||||||
@@ -39,6 +39,25 @@ describe("generation workflow", () => {
|
|||||||
expect(app.store.getState().document.artboards[0]?.layers.some((layer) => layer.id === "layer-new")).toBe(true);
|
expect(app.store.getState().document.artboards[0]?.layers.some((layer) => layer.id === "layer-new")).toBe(true);
|
||||||
expect(app.store.getState().editor.generation.candidates).toHaveLength(0);
|
expect(app.store.getState().editor.generation.candidates).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("cancels the active operation and records a cancelled job", async () => {
|
||||||
|
const app = createTestApp();
|
||||||
|
let receivedSignal: AbortSignal | undefined;
|
||||||
|
const workflow = createGenerationWorkflow(app.store, dependencies({
|
||||||
|
runGenerate: async (options) => {
|
||||||
|
receivedSignal = options.signal;
|
||||||
|
await new Promise<void>((_resolve, reject) => options.signal?.addEventListener("abort", () => reject(new DOMException("Cancelled", "AbortError")), { once: true }));
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const running = workflow.generate();
|
||||||
|
await Promise.resolve();
|
||||||
|
workflow.cancel();
|
||||||
|
await running;
|
||||||
|
|
||||||
|
expect(receivedSignal?.aborted).toBe(true);
|
||||||
|
expect(app.store.getState().editor.generation.jobs[0]?.status).toBe("cancelled");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function dependencies(overrides: Partial<GenerationWorkflowDependencies>): GenerationWorkflowDependencies {
|
function dependencies(overrides: Partial<GenerationWorkflowDependencies>): GenerationWorkflowDependencies {
|
||||||
|
|||||||
@@ -30,8 +30,17 @@ const defaultDependencies: GenerationWorkflowDependencies = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function createGenerationWorkflow(store: AppStore, dependencies: GenerationWorkflowDependencies = defaultDependencies) {
|
export function createGenerationWorkflow(store: AppStore, dependencies: GenerationWorkflowDependencies = defaultDependencies) {
|
||||||
const job = (kind: GenerationJobKind, label: string, task: () => Promise<void>) =>
|
let activeController: AbortController | undefined;
|
||||||
runGenerationJob({ kind, label, dispatch: store.dispatch, task });
|
const job = async (kind: GenerationJobKind, label: string, task: (signal: AbortSignal) => Promise<void>) => {
|
||||||
|
if (store.getState().editor.generation.jobs.some((candidate) => candidate.status === "running")) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
activeController = controller;
|
||||||
|
try {
|
||||||
|
await runGenerationJob({ kind, label, dispatch: store.dispatch, signal: controller.signal, task });
|
||||||
|
} finally {
|
||||||
|
if (activeController === controller) activeController = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
precondition: () => {
|
precondition: () => {
|
||||||
@@ -41,7 +50,7 @@ export function createGenerationWorkflow(store: AppStore, dependencies: Generati
|
|||||||
|
|
||||||
loadResources: () => dependencies.loadGenerationResources(store),
|
loadResources: () => dependencies.loadGenerationResources(store),
|
||||||
|
|
||||||
generate: () => job("generate", "Generating", async () => {
|
generate: () => job("generate", "Generating", async (signal) => {
|
||||||
const state = store.getState();
|
const state = store.getState();
|
||||||
await dependencies.runGenerate({
|
await dependencies.runGenerate({
|
||||||
document: state.document,
|
document: state.document,
|
||||||
@@ -49,15 +58,16 @@ export function createGenerationWorkflow(store: AppStore, dependencies: Generati
|
|||||||
viewport: state.editor.viewport,
|
viewport: state.editor.viewport,
|
||||||
settings: state.editor.tools.generate,
|
settings: state.editor.tools.generate,
|
||||||
dispatch: store.dispatch,
|
dispatch: store.dispatch,
|
||||||
|
signal,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
regenerate: (candidateId: string, settings?: GenerateSettings, label = "Regenerate") =>
|
regenerate: (candidateId: string, settings?: GenerateSettings, label = "Regenerate") =>
|
||||||
job("regenerate", label, async () => {
|
job("regenerate", label, async (signal) => {
|
||||||
const candidate = findCandidate(store, candidateId);
|
const candidate = findCandidate(store, candidateId);
|
||||||
const nextSettings = settings ?? candidate.settings;
|
const nextSettings = settings ?? candidate.settings;
|
||||||
store.dispatch(commandIds.toolSetGenerateSettings, nextSettings);
|
store.dispatch(commandIds.toolSetGenerateSettings, nextSettings);
|
||||||
await dependencies.runGenerateFromCandidate({ candidate, settings: nextSettings, dispatch: store.dispatch });
|
await dependencies.runGenerateFromCandidate({ candidate, settings: nextSettings, dispatch: store.dispatch, signal });
|
||||||
}),
|
}),
|
||||||
|
|
||||||
applyCandidateAsLayer: (candidateId: string) => {
|
applyCandidateAsLayer: (candidateId: string) => {
|
||||||
@@ -115,6 +125,8 @@ export function createGenerationWorkflow(store: AppStore, dependencies: Generati
|
|||||||
const source = await dependencies.createMaskedPixelReplacementSource(store.getState().document, candidate);
|
const source = await dependencies.createMaskedPixelReplacementSource(store.getState().document, candidate);
|
||||||
store.dispatch(commandIds.generationReplaceCandidatePixels, { candidateId, source, mimeType: "image/png" });
|
store.dispatch(commandIds.generationReplaceCandidatePixels, { candidateId, source, mimeType: "image/png" });
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
cancel: () => activeController?.abort(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,16 @@ export type BrowserImageFile = {
|
|||||||
|
|
||||||
export async function decodeBrowserImageFile(file: File): Promise<BrowserImageFile | undefined> {
|
export async function decodeBrowserImageFile(file: File): Promise<BrowserImageFile | undefined> {
|
||||||
if (!file.type.startsWith("image/")) return undefined;
|
if (!file.type.startsWith("image/")) return undefined;
|
||||||
|
if (file.size > 128 * 1024 * 1024) throw new Error("Image file is too large. The import limit is 128 MB.");
|
||||||
|
const objectUrl = URL.createObjectURL(file);
|
||||||
|
let intrinsicSize: { w: number; h: number };
|
||||||
|
try {
|
||||||
|
intrinsicSize = await loadImageSize(objectUrl);
|
||||||
|
assertCanvasRasterSize(intrinsicSize.w, intrinsicSize.h, "Imported image");
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
}
|
||||||
const source = await fileToDataUrl(file);
|
const source = await fileToDataUrl(file);
|
||||||
const intrinsicSize = await loadImageSize(source);
|
|
||||||
return { name: file.name, mimeType: file.type, source, intrinsicSize, release: () => undefined };
|
return { name: file.name, mimeType: file.type, source, intrinsicSize, release: () => undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,3 +38,4 @@ function loadImageSize(source: string): Promise<{ w: number; h: number }> {
|
|||||||
image.src = source;
|
image.src = source;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
import { assertCanvasRasterSize } from "./rasterLimits";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { cropMaskValuesToRgba, expandRectWithinBounds, invertMaskValues } from "./maskRaster";
|
import { blurMaskValues, cropMaskValuesToRgba, dilateMaskValues, erodeMaskValues, expandRectWithinBounds, invertMaskValues } from "./maskRaster";
|
||||||
|
|
||||||
describe("mask raster utilities", () => {
|
describe("mask raster utilities", () => {
|
||||||
test("exports the normalized drawn mask without filling the whole crop", () => {
|
test("exports the normalized drawn mask without filling the whole crop", () => {
|
||||||
@@ -28,6 +28,13 @@ describe("mask raster utilities", () => {
|
|||||||
h: 64,
|
h: 64,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("uses separable mask kernels without changing square-kernel semantics", () => {
|
||||||
|
const values = new Uint8ClampedArray([0, 0, 0, 0, 255, 0, 0, 0, 0]);
|
||||||
|
expect([...dilateMaskValues(values, 3, 3, 1)]).toEqual(new Array(9).fill(255));
|
||||||
|
expect([...erodeMaskValues(new Uint8ClampedArray(new Array(9).fill(255)), 3, 3, 1)]).toEqual(new Array(9).fill(255));
|
||||||
|
expect([...blurMaskValues(values, 3, 3, 1)]).toEqual(new Array(9).fill(28));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function activeRedPixels(rgba: Uint8ClampedArray) {
|
function activeRedPixels(rgba: Uint8ClampedArray) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Rect } from "@core/geometry";
|
import type { Rect } from "@core/geometry";
|
||||||
|
import { assertCanvasRasterSize, assertProcessingRasterSize } from "./rasterLimits";
|
||||||
|
|
||||||
export type MaskFill = "white" | "black" | "clear";
|
export type MaskFill = "white" | "black" | "clear";
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ export type NormalizedMaskOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function createSolidMaskSource(width: number, height: number, fill: MaskFill): Promise<string> {
|
export async function createSolidMaskSource(width: number, height: number, fill: MaskFill): Promise<string> {
|
||||||
|
assertProcessingRasterSize(width, height, "Mask");
|
||||||
const canvas = createCanvas(width, height);
|
const canvas = createCanvas(width, height);
|
||||||
const context = require2dContext(canvas);
|
const context = require2dContext(canvas);
|
||||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
@@ -48,6 +50,7 @@ export async function createSolidMaskSource(width: number, height: number, fill:
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function applyMaskRasterOperation(source: string, width: number, height: number, operation: MaskRasterOperation): Promise<string> {
|
export async function applyMaskRasterOperation(source: string, width: number, height: number, operation: MaskRasterOperation): Promise<string> {
|
||||||
|
assertProcessingRasterSize(width, height, "Mask");
|
||||||
if (operation.type === "fill") return createSolidMaskSource(width, height, operation.fill);
|
if (operation.type === "fill") return createSolidMaskSource(width, height, operation.fill);
|
||||||
|
|
||||||
const mask = await loadMaskValues(source, width, height);
|
const mask = await loadMaskValues(source, width, height);
|
||||||
@@ -56,6 +59,7 @@ export async function applyMaskRasterOperation(source: string, width: number, he
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function analyzeMaskSource(source: string, width: number, height: number): Promise<MaskAnalysis> {
|
export async function analyzeMaskSource(source: string, width: number, height: number): Promise<MaskAnalysis> {
|
||||||
|
assertProcessingRasterSize(width, height, "Mask");
|
||||||
const mask = await loadMaskValues(source, width, height);
|
const mask = await loadMaskValues(source, width, height);
|
||||||
const revealed = analyzeValues(mask.values, mask.width, mask.height, false);
|
const revealed = analyzeValues(mask.values, mask.width, mask.height, false);
|
||||||
const hiddenValues = invertMaskValues(mask.values);
|
const hiddenValues = invertMaskValues(mask.values);
|
||||||
@@ -74,6 +78,7 @@ export async function analyzeMaskSource(source: string, width: number, height: n
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createNormalizedMaskSource(source: string, width: number, height: number, options: NormalizedMaskOptions): Promise<{ source: string; values: Uint8ClampedArray; bounds?: Rect }> {
|
export async function createNormalizedMaskSource(source: string, width: number, height: number, options: NormalizedMaskOptions): Promise<{ source: string; values: Uint8ClampedArray; bounds?: Rect }> {
|
||||||
|
assertProcessingRasterSize(width, height, "Mask");
|
||||||
const mask = await loadMaskValues(source, width, height);
|
const mask = await loadMaskValues(source, width, height);
|
||||||
let values = options.polarity === "hidden" ? invertMaskValues(mask.values) : new Uint8ClampedArray(mask.values);
|
let values = options.polarity === "hidden" ? invertMaskValues(mask.values) : new Uint8ClampedArray(mask.values);
|
||||||
|
|
||||||
@@ -94,6 +99,7 @@ export async function createNormalizedMaskSource(source: string, width: number,
|
|||||||
|
|
||||||
export async function loadImageCanvas(source: string, width?: number, height?: number): Promise<HTMLCanvasElement> {
|
export async function loadImageCanvas(source: string, width?: number, height?: number): Promise<HTMLCanvasElement> {
|
||||||
const image = await loadImage(source);
|
const image = await loadImage(source);
|
||||||
|
assertCanvasRasterSize(width ?? image.naturalWidth, height ?? image.naturalHeight);
|
||||||
const canvas = createCanvas(width ?? image.naturalWidth, height ?? image.naturalHeight);
|
const canvas = createCanvas(width ?? image.naturalWidth, height ?? image.naturalHeight);
|
||||||
const context = require2dContext(canvas);
|
const context = require2dContext(canvas);
|
||||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
@@ -108,6 +114,7 @@ export async function imageSourceToPngDataUrl(source: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function cropCanvas(sourceCanvas: HTMLCanvasElement, crop: Rect, outputWidth = crop.w, outputHeight = crop.h): string {
|
export function cropCanvas(sourceCanvas: HTMLCanvasElement, crop: Rect, outputWidth = crop.w, outputHeight = crop.h): string {
|
||||||
|
assertProcessingRasterSize(outputWidth, outputHeight, "Crop");
|
||||||
const canvas = createCanvas(outputWidth, outputHeight);
|
const canvas = createCanvas(outputWidth, outputHeight);
|
||||||
const context = require2dContext(canvas);
|
const context = require2dContext(canvas);
|
||||||
context.clearRect(0, 0, outputWidth, outputHeight);
|
context.clearRect(0, 0, outputWidth, outputHeight);
|
||||||
@@ -116,6 +123,7 @@ export function cropCanvas(sourceCanvas: HTMLCanvasElement, crop: Rect, outputWi
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function cropMaskValuesToDataUrl(values: Uint8ClampedArray, width: number, height: number, crop: Rect, outputWidth = crop.w, outputHeight = crop.h): string {
|
export function cropMaskValuesToDataUrl(values: Uint8ClampedArray, width: number, height: number, crop: Rect, outputWidth = crop.w, outputHeight = crop.h): string {
|
||||||
|
assertProcessingRasterSize(outputWidth, outputHeight, "Mask crop");
|
||||||
const canvas = createCanvas(outputWidth, outputHeight);
|
const canvas = createCanvas(outputWidth, outputHeight);
|
||||||
const context = require2dContext(canvas);
|
const context = require2dContext(canvas);
|
||||||
const imageData = context.createImageData(outputWidth, outputHeight);
|
const imageData = context.createImageData(outputWidth, outputHeight);
|
||||||
@@ -203,52 +211,53 @@ export function invertMaskValues(values: Uint8ClampedArray): Uint8ClampedArray {
|
|||||||
export function erodeMaskValues(values: Uint8ClampedArray, width: number, height: number, radius: number): Uint8ClampedArray {
|
export function erodeMaskValues(values: Uint8ClampedArray, width: number, height: number, radius: number): Uint8ClampedArray {
|
||||||
const safeRadius = Math.round(clampNumber(radius, 0, 256));
|
const safeRadius = Math.round(clampNumber(radius, 0, 256));
|
||||||
if (safeRadius <= 0) return new Uint8ClampedArray(values);
|
if (safeRadius <= 0) return new Uint8ClampedArray(values);
|
||||||
const next = new Uint8ClampedArray(values.length);
|
return separableExtrema(values, width, height, safeRadius, Math.min, 255);
|
||||||
for (let y = 0; y < height; y += 1) {
|
|
||||||
for (let x = 0; x < width; x += 1) {
|
|
||||||
let value = 255;
|
|
||||||
for (let oy = -safeRadius; oy <= safeRadius; oy += 1) {
|
|
||||||
for (let ox = -safeRadius; ox <= safeRadius; ox += 1) value = Math.min(value, values[clampInt(y + oy, 0, height - 1) * width + clampInt(x + ox, 0, width - 1)] ?? 0);
|
|
||||||
}
|
|
||||||
next[y * width + x] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dilateMaskValues(values: Uint8ClampedArray, width: number, height: number, radius: number): Uint8ClampedArray {
|
export function dilateMaskValues(values: Uint8ClampedArray, width: number, height: number, radius: number): Uint8ClampedArray {
|
||||||
const safeRadius = Math.round(clampNumber(radius, 0, 256));
|
const safeRadius = Math.round(clampNumber(radius, 0, 256));
|
||||||
if (safeRadius <= 0) return new Uint8ClampedArray(values);
|
if (safeRadius <= 0) return new Uint8ClampedArray(values);
|
||||||
const next = new Uint8ClampedArray(values.length);
|
return separableExtrema(values, width, height, safeRadius, Math.max, 0);
|
||||||
for (let y = 0; y < height; y += 1) {
|
|
||||||
for (let x = 0; x < width; x += 1) {
|
|
||||||
let value = 0;
|
|
||||||
for (let oy = -safeRadius; oy <= safeRadius; oy += 1) {
|
|
||||||
for (let ox = -safeRadius; ox <= safeRadius; ox += 1) value = Math.max(value, values[clampInt(y + oy, 0, height - 1) * width + clampInt(x + ox, 0, width - 1)] ?? 0);
|
|
||||||
}
|
|
||||||
next[y * width + x] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function blurMaskValues(values: Uint8ClampedArray, width: number, height: number, radius: number): Uint8ClampedArray {
|
export function blurMaskValues(values: Uint8ClampedArray, width: number, height: number, radius: number): Uint8ClampedArray {
|
||||||
const safeRadius = Math.round(clampNumber(radius, 0, 256));
|
const safeRadius = Math.round(clampNumber(radius, 0, 256));
|
||||||
if (safeRadius <= 0) return new Uint8ClampedArray(values);
|
if (safeRadius <= 0) return new Uint8ClampedArray(values);
|
||||||
|
const horizontal = new Float64Array(values.length);
|
||||||
const next = new Uint8ClampedArray(values.length);
|
const next = new Uint8ClampedArray(values.length);
|
||||||
|
const span = safeRadius * 2 + 1;
|
||||||
for (let y = 0; y < height; y += 1) {
|
for (let y = 0; y < height; y += 1) {
|
||||||
|
let total = 0;
|
||||||
|
for (let ox = -safeRadius; ox <= safeRadius; ox += 1) total += values[y * width + clampInt(ox, 0, width - 1)] ?? 0;
|
||||||
for (let x = 0; x < width; x += 1) {
|
for (let x = 0; x < width; x += 1) {
|
||||||
let total = 0;
|
horizontal[y * width + x] = total / span;
|
||||||
let count = 0;
|
total += (values[y * width + clampInt(x + safeRadius + 1, 0, width - 1)] ?? 0) - (values[y * width + clampInt(x - safeRadius, 0, width - 1)] ?? 0);
|
||||||
for (let oy = -safeRadius; oy <= safeRadius; oy += 1) {
|
|
||||||
for (let ox = -safeRadius; ox <= safeRadius; ox += 1) {
|
|
||||||
total += values[clampInt(y + oy, 0, height - 1) * width + clampInt(x + ox, 0, width - 1)] ?? 0;
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
next[y * width + x] = Math.round(total / count);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (let x = 0; x < width; x += 1) {
|
||||||
|
let total = 0;
|
||||||
|
for (let oy = -safeRadius; oy <= safeRadius; oy += 1) total += horizontal[clampInt(oy, 0, height - 1) * width + x] ?? 0;
|
||||||
|
for (let y = 0; y < height; y += 1) {
|
||||||
|
next[y * width + x] = Math.round(total / span);
|
||||||
|
total += (horizontal[clampInt(y + safeRadius + 1, 0, height - 1) * width + x] ?? 0) - (horizontal[clampInt(y - safeRadius, 0, height - 1) * width + x] ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function separableExtrema(values: Uint8ClampedArray, width: number, height: number, radius: number, combine: (a: number, b: number) => number, initial: number) {
|
||||||
|
const horizontal = new Uint8ClampedArray(values.length);
|
||||||
|
const next = new Uint8ClampedArray(values.length);
|
||||||
|
for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) {
|
||||||
|
let value = initial;
|
||||||
|
for (let offset = -radius; offset <= radius; offset += 1) value = combine(value, values[y * width + clampInt(x + offset, 0, width - 1)] ?? 0);
|
||||||
|
horizontal[y * width + x] = value;
|
||||||
|
}
|
||||||
|
for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) {
|
||||||
|
let value = initial;
|
||||||
|
for (let offset = -radius; offset <= radius; offset += 1) value = combine(value, horizontal[clampInt(y + offset, 0, height - 1) * width + x] ?? 0);
|
||||||
|
next[y * width + x] = value;
|
||||||
|
}
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
20
platform/browser/rasterLimits.ts
Normal file
20
platform/browser/rasterLimits.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
const maxCanvasDimension = 16_384;
|
||||||
|
const maxCanvasPixels = 64 * 1024 * 1024;
|
||||||
|
const maxProcessingPixels = 24 * 1024 * 1024;
|
||||||
|
|
||||||
|
export function assertCanvasRasterSize(width: number, height: number, label = "Image") {
|
||||||
|
assertRasterSize(width, height, maxCanvasPixels, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertProcessingRasterSize(width: number, height: number, label = "Image") {
|
||||||
|
assertRasterSize(width, height, maxProcessingPixels, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertRasterSize(width: number, height: number, maxPixels: number, label: string) {
|
||||||
|
const w = Math.round(width);
|
||||||
|
const h = Math.round(height);
|
||||||
|
if (!Number.isSafeInteger(w) || !Number.isSafeInteger(h) || w <= 0 || h <= 0) throw new Error(`${label} dimensions are invalid.`);
|
||||||
|
if (w > maxCanvasDimension || h > maxCanvasDimension || w * h > maxPixels) {
|
||||||
|
throw new Error(`${label} is too large (${w} x ${h}). Maximum processing size is ${maxCanvasDimension} px per side and ${Math.floor(maxPixels / 1_000_000)} megapixels.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ export async function fetchGenerationOptions(): Promise<unknown> {
|
|||||||
return response.json() as Promise<unknown>;
|
return response.json() as Promise<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requestGeneration(body: unknown): Promise<{ source: string; mimeType: string }> {
|
export async function requestGeneration(body: unknown, signal?: AbortSignal): Promise<{ source: string; mimeType: string }> {
|
||||||
const response = await fetch("/api/comfy/generate", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
const response = await fetch("/api/comfy/generate", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal });
|
||||||
if (!response.ok) throw new Error(await response.text());
|
if (!response.ok) throw new Error(await response.text());
|
||||||
return response.json() as Promise<{ source: string; mimeType: string }>;
|
return response.json() as Promise<{ source: string; mimeType: string }>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { ImageDocument } from "@core/document";
|
|||||||
import type { Rect } from "@core/geometry";
|
import type { Rect } from "@core/geometry";
|
||||||
import type { Layer } from "@core/layer";
|
import type { Layer } from "@core/layer";
|
||||||
import { getLayerMask } from "@core/layer-mask-utils";
|
import { getLayerMask } from "@core/layer-mask-utils";
|
||||||
import { createDocumentReadIndex, forEachLayerBackToFront, resolveIndexedLayerBounds, type DocumentReadIndex } from "@editor/document-indexes";
|
import { createDocumentReadIndex, resolveIndexedLayerBounds, type DocumentReadIndex } from "@editor/document-indexes";
|
||||||
import type { EditorState, GenerationCandidate, MaskViewMode, ViewportState } from "@editor/state";
|
import type { EditorState, GenerationCandidate, MaskViewMode, ViewportState } from "@editor/state";
|
||||||
import { clearScreenRect } from "./clear-rect";
|
import { clearScreenRect } from "./clear-rect";
|
||||||
import type { ImageTextureRenderer } from "./image-textures";
|
import type { ImageTextureRenderer } from "./image-textures";
|
||||||
@@ -24,7 +24,7 @@ export function renderLayers(context: WebGlRendererContext, document: ImageDocum
|
|||||||
if (!artboard.visible) continue;
|
if (!artboard.visible) continue;
|
||||||
const clipRect = documentRectToScreenRect(context.canvas, artboard.bounds, editor.viewport);
|
const clipRect = documentRectToScreenRect(context.canvas, artboard.bounds, editor.viewport);
|
||||||
const maskLayerIds = documentIndex.maskLayerIdsByArtboardId.get(artboard.id) ?? emptyLayerIds;
|
const maskLayerIds = documentIndex.maskLayerIdsByArtboardId.get(artboard.id) ?? emptyLayerIds;
|
||||||
forEachLayerBackToFront(artboard.layers, (layer) => renderLayer(context, documentIndex, editor, layer, imageTextureRenderer, clipRect, maskLayerIds, 1));
|
renderLayerTree(context, documentIndex, editor, artboard.layers, imageTextureRenderer, clipRect, maskLayerIds);
|
||||||
if (generationCandidate?.placement.artboardId === artboard.id) renderGenerationCandidatePreview(context, editor, generationCandidate, imageTextureRenderer, clipRect);
|
if (generationCandidate?.placement.artboardId === artboard.id) renderGenerationCandidatePreview(context, editor, generationCandidate, imageTextureRenderer, clipRect);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,31 +33,46 @@ export function generationCandidatePreviewAssets(editor: EditorState): Asset[] {
|
|||||||
return editor.generation.candidates.map((candidate) => generationCandidateAsset(candidate));
|
return editor.generation.candidates.map((candidate) => generationCandidateAsset(candidate));
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLayer(
|
function renderLayerTree(
|
||||||
context: WebGlRendererContext,
|
context: WebGlRendererContext,
|
||||||
documentIndex: DocumentReadIndex,
|
documentIndex: DocumentReadIndex,
|
||||||
editor: EditorState,
|
editor: EditorState,
|
||||||
layer: Layer,
|
layers: readonly Layer[],
|
||||||
imageTextureRenderer: ImageTextureRenderer,
|
imageTextureRenderer: ImageTextureRenderer,
|
||||||
clipRect: ScreenRect,
|
clipRect: ScreenRect,
|
||||||
maskLayerIds: ReadonlySet<string>,
|
maskLayerIds: ReadonlySet<string>,
|
||||||
inheritedOpacity: number,
|
) {
|
||||||
|
const stack: Array<{ layer: Layer; clipRect: ScreenRect; inheritedOpacity: number }> = [];
|
||||||
|
for (const layer of layers) stack.push({ layer, clipRect, inheritedOpacity: 1 });
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const frame = stack.pop();
|
||||||
|
if (!frame) continue;
|
||||||
|
const { layer } = frame;
|
||||||
|
if (!layer.visible || maskLayerIds.has(layer.id)) continue;
|
||||||
|
const effectiveOpacity = frame.inheritedOpacity * layer.opacity;
|
||||||
|
if (effectiveOpacity <= 0) continue;
|
||||||
|
const effectiveClipRect = resolveLayerClipRect(context, documentIndex, editor.viewport, layer, frame.clipRect);
|
||||||
|
if (!effectiveClipRect) continue;
|
||||||
|
if (layer.type === "group") {
|
||||||
|
for (const child of layer.children) stack.push({ layer: child, clipRect: effectiveClipRect, inheritedOpacity: effectiveOpacity });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
renderLeafLayer(context, documentIndex, editor, layer, imageTextureRenderer, effectiveClipRect, effectiveOpacity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLeafLayer(
|
||||||
|
context: WebGlRendererContext,
|
||||||
|
documentIndex: DocumentReadIndex,
|
||||||
|
editor: EditorState,
|
||||||
|
layer: Exclude<Layer, { type: "group" }>,
|
||||||
|
imageTextureRenderer: ImageTextureRenderer,
|
||||||
|
effectiveClipRect: ScreenRect,
|
||||||
|
effectiveOpacity: number,
|
||||||
) {
|
) {
|
||||||
const editingMaskLayer = editor.maskEdit?.maskLayerId === layer.id;
|
const editingMaskLayer = editor.maskEdit?.maskLayerId === layer.id;
|
||||||
const maskViewMode = editor.maskEdit?.viewMode ?? "composite";
|
const maskViewMode = editor.maskEdit?.viewMode ?? "composite";
|
||||||
const isolatedMaskView = isIsolatedMaskView(maskViewMode);
|
const isolatedMaskView = isIsolatedMaskView(maskViewMode);
|
||||||
if (!layer.visible || maskLayerIds.has(layer.id)) return;
|
|
||||||
const effectiveOpacity = inheritedOpacity * layer.opacity;
|
|
||||||
if (effectiveOpacity <= 0) return;
|
|
||||||
|
|
||||||
const effectiveClipRect = resolveLayerClipRect(context, documentIndex, editor.viewport, layer, clipRect);
|
|
||||||
if (!effectiveClipRect) return;
|
|
||||||
|
|
||||||
if (layer.type === "group") {
|
|
||||||
forEachLayerBackToFront(layer.children, (child) => renderLayer(context, documentIndex, editor, child, imageTextureRenderer, effectiveClipRect, maskLayerIds, effectiveOpacity));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isolatedMaskView && layer.id !== editor.maskEdit?.targetLayerId) return;
|
if (isolatedMaskView && layer.id !== editor.maskEdit?.targetLayerId) return;
|
||||||
|
|
||||||
const bounds = resolveIndexedLayerBounds(documentIndex, layer);
|
const bounds = resolveIndexedLayerBounds(documentIndex, layer);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export async function handleComfyApi(request: Request) {
|
|||||||
try {
|
try {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
if (url.pathname === "/api/comfy/models" && request.method === "GET") return json(await listGenerationOptions());
|
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));
|
if (url.pathname === "/api/comfy/generate" && request.method === "POST") return json(await generate(await request.json() as ComfyGenerateRequest, request.signal));
|
||||||
return new Response("Not found", { status: 404 });
|
return new Response("Not found", { status: 404 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return new Response(error instanceof Error ? error.message : "ComfyUI request failed", { status: 500 });
|
return new Response(error instanceof Error ? error.message : "ComfyUI request failed", { status: 500 });
|
||||||
|
|||||||
@@ -111,15 +111,15 @@ async function listCheckpointModels() {
|
|||||||
return (await listGenerationOptions()).models;
|
return (await listGenerationOptions()).models;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generate(request: ComfyGenerateRequest) {
|
export async function generate(request: ComfyGenerateRequest, signal?: AbortSignal) {
|
||||||
if (!request.prompt?.trim()) throw new Error("Prompt is required");
|
if (!request.prompt?.trim()) throw new Error("Prompt is required");
|
||||||
const architecture = normalizeArchitecture(request.architecture);
|
const architecture = normalizeArchitecture(request.architecture);
|
||||||
if (request.mode !== "text-to-image" && architecture !== "sdxl") throw new Error(`${architectureLabel(architecture)} currently supports text-to-image only`);
|
if (request.mode !== "text-to-image" && architecture !== "sdxl") throw new Error(`${architectureLabel(architecture)} currently supports text-to-image only`);
|
||||||
if (!request.model || request.model === "auto") request.model = await defaultModelForArchitecture(architecture);
|
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`, signal) : 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`, signal) : 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 = buildComfyWorkflow({ ...request, architecture, inputImage: uploaded, maskImage: mask });
|
const prompt = buildComfyWorkflow({ ...request, architecture, inputImage: uploaded, maskImage: mask });
|
||||||
|
|
||||||
@@ -127,6 +127,7 @@ export async function generate(request: ComfyGenerateRequest) {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ client_id: clientId, prompt }),
|
body: JSON.stringify({ client_id: clientId, prompt }),
|
||||||
|
signal,
|
||||||
});
|
});
|
||||||
if (!queued.ok) throw new Error(`ComfyUI prompt failed: ${queued.status} ${await queued.text()}`);
|
if (!queued.ok) throw new Error(`ComfyUI prompt failed: ${queued.status} ${await queued.text()}`);
|
||||||
const queuedBody = await queued.json() as { prompt_id?: string; node_errors?: unknown };
|
const queuedBody = await queued.json() as { prompt_id?: string; node_errors?: unknown };
|
||||||
@@ -134,19 +135,25 @@ export async function generate(request: ComfyGenerateRequest) {
|
|||||||
if (nodeError) throw new Error(`ComfyUI rejected the workflow: ${nodeError}`);
|
if (nodeError) throw new Error(`ComfyUI rejected the workflow: ${nodeError}`);
|
||||||
if (!queuedBody.prompt_id) throw new Error("ComfyUI did not return a prompt id");
|
if (!queuedBody.prompt_id) throw new Error("ComfyUI did not return a prompt id");
|
||||||
const prompt_id = queuedBody.prompt_id;
|
const prompt_id = queuedBody.prompt_id;
|
||||||
const history = await waitForHistory(prompt_id);
|
let history: unknown;
|
||||||
|
try {
|
||||||
|
history = await waitForHistory(prompt_id, signal);
|
||||||
|
} catch (error) {
|
||||||
|
if (signal?.aborted) await cancelComfyPrompt(prompt_id);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
const historyError = historyErrorMessage(history);
|
const historyError = historyErrorMessage(history);
|
||||||
if (historyError) throw new Error(`ComfyUI generation failed: ${historyError}`);
|
if (historyError) throw new Error(`ComfyUI generation failed: ${historyError}`);
|
||||||
const image = selectGeneratedOutputImage(history);
|
const image = selectGeneratedOutputImage(history);
|
||||||
if (!image) throw new Error("ComfyUI did not return an image");
|
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" })}`);
|
const imageResponse = await fetch(`${comfyBaseUrl}/view?${new URLSearchParams({ filename: image.filename, subfolder: image.subfolder ?? "", type: image.type ?? "output" })}`, { signal });
|
||||||
if (!imageResponse.ok) throw new Error(`ComfyUI image fetch failed: ${imageResponse.status}`);
|
if (!imageResponse.ok) throw new Error(`ComfyUI image fetch failed: ${imageResponse.status}`);
|
||||||
const bytes = Buffer.from(await imageResponse.arrayBuffer());
|
const bytes = Buffer.from(await imageResponse.arrayBuffer());
|
||||||
return { source: `data:image/png;base64,${bytes.toString("base64")}`, mimeType: "image/png" };
|
return { source: `data:image/png;base64,${bytes.toString("base64")}`, mimeType: "image/png" };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadDataUrl(dataUrl: string, filename: string) {
|
async function uploadDataUrl(dataUrl: string, filename: string, signal?: AbortSignal) {
|
||||||
const match = /^data:([^;]+);base64,(.+)$/.exec(dataUrl);
|
const match = /^data:([^;]+);base64,(.+)$/.exec(dataUrl);
|
||||||
if (!match) throw new Error("Expected a base64 data URL image");
|
if (!match) throw new Error("Expected a base64 data URL image");
|
||||||
const mimeType = match[1] ?? "image/png";
|
const mimeType = match[1] ?? "image/png";
|
||||||
@@ -154,27 +161,53 @@ async function uploadDataUrl(dataUrl: string, filename: string) {
|
|||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("image", new File([new Uint8Array(Buffer.from(base64, "base64"))], filename, { type: mimeType }));
|
form.append("image", new File([new Uint8Array(Buffer.from(base64, "base64"))], filename, { type: mimeType }));
|
||||||
form.append("overwrite", "true");
|
form.append("overwrite", "true");
|
||||||
const response = await fetch(`${comfyBaseUrl}/upload/image`, { method: "POST", body: form });
|
const response = await fetch(`${comfyBaseUrl}/upload/image`, { method: "POST", body: form, signal });
|
||||||
if (!response.ok) throw new Error(`ComfyUI upload failed: ${response.status}`);
|
if (!response.ok) throw new Error(`ComfyUI upload failed: ${response.status}`);
|
||||||
const uploaded = await response.json() as { name: string };
|
const uploaded = await response.json() as { name: string };
|
||||||
return uploaded.name;
|
return uploaded.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForHistory(promptId: string) {
|
async function waitForHistory(promptId: string, signal?: AbortSignal) {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
while (Date.now() - startedAt < comfyHistoryTimeoutMs) {
|
while (Date.now() - startedAt < comfyHistoryTimeoutMs) {
|
||||||
const response = await fetch(`${comfyBaseUrl}/history/${promptId}`);
|
if (signal?.aborted) {
|
||||||
|
await cancelComfyPrompt(promptId);
|
||||||
|
throw signal.reason ?? new DOMException("Generation cancelled", "AbortError");
|
||||||
|
}
|
||||||
|
const response = await fetch(`${comfyBaseUrl}/history/${promptId}`, { signal });
|
||||||
attempts += 1;
|
attempts += 1;
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const history = await response.json() as Record<string, unknown>;
|
const history = await response.json() as Record<string, unknown>;
|
||||||
if (history[promptId]) return history[promptId];
|
if (history[promptId]) return history[promptId];
|
||||||
}
|
}
|
||||||
await Bun.sleep(comfyHistoryPollIntervalMs);
|
await abortableSleep(comfyHistoryPollIntervalMs, signal);
|
||||||
}
|
}
|
||||||
throw new Error(`Timed out waiting for ComfyUI prompt ${promptId} after ${Math.round(comfyHistoryTimeoutMs / 1000)} seconds and ${attempts} checks`);
|
throw new Error(`Timed out waiting for ComfyUI prompt ${promptId} after ${Math.round(comfyHistoryTimeoutMs / 1000)} seconds and ${attempts} checks`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function cancelComfyPrompt(promptId: string) {
|
||||||
|
const queueResponse = await fetch(`${comfyBaseUrl}/queue`).catch(() => undefined);
|
||||||
|
const queue = queueResponse?.ok ? await queueResponse.json() as { queue_running?: unknown[][]; queue_pending?: unknown[][] } : undefined;
|
||||||
|
const isRunning = queue?.queue_running?.some((entry) => entry.includes(promptId)) ?? false;
|
||||||
|
const isPending = queue?.queue_pending?.some((entry) => entry.includes(promptId)) ?? true;
|
||||||
|
const requests: Promise<unknown>[] = [];
|
||||||
|
if (isPending) requests.push(fetch(`${comfyBaseUrl}/queue`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ delete: [promptId] }) }));
|
||||||
|
if (isRunning) requests.push(fetch(`${comfyBaseUrl}/interrupt`, { method: "POST" }));
|
||||||
|
await Promise.allSettled(requests);
|
||||||
|
}
|
||||||
|
|
||||||
|
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
|
if (!signal) return Bun.sleep(ms);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(resolve, ms);
|
||||||
|
signal.addEventListener("abort", () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(signal.reason ?? new DOMException("Generation cancelled", "AbortError"));
|
||||||
|
}, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function selectGeneratedOutputImage(history: unknown): { filename: string; subfolder?: string; type?: string } | undefined {
|
export function selectGeneratedOutputImage(history: unknown): { filename: string; subfolder?: string; type?: string } | undefined {
|
||||||
const outputs = (history as { outputs?: Record<string, { images?: { filename: string; subfolder?: string; type?: string }[] }> }).outputs ?? {};
|
const outputs = (history as { outputs?: Record<string, { images?: { filename: string; subfolder?: string; type?: string }[] }> }).outputs ?? {};
|
||||||
const saveImageOutput = outputs["8"]?.images?.find(isGeneratedImage);
|
const saveImageOutput = outputs["8"]?.images?.find(isGeneratedImage);
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ export function GenerationJobStatus({ generation, compact = false }: { generatio
|
|||||||
|
|
||||||
if (!job) return null;
|
if (!job) return null;
|
||||||
const elapsed = Math.max(0, Math.floor(((job.finishedAt ?? Date.now()) - job.startedAt) / 1000));
|
const elapsed = Math.max(0, Math.floor(((job.finishedAt ?? Date.now()) - job.startedAt) / 1000));
|
||||||
const label = job.status === "running" ? `${job.label} ${formatElapsed(elapsed)}` : job.status === "failed" ? job.error ?? `${job.label} failed` : `${job.label} complete`;
|
const label = job.status === "running" ? `${job.label} ${formatElapsed(elapsed)}` : job.status === "failed" ? job.error ?? `${job.label} failed` : job.status === "cancelled" ? `${job.label} cancelled` : `${job.label} complete`;
|
||||||
const tone = job.status === "failed" ? "bg-red-500/15 text-red-100" : job.status === "running" ? "bg-white/10 text-white/70" : "bg-emerald-500/15 text-emerald-100";
|
const tone = job.status === "failed" ? "bg-red-500/15 text-red-100" : job.status === "running" ? "bg-white/10 text-white/70" : job.status === "cancelled" ? "bg-amber-500/15 text-amber-100" : "bg-emerald-500/15 text-emerald-100";
|
||||||
|
|
||||||
return <span className={`${compact ? "max-w-56" : "max-w-80"} truncate rounded-full px-3 py-2 text-xs font-medium ${tone}`} title={label} aria-live="polite">{label}</span>;
|
return <span className={`${compact ? "max-w-56" : "max-w-80"} truncate rounded-full px-3 py-2 text-xs font-medium ${tone}`} title={label} aria-live="polite">{label}</span>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export function GenerateActionControls({ settings, generation, dispatch, workflo
|
|||||||
</button>
|
</button>
|
||||||
{preconditionMessage ? <span className="max-w-72 text-xs text-white/55" role="status">{preconditionMessage}</span> : null}
|
{preconditionMessage ? <span className="max-w-72 text-xs text-white/55" role="status">{preconditionMessage}</span> : null}
|
||||||
<GenerationJobStatus generation={generation} />
|
<GenerationJobStatus generation={generation} />
|
||||||
|
{busy ? <button type="button" className="h-9 rounded-full bg-white/5 px-3 text-xs font-semibold text-white/70 hover:bg-white/10 hover:text-white" onClick={workflow.cancel}>Cancel</button> : null}
|
||||||
{candidate ? (
|
{candidate ? (
|
||||||
<>
|
<>
|
||||||
<CandidatePicker generation={generation} dispatch={dispatch} />
|
<CandidatePicker generation={generation} dispatch={dispatch} />
|
||||||
|
|||||||
Reference in New Issue
Block a user