MediaSlots.kt
src/jvmMain/kotlin/afield/ui/output/MediaSlots.kt · 482 lines
@file:afield.runtime.completion.CompletionCategory("Output")
package afield.ui.output
import afield.ai.inference.onnx.ClipVector
import afield.ai.inference.onnx.clipEncode
import afield.ai.inference.onnx.clipEncodeImage
import afield.document.graph.Box
import afield.app.diagnostics.logging.lprintln
import afield.study.source.BoxStudies
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.awt.RenderingHints
import java.awt.image.BufferedImage
import java.io.ByteArrayOutputStream
import java.io.File
import java.util.Base64
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
import javax.imageio.ImageIO
A row of input slots for pictures and text, in a box's output: what a program that compares a few things (CLIP embeddings, a style transfer, a classifier) reads its inputs from.
val inputs = mediaSlots {
image("public/docs/assets/madp_tetra/tabby_cat.jpg")
image("public/docs/assets/madp_tetra/labrador.jpg")
text("a photo of a cat")
tray("public/docs/assets/madp_tetra") // more pictures, to drag onto a slot
}
inputs.onChange { slots ->
val vectors = slots.map { it.clip() }
...
}
Each slot has a number and a colour, and holds one picture or one piece of text:
- click a picture slot to choose a file, or drop one on it; drop several and they fill that slot and the ones after it;
- drop a folder (or use folder…) to put its pictures in the tray under the slots, then drag any of them onto a slot; MediaSlotsBuilder.tray fills the tray from the box's own code;
- type into a text slot;
- reset puts every slot back as the code says.
Pictures are scaled in the browser to at most 640 pixels on the long side, with the photo's EXIF
orientation applied, before they are sent; the box sees a BufferedImage. What was dropped or typed
outlives a re-run of the box, but not a restart of Field: the code's own files are what a document
keeps.
onChange runs on a thread of its own, once now and again after every change, and only when every slot is filled. Changes that arrive while it is running are folded into one more run.
class MediaSlots internal constructor(private val box: Box, private val channel: String, private val defaults: List<MediaSlot>)
The row of slots a box is holding: read slots or index it with get, ask whether it is complete, and hand onChange a block to run whenever one of them changes.
There is one of these per box and output channel, and a re-run finds the slots as they were left. Where the code for a slot has itself changed, the code's new content wins over whatever was dropped on it.
class MediaSlots internal constructor(private val box: Box, private val channel: String, private val defaults: List<MediaSlot>) {
private val key = "${box.id}/$channel"
private val state: SlotState = slotStates.getOrPut(key) { SlotState() }
private val actionId = "media-slots-${UUID.randomUUID()}"
private val worker get() = state.worker
private val pending get() = state.pending
@Volatile private var listener: ((List<MediaSlot>) -> Unit)? = null
init
init {
// A slot whose code changed since the last run takes the code's new content over a drop.
val signatures = defaults.map { "${it.kind}|${it.file?.path}|${it.text}" }
signatures.forEachIndexed { i, sig -> if (state.signatures.getOrNull(i) != sig) state.slots.remove(i) }
state.signatures = signatures
registerStringCallback(actionId) { json -> runCatching { act(json) }.onFailure { lprintln("error,media-slots", "media slots: ${it.message}") } }
}
val slots: List<MediaSlot>
The slots as they are now: the code's, with whatever was dropped or typed over them.
val slots: List<MediaSlot>
get() = defaults.mapIndexed { i, d -> state.slots[i]?.let { d.with(it) } ?: d }
val tray: List<MediaSlot> get() = state.tray.toList()
The pictures waiting in the tray.
operator fun get(i: Int): MediaSlot = slots[i]
Slot i, counting from 0.
val complete: Boolean get() = slots.none { it.isEmpty }
Whether every slot holds something.
fun onChange(block: (List<MediaSlot>) -> Unit) {
Run block with the slots now, and again after every change, on a thread of its own. It is not
called while a slot is empty. Calling onChange again replaces the block.
fun onChange(block: (List<MediaSlot>) -> Unit) {
listener = block
changed()
}
private fun changed() {
if (listener == null || !complete) return
if (!pending.compareAndSet(false, true)) return
worker.execute {
pending.set(false)
val now = slots
if (now.any { it.isEmpty }) return@execute
try {
listener?.invoke(now)
} catch (t: Throwable) {
state.error = "${t::class.simpleName}: ${t.message}"
lprintln("error,media-slots", "media slots onChange failed: ${t.message}")
t.printStackTrace()
redraw()
}
}
}
internal fun act(json: String)
body — 51 lines, from line 128
internal fun act(json: String) {
val message = Json.parseToJsonElement(json).jsonObject
val slot = message["slot"]?.jsonPrimitive?.int
state.error = null
when (message["action"]?.jsonPrimitive?.content) {
"image" -> {
val files = message["files"]!!.jsonArray.map { it.jsonObject }
var target = slot ?: 0
for (f in files) {
while (target < defaults.size && defaults[target].kind != MediaSlot.Kind.IMAGE) target++
if (target >= defaults.size) break
state.slots[target] = decode(f)
target++
}
redraw(); changed()
}
"tray" -> {
val item = state.tray.getOrNull(message["item"]!!.jsonPrimitive.int) ?: return
if (slot != null && defaults.getOrNull(slot)?.kind == MediaSlot.Kind.IMAGE) {
state.slots[slot] = SlotContent(image = item.image, name = item.label, file = item.file)
redraw(); changed()
}
}
"tray-add" -> {
for (f in message["files"]!!.jsonArray.map { it.jsonObject }) {
if (state.tray.size >= MAX_TRAY) break
val c = decode(f)
state.tray += MediaSlot(-1, MediaSlot.Kind.IMAGE, "#808080", c.name, c.image, "", c.file)
}
redraw()
}
"text" -> {
if (slot != null && defaults.getOrNull(slot)?.kind == MediaSlot.Kind.TEXT) {
state.slots[slot] = SlotContent(text = message["text"]!!.jsonPrimitive.content)
// No redraw: the person is typing into the slot, and redrawing would take the caret away.
changed()
}
}
"reset" -> {
state.slots.clear(); state.tray.clear(); state.tray += initialTray
redraw(); changed()
}
}
}
private fun decode(f: JsonObject): SlotContent {
val name = f["name"]?.jsonPrimitive?.content ?: "image"
val data = f["dataUrl"]!!.jsonPrimitive.content.substringAfter(",")
val image = ImageIO.read(Base64.getDecoder().decode(data).inputStream()) ?: error("could not read $name as an image")
return SlotContent(image = image, name = name.substringBeforeLast('.'))
}
internal var initialTray: List<MediaSlot>
internal var initialTray: List<MediaSlot> = emptyList()
set(value) {
field = value
val signature = value.joinToString("|") { it.file?.path ?: it.label }
if (state.traySignature != signature) { state.tray.clear(); state.tray += value; state.traySignature = signature }
}
internal fun redraw() = BoxStudies.show(box, channel, html())
internal fun html(): String = renderMediaSlots(actionId, slots, state.tray, state.error)
companion object
companion object {
private val slotStates = ConcurrentHashMap<String, SlotState>()
private const val MAX_TRAY = 64
}
}
internal class SlotContent(val image: BufferedImage? = null, val name: String = "", val text: String? = null, val file: File? = null)
What a person put over a slot: a picture or a piece of text.
internal class SlotContent(val image: BufferedImage? = null, val name: String = "", val text: String? = null, val file: File? = null)
What outlives a re-run, by box and channel.
private class SlotState {
val slots = ConcurrentHashMap<Int, SlotContent>()
val tray = java.util.concurrent.CopyOnWriteArrayList<MediaSlot>()
@Volatile var traySignature: String? = null
@Volatile var signatures: List<String> = emptyList()
@Volatile var error: String? = null
val pending = AtomicBoolean(false)
val worker = Executors.newSingleThreadExecutor { r -> Thread(r, "media-slots").apply { isDaemon = true } }
}
class MediaSlot internal constructor
One slot of a MediaSlots: a picture or a piece of text, with the slot's number and colour.
class MediaSlot internal constructor(
/** The slot's position, from 0; the widget shows `index + 1`. */
val index: Int,
/** Whether the slot takes a picture or a piece of text. */
val kind: Kind,
/** The slot's CSS colour, which a drawing of the result should reuse. */
val color: String,
/** The picture's file name without its extension, or, for a text slot, the text itself. */
val label: String,
/** The picture, for an image slot that is filled, and null for one that is not. */
val image: BufferedImage?,
/** The text, for a text slot; empty for an image slot. */
val text: String,
/** Where the picture came from, when it came from a file on this machine rather than a drop. */
val file: File? = null,
) {
enum class Kind { IMAGE, TEXT }
A picture slot or a text slot.
val isEmpty: Boolean get() = if (kind == Kind.IMAGE) image == null else text.isBlank()
Whether there is nothing in the slot yet.
fun clip(): ClipVector
The slot's CLIP embedding: clipEncodeImage for a picture, clipEncode for text. Both land in the same 512-dimensional space, so a picture and a sentence can be compared.
fun clip(): ClipVector = when (kind) {
Kind.IMAGE -> clipEncodeImage(image ?: error("slot ${index + 1} is empty"))
Kind.TEXT -> clipEncode(text.ifBlank { error("slot ${index + 1} is empty") })
}
internal fun with(c: SlotContent): MediaSlot
internal fun with(c: SlotContent): MediaSlot = when (kind) {
Kind.IMAGE -> MediaSlot(index, kind, color, c.name, c.image, "", c.file)
Kind.TEXT -> MediaSlot(index, kind, color, c.text ?: "", null, c.text ?: "")
}
override fun toString(): String
The slot's number as the widget shows it, its kind and its label, and whether it is empty.
override fun toString(): String = "MediaSlot(${index + 1}, $kind, \"$label\"${if (isEmpty) ", empty" else ""})"
}
class MediaSlotsBuilder internal constructor(private val colors: List<String>)
The receiver of Box.mediaSlots's block: one call per slot, in order, and any trays.
internal val slots = ArrayList<MediaSlot>()
internal val tray = ArrayList<MediaSlot>()
private fun nextColor() = colors[slots.size % colors.size]
fun image(path: String)
A picture slot holding the image at path (relative to Field's working directory, or absolute).
fun image(path: String) {
val file = File(path)
val image = ImageIO.read(file) ?: error("mediaSlots: cannot read an image from $file")
slots += MediaSlot(slots.size, MediaSlot.Kind.IMAGE, nextColor(), file.nameWithoutExtension, image, "", file)
}
fun image()
An empty picture slot, to be filled by a drop.
fun image() {
slots += MediaSlot(slots.size, MediaSlot.Kind.IMAGE, nextColor(), "", null, "")
}
fun text(initial: String = "")
A text slot, starting with initial.
fun text(initial: String = "") {
slots += MediaSlot(slots.size, MediaSlot.Kind.TEXT, nextColor(), initial, null, initial)
}
fun tray(folder: String)
Put every picture in folder (JPEG, PNG, GIF, BMP; not its subfolders) in the tray, by name.
fun tray(folder: String) {
val files = File(folder).listFiles()?.filter { it.isFile && it.extension.lowercase() in IMAGE_EXTENSIONS }?.sortedBy { it.name }
?: error("mediaSlots: $folder is not a folder")
for (f in files) {
val image = ImageIO.read(f) ?: continue
tray += MediaSlot(-1, MediaSlot.Kind.IMAGE, "#808080", f.nameWithoutExtension, image, "", f)
}
}
private companion object {
val IMAGE_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "bmp")
}
}
fun Box.mediaSlots
Show a row of picture and text slots in this box's output and return them; see MediaSlots.
@param channel the output channel the slots open in.
@param colors the slots' colours in order: the madp tetra page's by default.
fun Box.mediaSlots(
channel: String = "inputs",
colors: List<String> = MEDIA_SLOT_COLORS,
block: MediaSlotsBuilder.() -> Unit,
): MediaSlots {
val built = MediaSlotsBuilder(colors).apply(block)
require(built.slots.isNotEmpty()) { "mediaSlots: add at least one image(...) or text(...) slot" }
val slots = MediaSlots(this, channel, built.slots)
slots.initialTray = built.tray
display(channel, slots.html())
return slots
}
val MEDIA_SLOT_COLORS = listOf("#8be9fd", "#50fa7b", "#ffb86c", "#ff79c6", "#bd93f9", "#f1fa8c")
The madp tetra page's four slot colours, then two more from the same palette.
source — 167 lines, from line 315
private fun thumbnail(image: BufferedImage, size: Int): String {
val scale = size.toDouble() / minOf(image.width, image.height)
val w = maxOf(1, (image.width * scale).toInt())
val h = maxOf(1, (image.height * scale).toInt())
val out = BufferedImage(w, h, BufferedImage.TYPE_INT_RGB)
out.createGraphics().apply {
setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR)
drawImage(image, 0, 0, w, h, null)
dispose()
}
val bytes = ByteArrayOutputStream().also { ImageIO.write(out, "jpg", it) }.toByteArray()
return "data:image/jpeg;base64," + Base64.getEncoder().encodeToString(bytes)
}
private fun esc(s: String) = s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """).replace("'", "'")
private fun renderMediaSlots(actionId: String, slots: List<MediaSlot>, tray: List<MediaSlot>, error: String?): String {
val root = "ms-" + actionId.takeLast(12)
val filled = slots.count { !it.isEmpty }
val images = slots.count { it.kind == MediaSlot.Kind.IMAGE }
val texts = slots.size - images
val status = error?.let { "<span style='color:#ff7777'>${esc(it)}</span>" } ?: when {
filled < slots.size -> "drop ${describe(images, texts)} to compute (${filled}/${slots.size} ready)"
else -> "${describe(images, texts)} ready"
}
return buildString {
append("<div id='$root' class='media-slots' style='font-family:\"Iosevka Web\",ui-monospace,monospace;font-size:12px;color:#b0b0b0;")
append("background:#0b0b0b;padding:10px;display:flex;flex-direction:column;gap:8px;max-width:820px'>")
append("<div style='display:grid;grid-template-columns:repeat(${slots.size.coerceAtMost(6)},minmax(0,1fr));gap:8px'>")
for (s in slots) {
append("<div class='ms-slot' data-slot='${s.index}' data-kind='${s.kind.name.lowercase()}' style='border:1px dashed #2d2d2d;background:#101010;")
append("padding:8px;display:flex;flex-direction:column;gap:6px;min-width:0;cursor:${if (s.kind == MediaSlot.Kind.IMAGE) "pointer" else "default"}'>")
append("<div style='display:flex;align-items:center;gap:6px;min-width:0'>")
append("<span style='width:20px;height:20px;flex:none;display:inline-flex;align-items:center;justify-content:center;color:#101010;background:${s.color};font-weight:500'>${s.index + 1}</span>")
val name = if (s.kind == MediaSlot.Kind.TEXT) "text" else s.label.ifBlank { "empty" }
append("<span style='color:#8a8a8a;overflow:hidden;white-space:nowrap;text-overflow:ellipsis'>${esc(name)}</span></div>")
if (s.kind == MediaSlot.Kind.IMAGE) {
append("<div style='aspect-ratio:4/3;border:1px solid #242424;background:#080808;display:flex;align-items:center;justify-content:center;color:#393939;font-size:22px;overflow:hidden'>")
if (s.image != null) append("<img alt='' src='../${thumbnail(s.image, 160)}' style='width:100%;height:100%;object-fit:cover'>")
else append("<span>${s.index + 1}</span>")
append("</div><input type='file' accept='image/*' multiple style='display:none'>")
} else {
append("<textarea spellcheck='true' placeholder='type text to embed' style='aspect-ratio:4/3;width:100%;box-sizing:border-box;resize:none;background:#080808;")
append("color:#d0d0d0;border:1px solid #242424;padding:6px;font:inherit;line-height:1.35;outline:none'>${esc(s.text)}</textarea>")
}
append("</div>")
}
append("</div>")
append("<div class='ms-tray' style='display:flex;flex-wrap:wrap;gap:4px;align-items:center;min-height:40px;border-top:1px solid #1a1a1a;padding-top:8px'>")
tray.forEachIndexed { i, t ->
append("<img class='ms-tray-item' draggable='true' data-item='$i' title='${esc(t.label)} — drag onto a slot' src='${t.image?.let { thumbnail(it, 64) } ?: ""}' ")
append("style='width:44px;height:44px;object-fit:cover;border:1px solid #242424;cursor:grab'>")
}
append("<span style='color:#444;font-size:11px;margin-left:4px'>${if (tray.isEmpty()) "drop a folder here for a tray of pictures" else "drag onto a slot"}</span>")
append("<span style='margin-left:auto;display:flex;gap:6px'>")
append("<button class='ms-folder' style='background:#1a1a1a;color:#808080;border:1px solid #252525;padding:2px 10px;font:inherit;cursor:pointer'>folder…</button>")
append("<button class='ms-reset' style='background:#1a1a1a;color:#808080;border:1px solid #252525;padding:2px 10px;font:inherit;cursor:pointer'>reset</button>")
append("<input class='ms-folder-input' type='file' webkitdirectory multiple style='display:none'></span></div>")
append("<div class='ms-status' style='color:#5a5a5a;font-size:11px'>$status</div>")
append("</div>")
append(mediaSlotsScript(root, actionId))
}
}
private fun describe(images: Int, texts: Int): String {
val words = listOf("no", "one", "two", "three", "four", "five", "six")
fun n(k: Int) = words.getOrElse(k) { "$k" }
val pictures = if (images == 1) "one image" else "${n(images)} images"
return when (texts) {
0 -> pictures
1 -> "$pictures and text"
else -> "$pictures and ${n(texts)} texts"
}
}
private fun mediaSlotsScript(root: String, actionId: String): String = """
<script>
(() => {
const root = document.getElementById('$root');
if (!root) return;
const send = (o) => { if (window.handleStringInput) window.handleStringInput({ id: '$actionId', value: JSON.stringify(o) }); };
const status = root.querySelector('.ms-status');
const isImage = (f) => f && f.type && f.type.startsWith('image/');
// Scaled in the page, orientation applied, so a 12-megapixel phone photo crosses the socket as ~60 kB.
async function shrink(file) {
const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' });
const s = Math.min(1, 640 / Math.max(bitmap.width, bitmap.height));
const c = document.createElement('canvas');
c.width = Math.max(1, Math.round(bitmap.width * s)); c.height = Math.max(1, Math.round(bitmap.height * s));
c.getContext('2d').drawImage(bitmap, 0, 0, c.width, c.height);
return { name: file.name || 'image', dataUrl: c.toDataURL('image/jpeg', 0.88) };
}
async function shrinkAll(files) {
status.textContent = 'reading ' + files.length + (files.length === 1 ? ' picture…' : ' pictures…');
const out = [];
for (const f of files) { try { out.push(await shrink(f)); } catch (e) { console.warn('media slots: skipped', f.name, e); } }
return out;
}
function entryFiles(entry) {
return new Promise((resolve) => {
if (entry.isFile) { entry.file((f) => resolve([f]), () => resolve([])); return; }
if (!entry.isDirectory) { resolve([]); return; }
const reader = entry.createReader(), all = [];
const next = () => reader.readEntries(async (batch) => {
if (!batch.length) { resolve((await Promise.all(all.map(entryFiles))).flat()); return; }
all.push(...batch); next();
}, () => resolve([]));
next();
});
}
async function dropped(event) {
const items = Array.from(event.dataTransfer.items || []);
const entries = items.map((i) => i.webkitGetAsEntry && i.webkitGetAsEntry()).filter(Boolean);
const folder = entries.some((e) => e.isDirectory);
const files = folder ? (await Promise.all(entries.map(entryFiles))).flat() : Array.from(event.dataTransfer.files || []);
return { folder, files: files.filter(isImage).sort((a, b) => a.name.localeCompare(b.name)) };
}
const over = (el, on, color) => { el.style.borderColor = on ? (color || '#8be9fd') : '#2d2d2d'; el.style.background = on ? '#12191b' : '#101010'; };
root.querySelectorAll('.ms-slot').forEach((slot) => {
const index = Number(slot.dataset.slot);
if (slot.dataset.kind === 'text') {
const area = slot.querySelector('textarea');
let timer = null;
area.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(() => send({ action: 'text', slot: index, text: area.value }), 350); });
area.addEventListener('keydown', (e) => e.stopPropagation());
return;
}
const input = slot.querySelector('input[type=file]');
input.addEventListener('click', (e) => e.stopPropagation());
slot.addEventListener('click', () => input.click());
input.addEventListener('change', async () => {
const files = Array.from(input.files || []).filter(isImage); input.value = '';
if (files.length) send({ action: 'image', slot: index, files: await shrinkAll(files) });
});
['dragenter', 'dragover'].forEach((t) => slot.addEventListener(t, (e) => { e.preventDefault(); e.stopPropagation(); over(slot, true); }));
slot.addEventListener('dragleave', () => over(slot, false));
slot.addEventListener('drop', async (e) => {
e.preventDefault(); e.stopPropagation(); over(slot, false);
const item = e.dataTransfer.getData('text/x-field-tray');
if (item !== '') { send({ action: 'tray', slot: index, item: Number(item) }); return; }
const got = await dropped(e);
if (!got.files.length) return;
if (got.folder) send({ action: 'tray-add', files: await shrinkAll(got.files) });
else send({ action: 'image', slot: index, files: await shrinkAll(got.files) });
});
});
root.querySelectorAll('.ms-tray-item').forEach((img) => {
img.addEventListener('dragstart', (e) => { e.dataTransfer.setData('text/x-field-tray', img.dataset.item); e.dataTransfer.effectAllowed = 'copy'; });
});
const tray = root.querySelector('.ms-tray');
['dragenter', 'dragover'].forEach((t) => tray.addEventListener(t, (e) => { e.preventDefault(); e.stopPropagation(); }));
tray.addEventListener('drop', async (e) => {
e.preventDefault(); e.stopPropagation();
if (e.dataTransfer.getData('text/x-field-tray') !== '') return;
const got = await dropped(e);
if (got.files.length) send({ action: 'tray-add', files: await shrinkAll(got.files) });
});
const folderInput = root.querySelector('.ms-folder-input');
root.querySelector('.ms-folder').addEventListener('click', () => folderInput.click());
folderInput.addEventListener('change', async () => {
const files = Array.from(folderInput.files || []).filter(isImage).sort((a, b) => a.name.localeCompare(b.name)); folderInput.value = '';
if (files.length) send({ action: 'tray-add', files: await shrinkAll(files) });
});
root.querySelector('.ms-reset').addEventListener('click', () => send({ action: 'reset' }));
})();
</script>
""".trimIndent()