polats Claude Opus 4.8 (1M context) commited on
Commit
69e8c08
·
1 Parent(s): 6a861b9

Portraits step 4: in-browser Janus-Pro-1B (WebGPU) portrait engine

Browse files

Adds 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>

Files changed (3) hide show
  1. web/imagen.js +2 -1
  2. web/imagenJanus.js +54 -0
  3. 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