ReallyFloppyPenguin commited on
Commit
ec59de0
·
verified ·
1 Parent(s): 8305ea5

Live 3D preview while generating, spinner + timing stats

Browse files
Files changed (1) hide show
  1. app.py +136 -40
app.py CHANGED
@@ -1,8 +1,8 @@
1
  import ctypes
2
  import glob
3
- import html
4
  import re
5
  import site
 
6
 
7
  import gradio as gr
8
  import spaces
@@ -61,13 +61,6 @@ def extract_html(text):
61
  return m.group(0) if m else text.strip()
62
 
63
 
64
- def iframe(code):
65
- return (
66
- f'<iframe sandbox="allow-scripts" srcdoc="{html.escape(code, quote=True)}" '
67
- 'style="width:100%;height:640px;border:0;border-radius:8px;background:#000"></iframe>'
68
- )
69
-
70
-
71
  # prefill the code fence so the model skips chatty preambles ("I cannot create files...")
72
  NO_THINK = "<think>\n\n</think>\n\n```html\n"
73
 
@@ -76,16 +69,19 @@ NO_THINK = "<think>\n\n</think>\n\n```html\n"
76
  @spaces.GPU(duration=107)
77
  def generate(prompt, thinking, max_tokens, temperature, top_p, top_k):
78
  global llm
 
79
  if llm is None:
80
  from llama_cpp import Llama # import inside GPU worker so CUDA is available
81
 
82
  llm = Llama(MODEL_PATH, n_gpu_layers=-1, n_ctx=32768, flash_attn=True, verbose=False)
 
83
  # ponytail: hand-built ChatML so thinking can be toggled; switch to create_chat_completion if template kwargs land
84
  text = (
85
  f"<|im_start|>system\n{SYSTEM_PROMPT}<|im_end|>\n<|im_start|>user\n{prompt}<|im_end|>\n<|im_start|>assistant\n"
86
  + ("<think>\n" if thinking else NO_THINK)
87
  )
88
  out = "<think>\n" if thinking else "```html\n"
 
89
  for chunk in llm(
90
  text,
91
  max_tokens=int(max_tokens),
@@ -97,18 +93,104 @@ def generate(prompt, thinking, max_tokens, temperature, top_p, top_k):
97
  stream=True,
98
  ):
99
  out += chunk["choices"][0]["text"]
100
- yield out
101
- yield out
 
 
 
 
 
 
 
 
 
 
102
 
103
 
104
  GPU, WEB = "ZeroGPU (server)", "WebGPU (your browser)"
105
 
106
- # Same prompt/extract/iframe flow as the server path, but llama.cpp runs in the browser via wllama.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  WEBGPU_JS = r"""
108
  async (prompt, thinking, maxTokens, temperature, topP, topK) => {
 
109
  const st = document.querySelector('#wg-status');
110
- const say = (h) => { st.innerHTML = h; };
111
  const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
 
 
 
 
 
 
 
 
 
112
  try {
113
  window.wg ??= (async () => {
114
  const V = 'https://cdn.jsdelivr.net/npm/@wllama/wllama@3.8.1/esm/';
@@ -116,58 +198,66 @@ async (prompt, thinking, maxTokens, temperature, topP, topK) => {
116
  const w = new Wllama({ default: V + 'wasm/wllama.wasm' });
117
  const load = () => w.loadModelFromHF({ repo: '__REPO__', quant: 'Q4_K_M' }, {
118
  n_ctx: 18432, n_gpu_layers: 999,
119
- progressCallback: ({ loaded, total }) => say(
120
- `Downloading model (cached after first run): ${(loaded / 1e9).toFixed(2)} / ${(total / 1e9).toFixed(2)} GB`),
 
121
  });
122
  // an interrupted download leaves a partial cache that wllama won't repair; wipe it and retry once
123
- try { await load(); } catch (e) { say('Model cache incomplete, re-downloading...'); await w.cacheManager.clear(); await load(); }
124
  return w;
125
  })().catch((e) => { window.wg = null; throw e; });
126
- if (!navigator.gpu) say('<b>No WebGPU in this browser, falling back to CPU (very slow).</b> Use Chrome/Edge 113+.');
127
  const w = await window.wg;
 
 
128
  const text = `<|im_start|>system\n__SYSTEM__<|im_end|>\n<|im_start|>user\n${prompt}<|im_end|>\n<|im_start|>assistant\n`
129
  + (thinking ? '<think>\n' : '__NO_THINK__');
130
- let out = thinking ? '<think>\n' : '```html\n', n = 0;
131
- const t0 = performance.now();
132
  const it = await w.createCompletion({ prompt: text, max_tokens: maxTokens, temperature, top_p: topP, top_k: topK,
133
  penalty_repeat: 1.0, stop: ['<|im_end|>'], stream: true });
134
  for await (const c of it) {
135
  out += c.choices[0].text;
136
- if (++n % 8 === 0) say(`Generating in your browser: ${n} tokens, ${(n * 1000 / (performance.now() - t0)).toFixed(1)} tok/s`
137
- + `<pre style="white-space:pre-wrap;max-height:200px;overflow:auto">${esc(out.slice(-800))}</pre>`);
 
 
138
  }
139
- const body = out.replace(/<think>[\s\S]*?(<\/think>|$)/g, '');
140
- let m = body.match(/```html\s*([\s\S]*?)(```|$)/);
141
- const code = m ? m[1].trim() : ((m = body.match(/<!DOCTYPE html[\s\S]*?(<\/html>|$)/i)) ? m[0] : body.trim());
142
- const url = URL.createObjectURL(new Blob([code], { type: 'text/html' }));
143
- say(`Done: ${n} tokens. <a href="${url}" download="scene.html">Download scene.html</a>`);
144
- return [out, `<iframe sandbox="allow-scripts" srcdoc="${esc(code)}" `
145
- + 'style="width:100%;height:640px;border:0;border-radius:8px;background:#000"></iframe>'];
 
146
  } catch (e) {
147
- say(`<b>WebGPU error:</b> ${esc(String(e))}. Try the ZeroGPU backend.`);
148
- return ['', ''];
149
  }
 
150
  }
