Documentation/Source example
Example source

samples/wasm/WebAssembly Image Filters.js

Referenced by:module:WebAssembly
// JSplitter WebAssembly demo: album-art Sobel filters in a real Worker.
// Installed at component/samples/wasm. Load this file via Script > File.
// Worker and WASM paths are resolved relative to their calling script.
// Use GDI mode (DrawMode = 0). No packages, Emscripten or WASI needed.
//
// LEFT CLICK: next filter (Pencil / Neon / Ink).
// MOUSE WHEEL: increase/decrease edge threshold.
// RIGHT CLICK: refresh currently playing or selected album art.
// If there is no cover, a generated example image is used automatically.

window.DefineScript('WebAssembly Image Filters');
window.DrawMode = 0;
const IMAGE_LIMIT = 384;
const MODES = ['Pencil', 'Neon', 'Ink'];
const fontTitle = gdi.Font('Segoe UI', 16, 1);
const fontBody = gdi.Font('Segoe UI', 12);
const fontSmall = gdi.Font('Segoe UI', 11);
const COLOR_BG = 0xff111820;
const COLOR_TEXT = 0xffe7edf3;
const COLOR_MUTED = 0xffaab9c6;

let worker = null;
let original = null;
let filtered = null;
let mode = 0;
let threshold = 180;
let revision = 0;
let requestId = 0;
let resultTimeMs = 0;
let edgeCount = 0;
let label = 'Loading…';
let status = 'Initializing WebAssembly…';
let loaded = false;

function createFallback() {
    // Procedural image - the example works even without playback or album art.
    const n = IMAGE_LIMIT;
    const pixels = new Uint8Array(n * n * 4);
    for (let y = 0; y < n; ++y) {
        for (let x = 0; x < n; ++x) {
            const at = (y * n + x) * 4;
            const dx = x - n / 2;
            const dy = y - n / 2;
            const radius = Math.sqrt(dx * dx + dy * dy);
            const ring = Math.sin(radius * 0.11) > 0.25;
            const square = x > 75 && x < 310 && y > 86 && y < 295;
            const lines = Math.sin(x * 0.12 + y * 0.03) > 0.85;
            pixels[at] = square ? 245 : (ring ? 45 : 30 + x * 160 / n);
            pixels[at + 1] = square ? (lines ? 48 : 110) : (ring ? 200 : 42 + y * 120 / n);
            pixels[at + 2] = square ? (lines ? 40 : 200) : (ring ? 210 : 165);
            pixels[at + 3] = 255;
        }
    }
    return gdi.CreateImageFromPixelData(pixels, n, n, 'rgba32');
}

function loadArtwork() {
    if (!loaded) return;
    let image = null;
    let name = 'Generated example';
    try {
        const handle = fb.GetNowPlaying() || fb.GetFocusItem();
        if (handle) {
            // GDI bitmap returned by JSplitter in DrawMode 0.
            image = utils.GetAlbumArtV2(handle, 0, false);
            if (image) name = 'Album art';
        }
    } catch (e) {
        console.log('Album-art lookup: ' + e);
    }
    if (!image) image = createFallback();
    if (!image) {
        status = 'Unable to create source image';
        window.Repaint();
        return;
    }

    // Bound both the work and the transfer size; preserve the aspect ratio.
    const scale = Math.min(1, IMAGE_LIMIT / Math.max(image.Width, image.Height));
    const w = Math.max(3, Math.round(image.Width * scale));
    const h = Math.max(3, Math.round(image.Height * scale));
    original = (w === image.Width && h === image.Height) ? image : image.Resize(w, h);
    const pixels = original.GetPixelData('rgba32');
    if (!pixels || pixels.length !== w * h * 4) {
        status = 'GetPixelData(rgba32) failed';
        window.Repaint();
        return;
    }

    filtered = null;
    label = name;
    status = 'Processing…';
    ++revision;
    ++requestId;
    worker.postMessage({
        type: 'load', width: w, height: h,
        pixels, threshold, mode, revision, requestId
    }, [pixels.buffer]);
    window.Repaint();
}

function refilter() {
    if (!loaded || !original) return;
    status = 'Processing…';
    ++requestId;
    worker.postMessage({type: 'filter', threshold, mode, revision, requestId});
    window.Repaint();
}

worker = new Worker({file: 'WebAssembly Image Filters.worker.js'}, 'WASM Image Filters');
worker.onmessage = function (e) {
    const m = e.data;
    if (m.type === 'ready') {
        loaded = true;
        loadArtwork();
    } else if (m.type === 'result') {
        // Ignore stale frames after an artwork change or a newer filter request.
        if (m.revision !== revision || m.requestId !== requestId) return;
        filtered = gdi.CreateImageFromPixelData(m.pixels, m.width, m.height, 'rgba32');
        resultTimeMs = m.elapsedMs;
        edgeCount = m.edgeCount;
        status = filtered ? 'Ready' : 'Bitmap creation failed';
        window.Repaint();
    } else if (m.type === 'error') {
        status = m.message;
        console.log('WebAssembly sample: ' + status);
        window.Repaint();
    }
};
worker.onerror = function (e) {
    status = 'Worker error';
    console.log(status + ': ' + e.message);
    window.Repaint();
};

function on_paint(gr) {
    const w = window.Width;
    const h = window.Height;
    gr.FillSolidRect(0, 0, w, h, COLOR_BG);
    gr.DrawString('WebAssembly · Album Art Filters', fontTitle, COLOR_TEXT, 15, 10, w - 30, 29);
    gr.DrawString('Original', fontBody, COLOR_MUTED, 15, 47, w / 2 - 20, 22);
    gr.DrawString('WASM: ' + MODES[mode], fontBody, COLOR_MUTED, w / 2 + 8, 47, w / 2 - 20, 22);
    const contentTop = 73;
    const contentBottom = Math.max(contentTop, h - 65);
    const tileW = Math.max(0, (w - 42) / 2);
    const tileH = Math.max(0, contentBottom - contentTop);

    function paintTile(image, x) {
        if (!image || tileW < 3 || tileH < 3) return;
        const scale = Math.min(tileW / image.Width, tileH / image.Height);
        const iw = image.Width * scale;
        const ih = image.Height * scale;
        gr.DrawImage(image, x + (tileW - iw) / 2, contentTop + (tileH - ih) / 2,
            iw, ih, 0, 0, image.Width, image.Height);
    }
    paintTile(original, 15);
    paintTile(filtered, w / 2 + 6);
    const footer = Math.max(contentTop + 5, h - 54);
    gr.DrawString(label + '  ·  ' + status, fontSmall, COLOR_TEXT, 15, footer, w - 25, 18);
    gr.DrawString('Click: filter  |  Wheel: threshold (' + threshold +
        ')  |  Right-click: refresh  |  WASM: ' + resultTimeMs.toFixed(2) +
        ' ms  |  Edges: ' + edgeCount,
        fontSmall, COLOR_MUTED, 15, footer + 21, w - 25, 21);
}

function on_mouse_lbtn_up() {
    mode = (mode + 1) % MODES.length;
    refilter();
}

function on_mouse_wheel(step) {
    threshold = Math.max(0, Math.min(600, threshold + (step > 0 ? 20 : -20)));
    refilter();
}

function on_mouse_rbtn_up() {
    loadArtwork();
    return true;
}

function on_playback_new_track() { loadArtwork(); }
function on_playback_stop() { loadArtwork(); }
// The script automatically disposes the Worker when the panel unloads.
// Closing it manually is not needed here because it lives with this panel.