Build for Ficus

JavaScript in, pixels out.

Everything you need to build your own visualizers and tiles: the frame API, the sandbox rules, the manifest, and the road to the marketplace. The whole thing fits on this page.

This code
viz.on('frame', (f) => {
  const ctx = f.ctx;
  if (!ctx) return;
  const { width: w, height: h } = f.size;
  ctx.clearRect(0, 0, w, h);

  const bins = viz.bins(24);
  const bw = w / bins.length;
  for (let i = 0; i < bins.length; i++) {
    ctx.fillStyle = f.theme.accent;
    ctx.fillRect(i * bw + 1, h - bins[i] * h,
                 bw - 2, bins[i] * h);
  }
});
This output, on whatever your PC is playing

Anatomy

How bundles work

Everything you can build for Ficus is a bundle: a folder holding a manifest.json and exactly one payload file. Which payload file it is decides what kind of bundle you've made.

manifest.json + main.js

Visualizer

JavaScript that draws every frame from live audio data. Runs in a locked-down sandbox — audio in, pixels out, nothing else. Full-power: canvas 2D, WebGL, or DOM.

manifest.json + view.json

Tile

A dashboard cell that shows data from an API you choose. Carries no code at all — one JSON file names the source and how to render it. That's why installing one is safe.

manifest.json + preset.json

MilkDrop preset

A preset for the built-in MilkDrop engine (Butterchurn-compatible). Data, not code — validated presets publish without review.

Visualizers you can build and run locally on your own machine, live. Tiles and presets are installed from the marketplace. Both roads end at Publishing.

Quick start

Your first visualizer

In Ficus, cycle the visualizer with V (or open the gallery) and pick the Scripted style, then hit + New visualizer — or create the folder yourself:

%APPDATA%\com.secondmonitor.hub\visualizers\my-first-viz\
├── manifest.json
└── main.js

Edits hot-reload within about two seconds, from the built-in editor (the ✎ on the tile) or any editor you like. manifest.json is five lines:

{
  "id": "my-first-viz",
  "name": "My First Viz",
  "version": "0.1.0",
  "api": 1,
  "permissions": []
}

id must equal the folder name (lowercase letters, digits, hyphens). permissions must stay empty for a visualizer — see the sandbox for why that's the deal. And main.js is one callback:

// Pulse — gradient bars with a kick flash.
const N = 48;

viz.on('frame', (f) => {
  const ctx = f.ctx;
  if (!ctx) return;                    // took a WebGL context instead? bail
  const { width: w, height: h } = f.size;
  if (w <= 0 || h <= 0) return;

  ctx.clearRect(0, 0, w, h);

  const bins = viz.bins(N);            // 64 host bins, resampled to N
  const bw = w / N;
  for (let i = 0; i < N; i++) {
    const v = bins[i];
    const grad = ctx.createLinearGradient(0, h - v * h, 0, h);
    grad.addColorStop(0, f.theme.accent);
    grad.addColorStop(1, f.theme.accent2);
    ctx.fillStyle = grad;
    ctx.fillRect(i * bw + 1, h - v * h, bw - 2, v * h);
  }

  if (f.onset.kick > 0.05) {           // transient envelope, 0..1
    ctx.globalAlpha = f.onset.kick * 0.5;
    ctx.strokeStyle = f.theme.accent;
    ctx.lineWidth = 2 + f.onset.kick * 8;
    ctx.beginPath();
    ctx.arc(w / 2, h / 2,
            Math.min(w, h) * (0.18 + f.bands.bass * 0.25),
            0, Math.PI * 2);
    ctx.stroke();
    ctx.globalAlpha = 1;
  }
});

Save, look at your second monitor, and it's moving. Runtime errors show up as an overlay on the tile with a line number — a bad frame never kills the script.

Keep everything inside the frame callback. The host drives your code with its own animation loop; you never call requestAnimationFrame. Timers started at the top level can survive a hot reload and haunt the next version of your code.

Reference

The viz API

Inside the sandbox, the global viz is your whole world:

MemberWhat it does
viz.on('frame', cb)Registers your draw callback. It runs every animation frame with the payload below.
viz.canvasThe <canvas>, auto-resized by the host. Call getContext('webgl2') yourself if you don't want the default 2D context — after that, f.ctx is null.
viz.rootA DOM root instead of a canvas, for bundles that declare "surface": "dom" — build your scene from elements and CSS transforms.
viz.bins(n)Resamples the host's 64 spectrum bins to n (nearest-neighbour, 1–4096). Use this instead of rolling your own — it's the formula every built-in style uses, so your output matches theirs.
viz.settings.get(key)
viz.settings.set(key, value)
Small key–value store, persisted per visualizer. Good for a user-tweakable speed or palette choice.

The frame payload

Every field your callback receives. Sensitivity and smoothing are already applied host-side — don't re-smooth.