151
- """.replace("__REPO__", "ReallyFloppyPenguin/Qwen3.5-4B-3D-Coder-GGUF-split").replace(
152
  "__SYSTEM__", SYSTEM_PROMPT.replace("\n", "\\n")
153
  ).replace("__NO_THINK__", NO_THINK.replace("\n", "\\n").replace("`", "\\`"))
154
 
155
 
156
  def run(prompt, thinking, max_tokens, temperature, top_p, top_k):
157
- raw = ""
158
- for raw in generate(prompt, thinking, max_tokens, temperature, top_p, top_k):
159
- yield raw, gr.update(), None
160
- code = extract_html(raw)
 
 
161
  with open("scene.html", "w", encoding="utf-8") as f:
162
- f.write(code)
163
- yield raw, iframe(code), "scene.html"
164
 
165
 
166
  with gr.Blocks(title="3D World Generator (WebGPU)") as demo:
167
  gr.Markdown(
168
  "# 3D World Generator (WebGPU)\nDescribe a world; "
169
  "[ccore/Qwen3.5-4B-3D-Coder](https://huggingface.co/ccore/Qwen3.5-4B-3D-Coder) writes a self-contained "
170
- "Three.js scene. Drag/scroll in the preview to move the camera (controls are model-written, so they vary).\n\n"
 
171
  "**WebGPU** runs the model in your browser (2.7 GB download once, then cached; needs Chrome/Edge 113+). "
172
  "**ZeroGPU** runs it on the server."
173
  )
@@ -186,13 +276,19 @@ with gr.Blocks(title="3D World Generator (WebGPU)") as demo:
186
  top_k = gr.Slider(1, 100, value=20, step=1, label="Top K")
187
  gr.Examples(EXAMPLES, inputs=prompt, example_labels=EXAMPLE_LABELS, label="Examples from the model card")
188
  with gr.Column(scale=2):
189
- preview = gr.HTML(label="Preview")
 
 
 
 
190
  download = gr.File(label="Download scene.html")
191
  with gr.Accordion("Raw model output", open=False):
192
  code = gr.Code(language="html", label="Output")
193
  inputs = [prompt, thinking, max_tokens, temperature, top_p, top_k]
194
- btn.click(run, inputs, [code, preview, download])
195
- web_btn.click(None, inputs, [code, preview], js=WEBGPU_JS)
 
 
196
  backend.change(lambda b: (gr.update(visible=b == GPU), gr.update(visible=b == WEB)), backend, [btn, web_btn])
197
 
198
  demo.queue().launch()
 
1
  import ctypes
2
  import glob
 
3
  import re
4
  import site
5
+ import time
6
 
7
  import gradio as gr
8
  import spaces
 
61
  return m.group(0) if m else text.strip()
62
 
63
 
 
 
 
 
 
 
 
64
  # prefill the code fence so the model skips chatty preambles ("I cannot create files...")
65
  NO_THINK = "<think>\n\n</think>\n\n```html\n"
66
 
 
69
  @spaces.GPU(duration=107)
70
  def generate(prompt, thinking, max_tokens, temperature, top_p, top_k):
71
  global llm
72
+ t0 = time.perf_counter()
73
  if llm is None:
74
  from llama_cpp import Llama # import inside GPU worker so CUDA is available
75
 
76
  llm = Llama(MODEL_PATH, n_gpu_layers=-1, n_ctx=32768, flash_attn=True, verbose=False)
77
+ t1 = time.perf_counter()
78
  # ponytail: hand-built ChatML so thinking can be toggled; switch to create_chat_completion if template kwargs land
79
  text = (
80
  f"<|im_start|>system\n{SYSTEM_PROMPT}<|im_end|>\n<|im_start|>user\n{prompt}<|im_end|>\n<|im_start|>assistant\n"
81
  + ("<think>\n" if thinking else NO_THINK)
82
  )
83
  out = "<think>\n" if thinking else "```html\n"
84
+ n, t2 = 0, None
85
  for chunk in llm(
86
  text,
87
  max_tokens=int(max_tokens),
 
93
  stream=True,
94
  ):
95
  out += chunk["choices"][0]["text"]
96
+ n += 1
97
+ t2 = t2 or time.perf_counter()
98
+ yield out, f"{n} tokens, {n / (time.perf_counter() - t2 + 1e-9):.1f} tok/s"
99
+ t3 = time.perf_counter()
100
+ t2 = t2 or t3
101
+ # logged so the @spaces.GPU duration can be sized from real runs
102
+ stats = (
103
+ f"GPU time {t3 - t0:.1f}s: model load {t1 - t0:.1f}s, prefill {t2 - t1:.1f}s, "
104
+ f"{n} tokens in {t3 - t2:.1f}s ({n / (t3 - t2 + 1e-9):.1f} tok/s)"
105
+ )
106
+ print(stats, flush=True)
107
+ yield out, stats
108
 
109
 
110
  GPU, WEB = "ZeroGPU (server)", "WebGPU (your browser)"
111
 
112
+ # Shared by both backends: window.wgLive(rawOutput) re-renders the partial scene into #wg-live every few seconds.
113
+ # Unfinished JS is cut back to the last complete-looking line, open brackets are closed and a fallback render loop
114
+ # is appended; the new iframe only replaces the old one once that patched script actually ran (it posts 'wg-ok').
115
+ LIVE_JS = r"""
116
+ window.wgCode ??= (out) => {
117
+ const body = out.replace(/<think>[\s\S]*?(<\/think>|$)/g, '');
118
+ let m = body.match(/```html\s*([\s\S]*?)(```|$)/);
119
+ return m ? m[1].trim() : ((m = body.match(/<!DOCTYPE html[\s\S]*?(<\/html>|$)/i)) ? m[0] : body.trim());
120
+ };
121
+ window.wgLive ??= (() => {
122
+ const TAIL = "\n;try{const R=renderer,S=scene,C=camera;(function f(){try{R.render(S,C)}catch(e){}"
123
+ + "requestAnimationFrame(f)})()}catch(e){}\nparent.postMessage('wg-ok','*');\n</scr" + "ipt>";
124
+ const closers = (s) => {
125
+ const st = [];
126
+ for (let i = 0; i < s.length; i++) {
127
+ const c = s[i];
128
+ if (c === '/' && s[i + 1] === '/') { i = s.indexOf('\n', i); if (i < 0) break; }
129
+ else if (c === '/' && s[i + 1] === '*') { i = s.indexOf('*/', i + 2) + 1; if (i <= 0) break; }
130
+ else if (c === '"' || c === "'" || c === '`') { while (++i < s.length && s[i] !== c) if (s[i] === '\\') i++; }
131
+ else if ('([{'.includes(c)) st.push({ '(': ')', '[': ']', '{': '}' }[c]);
132
+ else if (')]}'.includes(c)) st.pop();
133
+ }
134
+ return st.reverse().join('');
135
+ };
136
+ // -> [html, isPartial], or null when there is no script to run yet
137
+ const patch = (code) => {
138
+ const i = code.lastIndexOf('<script'), j = code.indexOf('>', i) + 1;
139
+ if (i < 0 || !j) return null;
140
+ if (/<\/script>/i.test(code.slice(j))) return [code, false];
141
+ const lines = code.slice(j).split('\n');
142
+ lines.pop();
143
+ while (lines.length && !/[;{},]\s*$/.test(lines.at(-1))) lines.pop();
144
+ const js = lines.join('\n');
145
+ return [code.slice(0, j) + js + '\n' + closers(js) + TAIL, true];
146
+ };
147
+ let latest = '', last = 0, timer = 0, pending = null, shown = '';
148
+ const box = () => document.querySelector('#wg-live');
149
+ const show = (f) => {
150
+ if (pending !== f) return;
151
+ pending = null; shown = f.srcdoc;
152
+ for (const c of [...box().children]) if (c !== f) c.remove();
153
+ f.style.visibility = 'visible';
154
+ };
155
+ addEventListener('message', (e) => { if (pending && e.source === pending.contentWindow && e.data === 'wg-ok') show(pending); });
156
+ const render = () => {
157
+ timer = 0;
158
+ const p = patch(window.wgCode(latest));
159
+ if (!p || p[0] === shown || p[0] === pending?.srcdoc) return;
160
+ if (pending && p[1]) { timer = setTimeout(render, 1000); return; } // let the in-flight partial finish first
161
+ pending?.remove();
162
+ last = performance.now();
163
+ const f = pending = document.createElement('iframe');
164
+ f.sandbox = 'allow-scripts';
165
+ f.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;border:0;visibility:hidden';
166
+ if (!p[1]) f.onload = () => show(f); // final scene: show it even if it errors
167
+ setTimeout(() => { if (pending === f) { f.remove(); pending = null; } }, 15000);
168
+ f.srcdoc = p[0];
169
+ box().append(f);
170
+ };
171
+ return (raw) => {
172
+ latest = raw;
173
+ if (!raw) { clearTimeout(timer); timer = 0; pending = null; shown = ''; box().replaceChildren(); return; }
174
+ timer ||= setTimeout(render, Math.max(0, 5000 - (performance.now() - last)));
175
+ };
176
+ })();
177
+ """
178
+
179
+ # Same prompt/extract flow as the server path, but llama.cpp runs in the browser via wllama.
180
  WEBGPU_JS = r"""
