Repository navigation
UI Media and LoRA
Viewers, players, pickers and timelines, plus the LoRA picker family. Part of
@nynxz/zenkit-ui.
An image/video/audio viewer: wheel-zoom about the pointer, drag to pan, rotate, slideshow, a
searchable thumbnail strip, download, and an optional "Load workflow" button. Fullscreen by
default, or inline inside a container.
<ZenLightbox v-if="viewing" :items="images" v-model:index="current" @close="viewing = false" />Visibility is mount/unmount: there is no open prop.
| Prop | Type | Default | Description |
|---|---|---|---|
items |
LightboxItem[] |
required | |
index |
number |
required | Use v-model:index
|
inline |
boolean |
false |
Fill the container instead of a fullscreen overlay |
slideshowMs |
number |
3000 |
Inline only; the overlay remembers its own speed |
slideshowToEnd |
boolean |
true |
Inline only: videos and audio play to the end before advancing |
controls |
'bottom' | 'top' | 'none' |
'bottom' |
'none' also hides the nav zones; 'top' behaves like 'bottom' (kept for compatibility) |
LightboxItem:
| Field | Type | Description |
|---|---|---|
src |
string |
Full-size URL |
kind |
'image' | 'video' | 'audio' |
Optional |
label |
string |
Title in the toolbar |
meta |
string |
Caption (dimensions, size) |
onWorkflow |
() => void |
Shows a "Load workflow" button that calls it |
| Event | Payload |
|---|---|
update:index |
number |
close |
— |
Exposed: zoom, rot, playing (refs), zoomIn(), zoomOut(), reset(), rotate(deg),
togglePlay(), prev(), next().
In overlay mode it handles Escape, ←/→, +/=/-, 0, r/R, Space and the ZenMediaControls
keys. Remembers the strip state and slideshow settings in localStorage
(zenkit.lightbox.strip, zenkit.lightbox.slideshow).
In a ZenKit plugin you usually want the shared viewer instead (openViewer in
@nynxz/zenkit-client, see Channels and media).
Hold Shift over a thumbnail to see it large, in a small floating window beside it: a picture at full size, a video playing (muted, looped), audio with a player, or content your component draws. Releasing Shift, Escape, moving off the thumbnail or starting a drag puts it away.
Drop one <ZenPeek /> inside the area it should cover. It watches the elements in that area
(its parent element) that carry these attributes:
| Attribute | Description |
|---|---|
data-peek |
The media URL to show |
data-peek-kind |
image, video, audio (else guessed from the URL), or custom
|
data-peek-id |
With custom: your id for what to draw (falls back to data-peek) |
data-peek-label |
The caption's title |
data-peek-detail |
The caption's second line |
<div class="grid">
<img v-for="a in assets" :key="a.url" :src="a.thumb" :data-peek="a.url" :data-peek-label="a.name" />
<ZenPeek />
</div>| Prop | Type | Default | Description |
|---|---|---|---|
render |
PeekRender |
— | Draws custom content into the window (see below) |
focus |
boolean |
false |
Also peek at the focused element when Shift is pressed (keyboard) |
For something that isn't a media file (a live shader preview, a chart, a rendered card), mark the
element data-peek-kind="custom" with a data-peek-id, and fill the window either with the
custom slot or the render callback. The window chrome, placement beside the element, caption,
z-layer and Escape handling are the same as for media.
<!-- A slot: Vue content, given the id and the element. -->
<ZenPeek>
<template #custom="{ id }"><MyCard :item="byId(id)" /></template>
</ZenPeek>
<!-- A callback: imperative content (a canvas, WebGL). -->
<ZenPeek :render="draw" focus />import type { PeekRender } from '@nynxz/zenkit-ui'
const draw: PeekRender = (id, container, { el, reducedMotion, place }) => {
const canvas = document.createElement('canvas')
container.append(canvas)
let raf = 0
const tick = (now: number) => {
paint(canvas, id, now)
if (!reducedMotion) raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf) // runs when the peek closes or moves to another element
}render(id, container, ctx) is called once per peek, after the window is in the page; the
function it returns is called when the peek closes, moves to another element, or ZenPeek unmounts.
Release everything there (animation frames, GL contexts, listeners). ctx:
| Field | Description |
|---|---|
el |
The element being peeked at |
reducedMotion |
The system asks for reduced motion: draw a still instead of animating |
place() |
Re-place the window; it also re-places itself when its size changes |
The content sets its own size, up to min(560px, 60vw) × min(480px, 70vh). With the slot
and render both given, both run (the slot's content first, inside the same container).
While it's open the peek is the top floating layer: Escape closes it (not the window or modal
under it) and it stays closed until Shift is pressed again. Its opening animation follows
--zen-dur-fast, and is off under reduced motion.
A control bar for a <video> or <audio> element you own: play, frame step, scrubber (buffered
range, hover time), loop, rate, volume. Switches to compact under 420px and tiny under 300px.
<script setup lang="ts">
import { ref } from 'vue'
import { ZenMediaControls } from '@nynxz/zenkit-ui'
const video = ref<HTMLVideoElement | null>(null)
const controls = ref<InstanceType<typeof ZenMediaControls> | null>(null)
</script>
<template>
<div tabindex="0" @keydown="(e) => controls?.onKey(e) && e.preventDefault()">
<video ref="video" :src="src" />
<ZenMediaControls ref="controls" :media="video" :fps="30" />
</div>
</template>| Prop | Type | Default | Description |
|---|---|---|---|
media |
HTMLMediaElement | null |
required | |
fps |
number |
24 |
For frame stepping and timecode |
compact |
boolean |
false |
Force the compact layout |
noLoop |
boolean |
— | Play once whatever the loop button says |
| Exposed | Description |
|---|---|
onKey(e: KeyboardEvent): boolean |
Forward keys from your host; returns whether it handled one. Space/k play, j/l ±5 s, ,/. frame step, m mute. |
toggle() |
Play/pause |
seek(t: number) |
Seconds |
Volume is remembered in localStorage (zenkit.media.volume).
A modal media browser: folder tree, filterable virtualised grid (videos play on hover), preview
pane, ordered multi-select, and drag-drop or button upload. It reads from a library you pass in;
nodekit's mediaLibrary fits it directly (Nodekit media).
<ZenMediaPicker
v-model:open="picking"
:library="mediaLibrary"
:kinds="['image']"
subfolder="mypack"
@pick="(refs) => (selected = refs)"
/>| Prop | Type | Default | Description |
|---|---|---|---|
open |
boolean |
required | Use v-model:open
|
library |
MediaPickerLibrary |
required | Data source |
kinds |
MediaPickerKind[] |
['image', 'video', 'audio'] |
Which kinds are shown |
multiple |
boolean |
true |
|
title |
string |
'Choose media' |
|
confirmLabel |
string |
'Add' |
|
subfolder |
string |
'zenkit' |
Upload target under input/
|
selected |
string[] |
[] |
Refs pre-selected when it opens |
| Event | Payload | Description |
|---|---|---|
update:open |
boolean |
|
pick |
string[] |
Chosen refs, in click order |
type MediaPickerKind = 'image' | 'video' | 'audio'
interface MediaPickerLibrary {
roots: string[]
list(root: string, force?: boolean): Promise<MediaPickerItem[]>
upload?(files: File[], subfolder?: string): Promise<string[]> // omit to hide upload
}
interface MediaPickerItem {
ref: string
root: string
folder: string
filename: string
kind: MediaPickerKind
size: number
mtime: number
url: string
thumb?: string
}Remembers view settings and open folders in localStorage (zenkit.mediaPicker,
zenkit.mediaPicker.open).
A controlled multi-track timeline: clips and markers, curve tracks, level/shape tracks, ruler
scrubbing, zoom, guides, a range, reorderable track headers with actions, and drop targets. It
renders tracks and reports edits through events; you update the data.
Clips on a track that touch (one ends where the next begins, to within half a pixel) read as two:
their inner corners are square and the later one starts with a seam, a hairline in --ztl-seam
(the theme's text colour at 62%) with a small notch top and bottom, as an editor marks a cut.
Clips being dragged don't join until they're dropped.
<script setup lang="ts">
import { ref } from 'vue'
import { ZenTimeline, type TimelineTrack } from '@nynxz/zenkit-ui'
const t = ref(0)
const zoom = ref(0) // 0 = fit
const tracks = ref<TimelineTrack[]>([
{
id: 'v1',
label: 'Video',
icon: 'mdi-filmstrip',
items: [
{ id: 'a', start: 0, end: 4, label: 'intro.mp4', resize: 'both' },
{ id: 'b', start: 4, end: 9, label: 'main.mp4' },
],
},
{ id: 'marks', label: 'Markers', items: [{ id: 'm1', start: 2, kind: 'marker' }] },
])
function onMove(itemId: string, trackId: string, change: { start?: number; end?: number }) {
const item = tracks.value.find((tr) => tr.id === trackId)?.items.find((i) => i.id === itemId)
if (item) Object.assign(item, change)
}
</script>
<template>
<ZenTimeline
:duration="12"
:tracks="tracks"
v-model:playhead="t"
v-model:zoom="zoom"
@move="onMove"
/>
</template>| Model | Type | Default | Description |
|---|---|---|---|
v-model:playhead |
number |
0 |
Seconds |
v-model:zoom |
number |
0 |
px per second; 0 fits the duration |
| Prop | Type | Default | Description |
|---|---|---|---|
duration |
number |
required | Seconds |
tracks |
TimelineTrack[] |
required | |
selected |
string | string[] | null |
null |
Selected item id(s) |
snap |
number |
0.05 |
Seconds |
guides |
number[] |
[] |
Vertical lines through every track (e.g. scene boundaries) |
range |
{ start: number; end: number } | null |
null |
Highlighted range |
headWidth |
number |
112 |
Track header width in px |
magnet |
number | false |
6 |
Magnetic snapping in px: a moved item's edges, a trimmed edge or a drop catch on other items' edges, the playhead and snapPoints, with a line while caught. Alt held while dragging skips it. Otherwise times fall on the snap grid |
snapPoints |
number[] |
[] |
Extra snap targets (markers, beats) |
headActions |
'below' | 'inline' |
'below' |
Where a track header's actions go: on a line under the name, or beside it (faint until hovered) |
marquee |
boolean |
false |
A drag on empty track space selects the clips in a box (marquee event) instead of scrubbing; the ruler still scrubs |
| Event | Payload | Description |
|---|---|---|
select |
(itemId | null, trackId, PointerEvent) |
Curve tracks send 'curve:<trackId>' as the id |
open |
(itemId, trackId) |
Double-click on an item |
move |
(itemId, trackId, { start?, end? }) |
Drag or resize |
context |
(target: TimelineTarget, time, MouseEvent) |
Right-click |
drop |
(trackId, time, DragEvent) |
Something dropped on a track |
dblclick |
(trackId, time) |
Double-click on empty track space |
reorder |
(trackId, beforeId | null) |
A movable track header was dragged |
curve |
(trackId, points: { t, v }[]) |
Curve edited |
level |
(trackId, segmentId, v | null) |
Level segment changed |
shape |
(trackId, segmentId, points: { x, y }[]) |
Segment shape changed |
action |
(trackId, actionId) |
Header action clicked |
marquee |
(itemIds: string[]) |
The selection a box drag makes, as it changes: what it touches, plus (Shift/Ctrl/⌘) what was selected |
Exposed: timeAt(clientX): number, follow() (scroll the playhead into view).
The wheel pans a zoomed-in timeline; Ctrl/⌘ (or Alt) + wheel zooms, keeping the time under the
pointer where it is. Inside a ComfyUI node the canvas would take Ctrl + wheel and sideways swipes
for itself, so the timeline listens for the wheel first: over its tracks, while the node has focus
(ComfyUI's data-capture-wheel rule), they're the timeline's. A zoom set from outside (a slider)
keeps the playhead where it is on screen. Scrolling doesn't re-render the tracks.
TimelineTrack:
| Field | Type | Description |
|---|---|---|
id, label
|
string |
|
title |
string? |
Full name on hover |
icon |
string? |
MDI glyph |
thumb |
string? |
Picture in the header instead of the icon |
movable |
boolean? |
Header can be dragged among other movable tracks |
color |
string? |
Header stripe |
curve |
TimelineCurve? |
{ points: {t, v}[], min?, max?, flat?, steps?, color?, unit? } |
levels |
TimelineLevels? |
{ segments: [{ id, start, end, v, inherited?, title?, shape?, shapeInherited? }], mode?: 'levels' | 'shape', min?, max?, unit?, color?, shapeFlat? } |
actions |
TimelineTrackAction[]? |
{ id, icon?, label?, title?, active? } |
height |
number? |
px |
items |
TimelineItem[] |
TimelineItem:
| Field | Type | Description |
|---|---|---|
id |
string |
|
start, end
|
number, number?
|
Seconds. A marker uses only start. |
kind |
'clip' | 'marker' |
|
label, title, color, thumb, icon
|
string? |
|
muted |
boolean? |
|
ghost |
boolean? |
Placeholder slot: dashed, not draggable |
resize |
'start' | 'end' | 'both' | false |
Draggable edges (clips default 'end') |
movable |
boolean? |
Markers default yes, clips no |
commit |
'live' | 'drop' |
'drop' emits one move on release instead of on every step |
progress |
number? |
0–1 progress fill along the bottom edge |
group |
string? |
Items sharing a group move together (a video and its sound): a drag shows them following, and they never snap to each other. A selected item drags the rest of the selection the same way |
peek |
{ src?, kind?, id?, label?, detail? } |
What Shift shows (ZenPeek): a media src, or kind: 'custom' with an id the host's ZenPeek draws |
frames, framesAspect
|
(t) => string?, number?
|
A filmstrip under the label: the picture at each timeline time, asked only for the frames near the view |
wave |
number[]? |
A waveform under the label: peaks 0–1, evenly across the clip |
badges |
string[]? |
Short tags after the label |
marks |
{ t, label?, strong? }[]? |
Lines across the clip at timeline times (where its source changes scene, say) |
tracks |
string[]? |
Other tracks it can be dragged onto (with commit: 'drop'); move then names the track it was let go on |
TimelineTarget: { trackId: string; itemId?: string }.
A canvas chart of a sampler run: the sigma schedule (dashed) and the per-step change (filled). Hover, click, drag or arrow keys pick a step.
<ZenStepChart
:sigmas="sigmas"
:deltas="deltas"
:total="steps + 1"
:current="done"
v-model:hover="hoverStep"
v-model:locked="lockedStep"
/>| Model | Type | Default | Description |
|---|---|---|---|
v-model:hover |
number | null |
null |
Step under the pointer |
v-model:locked |
number | null |
null |
Step pinned by click |
| Prop | Type | Default | Description |
|---|---|---|---|
total |
number |
required | Boundaries in the run (steps + 1) |
sigmas |
number[] | null |
null |
One sigma per boundary |
deltas |
number[] |
[] |
Change made by step i + 1
|
current |
number |
-1 |
Newest boundary that has arrived |
hoverScrub |
boolean |
true |
Hover drives hover; false = click/drag only |
Colours are read at draw time from --zen-border, --zen-muted, --zen-accent and --zen-text.
The LoRA components are UI only. The host pack owns the routes and registers a data source once, before any LoRA component renders:
// NynxzNodes frontend/main.ts + lib/loraApi.ts (abridged)
import { setLoraDetailOpener, setLoraSource, type LoraSource } from '@nynxz/zenkit-ui'
import { route } from '@/framework'
const loraSource: LoraSource = {
list: async () => (await (await fetch(route('loras'))).json()).loras ?? [],
favorites: async () => (await (await fetch(route('favorites'))).json()).loras ?? [],
setFavorite: async (name, pinned) =>
(
await (
await fetch(route('favorites'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, pinned }),
})
).json()
).loras ?? [],
previewUrl: (name) => `${route('lora/preview')}?name=${encodeURIComponent(name)}`,
mediaUrl: (name) => `${route('lora/preview')}?raw=1&name=${encodeURIComponent(name)}`,
info: async (name) => {
const res = await fetch(`${route('lora/info')}?name=${encodeURIComponent(name)}`)
return res.ok ? res.json() : null
},
}
setLoraSource(loraSource)
setLoraDetailOpener(openLoraDetailPanel) // optional: show details in a ZenKit panelLoraSource:
| Member | Signature | Description |
|---|---|---|
list |
(force?: boolean) => Promise<LoraItem[]> |
Every LoRA on disk |
previewUrl |
(name) => string |
Same-origin URL of a still preview |
mediaUrl |
(name) => string (optional) |
The preview as-is (video for a video preview). Defaults to previewUrl. |
info |
(name) => Promise<LoraInfo | null> (optional) |
Details for LoraDetail |
favorites |
(force?: boolean) => Promise<string[]> |
Bookmarked names |
setFavorite |
(name, pinned) => Promise<string[]> |
Returns the new complete list |
LoraItem: { name, has_preview, preview?: 'image' | 'video' | null, favorite?, size?, mtime? }
(name is the relative path with /). LoraInfo carries title, version, base_model,
creator, url, sha256, trigger_words, tags, description, notes, usage, images
(LoraExample[]), training; every field but name is optional.
| Function | Description |
|---|---|
setLoraSource(source) |
Register the data source (required) |
openLoraDetail(name) |
Show details: the opener if set, else a built-in ZenModal with LoraDetail |
setLoraDetailOpener(fn | null) |
fn(name) returns true if it showed the details itself (e.g. via openZenPanel), false to use the modal |
loraLibrary |
The shared store (namespace export), see below |
loraLibrary (one store for every LoRA component on the page):
| Member | Description |
|---|---|
loras, favorites, listLoaded
|
Refs |
ensure(force?) |
Load the list and favourites once |
folderNav, setFolderNav(on)
|
Picker steps through folders (default on) |
blurMature, setBlurMature(on)
|
Blur examples rated R and above (default on) |
folderIndex |
Computed folder map |
short(name), folder(name)
|
Name helpers |
hasPreview, preview, previewKind, media, hasThumb
|
Preview helpers |
hasInfo(), info(name)
|
Details |
isFav, toggleFav
|
Favourites |
isMissing(name) |
Not in the current listing |
onThumbError, onImageError
|
Error handlers for <img>
|
A ZenCombo for LoRAs: folder navigation with breadcrumbs, bookmarks, thumbnails and a "browse all" button.
<LoraPicker v-model="lora" @browse="browserOpen = true" />
<LoraBrowser
v-model:open="browserOpen"
:selected="lora"
@pick="
(n) => {
lora = n
browserOpen = false
}
"
/>| Prop | Type | Default | Description |
|---|---|---|---|
modelValue |
string |
required | LoRA name |
placeholder |
string |
'Select a LoRA…' |
|
menuWidth |
number |
400 |
px |
| Event | Payload | Description |
|---|---|---|
update:modelValue |
string |
|
browse |
— | User asked for the full browser; open a LoraBrowser |
A modal for the whole library: folder tree (plus All and Bookmarked), grid or list view (virtualised), sorting and card size, a LoraDetail pane, keyboard navigation.
| Prop | Type | Default | Description |
|---|---|---|---|
open |
boolean |
required | Use v-model:open
|
selected |
string |
— | Focused and marked on open |
| Event | Payload |
|---|---|
update:open |
boolean |
pick |
string |
Remembers sort, size and view in localStorage (zenkit.loraBrowser, zenkit.loraBrowser.open).
A 40px (or 18px) LoRA tile: preview, placeholder, or a "not on disk" warning. Shift-hover shows a peek; Shift-click opens details.
<LoraThumb :name="row.lora" sm check-missing />| Prop | Type | Default | Description |
|---|---|---|---|
name |
string |
required | |
sm |
boolean |
false |
18px inline tile |
checkMissing |
boolean |
false |
Show the "not on disk" state |
strict |
boolean |
false |
Trust the listing's preview flag (default is optimistic) |
Everything about one LoRA: cover, chips, bookmark, Civitai link, trigger words (click to copy), example gallery (mature blur), parameters, tags, description, notes and training metadata. Opens examples in ZenLightbox.
<LoraDetail :name="lora" pickable @pick="use" />| Prop | Type | Default | Description |
|---|---|---|---|
name |
string |
required | |
pickable |
boolean |
false |
Show a "Use" button |
| Event | Payload |
|---|---|
pick |
string |
Examples show in a viewer: the chosen one large (‹ › or ←/→, Home/End; click for fullscreen), a filmstrip of them all beneath that keeps the chosen one in view (the wheel scrolls it sideways), and its generation details beside it when the panel is wider than about 560px. Example and page URLs come from the LoRA's metadata, so they pass safeMediaUrl / safeLinkUrl first; anything else shows nothing.
Getting started
Guides
- Panels and workspaces
- Apps and routing
- Themes
- Backgrounds
- Channels and media
- Jobs
- Capabilities
- Storage and bus
Node packs
UI components
Reference