Skip to content

Channels and Media

github-actions[bot] edited this page Oct 14, 2026 · 4 revisions

Channels and media

Four pieces for moving images, video and audio between plugins:

Piece For
Channels A named live feed: producers publish the latest item, views subscribe.
Media refs One string per file, resolvable to a URL or a loader-node input.
Drag and drop Dragging media between panels, the graph and loader nodes.
Viewer The one shared full-screen lightbox.

Channels

Minimal example

import { LAST_CHANNEL, whenZen } from '@nynxz/zenkit-client'

const zen = await whenZen() // null without ZenKit

// A view
const stop = zen?.channels.subscribe('preview', (img) => (imgEl.src = img.url))

// A producer
zen?.channels.publish('preview', { filename: 'ComfyUI_00012_.png', type: 'output' })

// Anything published anywhere
zen?.channels.subscribe(LAST_CHANNEL, (img) => console.log(img.channel, img.url))

Channel names are global, never prefixed with your plugin id: a channel is how plugins share media.

Declare channels up front so views can list them before anything is published:

registerZenPlugin({
  id: 'myplugin',
  plugin: 'My Plugin',
  channels: ['preview', { name: 'ref', label: 'Reference' }],
})

API

window.ZenKit.channels Meaning
publish(name, input) Set the channel's latest item. '' means 'default'.
declare(name, { label? }) List a channel before it has content.
subscribe(name, cb) Called on each publish. '$last' (LAST_CHANNEL) = every channel. Returns unsubscribe. Does not replay the current item; call get for that.
get(name) Latest ChannelImage or null. '$last' works.
last() Most recent item on any channel.
list() Declared and published channel names.

