Merge branch 'main' into manager/menu-items-migration

This commit is contained in:
Jin Yi
2025-07-30 19:56:49 +09:00
committed by GitHub
182 changed files with 15831 additions and 2331 deletions

View File

@@ -17,6 +17,7 @@ import type {
LogsRawResponse,
LogsWsMessage,
PendingTaskItem,
ProgressStateWsMessage,
ProgressTextWsMessage,
ProgressWsMessage,
PromptResponse,
@@ -38,6 +39,7 @@ import {
validateComfyNodeDef
} from '@/schemas/nodeDefSchema'
import { useToastStore } from '@/stores/toastStore'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { WorkflowTemplates } from '@/types/workflowTemplateTypes'
interface QueuePromptRequestBody {
@@ -106,7 +108,17 @@ interface BackendApiCalls {
logs: LogsWsMessage
/** Binary preview/progress data */
b_preview: Blob
/** Binary preview with metadata (node_id, prompt_id) */
b_preview_with_metadata: {
blob: Blob
nodeId: string
parentNodeId: string
displayNodeId: string
realNodeId: string
promptId: string
}
progress_text: ProgressTextWsMessage
progress_state: ProgressStateWsMessage
display_component: DisplayComponentWsMessage
feature_flags: FeatureFlagsWsMessage
}
@@ -458,6 +470,33 @@ export class ComfyApi extends EventTarget {
})
this.dispatchCustomEvent('b_preview', imageBlob)
break
case 4:
// PREVIEW_IMAGE_WITH_METADATA
const decoder4 = new TextDecoder()
const metadataLength = view.getUint32(4)
const metadataBytes = event.data.slice(8, 8 + metadataLength)
const metadata = JSON.parse(decoder4.decode(metadataBytes))
const imageData4 = event.data.slice(8 + metadataLength)
let imageMime4 = metadata.image_type
const imageBlob4 = new Blob([imageData4], {
type: imageMime4
})
// Dispatch enhanced preview event with metadata
this.dispatchCustomEvent('b_preview_with_metadata', {
blob: imageBlob4,
nodeId: metadata.node_id,
displayNodeId: metadata.display_node_id,
parentNodeId: metadata.parent_node_id,
realNodeId: metadata.real_node_id,
promptId: metadata.prompt_id
})
// Also dispatch legacy b_preview for backward compatibility
this.dispatchCustomEvent('b_preview', imageBlob4)
break
default:
throw new Error(
`Unknown binary websocket message of type ${eventType}`
@@ -487,6 +526,7 @@ export class ComfyApi extends EventTarget {
case 'execution_cached':
case 'execution_success':
case 'progress':
case 'progress_state':
case 'executed':
case 'graphChanged':
case 'promptQueued':
@@ -567,31 +607,9 @@ export class ComfyApi extends EventTarget {
* Loads node object definitions for the graph
* @returns The node definitions
*/
async getNodeDefs({ validate = false }: { validate?: boolean } = {}): Promise<
Record<string, ComfyNodeDef>
> {
async getNodeDefs(): Promise<Record<string, ComfyNodeDef>> {
const resp = await this.fetchApi('/object_info', { cache: 'no-store' })
const objectInfoUnsafe = await resp.json()
if (!validate) {
return objectInfoUnsafe
}
// Validate node definitions against zod schema. (slow)
const objectInfo: Record<string, ComfyNodeDef> = {}
for (const key in objectInfoUnsafe) {
const validatedDef = validateComfyNodeDef(
objectInfoUnsafe[key],
/* onError=*/ (errorMessage: string) => {
console.warn(
`Skipping invalid node definition: ${key}. See debug log for more information.`
)
console.debug(errorMessage)
}
)
if (validatedDef !== null) {
objectInfo[key] = validatedDef
}
}
return objectInfo
return await resp.json()
}
/**

View File

@@ -47,6 +47,7 @@ import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useExtensionStore } from '@/stores/extensionStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { KeyComboImpl, useKeybindingStore } from '@/stores/keybindingStore'
import { useModelStore } from '@/stores/modelStore'
import { SYSTEM_NODE_DEFS, useNodeDefStore } from '@/stores/nodeDefStore'
@@ -60,6 +61,10 @@ import type { ComfyExtension, MissingNodeType } from '@/types/comfy'
import { ExtensionManager } from '@/types/extensionTypes'
import { ColorAdjustOptions, adjustColor } from '@/utils/colorUtil'
import { graphToPrompt } from '@/utils/executionUtil'
import {
getNodeByExecutionId,
triggerCallbackOnAllNodes
} from '@/utils/graphTraversalUtil'
import {
executeWidgetsCallback,
fixLinkInputSlots,
@@ -75,6 +80,7 @@ import { deserialiseAndCreate } from '@/utils/vintageClipboard'
import { type ComfyApi, PromptExecutionError, api } from './api'
import { defaultGraph } from './defaultGraph'
import {
getAvifMetadata,
getFlacMetadata,
getLatentMetadata,
getPngMetadata,
@@ -110,6 +116,8 @@ type Clipspace = {
images?: any[] | null
selectedIndex: number
img_paste_mode: string
paintedIndex: number
combinedIndex: number
}
export class ComfyApp {
@@ -194,6 +202,8 @@ export class ComfyApp {
/**
* @deprecated Use useExecutionStore().executingNodeId instead
* TODO: Update to support multiple executing nodes. This getter returns only the first executing node.
* Consider updating consumers to handle multiple nodes or use executingNodeIds array.
*/
get runningNodeId(): NodeId | null {
return useExecutionStore().executingNodeId
@@ -349,13 +359,18 @@ export class ComfyApp {
selectedIndex = node.imageIndex
}
const paintedIndex = selectedIndex + 1
const combinedIndex = selectedIndex + 2
ComfyApp.clipspace = {
widgets: widgets,
imgs: imgs,
original_imgs: orig_imgs,
images: node.images,
selectedIndex: selectedIndex,
img_paste_mode: 'selected' // reset to default im_paste_mode state on copy action
img_paste_mode: 'selected', // reset to default im_paste_mode state on copy action
paintedIndex: paintedIndex,
combinedIndex: combinedIndex
}
ComfyApp.clipspace_return_node = null
@@ -368,6 +383,8 @@ export class ComfyApp {
static pasteFromClipspace(node: LGraphNode) {
if (ComfyApp.clipspace) {
// image paste
const combinedImgSrc =
ComfyApp.clipspace.imgs?.[ComfyApp.clipspace.combinedIndex].src
if (ComfyApp.clipspace.imgs && node.imgs) {
if (node.images && ComfyApp.clipspace.images) {
if (ComfyApp.clipspace['img_paste_mode'] == 'selected') {
@@ -401,6 +418,28 @@ export class ComfyApp {
}
}
// Paste the RGB canvas if paintedindex exists
if (
ComfyApp.clipspace.imgs?.[ComfyApp.clipspace.paintedIndex] &&
node.imgs
) {
const paintedImg = new Image()
paintedImg.src =
ComfyApp.clipspace.imgs[ComfyApp.clipspace.paintedIndex].src
node.imgs.push(paintedImg) // Add the RGB canvas to the node's images
}
// Store only combined image inside the node if it exists
if (
ComfyApp.clipspace.imgs?.[ComfyApp.clipspace.combinedIndex] &&
node.imgs &&
combinedImgSrc
) {
const combinedImg = new Image()
combinedImg.src = combinedImgSrc
node.imgs = [combinedImg]
}
if (node.widgets) {
if (ComfyApp.clipspace.images) {
const clip_image =
@@ -635,36 +674,24 @@ export class ComfyApp {
api.addEventListener('executing', () => {
this.graph.setDirtyCanvas(true, false)
// @ts-expect-error fixme ts strict error
this.revokePreviews(this.runningNodeId)
// @ts-expect-error fixme ts strict error
delete this.nodePreviewImages[this.runningNodeId]
})
api.addEventListener('executed', ({ detail }) => {
const output = this.nodeOutputs[detail.display_node || detail.node]
if (detail.merge && output) {
for (const k in detail.output ?? {}) {
const v = output[k]
if (v instanceof Array) {
output[k] = v.concat(detail.output[k])
} else {
output[k] = detail.output[k]
}
}
} else {
this.nodeOutputs[detail.display_node || detail.node] = detail.output
}
const node = this.graph.getNodeById(detail.display_node || detail.node)
if (node) {
if (node.onExecuted) node.onExecuted(detail.output)
const nodeOutputStore = useNodeOutputStore()
const executionId = String(detail.display_node || detail.node)
nodeOutputStore.setNodeOutputsByExecutionId(executionId, detail.output, {
merge: detail.merge
})
const node = getNodeByExecutionId(this.graph, executionId)
if (node && node.onExecuted) {
node.onExecuted(detail.output)
}
})
api.addEventListener('execution_start', () => {
this.graph.nodes.forEach((node) => {
if (node.onExecutionStart) node.onExecutionStart()
})
triggerCallbackOnAllNodes(this.graph, 'onExecutionStart')
})
api.addEventListener('execution_error', ({ detail }) => {
@@ -689,15 +716,16 @@ export class ComfyApp {
this.canvas.draw(true, true)
})
api.addEventListener('b_preview', ({ detail }) => {
const id = this.runningNodeId
if (id == null) return
const blob = detail
const blobUrl = URL.createObjectURL(blob)
api.addEventListener('b_preview_with_metadata', ({ detail }) => {
// Enhanced preview with explicit node context
const { blob, displayNodeId } = detail
const { setNodePreviewsByExecutionId, revokePreviewsByExecutionId } =
useNodeOutputStore()
// Ensure clean up if `executing` event is missed.
this.revokePreviews(id)
this.nodePreviewImages[id] = [blobUrl]
revokePreviewsByExecutionId(displayNodeId)
const blobUrl = URL.createObjectURL(blob)
// Preview cleanup is handled in progress_state event to support multiple concurrent previews
setNodePreviewsByExecutionId(displayNodeId, [blobUrl])
})
api.init()
@@ -724,16 +752,12 @@ export class ComfyApp {
fixLinkInputSlots(this)
// Fire callbacks before the onConfigure, this is used by widget inputs to setup the config
for (const node of graph.nodes) {
node.onGraphConfigured?.()
}
triggerCallbackOnAllNodes(this, 'onGraphConfigured')
const r = onConfigure?.apply(this, args)
// Fire after onConfigure, used by primitives to generate widget using input nodes config
for (const node of graph.nodes) {
node.onAfterGraphConfigured?.()
}
triggerCallbackOnAllNodes(this, 'onAfterGraphConfigured')
return r
}
@@ -859,26 +883,33 @@ export class ComfyApp {
private updateVueAppNodeDefs(defs: Record<string, ComfyNodeDefV1>) {
// Frontend only nodes registered by custom nodes.
// Example: https://github.com/rgthree/rgthree-comfy/blob/dd534e5384be8cf0c0fa35865afe2126ba75ac55/src_web/comfyui/fast_groups_bypasser.ts#L10
const rawDefs: Record<string, ComfyNodeDefV1> = Object.fromEntries(
Object.entries(LiteGraph.registered_node_types).map(([name, node]) => [
// Only create frontend_only definitions for nodes that don't have backend definitions
const frontendOnlyDefs: Record<string, ComfyNodeDefV1> = {}
for (const [name, node] of Object.entries(
LiteGraph.registered_node_types
)) {
// Skip if we already have a backend definition or system definition
if (name in defs || name in SYSTEM_NODE_DEFS) {
continue
}
frontendOnlyDefs[name] = {
name,
{
name,
display_name: name,
category: node.category || '__frontend_only__',
input: { required: {}, optional: {} },
output: [],
output_name: [],
output_is_list: [],
output_node: false,
python_module: 'custom_nodes.frontend_only',
description: `Frontend only node for ${name}`
} as ComfyNodeDefV1
])
)
display_name: name,
category: node.category || '__frontend_only__',
input: { required: {}, optional: {} },
output: [],
output_name: [],
output_is_list: [],
output_node: false,
python_module: 'custom_nodes.frontend_only',
description: `Frontend only node for ${name}`
} as ComfyNodeDefV1
}
const allNodeDefs = {
...rawDefs,
...frontendOnlyDefs,
...defs,
...SYSTEM_NODE_DEFS
}
@@ -909,12 +940,7 @@ export class ComfyApp {
.join('/')
})
return _.mapValues(
await api.getNodeDefs({
validate: useSettingStore().get('Comfy.Validation.NodeDefs')
}),
(def) => translateNodeDef(def)
)
return _.mapValues(await api.getNodeDefs(), (def) => translateNodeDef(def))
}
/**
@@ -1355,6 +1381,16 @@ export class ComfyApp {
} else {
this.showErrorOnFileLoad(file)
}
} else if (file.type === 'image/avif') {
const { workflow, prompt } = await getAvifMetadata(file)
if (workflow) {
this.loadGraphData(JSON.parse(workflow), true, true, fileName)
} else if (prompt) {
this.loadApiJson(JSON.parse(prompt), fileName)
} else {
this.showErrorOnFileLoad(file)
}
} else if (file.type === 'image/webp') {
const pngInfo = await getWebpMetadata(file)
// Support loading workflows from that webp custom node.
@@ -1676,25 +1712,13 @@ export class ComfyApp {
}
}
/**
* Frees memory allocated to image preview blobs for a specific node, by revoking the URLs associated with them.
* @param nodeId ID of the node to revoke all preview images of
*/
revokePreviews(nodeId: NodeId) {
if (!this.nodePreviewImages[nodeId]?.[Symbol.iterator]) return
for (const url of this.nodePreviewImages[nodeId]) {
URL.revokeObjectURL(url)
}
}
/**
* Clean current state
*/
clean() {
this.nodeOutputs = {}
for (const id of Object.keys(this.nodePreviewImages)) {
this.revokePreviews(id)
}
this.nodePreviewImages = {}
const { revokeAllPreviews } = useNodeOutputStore()
revokeAllPreviews()
const executionStore = useExecutionStore()
executionStore.lastNodeErrors = null
executionStore.lastExecutionError = null

View File

@@ -44,12 +44,30 @@ export interface DOMWidget<T extends HTMLElement, V extends object | string>
inputEl?: T
}
/**
* Additional props that can be passed to component widgets.
* These are in addition to the standard props that are always provided:
* - modelValue: The widget's value (handled by v-model)
* - widget: Reference to the widget instance
* - onUpdate:modelValue: The update handler for v-model
*/
export type ComponentWidgetCustomProps = Record<string, unknown>
/**
* Standard props that are handled separately by DomWidget.vue and should be
* omitted when defining custom props for component widgets
*/
export type ComponentWidgetStandardProps =
| 'modelValue'
| 'widget'
| 'onUpdate:modelValue'
/**
* A DOM widget that wraps a Vue component as a litegraph widget.
*/
export interface ComponentWidget<
V extends object | string,
P = Record<string, unknown>
P extends ComponentWidgetCustomProps = ComponentWidgetCustomProps
> extends BaseDOMWidget<V> {
readonly component: Component
readonly inputSpec: InputSpec
@@ -158,6 +176,21 @@ abstract class BaseDOMWidgetImpl<V extends object | string>
override onRemove(): void {
useDomWidgetStore().unregisterWidget(this.id)
}
override createCopyForNode(node: LGraphNode): this {
// @ts-expect-error
const cloned: this = new (this.constructor as typeof this)({
node: node,
name: this.name,
type: this.type,
options: this.options
})
cloned.value = this.value
// Preserve the Y position from the original widget to maintain proper positioning
// when widgets are promoted through subgraph nesting
cloned.y = this.y
return cloned
}
}
export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
@@ -177,6 +210,22 @@ export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
this.element = obj.element
}
override createCopyForNode(node: LGraphNode): this {
// @ts-expect-error
const cloned: this = new (this.constructor as typeof this)({
node: node,
name: this.name,
type: this.type,
element: this.element, // Include the element!
options: this.options
})
cloned.value = this.value
// Preserve the Y position from the original widget to maintain proper positioning
// when widgets are promoted through subgraph nesting
cloned.y = this.y
return cloned
}
/** Extract DOM widget size info */
override computeLayoutSize(node: LGraphNode) {
if (this.type === 'hidden') {
@@ -222,7 +271,7 @@ export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
export class ComponentWidgetImpl<
V extends object | string,
P = Record<string, unknown>
P extends ComponentWidgetCustomProps = ComponentWidgetCustomProps
>
extends BaseDOMWidgetImpl<V>
implements ComponentWidget<V, P>

View File

@@ -0,0 +1,412 @@
import {
type AvifIinfBox,
type AvifIlocBox,
type AvifInfeBox,
ComfyMetadata,
ComfyMetadataTags,
type IsobmffBoxContentRange
} from '@/types/metadataTypes'
const readNullTerminatedString = (
dataView: DataView,
start: number,
end: number
): { str: string; length: number } => {
let length = 0
while (start + length < end && dataView.getUint8(start + length) !== 0) {
length++
}
const str = new TextDecoder('utf-8').decode(
new Uint8Array(dataView.buffer, dataView.byteOffset + start, length)
)
return { str, length: length + 1 } // Include null terminator
}
const parseInfeBox = (dataView: DataView, start: number): AvifInfeBox => {
const version = dataView.getUint8(start)
const flags = dataView.getUint32(start) & 0xffffff
let offset = start + 4
let item_ID: number, item_protection_index: number, item_type: string
if (version >= 2) {
if (version === 2) {
item_ID = dataView.getUint16(offset)
offset += 2
} else {
item_ID = dataView.getUint32(offset)
offset += 4
}
item_protection_index = dataView.getUint16(offset)
offset += 2
item_type = String.fromCharCode(
...new Uint8Array(dataView.buffer, dataView.byteOffset + offset, 4)
)
offset += 4
const { str: item_name, length: name_len } = readNullTerminatedString(
dataView,
offset,
dataView.byteLength
)
offset += name_len
const content_type = readNullTerminatedString(
dataView,
offset,
dataView.byteLength
).str
return {
box_header: { size: 0, type: 'infe' }, // Size is dynamic
version,
flags,
item_ID,
item_protection_index,
item_type,
item_name,
content_type
}
}
throw new Error(`Unsupported infe box version: ${version}`)
}
const parseIinfBox = (
dataView: DataView,
range: IsobmffBoxContentRange
): AvifIinfBox => {
if (!range) throw new Error('iinf box not found')
const version = dataView.getUint8(range.start)
const flags = dataView.getUint32(range.start) & 0xffffff
let offset = range.start + 4
const entry_count =
version === 0 ? dataView.getUint16(offset) : dataView.getUint32(offset)
offset += version === 0 ? 2 : 4
const entries: AvifInfeBox[] = []
for (let i = 0; i < entry_count; i++) {
const boxSize = dataView.getUint32(offset)
const boxType = String.fromCharCode(
...new Uint8Array(dataView.buffer, dataView.byteOffset + offset + 4, 4)
)
if (boxType === 'infe') {
const infe = parseInfeBox(dataView, offset + 8)
infe.box_header.size = boxSize
entries.push(infe)
}
offset += boxSize
}
return {
box_header: { size: range.end - range.start + 8, type: 'iinf' },
version,
flags,
entry_count,
entries
}
}
const parseIlocBox = (
dataView: DataView,
range: IsobmffBoxContentRange
): AvifIlocBox => {
if (!range) throw new Error('iloc box not found')
const version = dataView.getUint8(range.start)
const flags = dataView.getUint32(range.start) & 0xffffff
let offset = range.start + 4
const sizes = dataView.getUint8(offset++)
const offset_size = (sizes >> 4) & 0x0f
const length_size = sizes & 0x0f
const base_offset_size = (dataView.getUint8(offset) >> 4) & 0x0f
const index_size =
version === 1 || version === 2 ? dataView.getUint8(offset) & 0x0f : 0
offset++
const item_count =
version < 2 ? dataView.getUint16(offset) : dataView.getUint32(offset)
offset += version < 2 ? 2 : 4
const items = []
for (let i = 0; i < item_count; i++) {
const item_ID =
version < 2 ? dataView.getUint16(offset) : dataView.getUint32(offset)
offset += version < 2 ? 2 : 4
if (version === 1 || version === 2) {
offset += 2 // construction_method
}
const data_reference_index = dataView.getUint16(offset)
offset += 2
const base_offset = base_offset_size > 0 ? dataView.getUint32(offset) : 0 // Simplified
offset += base_offset_size
const extent_count = dataView.getUint16(offset)
offset += 2
const extents = []
for (let j = 0; j < extent_count; j++) {
if ((version === 1 || version === 2) && index_size > 0) {
offset += index_size
}
const extent_offset = dataView.getUint32(offset) // Simplified
offset += offset_size
const extent_length = dataView.getUint32(offset) // Simplified
offset += length_size
extents.push({ extent_offset, extent_length })
}
items.push({
item_ID,
data_reference_index,
base_offset,
extent_count,
extents
})
}
return {
box_header: { size: range.end - range.start + 8, type: 'iloc' },
version,
flags,
offset_size,
length_size,
base_offset_size,
index_size,
item_count,
items
}
}
function findBox(
dataView: DataView,
start: number,
end: number,
type: string
): IsobmffBoxContentRange {
let offset = start
while (offset < end) {
if (offset + 8 > end) break
const boxLength = dataView.getUint32(offset)
const boxType = String.fromCharCode(
...new Uint8Array(dataView.buffer, dataView.byteOffset + offset + 4, 4)
)
if (boxLength === 0) break
if (boxType === type) {
return { start: offset + 8, end: offset + boxLength }
}
if (offset + boxLength > end) break
offset += boxLength
}
return null
}
function parseAvifMetadata(buffer: ArrayBuffer): ComfyMetadata {
const metadata: ComfyMetadata = {}
const dataView = new DataView(buffer)
if (
dataView.getUint32(4) !== 0x66747970 ||
dataView.getUint32(8) !== 0x61766966
) {
console.error('Not a valid AVIF file')
return {}
}
const metaBox = findBox(dataView, 0, dataView.byteLength, 'meta')
if (!metaBox) return {}
const metaBoxContentStart = metaBox.start + 4 // Skip version and flags
const iinfBoxRange = findBox(
dataView,
metaBoxContentStart,
metaBox.end,
'iinf'
)
const iinf = parseIinfBox(dataView, iinfBoxRange)
const exifInfe = iinf.entries.find((e) => e.item_type === 'Exif')
if (!exifInfe) return {}
const ilocBoxRange = findBox(
dataView,
metaBoxContentStart,
metaBox.end,
'iloc'
)
const iloc = parseIlocBox(dataView, ilocBoxRange)
const exifIloc = iloc.items.find((i) => i.item_ID === exifInfe.item_ID)
if (!exifIloc || exifIloc.extents.length === 0) return {}
const exifExtent = exifIloc.extents[0]
const itemData = new Uint8Array(
buffer,
exifExtent.extent_offset,
exifExtent.extent_length
)
let tiffHeaderOffset = -1
for (let i = 0; i < itemData.length - 4; i++) {
if (
(itemData[i] === 0x4d &&
itemData[i + 1] === 0x4d &&
itemData[i + 2] === 0x00 &&
itemData[i + 3] === 0x2a) || // MM*
(itemData[i] === 0x49 &&
itemData[i + 1] === 0x49 &&
itemData[i + 2] === 0x2a &&
itemData[i + 3] === 0x00) // II*
) {
tiffHeaderOffset = i
break
}
}
if (tiffHeaderOffset !== -1) {
const exifData = itemData.subarray(tiffHeaderOffset)
const data: Record<string, any> = parseExifData(exifData)
for (const key in data) {
const value = data[key]
if (typeof value === 'string') {
if (key === 'usercomment') {
try {
const metadataJson = JSON.parse(value)
if (metadataJson.prompt) {
metadata[ComfyMetadataTags.PROMPT] = metadataJson.prompt
}
if (metadataJson.workflow) {
metadata[ComfyMetadataTags.WORKFLOW] = metadataJson.workflow
}
} catch (e) {
console.error('Failed to parse usercomment JSON', e)
}
} else {
const [metadataKey, ...metadataValueParts] = value.split(':')
const metadataValue = metadataValueParts.join(':').trim()
if (
metadataKey.toLowerCase() ===
ComfyMetadataTags.PROMPT.toLowerCase() ||
metadataKey.toLowerCase() ===
ComfyMetadataTags.WORKFLOW.toLowerCase()
) {
try {
const jsonValue = JSON.parse(metadataValue)
metadata[metadataKey.toLowerCase() as keyof ComfyMetadata] =
jsonValue
} catch (e) {
console.error(`Failed to parse JSON for ${metadataKey}`, e)
}
}
}
}
}
} else {
console.log('Warning: TIFF header not found in EXIF data.')
}
return metadata
}
// @ts-expect-error fixme ts strict error
export function parseExifData(exifData) {
// Check for the correct TIFF header (0x4949 for little-endian or 0x4D4D for big-endian)
const isLittleEndian = String.fromCharCode(...exifData.slice(0, 2)) === 'II'
// Function to read 16-bit and 32-bit integers from binary data
// @ts-expect-error fixme ts strict error
function readInt(offset, isLittleEndian, length) {
let arr = exifData.slice(offset, offset + length)
if (length === 2) {
return new DataView(arr.buffer, arr.byteOffset, arr.byteLength).getUint16(
0,
isLittleEndian
)
} else if (length === 4) {
return new DataView(arr.buffer, arr.byteOffset, arr.byteLength).getUint32(
0,
isLittleEndian
)
}
}
// Read the offset to the first IFD (Image File Directory)
const ifdOffset = readInt(4, isLittleEndian, 4)
// @ts-expect-error fixme ts strict error
function parseIFD(offset) {
const numEntries = readInt(offset, isLittleEndian, 2)
const result = {}
// @ts-expect-error fixme ts strict error
for (let i = 0; i < numEntries; i++) {
const entryOffset = offset + 2 + i * 12
const tag = readInt(entryOffset, isLittleEndian, 2)
const type = readInt(entryOffset + 2, isLittleEndian, 2)
const numValues = readInt(entryOffset + 4, isLittleEndian, 4)
const valueOffset = readInt(entryOffset + 8, isLittleEndian, 4)
// Read the value(s) based on the data type
let value
if (type === 2) {
// ASCII string
value = new TextDecoder('utf-8').decode(
// @ts-expect-error fixme ts strict error
exifData.subarray(valueOffset, valueOffset + numValues - 1)
)
}
// @ts-expect-error fixme ts strict error
result[tag] = value
}
return result
}
// Parse the first IFD
const ifdData = parseIFD(ifdOffset)
return ifdData
}
export function getFromAvifFile(file: File): Promise<Record<string, string>> {
return new Promise<Record<string, string>>((resolve) => {
const reader = new FileReader()
reader.onload = (event) => {
const buffer = event.target?.result as ArrayBuffer
if (!buffer) {
resolve({})
return
}
try {
const comfyMetadata = parseAvifMetadata(buffer)
const result: Record<string, string> = {}
if (comfyMetadata.prompt) {
result.prompt = JSON.stringify(comfyMetadata.prompt)
}
if (comfyMetadata.workflow) {
result.workflow = JSON.stringify(comfyMetadata.workflow)
}
resolve(result)
} catch (e) {
console.error('Parser: Error parsing AVIF metadata:', e)
resolve({})
}
}
reader.onerror = (err) => {
console.error('FileReader: Error reading AVIF file:', err)
resolve({})
}
reader.readAsArrayBuffer(file)
})
}

View File

@@ -1,6 +1,7 @@
import { LiteGraph } from '@comfyorg/litegraph'
import { api } from './api'
import { getFromAvifFile } from './metadata/avif'
import { getFromFlacFile } from './metadata/flac'
import { getFromPngFile } from './metadata/png'
@@ -13,6 +14,10 @@ export function getFlacMetadata(file: File): Promise<Record<string, string>> {
return getFromFlacFile(file)
}
export function getAvifMetadata(file: File): Promise<Record<string, string>> {
return getFromAvifFile(file)
}
// @ts-expect-error fixme ts strict error
function parseExifData(exifData) {
// Check for the correct TIFF header (0x4949 for little-endian or 0x4D4D for big-endian)

View File

@@ -21,7 +21,7 @@ export function clone<T>(obj: T): T {
* There are external callers to this function, so we need to keep it for now
*/
export function applyTextReplacements(app: ComfyApp, value: string): string {
return _applyTextReplacements(app.graph.nodes, value)
return _applyTextReplacements(app.graph, value)
}
export async function addStylesheet(