FieldTypeMeaning
ctxCanvasRenderingContext2D | null2D context — null if you took a WebGL context first.
spectrumFloat32Array(64)Log-spaced 30 Hz–16 kHz magnitudes, 0..1.
waveformUint8Array(1024)Raw time-domain samples; 128 = silence.
waveformL / waveformRUint8Array(1024)?Per-channel time domain. Only present when your manifest declares "stereo": true and live stereo has arrived — always fall back: f.waveformL || f.waveform.
bands{ bass, mid, treble }Musical-thirds means, 0..1.
onset{ kick, snare, hat }Transient envelopes that decay over ~150 ms, 0..1. The heartbeat of anything that flashes.
levelnumberOverall loudness, 0..1.
dtnumberSeconds since the previous frame, capped at 0.25. Scale all motion by it.
size{ width, height }Canvas size in device pixels. Can be 0×0 mid-layout — guard for it.
theme{ accent, accent2 }The user's current accent colors, hex strings. Use them and your visualizer belongs in every theme.
track{ title, artist } | nullNow-playing metadata; null when nothing is playing.
playback{ playing, position, duration } | nullLive playback state in seconds. position is already interpolated for you.
syncgrid | nullSpotify beat/bar/section grid (needs "sync": true in the manifest): { track_id, progress_ms, fetched_at_ms, playing, beats[], bars[], sections[] }, each beat/bar { start, duration, confidence }. Honestly optional — often null; fall back to f.onset.

API v1 is frozen: future fields are added, never changed or removed. What your bundle uses today works forever.

DOM visualizers

Declare "surface": "dom" in the manifest and you get viz.root instead of a canvas — build elements once at the top level, then only mutate styles in the frame callback:

const dot = document.createElement('div');
dot.style.cssText =
  'position:absolute;left:50%;top:50%;width:140px;height:140px;' +
  'margin:-70px 0 0 -70px;border-radius:50%;transition:none';
viz.root.appendChild(dot);

viz.on('frame', (f) => {
  dot.style.background = f.theme.accent;
  dot.style.boxShadow = '0 0 80px ' + f.theme.accent;
  dot.style.transform = 'scale(' + (0.5 + f.bands.bass * 1.3) + ')';
});
Cache what doesn't change. Setting the same color or shadow on every frame forces style work for nothing — write it once, and again only when f.theme actually changes.

Trust

The sandbox

Your code runs inside a sandboxed iframe with an opaque origin: no cookies, no storage, no app bridge. Its content security policy is default-src 'none' — so from inside a visualizer:

  • No fetch, no XHR, no WebSockets.
  • No external images, scripts, stylesheets, or fonts.
  • No filesystem, no app APIs — the only channel in or out is the frame protocol above.

This isn't a limitation to work around; it's the promise that makes the marketplace possible. Because a visualizer cannot touch the network or the machine, a user can install one from a stranger and the worst it can do is draw badly. Audio in, pixels out.

Tiles keep the same promise a different way: they ship no code at all, and their one declared API host is enforced by the app — see Data tiles.

Reference

Manifest reference

Every bundle's manifest.json, all fields. Only the first five are required.

FieldRules
idRequired. 1–64 chars of [a-z0-9-]; must equal the folder name. First publisher owns the id forever.
nameRequired. The display name on the tile and in the store.
versionRequired, max 32 chars. Use dotted numbers like 1.0.0 — stick to digits and dots, no hyphens. Every published version is immutable.
apiRequired. Literally 1 — the only published API version.
permissionsRequired array, max 16. Must be [] for visualizers and presets. Tiles may declare net:<host> (bare hostname) and secret:<key>.
authorOptional display attribution.
surfaceOptional, visualizers: "canvas" (default) or "dom". Anything else is rejected, not coerced.
stereoOptional, visualizers: true to receive f.waveformL/R. Costs per-frame IPC, so only declare it if you read them.
syncOptional, visualizers: true to receive f.sync (the Spotify beat grid).
configOptional, max 8: { key, label, type: "text" | "number" }. Renders a settings input per entry; values reach a tile as {{config.key}}.
secretsOptional, tiles, max 8: { key, label, kind: "password" | "text", help? }. The app collects and stores the credential and injects it into your requests — your bundle never sees the value. Every entry needs a matching secret:<key> permission, and vice versa.

Store listing fields

Optional metadata the marketplace reads at submission:

FieldRules
summaryOne line for the card, ≤ 100 chars.
descriptionThe full listing, ≤ 4000 chars.
categoryVisualizers: spectrum, wave, scene, or engine. Tiles: media, system, weather, productivity, ambient, or integrations. Presets: milkdrop. Anything else is rejected at submission.
tagsMax 8, each 1–24 chars of [a-z0-9-].
icon1–2 characters — an emoji works.
changelogWhat changed this version, ≤ 1000 chars.
minAppVersionDotted numeric floor, e.g. "0.9.10" if you read f.sync.

Declarative

Data tiles

A tile puts data from an API onto the dashboard — a stock price, a feed, a score. It carries no code: view.json declares where the data comes from and which native view renders it, and the app does the fetching and drawing. Here's the official Stocks tile, complete:

// view.json
{
  "source": {
    "kind": "http",
    "url": "https://query1.finance.yahoo.com/v8/finance/chart/{{config.symbol}}?interval=1d&range=2d",
    "intervalMs": 60000
  },
  "select": "chart.result.0.meta",
  "view": {
    "type": "stat",
    "value": "{{data.regularMarketPrice}}",
    "label": "{{data.symbol}}",
    "delta": "prev {{data.chartPreviousClose}}"
  }
}
// manifest.json — the host it calls, declared up front
{
  "id": "tile-stocks",
  "name": "Stocks",
  "version": "1.0.0",
  "api": 1,
  "permissions": ["net:query1.finance.yahoo.com"],
  "config": [
    { "key": "symbol", "label": "Ticker symbol (e.g. AAPL)", "type": "text" }
  ]
}

The three parts

  • source{ "kind": "http", "url", "headers"?, "intervalMs" }. The URL must be https:// and its host must match a declared net: permission. intervalMs is clamped to 15 seconds–24 hours, so an installed tile can never hammer anyone's API.
  • select — a dot-path into the JSON response (chart.result.0.meta; literal integers index arrays). Omit it to use the whole response. The list view expects it to land on an array.
  • view — one of five native renderers: stat (value/label/delta), list (a row template over an array, with optional openUrl), rows (label/value pairs), text (body/attribution), badge (value/label).

Templating

{{path}} is substitution only — no expressions, no conditionals, no arithmetic. That deliberate dumbness is what keeps an installed tile auditable at a glance. Scopes:

  • In source.url and source.headers: {{config.*}} and {{secret.*}} — secrets are injected app-side into the outgoing request only.
  • In view: {{data.*}} (the selected response), {{item.*}} (the current element, inside list rows), and {{config.*}}. {{secret.*}} is forbidden anywhere in view — a credential can never reach the screen.
Objects render as blank, on purpose. A placeholder that resolves to an object or array renders as an empty string rather than [object Object] — if your tile shows a blank, your select path stopped one level too early.

There's no local sideloading for tiles yet — the road to running your tile is publishing it, which is also what keeps every installed tile a reviewed one.

Marketplace

Publishing

The marketplace is curated: humans approve visualizers and tiles before anyone can install them, and the app cryptographically verifies everything it downloads. Here's the whole road:

  1. Make an account. Register with your email on the marketplace server (POST /auth/register, verify, POST /auth/login for your token) and choose a handle — published work carries real attribution, so a handle is required before you can submit.
  2. Submit. POST /submissions with your session token and a JSON body: kind ("visualizer" | "tile" | "preset"), manifest (the raw manifest.json text), and code (your main.js or view.json text) or preset_json. Optionally preview: a base64 PNG or JPEG thumbnail, up to 256 KiB.
  3. Pass the static gates. Instant, automatic checks: manifest and view schema, permission grammar, a 256 KB code cap, and a hard rejection of eval( and new Function — as literal substrings, so keep them out of your comments too.
  4. Review. Presets auto-approve after validation. Visualizers and tiles wait in a human review queue (an AI report advises the reviewer; it never approves anything). Version updates are reviewed as a diff against your previously approved code.
  5. Ship. On approval the server packages your bundle, hashes it, and adds it to a signed index. Every Ficus install verifies the index signature and your bundle's SHA-256 before trusting either — and shows users your exact permission list before install.

To update, bump version and submit again — every published id@version is immutable, so a resubmission at the same version is rejected. Your id is yours: nobody else can ever publish under it.

Field notes

Sharp edges

Honest notes from building the sixty-plus official bundles. Each of these has bitten someone.

  • Guard the degenerate frames. f.ctx is null after you take a WebGL context, and f.size can be 0×0 mid-layout. The first two lines of every official bundle are those two guards.
  • Don't re-smooth the spectrum. The host already applies sensitivity and smoothing before your callback. Layering your own EMA on top makes your visualizer feel laggy compared to every built-in.
  • Scale motion by f.dt. It's the seconds since the last frame, capped at 0.25 — use it and your visualizer moves at the same speed at 30 and 120 FPS.
  • Versions: digits and dots only. 1.0.0, never 1.0.0-beta — hyphenated versions break the update pipeline.
  • eval( fails even in a comment. The submission gate rejects the literal substrings eval( and new Function wherever they appear in your file.
  • Secrets and permissions come in pairs. Every secrets entry needs a matching secret:<key> permission and every secret: permission needs a matching entry — the manifest is rejected otherwise, because the install dialog must be able to show users exactly what they're approving.
  • surface typos are rejected, not defaulted. By design: a typo should fail loudly at validation, not render a blank canvas you can't diagnose.
  • Stereo isn't guaranteed. Even with "stereo": true, f.waveformL/R appear only once live stereo frames arrive. Always write f.waveformL || f.waveform.
  • Theme colors are an input, not a suggestion. Bundles that hardcode their palette look broken the moment a user picks a different accent. Read f.theme every frame — it can change while you're running.

Something here doesn't match what the app does? That's a bug in the docs — tell us.