Repository navigation
Nodekit Media
Helpers for node widgets that hold pictures, video or audio: accept drops, upload files, browse
ComfyUI's folders, and build /view URLs. They work with or without ZenKit.
A media ref is one string per file: input/sub/name.png, output/name.mp4, temp/…. It is the
same format ZenKit's zen.media uses (see Channels and media). Widgets
store refs, display them with mediaRefUrl, and hand them to Python, which loads them from
ComfyUI's folders.
<script setup lang="ts">
import { ref } from 'vue'
import { hasMediaDrop, mediaKind, mediaRefUrl, readMediaDrop } from '@/framework'
const refs = ref<string[]>([])
const over = ref(false)
function onDragOver(e: DragEvent) {
if (!hasMediaDrop(e)) return
e.preventDefault()
over.value = true
}
async function onDrop(e: DragEvent) {
over.value = false
if (!hasMediaDrop(e)) return
e.preventDefault() // stop ComfyUI also loading the file as a workflow
refs.value.push(...(await readMediaDrop(e, 'mypack')))
}
</script>
<template>
<div @dragover="onDragOver" @dragleave="over = false" @drop="onDrop">
<template v-for="r in refs" :key="r">
<video v-if="mediaKind(r) === 'video'" :src="mediaRefUrl(r)" muted />
<img v-else-if="mediaKind(r) === 'image'" :src="mediaRefUrl(r)" />
</template>
</div>
</template>| Function | Signature | Description |
|---|---|---|
readMediaDrop |
(e: DragEvent, subfolder = 'zenkit', upload = uploadMediaFile) => Promise<string[]> |
Refs carried by a drop. Desktop files are uploaded into input/<subfolder> with upload. |
hasMediaDrop |
(e: DragEvent) => boolean |
Whether a drag carries something readMediaDrop understands. Checks only dataTransfer.types, so it works in dragover. |
uploadMediaFile |
(file: File, subfolder = 'zenkit') => Promise<string> |
POSTs to ComfyUI's /upload/image, returns input/<subfolder>/<name>. Throws on a non-OK response. |
parseMediaRef |
(ref: string) => { type, subfolder, filename } | null |
Null unless the first segment is input, output or temp (and no segment is empty or ..) |
mediaRefUrl |
(ref: string) => string |
/view URL for a ref (via viewUrl). Anything else (a URL, /path, bad ref) gets a /view URL ComfyUI refuses, see below |
mediaKind |
(ref: string) => 'image' | 'video' | 'audio' |
By extension; anything unknown is 'image', and so is GIF. The client's mediaKind, under the name packs know it by. |
viewUrl |
(record: Record<string, string>) => string |
ComfyUI /view?… from a saved-image record ({filename, subfolder, type}), base-path safe, cache-busted. Published. |
Only ComfyUI refs resolve. A ref a widget stored can come from a shared workflow, so mediaRefUrl
never turns an http:, data: or blob: URL, a /path or a malformed ref into its own URL;
it returns /view?filename= instead, which ComfyUI answers with 400. An <img> or <video>
then shows its error state and a HEAD check reports the file missing, without anything being
fetched from the address the workflow named. Use parseMediaRef(ref) to tell refs apart up front.
Checked in this order; the first match wins.
| Source | MIME type | Result |
|---|---|---|
| A ZenKit media list (an album, a folder) | application/x-zenkit-media-list |
Every ComfyUI file in the fetched list; if it can't be fetched, the rows below |
| ZenKit drag (Media Viewer, Asset Browser, …) | application/x-zenkit-image |
Its ref, or a ref parsed from its /view url
|
| ComfyUI's asset browser | application/x-comfy-asset-info |
type/subfolder/filename (type defaults to input) |
A /view link |
text/uri-list (first line) |
Ref parsed from the URL |
| Desktop files | Files |
Each image/*, video/*, audio/* file uploaded with upload(file, subfolder)
|
Rules:
- Call it from the
drophandler itself. It reads theDataTransfersynchronously before awaiting, because aDataTransferempties once its event returns. - Call
e.preventDefault()when you accept the drop. - Only file drops return more than one ref.
The third argument replaces the upload. createNodekit provides uploadOrReuse, which hashes the
file (SHA-256, in the browser) and asks the pack's /media/find route whether the same bytes are
already somewhere in input/, under any name. Only new files are uploaded:
// frontend/framework.ts
export const { route, mediaLibrary, thumbUrl, uploadOrReuse, mediaInfo /* … */ } =
createNodekit(manifest)// ZenCut components/CutEditor.vue
import { readMediaDrop, uploadOrReuse } from '@/framework'
const refs = await readMediaDrop(e, 'zencut', uploadOrReuse)It also works on its own: NynxzNodes saves a rendered contact sheet with
uploadOrReuse(new File([blob], name), 'h3studio'). Every lookup goes through api.apiURL, so it
works under a ComfyUI base path. Without crypto.subtle (a plain-http, non-localhost page), or if
the lookup fails, it simply uploads.
Bound to the pack's route('media') by createNodekit, alongside uploadOrReuse:
| Member | Signature | Description |
|---|---|---|
thumbUrl |
(ref: string, size = 256, t?: number) => string |
<route>/thumb?type&name&size[&t], base-path safe: a cached JPEG of a picture, or of a video's frame at t seconds (its first frame without t). '' for audio or anything not a ref. See frames at a time. |
uploadOrReuse |
(file: File, subfolder = 'zenkit') => Promise<string> |
The ref of an input/ file with the same bytes, else uploadMediaFile(file, subfolder)
|
mediaInfo |
(ref: string) => Promise<MediaInfo | null> |
<route>/info: { kind, duration, width, height, fps, hasAudio }, each null where the server can't tell. null for a missing or unreadable file. |
createPackMedia(route) builds the same three for any other route.
thumbUrl(ref, size, t) asks for the video's frame showing t seconds in: a piece of a clip can
show its own in-point rather than the file's first frame. Pictures (GIFs included) ignore t.
A pack whose vendored route predates t ignores it and answers with the first frame, which the
browser would then keep under the URL with t (the route's responses are cached as immutable). So
t only goes into URLs once the server is known to take it: the first call with a t returns the
first frame's URL and asks the server once in the background (an uncached request; the route
answers with an X-Thumb-Time header when it took t). The answer is held in a Vue ref that
thumbUrl reads, so a computed or template that called it updates by itself when the timed URLs
become available. Against an older route everything stays on the first frame, as before. Run
zenkit-nodekit sync and restart ComfyUI to get the new route.
createNodekit builds mediaLibrary = createMediaLibrary(route('media')) for every pack. It
lists ComfyUI's input, output and temp folders through the pack's own route and plugs
straight into ZenMediaPicker from @nynxz/zenkit-ui:
<script setup lang="ts">
import { ref } from 'vue'
import { ZenMediaPicker } from '@nynxz/zenkit-ui'
import { mediaLibrary } from '@/framework'
const open = ref(false)
const refs = ref<string[]>([])
</script>
<template>
<ZenMediaPicker
v-model:open="open"
:library="mediaLibrary"
:kinds="['video', 'image', 'audio']"
multiple
subfolder="zencut"
@pick="(r) => refs.push(...r)"
/>
</template>| Member | Type | Description |
|---|---|---|
roots |
MediaRoot[] |
['input', 'output', 'temp'] |
list(root, force?) |
(MediaRoot, boolean) => Promise<MediaLibraryItem[]> |
GET <route>?type=<root>. Cached per root; force refetches; a failed fetch is not cached. |
upload(files, subfolder?) |
(File[], string = 'zenkit') => Promise<string[]> |
Uploads to input/<subfolder>, reusing an input file with the same name and size. Clears the input cache. |
MediaLibraryItem:
| Field | Type | Description |
|---|---|---|
ref |
string |
<root>/<name> |
root |
'input' | 'output' | 'temp' |
|
folder |
string |
Subfolder ('' at the root) |
filename |
string |
|
kind |
'image' | 'video' | 'audio' |
From the server |
size, mtime
|
number |
Bytes, epoch seconds |
url |
string |
mediaRefUrl(ref) |
thumb |
string? |
<route>/thumb?type&name&size=320&v=<mtime>; absent for audio |
For any other route, call createMediaLibrary(route) yourself.
Note: every
createNodekitbuilds a library, which assumes the pack serves/<ns>/media. A pack that runszenkit-nodekit syncdoes (below); one without those routes gets a library whoselist()fails with 404, which is harmless unless you use it.
mediaLibrary and the pack media helpers need four routes. zenkit-nodekit sync vendors them into
the pack as nodes/_zenkit/media_api.py, and comfy_entrypoint registers them (see
Nodekit pack pattern); the paths come
from pack.json, so they always equal route('media') on the frontend.
| Route | Response |
|---|---|
GET /<ns>/media?type=input|output|temp |
{"type", "items": [{name, filename, subfolder, type, kind, size, mtime}], "truncated"}. name is the path relative to the folder. Max 20000 items; hidden files and folders are skipped. 400 for an unknown folder. |
GET /<ns>/media/thumb?type=&name=&size=[&t=] |
Cached JPEG poster for an image or a video's frame (PIL / PyAV). size clamped to 32–768, default 256. t (seconds, video only) picks the frame showing then, ⌊t × fps + 0.001⌋, clamped to the video (default: the first frame); with t the answer carries X-Thumb-Time (that frame's time, 0 for a picture), and a t that isn't a number is a 400. 404 for missing files or audio, 415 if the file can't be decoded. Cached in <user dir>/<ns>/thumbs per file version, size and frame; Cache-Control: immutable. |
GET /<ns>/media/info?ref=input/a.mp4 |
{"kind", "duration", "width", "height", "fps", "has_audio"}. 404 for a missing file, a non-media file or a ref outside input/output/temp; 415 if it can't be read. |
GET /<ns>/media/find?size=&sha256= |
{"ref": "input/sub/name.png" | null}. Only files of the same size are hashed; hashes are cached per (mtime_ns, size). |
Names and refs are resolved with a realpath + commonpath guard, so they can't escape their
folder, and thumbnail work is limited to four at a time.
A GIF is an image, here as everywhere in ZenKit: its thumbnail and size come from its first frame.
PyAV (which ComfyUI installs) is optional: without it, video thumbnails answer 415 and info still
sizes pictures, but reports null for everything else.
Full files are served by ComfyUI's own /view, and uploads go to its /upload/image.
For images a node saves, map the ui output through viewUrl in nodeDef.output (see
Nodekit pack pattern). viewUrl uses
api.apiURL (base-path safe) and ComfyUI's app.getRandParam() as a cache-buster, because a re-run
can reuse a filename.
Note: when
app.getRandParamis missing, the fallback cache-buster is&r=<query length>, which does not change between runs.
To open results fullscreen, see openViewer in
Nodekit panels and canvas.
Getting started
Guides
- Panels and workspaces
- Apps and routing
- Themes
- Backgrounds
- Channels and media
- Jobs
- Capabilities
- Storage and bus
Node packs
UI components
Reference