mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
fix: improve mask editor code quality and type safety
- Extract duplicated mask refinement logic into shared applyInvertedMaskAlpha() - Replace non-null assertions with getContext2D() helper that throws descriptively - Unify uploadMask/uploadImage into single uploadLayer(), remove silent error swallowing - Rename single-letter generics <H,B,F> to <Header,Body,Footer> in dialogStore - Replace JSON.parse(JSON.stringify()) with structuredClone() in workflow duplication Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,6 @@ import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
// Private layer filename functions
|
||||
interface ImageLayerFilenames {
|
||||
maskedImage: string
|
||||
paint: string
|
||||
@@ -30,6 +29,32 @@ function imageLayerFilenamesByTimestamp(
|
||||
}
|
||||
}
|
||||
|
||||
function getContext2D(canvas: HTMLCanvasElement): CanvasRenderingContext2D {
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('Failed to get 2D rendering context')
|
||||
return ctx
|
||||
}
|
||||
|
||||
function applyInvertedMaskAlpha(
|
||||
targetCtx: CanvasRenderingContext2D,
|
||||
maskCanvas: HTMLCanvasElement
|
||||
): void {
|
||||
const maskCtx = getContext2D(maskCanvas)
|
||||
const maskData = maskCtx.getImageData(
|
||||
0,
|
||||
0,
|
||||
maskCanvas.width,
|
||||
maskCanvas.height
|
||||
)
|
||||
|
||||
const { width, height } = targetCtx.canvas
|
||||
const imageData = targetCtx.getImageData(0, 0, width, height)
|
||||
for (let i = 0; i < imageData.data.length; i += 4) {
|
||||
imageData.data[i + 3] = 255 - maskData.data[i + 3]
|
||||
}
|
||||
targetCtx.putImageData(imageData, 0, 0)
|
||||
}
|
||||
|
||||
export function useMaskEditorSaver() {
|
||||
const dataStore = useMaskEditorDataStore()
|
||||
const editorStore = useMaskEditorStore()
|
||||
@@ -99,31 +124,10 @@ export function useMaskEditorSaver() {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = imgCanvas.width
|
||||
canvas.height = imgCanvas.height
|
||||
const ctx = canvas.getContext('2d')!
|
||||
const ctx = getContext2D(canvas)
|
||||
|
||||
ctx.drawImage(imgCanvas, 0, 0)
|
||||
|
||||
const maskCtx = maskCanvas.getContext('2d')!
|
||||
const maskData = maskCtx.getImageData(
|
||||
0,
|
||||
0,
|
||||
maskCanvas.width,
|
||||
maskCanvas.height
|
||||
)
|
||||
|
||||
const refinedMaskData = new Uint8ClampedArray(maskData.data.length)
|
||||
for (let i = 0; i < maskData.data.length; i += 4) {
|
||||
refinedMaskData[i] = 0
|
||||
refinedMaskData[i + 1] = 0
|
||||
refinedMaskData[i + 2] = 0
|
||||
refinedMaskData[i + 3] = 255 - maskData.data[i + 3]
|
||||
}
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||||
for (let i = 0; i < imageData.data.length; i += 4) {
|
||||
imageData.data[i + 3] = refinedMaskData[i + 3]
|
||||
}
|
||||
ctx.putImageData(imageData, 0, 0)
|
||||
applyInvertedMaskAlpha(ctx, maskCanvas)
|
||||
|
||||
const blob = await canvasToBlob(canvas)
|
||||
const ref = createFileRef(filename)
|
||||
@@ -150,11 +154,9 @@ export function useMaskEditorSaver() {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = imgCanvas.width
|
||||
canvas.height = imgCanvas.height
|
||||
const ctx = canvas.getContext('2d')!
|
||||
const ctx = getContext2D(canvas)
|
||||
|
||||
ctx.drawImage(imgCanvas, 0, 0)
|
||||
|
||||
ctx.globalCompositeOperation = 'source-over'
|
||||
ctx.drawImage(paintCanvas, 0, 0)
|
||||
|
||||
const blob = await canvasToBlob(canvas)
|
||||
@@ -172,34 +174,11 @@ export function useMaskEditorSaver() {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = imgCanvas.width
|
||||
canvas.height = imgCanvas.height
|
||||
const ctx = canvas.getContext('2d')!
|
||||
const ctx = getContext2D(canvas)
|
||||
|
||||
ctx.drawImage(imgCanvas, 0, 0)
|
||||
|
||||
ctx.globalCompositeOperation = 'source-over'
|
||||
ctx.drawImage(paintCanvas, 0, 0)
|
||||
|
||||
const maskCtx = maskCanvas.getContext('2d')!
|
||||
const maskData = maskCtx.getImageData(
|
||||
0,
|
||||
0,
|
||||
maskCanvas.width,
|
||||
maskCanvas.height
|
||||
)
|
||||
|
||||
const refinedMaskData = new Uint8ClampedArray(maskData.data.length)
|
||||
for (let i = 0; i < maskData.data.length; i += 4) {
|
||||
refinedMaskData[i] = 0
|
||||
refinedMaskData[i + 1] = 0
|
||||
refinedMaskData[i + 2] = 0
|
||||
refinedMaskData[i + 3] = 255 - maskData.data[i + 3]
|
||||
}
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||||
for (let i = 0; i < imageData.data.length; i += 4) {
|
||||
imageData.data[i + 3] = refinedMaskData[i + 3]
|
||||
}
|
||||
ctx.putImageData(imageData, 0, 0)
|
||||
applyInvertedMaskAlpha(ctx, maskCanvas)
|
||||
|
||||
const blob = await canvasToBlob(canvas)
|
||||
const ref = createFileRef(filename)
|
||||
@@ -210,16 +189,26 @@ export function useMaskEditorSaver() {
|
||||
async function uploadAllLayers(outputData: EditorOutputData): Promise<void> {
|
||||
const sourceRef = dataStore.inputData!.sourceRef
|
||||
|
||||
const actualMaskedRef = await uploadMask(outputData.maskedImage, sourceRef)
|
||||
const actualPaintRef = await uploadImage(outputData.paintLayer, sourceRef)
|
||||
const actualPaintedRef = await uploadImage(
|
||||
const actualMaskedRef = await uploadLayer(
|
||||
outputData.maskedImage,
|
||||
sourceRef,
|
||||
'/upload/mask'
|
||||
)
|
||||
const actualPaintRef = await uploadLayer(
|
||||
outputData.paintLayer,
|
||||
sourceRef,
|
||||
'/upload/image'
|
||||
)
|
||||
const actualPaintedRef = await uploadLayer(
|
||||
outputData.paintedImage,
|
||||
sourceRef
|
||||
sourceRef,
|
||||
'/upload/image'
|
||||
)
|
||||
|
||||
const actualPaintedMaskedRef = await uploadMask(
|
||||
const actualPaintedMaskedRef = await uploadLayer(
|
||||
outputData.paintedMaskedImage,
|
||||
actualPaintedRef
|
||||
actualPaintedRef,
|
||||
'/upload/mask'
|
||||
)
|
||||
|
||||
outputData.maskedImage.ref = actualMaskedRef
|
||||
@@ -228,9 +217,10 @@ export function useMaskEditorSaver() {
|
||||
outputData.paintedMaskedImage.ref = actualPaintedMaskedRef
|
||||
}
|
||||
|
||||
async function uploadMask(
|
||||
async function uploadLayer(
|
||||
layer: EditorOutputLayer,
|
||||
originalRef: ImageRef
|
||||
originalRef: ImageRef,
|
||||
endpoint: '/upload/mask' | '/upload/image'
|
||||
): Promise<ImageRef> {
|
||||
const formData = new FormData()
|
||||
formData.append('image', layer.blob, layer.ref.filename)
|
||||
@@ -238,61 +228,22 @@ export function useMaskEditorSaver() {
|
||||
formData.append('type', 'input')
|
||||
formData.append('subfolder', 'clipspace')
|
||||
|
||||
const response = await api.fetchApi('/upload/mask', {
|
||||
const response = await api.fetchApi(endpoint, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to upload mask: ${layer.ref.filename}`)
|
||||
throw new Error(`Failed to upload to ${endpoint}: ${layer.ref.filename}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await response.json()
|
||||
if (data?.name) {
|
||||
return {
|
||||
filename: data.name,
|
||||
subfolder: data.subfolder || layer.ref.subfolder,
|
||||
type: data.type || layer.ref.type
|
||||
}
|
||||
const data = await response.json()
|
||||
if (data?.name) {
|
||||
return {
|
||||
filename: data.name,
|
||||
subfolder: data.subfolder || layer.ref.subfolder,
|
||||
type: data.type || layer.ref.type
|
||||
}
|
||||
} catch {
|
||||
// JSON parse failed — fall through to return existing ref
|
||||
}
|
||||
|
||||
return layer.ref
|
||||
}
|
||||
|
||||
async function uploadImage(
|
||||
layer: EditorOutputLayer,
|
||||
originalRef: ImageRef
|
||||
): Promise<ImageRef> {
|
||||
const formData = new FormData()
|
||||
formData.append('image', layer.blob, layer.ref.filename)
|
||||
formData.append('original_ref', JSON.stringify(originalRef))
|
||||
formData.append('type', 'input')
|
||||
formData.append('subfolder', 'clipspace')
|
||||
|
||||
const response = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to upload image: ${layer.ref.filename}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await response.json()
|
||||
if (data?.name) {
|
||||
return {
|
||||
filename: data.name,
|
||||
subfolder: data.subfolder || layer.ref.subfolder,
|
||||
type: data.type || layer.ref.type
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// JSON parse failed — fall through to return existing ref
|
||||
}
|
||||
|
||||
return layer.ref
|
||||
@@ -373,7 +324,7 @@ export function useMaskEditorSaver() {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = source.width
|
||||
canvas.height = source.height
|
||||
const ctx = canvas.getContext('2d')!
|
||||
const ctx = getContext2D(canvas)
|
||||
ctx.drawImage(source, 0, 0)
|
||||
return canvas
|
||||
}
|
||||
|
||||
@@ -476,7 +476,7 @@ export const useWorkflowService = () => {
|
||||
* Takes an existing workflow and duplicates it with a new name
|
||||
*/
|
||||
const duplicateWorkflow = async (workflow: ComfyWorkflow) => {
|
||||
const state = JSON.parse(JSON.stringify(workflow.activeState))
|
||||
const state = structuredClone(workflow.activeState) as ComfyWorkflowJSON
|
||||
const suffix = workflow.isPersisted ? ' (Copy)' : ''
|
||||
// Remove the suffix `(2)` or similar
|
||||
const filename = workflow.filename.replace(/\s*\(\d+\)$/, '') + suffix
|
||||
|
||||
@@ -38,36 +38,36 @@ export type DialogComponentProps = ComponentAttrs<typeof GlobalDialog> &
|
||||
CustomDialogComponentProps
|
||||
|
||||
export interface DialogInstance<
|
||||
H extends Component = Component,
|
||||
B extends Component = Component,
|
||||
F extends Component = Component
|
||||
Header extends Component = Component,
|
||||
Body extends Component = Component,
|
||||
Footer extends Component = Component
|
||||
> {
|
||||
key: string
|
||||
visible: boolean
|
||||
title?: string
|
||||
headerComponent?: H
|
||||
headerProps?: ComponentAttrs<H>
|
||||
component: B
|
||||
contentProps: ComponentAttrs<B>
|
||||
footerComponent?: F
|
||||
footerProps?: ComponentAttrs<F>
|
||||
headerComponent?: Header
|
||||
headerProps?: ComponentAttrs<Header>
|
||||
component: Body
|
||||
contentProps: ComponentAttrs<Body>
|
||||
footerComponent?: Footer
|
||||
footerProps?: ComponentAttrs<Footer>
|
||||
dialogComponentProps: DialogComponentProps
|
||||
priority: number
|
||||
}
|
||||
|
||||
export interface ShowDialogOptions<
|
||||
H extends Component = Component,
|
||||
B extends Component = Component,
|
||||
F extends Component = Component
|
||||
Header extends Component = Component,
|
||||
Body extends Component = Component,
|
||||
Footer extends Component = Component
|
||||
> {
|
||||
key?: string
|
||||
title?: string
|
||||
headerComponent?: H
|
||||
footerComponent?: F
|
||||
component: B
|
||||
props?: ComponentAttrs<B>
|
||||
headerProps?: ComponentAttrs<H>
|
||||
footerProps?: ComponentAttrs<F>
|
||||
headerComponent?: Header
|
||||
footerComponent?: Footer
|
||||
component: Body
|
||||
props?: ComponentAttrs<Body>
|
||||
headerProps?: ComponentAttrs<Header>
|
||||
footerProps?: ComponentAttrs<Footer>
|
||||
dialogComponentProps?: DialogComponentProps
|
||||
/**
|
||||
* Optional priority for dialog stacking.
|
||||
@@ -135,10 +135,10 @@ export const useDialogStore = defineStore('dialog', () => {
|
||||
}
|
||||
|
||||
function createDialog<
|
||||
H extends Component = Component,
|
||||
B extends Component = Component,
|
||||
F extends Component = Component
|
||||
>(options: ShowDialogOptions<H, B, F> & { key: string }) {
|
||||
Header extends Component = Component,
|
||||
Body extends Component = Component,
|
||||
Footer extends Component = Component
|
||||
>(options: ShowDialogOptions<Header, Body, Footer> & { key: string }) {
|
||||
if (dialogStack.value.length >= 10) {
|
||||
dialogStack.value.shift()
|
||||
}
|
||||
@@ -210,10 +210,10 @@ export const useDialogStore = defineStore('dialog', () => {
|
||||
}
|
||||
|
||||
function showDialog<
|
||||
H extends Component = Component,
|
||||
B extends Component = Component,
|
||||
F extends Component = Component
|
||||
>(options: ShowDialogOptions<H, B, F>) {
|
||||
Header extends Component = Component,
|
||||
Body extends Component = Component,
|
||||
Footer extends Component = Component
|
||||
>(options: ShowDialogOptions<Header, Body, Footer>) {
|
||||
const dialogKey = options.key || genDialogKey()
|
||||
|
||||
let dialog = dialogStack.value.find((d) => d.key === dialogKey)
|
||||
|
||||
@@ -324,8 +324,7 @@ export const migrateLegacyRerouteNodes = (
|
||||
return workflow
|
||||
}
|
||||
|
||||
// Create a deep copy of the workflow to avoid mutating the original
|
||||
const newWorkflow = JSON.parse(JSON.stringify(workflow)) as WorkflowJSON04
|
||||
const newWorkflow = structuredClone(workflow)
|
||||
|
||||
// Initialize extra structure if needed
|
||||
if (!newWorkflow.extra) {
|
||||
|
||||
Reference in New Issue
Block a user