Spaces:
Running
Running
Portraits step 4: in-browser Janus-Pro-1B (WebGPU) portrait engine
Browse filesAdds web/imagenJanus.js — Janus-Pro-1B (DeepSeek, MIT) via Transformers.js +
WebGPU, the SAME runtime our LLM/Kokoro engines use. Runs on the device (no
cloud); generate_images → RawImage.toBlob() → PNG. WebGPU-required, fp16 weights
where shader-f16 is available else fp32, prepare-inputs on WASM.
Wired into the imagen.js engine list and the Settings Portrait picker (selectable
alongside Z-Image-local + cloud FLUX). The persona panel now shows one-time
download progress for in-browser engines before painting.
Verified: appears + enabled in the picker. On-device generation needs a real
WebGPU browser (not the SwiftShader headless one).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- web/imagen.js +2 -1
- web/imagenJanus.js +54 -0
- web/personaPanel.js +7 -2
web/imagen.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
| 2 |
// GPU, or cloud FLUX; in-browser SD-Turbo / Janus get added here later) and exposes one
|
| 3 |
// generatePortrait(). The persona panel + the Settings image bar import only from here.
|
| 4 |
import { engineLocal as zimagelocal, engineCloud as flux, engineCloudDev as fluxdev, isLocalhost } from '/web/imagenServer.js'
|
|
|
|
| 5 |
|
| 6 |
-
const ENGINES = [zimagelocal, flux, fluxdev]
|
| 7 |
// Default: local Z-Image on localhost (your GPU), cloud FLUX in prod. Persisted across
|
| 8 |
// refreshes; a saved choice wins if it's still available.
|
| 9 |
const KEY = 'tinyarmy.imageEngine'
|
|
|
|
| 2 |
// GPU, or cloud FLUX; in-browser SD-Turbo / Janus get added here later) and exposes one
|
| 3 |
// generatePortrait(). The persona panel + the Settings image bar import only from here.
|
| 4 |
import { engineLocal as zimagelocal, engineCloud as flux, engineCloudDev as fluxdev, isLocalhost } from '/web/imagenServer.js'
|
| 5 |
+
import { engine as janus } from '/web/imagenJanus.js'
|
| 6 |
|
| 7 |
+
const ENGINES = [zimagelocal, janus, flux, fluxdev]
|
| 8 |
// Default: local Z-Image on localhost (your GPU), cloud FLUX in prod. Persisted across
|
| 9 |
// refreshes; a saved choice wins if it's still available.
|
| 10 |
const KEY = 'tinyarmy.imageEngine'
|
web/imagenJanus.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// In-browser image engine: Janus-Pro-1B (DeepSeek, MIT) via Transformers.js + WebGPU —
|
| 2 |
+
// the SAME runtime our LLM transformers engine + Kokoro already use. Runs on the device,
|
| 3 |
+
// no cloud. WebGPU-required (a small WASM stage prepares inputs). generate() → PNG Blob.
|
| 4 |
+
const MODEL_ID = 'onnx-community/Janus-Pro-1B-ONNX'
|
| 5 |
+
|
| 6 |
+
let _lib = null, _proc = null, _model = null, _loadP = null
|
| 7 |
+
async function lib() { if (!_lib) _lib = await import('https://cdn.jsdelivr.net/npm/@huggingface/transformers@3'); return _lib }
|
| 8 |
+
|
| 9 |
+
// shader-f16 lets us use the smaller/faster fp16 weights; else fall back to fp32.
|
| 10 |
+
async function fp16Supported() {
|
| 11 |
+
try { const a = navigator.gpu && await navigator.gpu.requestAdapter(); return !!(a && a.features && a.features.has('shader-f16')) } catch { return false }
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
async function ensure(onProgress) {
|
| 15 |
+
if (_proc && _model) return
|
| 16 |
+
if (_loadP) return _loadP
|
| 17 |
+
_loadP = (async () => {
|
| 18 |
+
const { AutoProcessor, MultiModalityCausalLM } = await lib()
|
| 19 |
+
const prog = (p) => { if (onProgress && p.status === 'progress' && p.total) onProgress(p.loaded / p.total) }
|
| 20 |
+
const proc = await AutoProcessor.from_pretrained(MODEL_ID, { progress_callback: prog })
|
| 21 |
+
const fp16 = await fp16Supported()
|
| 22 |
+
// Per-component placement: the input-embed prep runs on WASM, the rest on WebGPU.
|
| 23 |
+
const dtype = fp16
|
| 24 |
+
? { prepare_inputs_embeds: 'q4', language_model: 'q4f16', lm_head: 'fp16', gen_head: 'fp16', gen_img_embeds: 'fp16', image_decode: 'fp32' }
|
| 25 |
+
: { prepare_inputs_embeds: 'fp32', language_model: 'q4', lm_head: 'fp32', gen_head: 'fp32', gen_img_embeds: 'fp32', image_decode: 'fp32' }
|
| 26 |
+
const device = { prepare_inputs_embeds: 'wasm', language_model: 'webgpu', lm_head: 'webgpu', gen_head: 'webgpu', gen_img_embeds: 'webgpu', image_decode: 'webgpu' }
|
| 27 |
+
const model = await MultiModalityCausalLM.from_pretrained(MODEL_ID, { dtype, device, progress_callback: prog })
|
| 28 |
+
_proc = proc; _model = model
|
| 29 |
+
})().catch((e) => { _loadP = null; throw e })
|
| 30 |
+
return _loadP
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
async function generate(prompt) {
|
| 34 |
+
await ensure()
|
| 35 |
+
const conversation = [{ role: '<|User|>', content: prompt }]
|
| 36 |
+
const inputs = await _proc(conversation, { chat_template: 'text_to_image' })
|
| 37 |
+
const n = _proc.num_image_tokens
|
| 38 |
+
const outputs = await _model.generate_images({ ...inputs, min_new_tokens: n, max_new_tokens: n, do_sample: true })
|
| 39 |
+
return outputs[0].toBlob() // RawImage → PNG Blob
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export const engine = {
|
| 43 |
+
id: 'janus',
|
| 44 |
+
label: 'Janus-Pro-1B · in-browser (WebGPU)',
|
| 45 |
+
// WebGPU-only; mirror the WebLLM check (gpu object present). ensure() fails gracefully
|
| 46 |
+
// and the picker disables it where there's genuinely no WebGPU.
|
| 47 |
+
available: () => { try { return !!navigator.gpu } catch { return false } },
|
| 48 |
+
note: 'needs WebGPU',
|
| 49 |
+
needsDownload: true,
|
| 50 |
+
networked: false,
|
| 51 |
+
ensure,
|
| 52 |
+
generate: (prompt, _opts = {}) => generate(prompt), // Janus is prompt-only (no seed/size)
|
| 53 |
+
backendLabel: () => '⚡ WebGPU · Janus-Pro',
|
| 54 |
+
}
|
web/personaPanel.js
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
| 12 |
activeEngineIsDesign, activeEngineIsNative, activeVoices, activeDefaultVoice, onTtsEngineChange,
|
| 13 |
} from '/web/tts.js'
|
| 14 |
import { listPersonas, savePersona, removePersona, onRosterChange, putAudio, getAudio, putPortrait, getPortrait } from '/web/personaStore.js'
|
| 15 |
-
import { generatePortrait, imageBackendLabel } from '/web/imagen.js'
|
| 16 |
|
| 17 |
const CLASSES = ['Warrior', 'Ranger', 'Monk', 'Assassin', 'Mage', 'Paladin', 'Cleric', 'Knight']
|
| 18 |
const MAX_TOKENS = 200 // persona JSON + a voice line + a quote
|
|
@@ -328,8 +328,13 @@ export function mountPersonaPanel(host) {
|
|
| 328 |
const appearance = appearanceFor(lastPersona)
|
| 329 |
portraitBusy = true; portraitBtn.classList.add('busy'); portraitBtn.disabled = true
|
| 330 |
const prev = status.textContent
|
| 331 |
-
status.textContent = `painting with ${imageBackendLabel()}…`
|
| 332 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
const blob = await generatePortrait(`${appearance}. ${PORTRAIT_STYLE}`, { seed: 42 })
|
| 334 |
await putPortrait(savedId, blob)
|
| 335 |
lastPersona.appearance = appearance; lastPersona.portraitUsed = appearance
|
|
|
|
| 12 |
activeEngineIsDesign, activeEngineIsNative, activeVoices, activeDefaultVoice, onTtsEngineChange,
|
| 13 |
} from '/web/tts.js'
|
| 14 |
import { listPersonas, savePersona, removePersona, onRosterChange, putAudio, getAudio, putPortrait, getPortrait } from '/web/personaStore.js'
|
| 15 |
+
import { generatePortrait, imageBackendLabel, imageNeedsDownload, ensureImage } from '/web/imagen.js'
|
| 16 |
|
| 17 |
const CLASSES = ['Warrior', 'Ranger', 'Monk', 'Assassin', 'Mage', 'Paladin', 'Cleric', 'Knight']
|
| 18 |
const MAX_TOKENS = 200 // persona JSON + a voice line + a quote
|
|
|
|
| 328 |
const appearance = appearanceFor(lastPersona)
|
| 329 |
portraitBusy = true; portraitBtn.classList.add('busy'); portraitBtn.disabled = true
|
| 330 |
const prev = status.textContent
|
|
|
|
| 331 |
try {
|
| 332 |
+
// In-browser engines (Janus) download the model on first use — show progress.
|
| 333 |
+
if (imageNeedsDownload()) {
|
| 334 |
+
status.textContent = 'loading portrait model…'
|
| 335 |
+
await ensureImage((f) => { status.textContent = `downloading portrait model… ${Math.round(f * 100)}% (one-time)` })
|
| 336 |
+
}
|
| 337 |
+
status.textContent = `painting with ${imageBackendLabel()}…`
|
| 338 |
const blob = await generatePortrait(`${appearance}. ${PORTRAIT_STYLE}`, { seed: 42 })
|
| 339 |
await putPortrait(savedId, blob)
|
| 340 |
lastPersona.appearance = appearance; lastPersona.portraitUsed = appearance
|