181
  async (prompt, thinking, maxTokens, temperature, topP, topK) => {
182
+ __LIVE__
183
  const st = document.querySelector('#wg-status');
 
184
  const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
185
+ const t0 = performance.now(), sec = (a, b = performance.now()) => ((b - a) / 1000).toFixed(1);
186
+ const S = window.wgS = { phase: 'Loading wllama runtime', tail: '' };
187
+ const cpu = navigator.gpu ? '' : '<br><b>No WebGPU in this browser, running on CPU (very slow).</b> Use Chrome/Edge 113+.';
188
+ let k = 0, out = '', n = 0, tLoad, tFirst;
189
+ const tick = setInterval(() => {
190
+ st.innerHTML = `<b>${'⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'[k++ % 10]} ${S.phase}</b> · ${sec(t0)}s elapsed${cpu}`
191
+ + (S.tail && `<pre style="white-space:pre-wrap;max-height:200px;overflow:auto">${esc(S.tail)}</pre>`);
192
+ }, 100);
193
+ window.wgLive('');
194
  try {
195
  window.wg ??= (async () => {
196
  const V = 'https://cdn.jsdelivr.net/npm/@wllama/wllama@3.8.1/esm/';
 
198
  const w = new Wllama({ default: V + 'wasm/wllama.wasm' });
199
  const load = () => w.loadModelFromHF({ repo: '__REPO__', quant: 'Q4_K_M' }, {
200
  n_ctx: 18432, n_gpu_layers: 999,
201
+ progressCallback: ({ loaded, total }) => { window.wgS.phase = loaded < total
202
+ ? `Downloading model (cached after first run): ${(loaded / 1e9).toFixed(2)} / ${(total / 1e9).toFixed(2)} GB`
203
+ : 'Loading model onto GPU'; },
204
  });
205
  // an interrupted download leaves a partial cache that wllama won't repair; wipe it and retry once
206
+ try { await load(); } catch (e) { window.wgS.phase = 'Model cache incomplete, re-downloading'; await w.cacheManager.clear(); await load(); }
207
  return w;
208
  })().catch((e) => { window.wg = null; throw e; });
 
209
  const w = await window.wg;
210
+ tLoad = performance.now();
211
+ S.phase = 'Reading prompt (prefill)';
212
  const text = `<|im_start|>system\n__SYSTEM__<|im_end|>\n<|im_start|>user\n${prompt}<|im_end|>\n<|im_start|>assistant\n`
213
  + (thinking ? '<think>\n' : '__NO_THINK__');
214
+ out = thinking ? '<think>\n' : '```html\n';
 
215
  const it = await w.createCompletion({ prompt: text, max_tokens: maxTokens, temperature, top_p: topP, top_k: topK,
216
  penalty_repeat: 1.0, stop: ['<|im_end|>'], stream: true });
217
  for await (const c of it) {
218
  out += c.choices[0].text;
219
+ tFirst ??= performance.now();
220
+ S.phase = `Generating in your browser: ${++n} tokens, ${(n * 1000 / (performance.now() - tFirst + 1)).toFixed(1)} tok/s`;
221
+ S.tail = out.slice(-800);
222
+ window.wgLive(out);
223
  }
224
+ const tEnd = performance.now();
225
+ tFirst ??= tEnd;
226
+ const msg = `Done in ${sec(t0, tEnd)}s: model load ${sec(t0, tLoad)}s, prefill ${sec(tLoad, tFirst)}s, `
227
+ + `${n} tokens in ${sec(tFirst, tEnd)}s (${(n * 1000 / (tEnd - tFirst + 1)).toFixed(1)} tok/s)`;
228
+ console.log('[3D World Generator]', msg);
229
+ const url = URL.createObjectURL(new Blob([window.wgCode(out)], { type: 'text/html' }));
230
+ clearInterval(tick);
231
+ st.innerHTML = `${msg}${cpu}<br><a href="${url}" download="scene.html">Download scene.html</a>`;
232
  } catch (e) {
233
+ clearInterval(tick);
234
+ st.innerHTML = `<b>WebGPU error:</b> ${esc(String(e))}. Try the ZeroGPU backend.`;
235
  }
236
+ return [out];
237
  }
