mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-05 23:50:08 +00:00
Merge branch 'main' into manager/menu-items-migration
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
412
src/scripts/metadata/avif.ts
Normal file
412
src/scripts/metadata/avif.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user