ChannelInput (what you publish): either url, or filename + subfolder? + type? (input / output / temp, default temp) which ZenKit turns into a /view URL (through ComfyUI's API base, like media URLs). Optional ref (its media ref, when you know it), label, kind ('image' | 'video' | 'audio', absent means image), width, height.

ChannelImage (what subscribers get) adds channel and ts, and always has url. Its ref is the one you gave, else derived: output/sub/name.png for a ComfyUI file. An image published with only a url is shown from that URL but has no ref, since URLs are not media refs. When there is a ref, a subscriber can hand it straight to a capability.

Bus mirror: channel and channel:<name> with the ChannelImage; channels:change with the name when a new channel appears.

From Python

Send the zenkit.channel websocket event. With filename or url it publishes; with only channel (and optional label) it declares.

from server import PromptServer

PromptServer.instance.send_sync("zenkit.channel", {
    "channel": "preview",
    "filename": fn, "subfolder": "", "type": "temp",
    "kind": "image",          # or "video" / "audio"
    "width": w, "height": h,  # optional
    "label": "Step 3",        # optional
})

There is no shared Python helper; send_sync without a client id goes to every connected tab. Events that arrive before the ZenKit runtime has installed are buffered (up to 200) and replayed. ComfyUI-ZenKit's Zen Sync nodes and built-in Media Viewer panel are the reference producer and consumer (plugins/ComfyUI-ZenKit/suite/channel_node.py).

Media refs

A MediaRef is a short string that names one file, so capabilities and agents can pass media along without URLs.

Ref Means
output/sub/name.png ComfyUI's output folder (also input/…, temp/…)
<prefix>:… or <prefix>/… Resolved by a source a plugin registered for that prefix

Nothing else is a ref. https://…, data:…, blob:… and /path are rejected, as are .. or empty segments in a ComfyUI ref: refs arrive from workflows, agents and capabilities, so a ref must never make ZenKit fetch an arbitrary address or copy it into input/. Code that really holds a URL (a channel image, say) shows it directly rather than passing it through zen.media.

const zen = await whenZen()
const info = await zen!.media.resolve('output/ComfyUI_00012_.png')
// { ref, url: '/api/view?…', kind: 'image', label: 'ComfyUI_00012_.png' }

const widgetValue = await zen!.media.toInput('output/ComfyUI_00012_.png')
// 'ComfyUI_00012_.png [output]'  → set a LoadImage widget to this

const ref = zen!.media.fromComfyFile({ filename: 'a.png', subfolder: 'x', type: 'output' })
// 'output/x/a.png'
window.ZenKit.media Meaning
resolve(ref) Promise<MediaInfo> ({ ref, url, kind, label? }). Rejects for a URL or an unknown prefix.
toInput(ref) Value for a ComfyUI loader widget. input/ refs give the path, output/ and temp/ give path [output] / path [temp], other refs use the source's toInput, else the file is fetched and uploaded to input/zenkit/ (only from a same-origin, blob: or data: URL).
fromComfyFile({ filename, subfolder?, type? }) The ref for a file as executed / history outputs describe it.
registerSource(source) Handle a prefix (a plain word; URL schemes such as http or data are refused). Returns unregister.

Adding a source

const off = zen.media.registerSource({
  prefix: 'stash', // refs like 'stash:1234'
  resolve: async (ref) => {
    const id = ref.slice('stash:'.length)
    return { ref, url: `/stash/file/${id}`, kind: 'image', label: `Stash ${id}` }
  },
  // toInput omitted: ZenKit downloads the url (it must be same-origin) and uploads it to input/
})

The prefix is everything before the first : or /. Registering the same prefix replaces the previous source.

Drag and drop

Helpers in @nynxz/zenkit-client; they don't need the runtime.

<img
  :src="zenThumbUrl({ filename, type: 'output' })"
  draggable="true"
  @dragstart="(e) => setMediaDragData(e, { url: viewUrl, filename, type: 'output' })"
/>

<div @dragover="(e) => hasMediaDragData(e) && e.preventDefault()" @drop="onDrop" />
async function onDrop(e: DragEvent) {
  const { title, items } = await readMediaListDragData(e) // call synchronously from the handler
  if (!items.length) return // not ours: let ComfyUI handle it
  e.preventDefault() // stop ComfyUI also importing it into the graph
  for (const it of items) show(it.url, it.kind)
}
Export Meaning
setMediaDragData(e, img, dragImage?) Start a drag of one item. img: { url, filename?, subfolder?, type?, hasWorkflow?, ref? }. With filename + type it also sets ComfyUI's asset MIME, so dropping on a loader node reuses the server file instead of uploading a copy.
readMediaDragData(e) DroppedMedia[] from ZenKit's payload, ComfyUI's asset drag, a URL, or desktop files. [] when there is nothing usable.
readMediaListDragData(e) Async. Same, but expands a dragged media list. Returns { title?, items }.
setMediaListDragData(e, { url, title?, count? }) Drag a whole collection. url must return a MediaList ({ title?, items: [{ url, filename?, kind?, label? }] }) as JSON. Set a single image too, for targets that take one.
hasMediaDragData(e) For dragover, where data can't be read yet. Checks the types only.
mediaKind(nameOrUrl) 'image' | 'video' | 'audio' from extension or data: type. GIF is an image.
parseMediaRef(ref) Parses input/, output/ or temp/ refs into { type, subfolder, filename }. Returns null for URLs, absolute paths and empty/dot path segments. Shared with nodekit.
zenThumbUrl({ filename, subfolder?, type? }, size = 256) ComfyUI-ZenKit's cached thumbnail (/zenkit/thumb, JPEG, video first frame).
ZEN_MEDIA_MIME, COMFY_ASSET_MIME, ZEN_MEDIA_LIST_MIME The MIME types.

DroppedMedia is { url, ref?, filename?, kind?, objectUrl? }. When objectUrl is true the url was created from a desktop file: call URL.revokeObjectURL(url) when done.

Viewer

One full-screen lightbox owned by ZenKit, shared by every plugin.

import { openViewer } from '@nynxz/zenkit-client'

const viewer = await openViewer(
  [
    { src: '/api/view?filename=a.png&type=output', label: 'a.png', meta: '1024×1024' },
    { src: '/api/view?filename=b.mp4&type=output', kind: 'video' },
  ],
  { index: 0, onIndex: (i) => console.log('showing', i), onClose: () => console.log('closed') },
)
viewer?.setIndex(1)

In a Vue component, useLightbox() wraps this with reactive isOpen / index and closes the viewer when the component unmounts:

const lightbox = useLightbox()
lightbox.open(items, { index: 3 })
ViewerItem field Meaning
src Full-size URL.
kind 'image' (default), 'video', 'audio'.
label, meta Title and small caption.
onWorkflow Shows a "Load workflow" button that calls it.
window.ZenKit.viewer Meaning
open(items, { index?, onIndex?, onClose? }) Opens, replacing any open viewer (whose onClose fires). Returns { close(), setIndex(i) }, which act on this viewer only: once another open has replaced it they do nothing.
close() Close the current viewer.

Without ZenKit, openViewer opens the selected item in a new tab and resolves null.

Clone this wiki locally