238
+ """.replace("__LIVE__", LIVE_JS).replace("__REPO__", "ReallyFloppyPenguin/Qwen3.5-4B-3D-Coder-GGUF-split").replace(
239
  "__SYSTEM__", SYSTEM_PROMPT.replace("\n", "\\n")
240
  ).replace("__NO_THINK__", NO_THINK.replace("\n", "\\n").replace("`", "\\`"))
241
 
242
 
243
  def run(prompt, thinking, max_tokens, temperature, top_p, top_k):
244
+ t0 = time.perf_counter()
245
+ elapsed = lambda: f"{time.perf_counter() - t0:.0f}s elapsed" # noqa: E731
246
+ yield "", None, f"<b>⏳ Waiting for ZeroGPU, loading model</b> · {elapsed()}"
247
+ raw, info = "", ""
248
+ for raw, info in generate(prompt, thinking, max_tokens, temperature, top_p, top_k):
249
+ yield raw, None, f"<b>⏳ Generating on ZeroGPU: {info}</b> · {elapsed()}"
250
  with open("scene.html", "w", encoding="utf-8") as f:
251
+ f.write(extract_html(raw))
252
+ yield raw, "scene.html", f"Done in {time.perf_counter() - t0:.1f}s total. {info}"
253
 
254
 
255
  with gr.Blocks(title="3D World Generator (WebGPU)") as demo:
256
  gr.Markdown(
257
  "# 3D World Generator (WebGPU)\nDescribe a world; "
258
  "[ccore/Qwen3.5-4B-3D-Coder](https://huggingface.co/ccore/Qwen3.5-4B-3D-Coder) writes a self-contained "
259
+ "Three.js scene. Drag/scroll in the preview to move the camera (controls are model-written, so they vary). "
260
+ "The preview rebuilds every few seconds while the scene is being written.\n\n"
261
  "**WebGPU** runs the model in your browser (2.7 GB download once, then cached; needs Chrome/Edge 113+). "
262
  "**ZeroGPU** runs it on the server."
263
  )
 
276
  top_k = gr.Slider(1, 100, value=20, step=1, label="Top K")
277
  gr.Examples(EXAMPLES, inputs=prompt, example_labels=EXAMPLE_LABELS, label="Examples from the model card")
278
  with gr.Column(scale=2):
279
+ # filled client-side by window.wgLive for both backends
280
+ gr.HTML(
281
+ '<div id="wg-live" style="position:relative;height:640px;border-radius:8px;overflow:hidden;'
282
+ 'background:#000"></div>'
283
+ )
284
  download = gr.File(label="Download scene.html")
285
  with gr.Accordion("Raw model output", open=False):
286
  code = gr.Code(language="html", label="Output")
287
  inputs = [prompt, thinking, max_tokens, temperature, top_p, top_k]
288
+ btn.click(None, js="() => {" + LIVE_JS + "window.wgLive('');}")
289
+ btn.click(run, inputs, [code, download, status])
290
+ code.change(None, code, js="(c) => {" + LIVE_JS + "window.wgLive(c);}")
291
+ web_btn.click(None, inputs, [code], js=WEBGPU_JS)
292
  backend.change(lambda b: (gr.update(visible=b == GPU), gr.update(visible=b == WEB)), backend, [btn, web_btn])
293
 
294
  demo.queue().launch()