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

Portraits step 3: persona panel Portrait section + per-hero caching

Browse files

- New "Portrait" section in the hero panel: an editable appearance prompt (auto-
built from name/class/about) and a 🎨 paint button that generates a portrait via
the active image provider (local Z-Image or cloud FLUX), shown beneath it.
- Reuses the voice spinner/badge UX (now generalized to .persona-ico): the button
badges when there's no portrait or the appearance changed, spins while painting.
- Per-hero PNG cached in IndexedDB (new 'portraits' store, DB v2) and the appearance
prompt persisted, so portraits survive a refresh and live in the barracks.

Verified locally: section renders with auto-appearance; 🎨 paints a 1024² portrait
(~15 s on the 3090) that renders + persists across reload; badge clears when current.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files changed (3) hide show
  1. web/personaPanel.js +63 -5
  2. web/personaStore.js +23 -11
  3. web/shell/persona.css +17 -5
web/personaPanel.js CHANGED
@@ -11,7 +11,8 @@ import {
11
  createVoiceWav, cloneVoiceWav, playWav, synthVoiceWav, speakVoiceLive, stopVoiceLive,
12
  activeEngineIsDesign, activeEngineIsNative, activeVoices, activeDefaultVoice, onTtsEngineChange,
13
  } from '/web/tts.js'
14
- import { listPersonas, savePersona, removePersona, onRosterChange, putAudio, getAudio } from '/web/personaStore.js'
 
15
 
16
  const CLASSES = ['Warrior', 'Ranger', 'Monk', 'Assassin', 'Mage', 'Paladin', 'Cleric', 'Knight']
17
  const MAX_TOKENS = 200 // persona JSON + a voice line + a quote
@@ -155,6 +156,12 @@ export function mountPersonaPanel(host) {
155
  // needed and replays it otherwise. A pulsing badge shows when there's no voice yet, or
156
  // the quote/voice was edited since the last one was made.
157
  const playBtn = el('button', { class: 'persona-ico persona-play', type: 'button', title: 'Play voice' }, '▶')
 
 
 
 
 
 
158
  const thinkEl = el('pre', { class: 'persona-think' })
159
  const copyBtn = el('button', { class: 'persona-copy', type: 'button' }, '📋 Copy debug')
160
  const thinkWrap = el('details', { class: 'persona-think-wrap' },
@@ -176,6 +183,7 @@ export function mountPersonaPanel(host) {
176
  secHead('About'), aboutEl,
177
  secHead('Quote', playBtn), quoteEl,
178
  secHead('Voice design'), voiceEl, voicePickRow,
 
179
  ])
180
  const result = el('div', { class: 'persona-result' }, [emptyEl, bodyEl, thinkWrap])
181
  host.appendChild(el('div', { class: 'persona-view' }, [controls, result]))
@@ -183,6 +191,9 @@ export function mountPersonaPanel(host) {
183
  let lastPersona = null // the persona currently shown
184
  let savedId = null // its roster id (set the moment it's shown — always saved)
185
  let hasVoice = false // a cached voice file exists for this persona
 
 
 
186
  let working = false
187
  let busy = false
188
  let playing = false // audio is currently sounding (▶ becomes ⏹)
@@ -286,6 +297,49 @@ export function mountPersonaPanel(host) {
286
  editable(quoteEl, 'quote', { single: true })
287
  editable(voiceEl, 'voice')
288
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
  async function showPersona(p, opts = {}) {
290
  stopVoice() // picking another hero cuts the current voice
291
  lastPersona = { ...p }
@@ -294,8 +348,12 @@ export function mountPersonaPanel(host) {
294
  aboutEl.textContent = p.about || ''
295
  quoteEl.textContent = p.quote || ''
296
  voiceEl.textContent = p.voice || ''
 
297
  hasVoice = savedId ? !!(await getAudio(savedId)) : false
298
- refreshVoiceMode(); updateVoiceUI(); refreshVisibility()
 
 
 
299
  }
300
 
301
  // ▶ The one voice button: if the cached voice is current, just replay it. If the voice
@@ -409,9 +467,9 @@ export function mountPersonaPanel(host) {
409
  busy = true; btn.disabled = true; refreshVisibility()
410
  if (window.innerWidth <= 768) result.scrollIntoView({ behavior: 'smooth', block: 'start' })
411
  nameEl.textContent = '…'; aboutEl.textContent = ''
412
- quoteEl.textContent = ''; voiceEl.textContent = ''
413
- lastPersona = null; savedId = null; hasVoice = false
414
- stopVoice(); updateVoiceUI()
415
  thinkEl.textContent = ''; thinkWrap.open = true; stats.textContent = ''
416
  let acc = ''
417
  try {
 
11
  createVoiceWav, cloneVoiceWav, playWav, synthVoiceWav, speakVoiceLive, stopVoiceLive,
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
 
156
  // needed and replays it otherwise. A pulsing badge shows when there's no voice yet, or
157
  // the quote/voice was edited since the last one was made.
158
  const playBtn = el('button', { class: 'persona-ico persona-play', type: 'button', title: 'Play voice' }, '▶')
159
+ // Portrait: an editable appearance prompt + a 🎨 button that paints it (Z-Image/FLUX),
160
+ // cached per hero. Badge pulses when there's no portrait yet or the appearance changed.
161
+ const appearanceEl = el('div', { class: 'persona-appearance persona-edit', 'data-ph': 'How they look…' })
162
+ const portraitBtn = el('button', { class: 'persona-ico persona-portrait-btn', type: 'button', title: 'Paint portrait' }, '🎨')
163
+ const portraitImg = el('img', { class: 'persona-portrait-img', alt: '' })
164
+ const portraitWrap = el('div', { class: 'persona-portrait-wrap' }, [portraitImg])
165
  const thinkEl = el('pre', { class: 'persona-think' })
166
  const copyBtn = el('button', { class: 'persona-copy', type: 'button' }, '📋 Copy debug')
167
  const thinkWrap = el('details', { class: 'persona-think-wrap' },
 
183
  secHead('About'), aboutEl,
184
  secHead('Quote', playBtn), quoteEl,
185
  secHead('Voice design'), voiceEl, voicePickRow,
186
+ secHead('Portrait', portraitBtn), appearanceEl, portraitWrap,
187
  ])
188
  const result = el('div', { class: 'persona-result' }, [emptyEl, bodyEl, thinkWrap])
189
  host.appendChild(el('div', { class: 'persona-view' }, [controls, result]))
 
191
  let lastPersona = null // the persona currently shown
192
  let savedId = null // its roster id (set the moment it's shown — always saved)
193
  let hasVoice = false // a cached voice file exists for this persona
194
+ let hasPortrait = false // a cached portrait exists for this persona
195
+ let portraitBusy = false
196
+ let portraitUrl = null // object URL for the shown image (revoked on replace)
197
  let working = false
198
  let busy = false
199
  let playing = false // audio is currently sounding (▶ becomes ⏹)
 
297
  editable(quoteEl, 'quote', { single: true })
298
  editable(voiceEl, 'voice')
299
 
300
+ // ── Portrait ──────────────────────────────────────────────────────────────
301
+ const PORTRAIT_STYLE = 'Fantasy hero character portrait, painterly digital art, dramatic lighting, head and shoulders, detailed face, plain background.'
302
+ const buildAppearance = (p) => [
303
+ [p.name, p.unitClass && `a ${p.unitClass}`].filter(Boolean).join(', '),
304
+ (p.about || '').trim(),
305
+ ].filter(Boolean).join('. ')
306
+ const appearanceFor = (p) => (p.appearance || '').trim() || buildAppearance(p)
307
+ const portraitDirty = () => hasPortrait && lastPersona && appearanceFor(lastPersona) !== (lastPersona.portraitUsed || '')
308
+
309
+ function setPortrait(blob) {
310
+ if (portraitUrl) { try { URL.revokeObjectURL(portraitUrl) } catch { /* ignore */ } }
311
+ portraitUrl = blob ? URL.createObjectURL(blob) : null
312
+ portraitImg.src = portraitUrl || ''
313
+ portraitWrap.classList.toggle('has-img', !!blob)
314
+ }
315
+ function updatePortraitUI() {
316
+ portraitBtn.classList.toggle('badged', !!lastPersona && (!hasPortrait || portraitDirty()))
317
+ portraitBtn.title = !hasPortrait ? 'Paint portrait' : 'Repaint portrait'
318
+ }
319
+ // Make the appearance field click-to-edit (drives the prompt + the badge).
320
+ appearanceEl.contentEditable = 'true'; appearanceEl.spellcheck = false
321
+ appearanceEl.addEventListener('input', () => { if (lastPersona) { lastPersona.appearance = appearanceEl.textContent.trim(); updatePortraitUI() } })
322
+ appearanceEl.addEventListener('blur', () => { if (!lastPersona) return; lastPersona.appearance = appearanceEl.textContent.trim(); autosave(); updatePortraitUI() })
323
+
324
+ // 🎨 Generate (or regenerate) the portrait from the appearance prompt; cache + persist.
325
+ async function makePortrait() {
326
+ if (portraitBusy || !lastPersona) return
327
+ autosave() // ensure an id to key the image
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
336
+ hasPortrait = true; setPortrait(blob); autosave()
337
+ status.textContent = prev
338
+ } catch (e) { status.textContent = `portrait failed: ${e.message || e}` }
339
+ finally { portraitBusy = false; portraitBtn.classList.remove('busy'); portraitBtn.disabled = false; updatePortraitUI() }
340
+ }
341
+ portraitBtn.addEventListener('click', makePortrait)
342
+
343
  async function showPersona(p, opts = {}) {
344
  stopVoice() // picking another hero cuts the current voice
345
  lastPersona = { ...p }
 
348
  aboutEl.textContent = p.about || ''
349
  quoteEl.textContent = p.quote || ''
350
  voiceEl.textContent = p.voice || ''
351
+ appearanceEl.textContent = p.appearance || buildAppearance(p)
352
  hasVoice = savedId ? !!(await getAudio(savedId)) : false
353
+ let pblob = null
354
+ hasPortrait = savedId ? !!(pblob = await getPortrait(savedId)) : false
355
+ setPortrait(pblob)
356
+ refreshVoiceMode(); updateVoiceUI(); updatePortraitUI(); refreshVisibility()
357
  }
358
 
359
  // ▶ The one voice button: if the cached voice is current, just replay it. If the voice
 
467
  busy = true; btn.disabled = true; refreshVisibility()
468
  if (window.innerWidth <= 768) result.scrollIntoView({ behavior: 'smooth', block: 'start' })
469
  nameEl.textContent = '…'; aboutEl.textContent = ''
470
+ quoteEl.textContent = ''; voiceEl.textContent = ''; appearanceEl.textContent = ''
471
+ lastPersona = null; savedId = null; hasVoice = false; hasPortrait = false
472
+ stopVoice(); setPortrait(null); updateVoiceUI(); updatePortraitUI()
473
  thinkEl.textContent = ''; thinkWrap.open = true; stats.textContent = ''
474
  let acc = ''
475
  try {
web/personaStore.js CHANGED
@@ -50,6 +50,10 @@ export function savePersona(p) {
50
  voiceQuote: p.voiceQuote || '',
51
  voiceDesignUsed: p.voiceDesignUsed || '',
52
  voiceIdUsed: p.voiceIdUsed || '',
 
 
 
 
53
  createdAt: now,
54
  updatedAt: now,
55
  }
@@ -64,35 +68,43 @@ export function removePersona(id) {
64
  const d = read()
65
  const next = d.personas.filter((x) => x.id !== id)
66
  if (next.length !== d.personas.length) write({ personas: next })
67
- delAudio(id)
68
  }
69
 
70
- // ── Voice audio store (IndexedDB — WAV blobs are too big for localStorage) ──────
71
- const DB = 'tinyarmy', STORE = 'voices'
72
  let _dbp = null
73
  function db() {
74
  if (!_dbp) {
75
  _dbp = new Promise((res, rej) => {
76
- const r = indexedDB.open(DB, 1)
77
- r.onupgradeneeded = () => { if (!r.result.objectStoreNames.contains(STORE)) r.result.createObjectStore(STORE) }
 
 
 
 
78
  r.onsuccess = () => res(r.result)
79
  r.onerror = () => rej(r.error)
80
  })
81
  }
82
  return _dbp
83
  }
84
- export async function putAudio(id, blob) {
85
  try {
86
  const d = await db()
87
- await new Promise((res, rej) => { const t = d.transaction(STORE, 'readwrite'); t.objectStore(STORE).put(blob, id); t.oncomplete = res; t.onerror = () => rej(t.error) })
88
  } catch { /* best-effort */ }
89
  }
90
- export async function getAudio(id) {
91
  try {
92
  const d = await db()
93
- return await new Promise((res) => { const t = d.transaction(STORE, 'readonly'); const q = t.objectStore(STORE).get(id); q.onsuccess = () => res(q.result || null); q.onerror = () => res(null) })
94
  } catch { return null }
95
  }
96
- async function delAudio(id) {
97
- try { const d = await db(); d.transaction(STORE, 'readwrite').objectStore(STORE).delete(id) } catch { /* ignore */ }
98
  }
 
 
 
 
 
50
  voiceQuote: p.voiceQuote || '',
51
  voiceDesignUsed: p.voiceDesignUsed || '',
52
  voiceIdUsed: p.voiceIdUsed || '',
53
+ // Portrait: the editable appearance prompt + what the cached image (IndexedDB) was
54
+ // made from, so a reload knows the image is current vs stale (re-generate it).
55
+ appearance: p.appearance || '',
56
+ portraitUsed: p.portraitUsed || '',
57
  createdAt: now,
58
  updatedAt: now,
59
  }
 
68
  const d = read()
69
  const next = d.personas.filter((x) => x.id !== id)
70
  if (next.length !== d.personas.length) write({ personas: next })
71
+ _del(STORE, id); _del(PSTORE, id)
72
  }
73
 
74
+ // ── Media store (IndexedDB — WAV + PNG blobs are too big for localStorage) ──────
75
+ const DB = 'tinyarmy', STORE = 'voices', PSTORE = 'portraits'
76
  let _dbp = null
77
  function db() {
78
  if (!_dbp) {
79
  _dbp = new Promise((res, rej) => {
80
+ const r = indexedDB.open(DB, 2) // v2 adds the 'portraits' store alongside 'voices'
81
+ r.onupgradeneeded = () => {
82
+ const d = r.result
83
+ if (!d.objectStoreNames.contains(STORE)) d.createObjectStore(STORE)
84
+ if (!d.objectStoreNames.contains(PSTORE)) d.createObjectStore(PSTORE)
85
+ }
86
  r.onsuccess = () => res(r.result)
87
  r.onerror = () => rej(r.error)
88
  })
89
  }
90
  return _dbp
91
  }
92
+ async function _put(store, id, blob) {
93
  try {
94
  const d = await db()
95
+ await new Promise((res, rej) => { const t = d.transaction(store, 'readwrite'); t.objectStore(store).put(blob, id); t.oncomplete = res; t.onerror = () => rej(t.error) })
96
  } catch { /* best-effort */ }
97
  }
98
+ async function _get(store, id) {
99
  try {
100
  const d = await db()
101
+ return await new Promise((res) => { const t = d.transaction(store, 'readonly'); const q = t.objectStore(store).get(id); q.onsuccess = () => res(q.result || null); q.onerror = () => res(null) })
102
  } catch { return null }
103
  }
104
+ async function _del(store, id) {
105
+ try { const d = await db(); d.transaction(store, 'readwrite').objectStore(store).delete(id) } catch { /* ignore */ }
106
  }
107
+ export const putAudio = (id, blob) => _put(STORE, id, blob)
108
+ export const getAudio = (id) => _get(STORE, id)
109
+ export const putPortrait = (id, blob) => _put(PSTORE, id, blob)
110
+ export const getPortrait = (id) => _get(PSTORE, id)
web/shell/persona.css CHANGED
@@ -111,6 +111,18 @@
111
  .persona-voice-pick-row { margin-top: 12px; max-width: 320px; }
112
  .persona-voice-pick-row .persona-label { margin-top: 0; }
113
  .persona-voice-pick { margin-top: 4px; }
 
 
 
 
 
 
 
 
 
 
 
 
114
  .persona-quote {
115
  margin: 8px 0 0; padding: 4px 0 4px 16px; border-left: 3px solid var(--p-transmit);
116
  font-family: 'Fraunces', Georgia, serif; font-size: 21px; font-style: italic;
@@ -127,16 +139,16 @@
127
  }
128
  .persona-ico:hover { background: var(--p-paper-2) !important; }
129
  .persona-ico.busy { cursor: default; }
130
- /* Generating a voice → hide the glyph and spin a small ring in its place. */
131
- .persona-play.busy { color: transparent !important; }
132
- .persona-play.busy::before {
133
  content: ''; position: absolute; inset: 0; margin: auto; width: 11px; height: 11px;
134
  border: 2px solid var(--p-paper-2); border-top-color: var(--p-transmit); border-radius: 50%;
135
  animation: tac-spin .7s linear infinite;
136
  }
137
  @keyframes tac-spin { to { transform: rotate(360deg); } }
138
- /* Badge = "no voice yet, or the quote/voice changed — tap to (re)make it." Pulses. */
139
- .persona-play.badged::after {
140
  content: ''; position: absolute; top: -4px; right: -4px; width: 9px; height: 9px;
141
  background: var(--p-transmit); border: 1.5px solid var(--p-card); border-radius: 50%;
142
  animation: tac-badge-pulse 1.3s ease-out infinite;
 
111
  .persona-voice-pick-row { margin-top: 12px; max-width: 320px; }
112
  .persona-voice-pick-row .persona-label { margin-top: 0; }
113
  .persona-voice-pick { margin-top: 4px; }
114
+
115
+ /* Portrait — an editable appearance prompt + the painted image. */
116
+ .persona-appearance {
117
+ font-family: var(--p-mono); font-size: 12px; line-height: 1.5; color: var(--p-muted);
118
+ max-width: 60ch; margin-top: 8px; font-style: italic;
119
+ }
120
+ .persona-portrait-wrap { margin-top: 12px; }
121
+ .persona-portrait-wrap:not(.has-img) { display: none; }
122
+ .persona-portrait-img {
123
+ width: 320px; max-width: 100%; aspect-ratio: 1 / 1; object-fit: cover; display: block;
124
+ border: 1.5px solid var(--p-ink); box-shadow: 4px 4px 0 var(--p-transmit); background: var(--p-card);
125
+ }
126
  .persona-quote {
127
  margin: 8px 0 0; padding: 4px 0 4px 16px; border-left: 3px solid var(--p-transmit);
128
  font-family: 'Fraunces', Georgia, serif; font-size: 21px; font-style: italic;
 
139
  }
140
  .persona-ico:hover { background: var(--p-paper-2) !important; }
141
  .persona-ico.busy { cursor: default; }
142
+ /* Working (voice/portrait) → hide the glyph and spin a small ring in its place. */
143
+ .persona-ico.busy { color: transparent !important; }
144
+ .persona-ico.busy::before {
145
  content: ''; position: absolute; inset: 0; margin: auto; width: 11px; height: 11px;
146
  border: 2px solid var(--p-paper-2); border-top-color: var(--p-transmit); border-radius: 50%;
147
  animation: tac-spin .7s linear infinite;
148
  }
149
  @keyframes tac-spin { to { transform: rotate(360deg); } }
150
+ /* Badge = "nothing made yet, or the inputs changed — tap to (re)make it." Pulses. */
151
+ .persona-ico.badged::after {
152
  content: ''; position: absolute; top: -4px; right: -4px; width: 9px; height: 9px;
153
  background: var(--p-transmit); border: 1.5px solid var(--p-card); border-radius: 50%;
154
  animation: tac-badge-pulse 1.3s ease-out infinite;