qqyule commited on
Commit
6e6f5db
·
verified ·
1 Parent(s): 13e50c5

Deploy latest Objectverse Diary UI

Browse files
Files changed (5) hide show
  1. .gitignore +1 -1
  2. README.md +12 -0
  3. scripts/capture_demo_assets.py +490 -0
  4. src/ui/layout.py +88 -4
  5. src/ui/styles.css +121 -14
.gitignore CHANGED
@@ -9,7 +9,7 @@ __pycache__/
9
  .venv/
10
  venv/
11
  .tmp/
12
-
13
  # Environment and secrets
14
  .env
15
  .env.*
 
9
  .venv/
10
  venv/
11
  .tmp/
12
+ video/
13
  # Environment and secrets
14
  .env
15
  .env.*
README.md CHANGED
@@ -21,6 +21,16 @@ Objectverse Diary is a small-model AI toy built for the Build Small Hackathon.
21
 
22
  Upload a photo of any everyday object. The app wakes it up, gives it a secret personality, writes its diary, and lets you chat with it.
23
 
 
 
 
 
 
 
 
 
 
 
24
  ## Current Status
25
 
26
  Stable mock-safe local baseline, live MiniCPM-V Space vision, non-secret hosted vision diagnostics, optional llama.cpp text runtime wiring, a passing local LoRA v2 GGUF smoke test, public mock traces, Space validation evidence, a published curated v2 SFT dataset, a published Qwen 1.5B LoRA v2 adapter, and a published Q4_K_M GGUF are available.
@@ -147,6 +157,8 @@ The stable submission baseline supports:
147
  ## Stable Submission Evidence
148
 
149
  - Live MiniCPM-V Space: https://huggingface.co/spaces/build-small-hackathon/ObjectverseDiary
 
 
150
  - Initial acceptance report: `docs/INITIAL_STAGE_REPORT.md`
151
  - Runtime notes: `docs/RUNTIME.md`
152
  - Dataset preview notes: `docs/DATASET.md`
 
21
 
22
  Upload a photo of any everyday object. The app wakes it up, gives it a secret personality, writes its diary, and lets you chat with it.
23
 
24
+ ## Hackathon Submission Links
25
+
26
+ Required public submission package for Build Small Hackathon:
27
+
28
+ - Hugging Face Space: https://huggingface.co/spaces/build-small-hackathon/ObjectverseDiary
29
+ - Demo video: https://youtu.be/5HbhP21hooA
30
+ - Social media post: https://x.com/GeekCrafter/status/2064250293001576556?s=20
31
+
32
+ These links cover the required Space link, short demo video, and social post for the official submission flow.
33
+
34
  ## Current Status
35
 
36
  Stable mock-safe local baseline, live MiniCPM-V Space vision, non-secret hosted vision diagnostics, optional llama.cpp text runtime wiring, a passing local LoRA v2 GGUF smoke test, public mock traces, Space validation evidence, a published curated v2 SFT dataset, a published Qwen 1.5B LoRA v2 adapter, and a published Q4_K_M GGUF are available.
 
157
  ## Stable Submission Evidence
158
 
159
  - Live MiniCPM-V Space: https://huggingface.co/spaces/build-small-hackathon/ObjectverseDiary
160
+ - Published demo video: https://youtu.be/5HbhP21hooA
161
+ - Published social media post: https://x.com/GeekCrafter/status/2064250293001576556?s=20
162
  - Initial acceptance report: `docs/INITIAL_STAGE_REPORT.md`
163
  - Runtime notes: `docs/RUNTIME.md`
164
  - Dataset preview notes: `docs/DATASET.md`
scripts/capture_demo_assets.py ADDED
@@ -0,0 +1,490 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Capture real Objectverse Diary UI assets for the HyperFrames demo video.
2
+
3
+ This script starts the local Gradio app with explicit mock runtimes, drives a
4
+ system Chrome instance through the Chrome DevTools Protocol, and saves
5
+ screenshots into ``video/objectverse-diary-demo/assets``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import os
13
+ import shutil
14
+ import signal
15
+ import socket
16
+ import subprocess
17
+ import textwrap
18
+ import time
19
+ import urllib.request
20
+ from pathlib import Path
21
+
22
+
23
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
24
+ DEFAULT_ASSET_DIR = PROJECT_ROOT / "video" / "objectverse-diary-demo" / "assets"
25
+ DEFAULT_TRACE_DIR = PROJECT_ROOT / ".tmp" / "demo-video-traces"
26
+ DEFAULT_CHROME_PATHS = [
27
+ Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
28
+ Path("/Applications/Chromium.app/Contents/MacOS/Chromium"),
29
+ ]
30
+
31
+
32
+ NODE_CAPTURE_SCRIPT = r"""
33
+ const fs = require("fs");
34
+ const path = require("path");
35
+ const http = require("http");
36
+ const { spawn } = require("child_process");
37
+
38
+ const options = JSON.parse(process.argv[1] || process.argv[2]);
39
+ const chromeArgs = [
40
+ "--headless=new",
41
+ "--disable-gpu",
42
+ "--hide-scrollbars",
43
+ "--mute-audio",
44
+ "--no-first-run",
45
+ "--no-default-browser-check",
46
+ "--disable-background-networking",
47
+ "--disable-sync",
48
+ "--disable-extensions",
49
+ "--disable-features=Translate",
50
+ "--remote-debugging-port=0",
51
+ `--user-data-dir=${options.profileDir}`,
52
+ "about:blank",
53
+ ];
54
+
55
+ function sleep(ms) {
56
+ return new Promise((resolve) => setTimeout(resolve, ms));
57
+ }
58
+
59
+ function requestJson(url) {
60
+ return new Promise((resolve, reject) => {
61
+ http.get(url, (res) => {
62
+ let body = "";
63
+ res.setEncoding("utf8");
64
+ res.on("data", (chunk) => (body += chunk));
65
+ res.on("end", () => {
66
+ try {
67
+ resolve(JSON.parse(body));
68
+ } catch (error) {
69
+ reject(error);
70
+ }
71
+ });
72
+ }).on("error", reject);
73
+ });
74
+ }
75
+
76
+ async function openWebSocket(url) {
77
+ if (typeof WebSocket === "undefined") {
78
+ throw new Error("Node WebSocket global is unavailable. Node 22+ is required.");
79
+ }
80
+ return new Promise((resolve, reject) => {
81
+ const socket = new WebSocket(url);
82
+ socket.addEventListener("open", () => resolve(socket), { once: true });
83
+ socket.addEventListener("error", (event) => reject(event.error || new Error("WebSocket error")), { once: true });
84
+ });
85
+ }
86
+
87
+ function makeClient(socket) {
88
+ let id = 0;
89
+ const pending = new Map();
90
+ socket.addEventListener("message", (event) => {
91
+ const message = JSON.parse(event.data);
92
+ if (message.id && pending.has(message.id)) {
93
+ const { resolve, reject } = pending.get(message.id);
94
+ pending.delete(message.id);
95
+ if (message.error) reject(new Error(`${message.error.message || "CDP error"} ${message.error.data || ""}`));
96
+ else resolve(message.result || {});
97
+ }
98
+ });
99
+ return {
100
+ send(method, params = {}) {
101
+ const commandId = ++id;
102
+ socket.send(JSON.stringify({ id: commandId, method, params }));
103
+ return new Promise((resolve, reject) => {
104
+ pending.set(commandId, { resolve, reject });
105
+ setTimeout(() => {
106
+ if (pending.has(commandId)) {
107
+ pending.delete(commandId);
108
+ reject(new Error(`Timed out waiting for ${method}`));
109
+ }
110
+ }, 15000);
111
+ });
112
+ },
113
+ close() {
114
+ socket.close();
115
+ },
116
+ };
117
+ }
118
+
119
+ async function waitForExpression(client, expression, timeoutMs = 20000) {
120
+ const start = Date.now();
121
+ while (Date.now() - start < timeoutMs) {
122
+ const result = await client.send("Runtime.evaluate", {
123
+ expression,
124
+ returnByValue: true,
125
+ awaitPromise: true,
126
+ });
127
+ if (result.result && result.result.value) return true;
128
+ await sleep(250);
129
+ }
130
+ let debugValue = {};
131
+ try {
132
+ const debug = await client.send("Runtime.evaluate", {
133
+ expression: "(() => ({ href: location.href, readyState: document.readyState, text: document.body ? document.body.innerText.slice(0, 500) : '', html: document.documentElement ? document.documentElement.outerHTML.slice(0, 500) : '' }))()",
134
+ returnByValue: true,
135
+ });
136
+ debugValue = debug.result ? debug.result.value : {};
137
+ } catch (error) {
138
+ debugValue = { debugError: String(error) };
139
+ }
140
+ throw new Error(`Timed out waiting for expression: ${expression}\nCurrent page: ${JSON.stringify(debugValue, null, 2)}`);
141
+ }
142
+
143
+ async function evaluate(client, expression) {
144
+ return client.send("Runtime.evaluate", {
145
+ expression,
146
+ returnByValue: true,
147
+ awaitPromise: true,
148
+ });
149
+ }
150
+
151
+ function withGradioRoot(source) {
152
+ return `
153
+ (() => {
154
+ const app = document.querySelector('gradio-app');
155
+ const root = app && app.shadowRoot ? app.shadowRoot : document;
156
+ const pageText = document.body ? document.body.innerText : '';
157
+ ${source}
158
+ })()
159
+ `;
160
+ }
161
+
162
+ async function screenshot(client, name) {
163
+ await sleep(650);
164
+ const result = await client.send("Page.captureScreenshot", {
165
+ format: "png",
166
+ captureBeyondViewport: false,
167
+ fromSurface: true,
168
+ });
169
+ const output = path.join(options.assetDir, `${name}.png`);
170
+ fs.writeFileSync(output, Buffer.from(result.data, "base64"));
171
+ console.log(output);
172
+ }
173
+
174
+ async function main() {
175
+ fs.mkdirSync(options.assetDir, { recursive: true });
176
+ fs.mkdirSync(options.profileDir, { recursive: true });
177
+ const chrome = spawn(options.chromePath, chromeArgs, { stdio: ["ignore", "ignore", "pipe"] });
178
+ let stderr = "";
179
+ chrome.stderr.on("data", (chunk) => {
180
+ stderr += chunk.toString();
181
+ });
182
+
183
+ let debuggerUrl = "";
184
+ const start = Date.now();
185
+ while (Date.now() - start < 12000) {
186
+ const match = stderr.match(/DevTools listening on (ws:\/\/[^\s]+)/);
187
+ if (match) {
188
+ debuggerUrl = match[1];
189
+ break;
190
+ }
191
+ await sleep(100);
192
+ }
193
+ if (!debuggerUrl) {
194
+ chrome.kill("SIGTERM");
195
+ throw new Error("Could not find Chrome DevTools WebSocket URL.");
196
+ }
197
+
198
+ const debugPort = new URL(debuggerUrl).port;
199
+ const browserSocket = await openWebSocket(debuggerUrl);
200
+ const browserClient = makeClient(browserSocket);
201
+ const createdTarget = await browserClient.send("Target.createTarget", {
202
+ url: options.appUrl,
203
+ });
204
+ browserClient.close();
205
+ await sleep(500);
206
+
207
+ const targets = await requestJson(`http://127.0.0.1:${debugPort}/json/list`);
208
+ const pageTarget = targets.find((target) => target.id === createdTarget.targetId) || targets.find(
209
+ (target) =>
210
+ target.type === "page" &&
211
+ target.webSocketDebuggerUrl &&
212
+ String(target.url || "").startsWith(options.appUrl)
213
+ );
214
+ if (!pageTarget) {
215
+ chrome.kill("SIGTERM");
216
+ throw new Error("Could not find a Chrome page target.");
217
+ }
218
+
219
+ const socket = await openWebSocket(pageTarget.webSocketDebuggerUrl);
220
+ const client = makeClient(socket);
221
+ try {
222
+ await client.send("Page.enable");
223
+ await client.send("Runtime.enable");
224
+ await client.send("Emulation.setDeviceMetricsOverride", {
225
+ width: 1920,
226
+ height: 1080,
227
+ deviceScaleFactor: 1,
228
+ mobile: false,
229
+ });
230
+ await client.send("Page.navigate", { url: options.appUrl });
231
+ await waitForExpression(client, withGradioRoot("return Boolean(root.querySelector('#objectverse-app') || pageText.includes('Objectverse Diary'));"), 60000);
232
+ await sleep(3000);
233
+ await screenshot(client, "01-hero");
234
+
235
+ await evaluate(client, withGradioRoot(`
236
+ const button = [...root.querySelectorAll('button')].find((el) => el.textContent.includes('OVD-001'));
237
+ if (!button) throw new Error('Coffee mug example button not found');
238
+ button.click();
239
+ return true;
240
+ `));
241
+ await waitForExpression(client, withGradioRoot("return pageText.includes('CoffeeMug worth is awake');"), 35000);
242
+ await sleep(1300);
243
+ await screenshot(client, "02-intake-awake");
244
+
245
+ const captureScroll = async (name, selector, offset = -120) => {
246
+ await evaluate(client, withGradioRoot(`
247
+ const el = document.querySelector(${JSON.stringify(selector)});
248
+ const shadowEl = root.querySelector(${JSON.stringify(selector)});
249
+ const target = shadowEl || el;
250
+ if (!target) throw new Error('Missing selector ${selector}');
251
+ target.scrollIntoView({ block: 'start', inline: 'nearest' });
252
+ window.scrollBy(0, ${offset});
253
+ return true;
254
+ `));
255
+ await screenshot(client, name);
256
+ };
257
+
258
+ await captureScroll("03-object-file", "#results", -80);
259
+ await captureScroll("04-diary", "#diary-output", -190);
260
+ await captureScroll("05-share-card", "#share-chat", -80);
261
+
262
+ await evaluate(client, withGradioRoot(`
263
+ const input = root.querySelector('#chat-panel textarea');
264
+ if (!input) throw new Error('Chat textarea not found');
265
+ input.value = "What have you been hiding on my desk?";
266
+ input.dispatchEvent(new Event('input', { bubbles: true }));
267
+ const button = [...root.querySelectorAll('#chat-panel button')].find((el) => el.textContent.trim() === 'Ask');
268
+ if (!button) throw new Error('Ask button not found');
269
+ button.click();
270
+ return true;
271
+ `));
272
+ await waitForExpression(client, withGradioRoot("return pageText.includes('unlimited office hours');"), 20000);
273
+ await evaluate(client, withGradioRoot(`
274
+ const input = root.querySelector('#chat-panel textarea');
275
+ if (input) {
276
+ input.value = "";
277
+ input.dispatchEvent(new Event('input', { bubbles: true }));
278
+ input.blur();
279
+ }
280
+ return true;
281
+ `));
282
+ await captureScroll("06-chat", "#chat-panel", -140);
283
+
284
+ await evaluate(client, withGradioRoot(`
285
+ const label = [...root.querySelectorAll('button, summary, [role="button"], .label-wrap, .accordion')].find((el) => el.textContent.includes('Developer details'));
286
+ const trigger = label ? (label.closest('button, summary, [role="button"]') || label) : null;
287
+ if (!trigger) throw new Error('Developer details trigger not found');
288
+ const expanded = trigger.getAttribute('aria-expanded');
289
+ if (expanded !== 'true') trigger.click();
290
+ window.scrollTo({ top: document.body.scrollHeight, behavior: 'instant' });
291
+ return true;
292
+ `));
293
+ await waitForExpression(client, withGradioRoot("return pageText.includes('Trace saved') || pageText.includes('sample-01');"), 10000);
294
+ await sleep(1200);
295
+ await screenshot(client, "07-trace");
296
+ } finally {
297
+ client.close();
298
+ chrome.kill("SIGTERM");
299
+ }
300
+ }
301
+
302
+ main().catch((error) => {
303
+ console.error(error.stack || String(error));
304
+ process.exit(1);
305
+ });
306
+ """
307
+
308
+
309
+ def parse_args() -> argparse.Namespace:
310
+ parser = argparse.ArgumentParser(description=__doc__)
311
+ parser.add_argument("--asset-dir", type=Path, default=DEFAULT_ASSET_DIR)
312
+ parser.add_argument("--trace-dir", type=Path, default=DEFAULT_TRACE_DIR)
313
+ parser.add_argument("--port", type=int, default=7860)
314
+ parser.add_argument("--chrome-path", type=Path, default=None)
315
+ parser.add_argument("--python", type=Path, default=PROJECT_ROOT / ".venv" / "bin" / "python")
316
+ parser.add_argument(
317
+ "--use-uv",
318
+ action="store_true",
319
+ help="Run the app in a temporary uv environment instead of the project virtualenv.",
320
+ )
321
+ return parser.parse_args()
322
+
323
+
324
+ def find_free_port(preferred: int) -> int:
325
+ for port in range(preferred, preferred + 50):
326
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
327
+ probe.settimeout(0.2)
328
+ if probe.connect_ex(("127.0.0.1", port)) != 0:
329
+ return port
330
+ raise RuntimeError("Could not find a free local port for Gradio.")
331
+
332
+
333
+ def wait_for_http(url: str, timeout: float = 45.0) -> None:
334
+ deadline = time.time() + timeout
335
+ last_error: Exception | None = None
336
+ while time.time() < deadline:
337
+ try:
338
+ with urllib.request.urlopen(url, timeout=3) as response:
339
+ if response.status < 500:
340
+ return
341
+ except Exception as exc: # noqa: BLE001 - retain last startup error for diagnostics.
342
+ last_error = exc
343
+ time.sleep(0.5)
344
+ raise RuntimeError(f"Timed out waiting for {url}: {last_error}")
345
+
346
+
347
+ def chrome_path(explicit_path: Path | None) -> Path:
348
+ candidates = [explicit_path] if explicit_path else []
349
+ candidates.extend(DEFAULT_CHROME_PATHS)
350
+ for candidate in candidates:
351
+ if candidate and candidate.exists():
352
+ return candidate
353
+ raise RuntimeError("Could not find Chrome. Pass --chrome-path with a Chrome or Chromium executable.")
354
+
355
+
356
+ def start_gradio(
357
+ python_path: Path,
358
+ port: int,
359
+ trace_dir: Path,
360
+ *,
361
+ use_uv: bool = False,
362
+ ) -> subprocess.Popen[str]:
363
+ env = os.environ.copy()
364
+ env.update(
365
+ {
366
+ "OBJECTVERSE_VISION_BACKEND": "mock",
367
+ "OBJECTVERSE_TEXT_BACKEND": "mock",
368
+ "TRACE_OUTPUT_DIR": str(trace_dir),
369
+ "GRADIO_SERVER_NAME": "127.0.0.1",
370
+ "GRADIO_SERVER_PORT": str(port),
371
+ }
372
+ )
373
+ return subprocess.Popen(
374
+ _app_command(python_path, use_uv),
375
+ cwd=PROJECT_ROOT,
376
+ env=env,
377
+ text=True,
378
+ stdout=subprocess.PIPE,
379
+ stderr=subprocess.STDOUT,
380
+ )
381
+
382
+
383
+ def _app_command(python_path: Path, use_uv: bool) -> list[str]:
384
+ if not use_uv:
385
+ return [str(python_path), "app.py"]
386
+
387
+ return [
388
+ "uv",
389
+ "run",
390
+ "--no-project",
391
+ "--with",
392
+ "gradio>=4.44,<6",
393
+ "--with",
394
+ "pydantic>=2.7,<3",
395
+ "--with",
396
+ "spaces>=0.30",
397
+ "--python",
398
+ str(python_path),
399
+ "python",
400
+ "app.py",
401
+ ]
402
+
403
+
404
+ def run_node_capture(app_url: str, asset_dir: Path, chrome: Path) -> None:
405
+ profile_dir = PROJECT_ROOT / ".tmp" / "demo-video-chrome-profile"
406
+ if profile_dir.exists():
407
+ shutil.rmtree(profile_dir)
408
+ options = {
409
+ "appUrl": app_url,
410
+ "assetDir": str(asset_dir),
411
+ "profileDir": str(profile_dir),
412
+ "chromePath": str(chrome),
413
+ }
414
+ node = subprocess.run(
415
+ ["node", "-e", NODE_CAPTURE_SCRIPT, json.dumps(options)],
416
+ cwd=PROJECT_ROOT,
417
+ text=True,
418
+ check=False,
419
+ stdout=subprocess.PIPE,
420
+ stderr=subprocess.STDOUT,
421
+ )
422
+ print(node.stdout, end="")
423
+ if node.returncode != 0:
424
+ raise RuntimeError(f"Node UI capture failed with exit code {node.returncode}.")
425
+
426
+
427
+ def main() -> int:
428
+ args = parse_args()
429
+ asset_dir = args.asset_dir.resolve()
430
+ trace_dir = args.trace_dir.resolve()
431
+ python_path = args.python.resolve()
432
+ if not args.use_uv and not python_path.exists():
433
+ raise RuntimeError(f"Python interpreter not found: {python_path}")
434
+ if args.use_uv and shutil.which("uv") is None:
435
+ raise RuntimeError("uv is required when --use-uv is set.")
436
+ chrome = chrome_path(args.chrome_path)
437
+ port = find_free_port(args.port)
438
+ app_url = f"http://127.0.0.1:{port}"
439
+ asset_dir.mkdir(parents=True, exist_ok=True)
440
+ trace_dir.mkdir(parents=True, exist_ok=True)
441
+
442
+ print(
443
+ textwrap.dedent(
444
+ f"""
445
+ Capturing Objectverse Diary demo assets
446
+ app: {app_url}
447
+ assets: {asset_dir}
448
+ traces: {trace_dir}
449
+ chrome: {chrome}
450
+ """
451
+ ).strip()
452
+ )
453
+
454
+ server = start_gradio(python_path, port, trace_dir, use_uv=args.use_uv)
455
+ try:
456
+ wait_for_http(app_url)
457
+ run_node_capture(app_url, asset_dir, chrome)
458
+ finally:
459
+ if server.poll() is None:
460
+ server.send_signal(signal.SIGINT)
461
+ try:
462
+ server.wait(timeout=8)
463
+ except subprocess.TimeoutExpired:
464
+ server.terminate()
465
+ server.wait(timeout=8)
466
+ if server.stdout:
467
+ output = server.stdout.read()
468
+ if output:
469
+ print(output)
470
+
471
+ expected = [
472
+ "01-hero.png",
473
+ "02-intake-awake.png",
474
+ "03-object-file.png",
475
+ "04-diary.png",
476
+ "05-share-card.png",
477
+ "06-chat.png",
478
+ "07-trace.png",
479
+ ]
480
+ missing = [name for name in expected if not (asset_dir / name).exists()]
481
+ if missing:
482
+ raise RuntimeError(f"Missing captured assets: {', '.join(missing)}")
483
+ print("Captured assets:")
484
+ for name in expected:
485
+ print(f"- {asset_dir / name}")
486
+ return 0
487
+
488
+
489
+ if __name__ == "__main__":
490
+ raise SystemExit(main())
src/ui/layout.py CHANGED
@@ -90,6 +90,16 @@ UI_CONTROL_SCRIPT = r"""
90
  const CJK_WRAP_RE = /[\u3400-\u9fff,。!?、;::“”‘’()《》【】]+/g;
91
  const SKIP_TEXT_SELECTOR = "script, style, textarea, input, select, option, svg, .lang-zh, .auto-zh";
92
 
 
 
 
 
 
 
 
 
 
 
93
  function syncLanguageButtons(value) {
94
  document.querySelectorAll("[data-lang-toggle]").forEach((button) => {
95
  const active = button.dataset.langToggle === value;
@@ -98,6 +108,34 @@ UI_CONTROL_SCRIPT = r"""
98
  });
99
  }
100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  function applyLanguage(value) {
102
  const language = value === "zh" ? "zh" : "en";
103
  root.dataset.ovLang = language;
@@ -107,11 +145,27 @@ UI_CONTROL_SCRIPT = r"""
107
  syncLanguageButtons(language);
108
  }
109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  function initControls() {
111
  root.lang = "en";
112
  applyLanguage("en");
 
113
  normalizeGradioChrome(document.body);
114
  wrapChineseText(document.body);
 
115
  }
116
 
117
  function normalizeString(value) {
@@ -199,6 +253,13 @@ UI_CONTROL_SCRIPT = r"""
199
  const langButton = event.target.closest("[data-lang-toggle]");
200
  if (langButton) {
201
  applyLanguage(langButton.dataset.langToggle);
 
 
 
 
 
 
 
202
  }
203
  });
204
 
@@ -211,6 +272,7 @@ UI_CONTROL_SCRIPT = r"""
211
  const observer = new MutationObserver(() => {
212
  normalizeGradioChrome(document.body);
213
  wrapChineseText(document.body);
 
214
  });
215
  if (document.body) {
216
  observer.observe(document.body, { childList: true, subtree: true });
@@ -339,10 +401,32 @@ def build_app() -> gr.Blocks:
339
  </div>
340
  </div>
341
  <div class="top-controls" aria-label="Display controls">
342
- <span>Language</span>
343
- <div class="segmented-control">
344
- <button type="button" class="active" data-lang-toggle="en" aria-pressed="true">EN</button>
345
- <button type="button" data-lang-toggle="zh" aria-pressed="false">ZH</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
  </div>
347
  </div>
348
  </header>
 
90
  const CJK_WRAP_RE = /[\u3400-\u9fff,。!?、;::“”‘’()《》【】]+/g;
91
  const SKIP_TEXT_SELECTOR = "script, style, textarea, input, select, option, svg, .lang-zh, .auto-zh";
92
 
93
+ function readStoredTheme() {
94
+ try {
95
+ return localStorage.getItem("objectverse-theme") === "light" ? "light" : "dark";
96
+ } catch {
97
+ return "dark";
98
+ }
99
+ }
100
+
101
+ root.dataset.ovTheme = readStoredTheme();
102
+
103
  function syncLanguageButtons(value) {
104
  document.querySelectorAll("[data-lang-toggle]").forEach((button) => {
105
  const active = button.dataset.langToggle === value;
 
108
  });
109
  }
110
 
111
+ function syncThemeButtons(value) {
112
+ const isLight = value === "light";
113
+ document.querySelectorAll("[data-theme-toggle]").forEach((button) => {
114
+ button.dataset.themeToggle = value;
115
+ button.classList.toggle("active", isLight);
116
+ button.setAttribute("aria-pressed", String(isLight));
117
+ button.setAttribute("aria-label", isLight ? "Switch to dark theme" : "Switch to light theme");
118
+ button.setAttribute("title", isLight ? "Switch to dark theme" : "Switch to light theme");
119
+ button.querySelectorAll("[data-theme-icon]").forEach((icon) => {
120
+ const hidden = icon.dataset.themeIcon !== value;
121
+ icon.hidden = hidden;
122
+ icon.toggleAttribute("hidden", hidden);
123
+ });
124
+ });
125
+ }
126
+
127
+ function syncControls() {
128
+ syncLanguageButtons(root.dataset.ovLang === "zh" ? "zh" : "en");
129
+ syncThemeButtons(root.dataset.ovTheme === "light" ? "light" : "dark");
130
+ }
131
+
132
+ function scheduleControlSync() {
133
+ syncControls();
134
+ window.setTimeout(syncControls, 50);
135
+ window.setTimeout(syncControls, 250);
136
+ window.setTimeout(syncControls, 1000);
137
+ }
138
+
139
  function applyLanguage(value) {
140
  const language = value === "zh" ? "zh" : "en";
141
  root.dataset.ovLang = language;
 
145
  syncLanguageButtons(language);
146
  }
147
 
148
+ function applyTheme(value) {
149
+ const theme = value === "light" ? "light" : "dark";
150
+ root.dataset.ovTheme = theme;
151
+ if (document.body) {
152
+ document.body.dataset.ovTheme = theme;
153
+ }
154
+ try {
155
+ localStorage.setItem("objectverse-theme", theme);
156
+ } catch {
157
+ // Local storage can be unavailable in embedded previews.
158
+ }
159
+ syncThemeButtons(theme);
160
+ }
161
+
162
  function initControls() {
163
  root.lang = "en";
164
  applyLanguage("en");
165
+ applyTheme(readStoredTheme());
166
  normalizeGradioChrome(document.body);
167
  wrapChineseText(document.body);
168
+ scheduleControlSync();
169
  }
170
 
171
  function normalizeString(value) {
 
253
  const langButton = event.target.closest("[data-lang-toggle]");
254
  if (langButton) {
255
  applyLanguage(langButton.dataset.langToggle);
256
+ return;
257
+ }
258
+
259
+ const themeButton = event.target.closest("[data-theme-toggle]");
260
+ if (themeButton) {
261
+ const currentTheme = root.dataset.ovTheme === "light" ? "light" : "dark";
262
+ applyTheme(currentTheme === "light" ? "dark" : "light");
263
  }
264
  });
265
 
 
272
  const observer = new MutationObserver(() => {
273
  normalizeGradioChrome(document.body);
274
  wrapChineseText(document.body);
275
+ syncControls();
276
  });
277
  if (document.body) {
278
  observer.observe(document.body, { childList: true, subtree: true });
 
401
  </div>
402
  </div>
403
  <div class="top-controls" aria-label="Display controls">
404
+ <div class="top-actions" aria-label="Project links and theme">
405
+ <a class="icon-link" href="https://x.com/GeekCrafter/status/2064250293001576556?s=20" target="_blank" rel="noopener noreferrer" aria-label="Open X social post" title="X post">
406
+ <span class="x-mark" aria-hidden="true">X</span>
407
+ </a>
408
+ <a class="icon-link" href="https://youtu.be/5HbhP21hooA" target="_blank" rel="noopener noreferrer" aria-label="Open YouTube demo video" title="YouTube demo">
409
+ <svg aria-hidden="true" viewBox="0 0 24 24" focusable="false">
410
+ <rect x="3" y="6" width="18" height="12" rx="3"></rect>
411
+ <path d="M10 9.5v5l5-2.5-5-2.5Z"></path>
412
+ </svg>
413
+ </a>
414
+ <button type="button" class="icon-link theme-toggle" data-theme-toggle="dark" aria-pressed="false" aria-label="Switch to light theme" title="Switch to light theme">
415
+ <svg data-theme-icon="dark" aria-hidden="true" viewBox="0 0 24 24" focusable="false">
416
+ <path d="M20 15.5A8.5 8.5 0 0 1 8.5 4a7.2 7.2 0 1 0 11.5 11.5Z"></path>
417
+ </svg>
418
+ <svg data-theme-icon="light" aria-hidden="true" viewBox="0 0 24 24" focusable="false" hidden>
419
+ <circle cx="12" cy="12" r="4"></circle>
420
+ <path d="M12 2v2M12 20v2M4 12H2M22 12h-2M5 5l1.5 1.5M17.5 17.5 19 19M19 5l-1.5 1.5M6.5 17.5 5 19"></path>
421
+ </svg>
422
+ </button>
423
+ </div>
424
+ <div class="language-control">
425
+ <span>Language</span>
426
+ <div class="segmented-control">
427
+ <button type="button" class="active" data-lang-toggle="en" aria-pressed="true">EN</button>
428
+ <button type="button" data-lang-toggle="zh" aria-pressed="false">ZH</button>
429
+ </div>
430
  </div>
431
  </div>
432
  </header>
src/ui/styles.css CHANGED
@@ -8,6 +8,9 @@
8
  --ov-bg: #161513;
9
  --ov-bg-panel: rgba(30, 28, 25, 0.82);
10
  --ov-bg-panel-solid: #1f1d1a;
 
 
 
11
  --ov-bg-input: #1b1a18;
12
  --ov-border-faint: rgba(212, 175, 55, 0.16);
13
  --ov-border-light: rgba(212, 175, 55, 0.34);
@@ -23,6 +26,25 @@
23
  --font-serif: Georgia, serif;
24
  }
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  .lang-zh,
27
  .auto-zh {
28
  display: none !important;
@@ -148,7 +170,7 @@ footer,
148
  border: 1px solid var(--ov-border-light);
149
  border-radius: 999px;
150
  color: var(--ov-gold-bright) !important;
151
- background: rgba(212, 175, 55, 0.06);
152
  font-family: var(--font-typewriter);
153
  font-size: 12px;
154
  line-height: 1.2;
@@ -165,14 +187,94 @@ footer,
165
 
166
  .top-controls {
167
  display: flex;
 
168
  align-items: center;
169
- gap: 12px;
170
  min-width: 176px;
171
  color: var(--ov-text-muted);
172
  font-family: var(--font-typewriter);
173
  font-size: 12px;
174
  }
175
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  .segmented-control {
177
  display: grid;
178
  grid-template-columns: 1fr 1fr;
@@ -180,7 +282,7 @@ footer,
180
  overflow: hidden;
181
  border: 1px solid var(--ov-border-light);
182
  border-radius: 6px;
183
- background: rgba(22, 21, 19, 0.65);
184
  }
185
 
186
  .segmented-control button {
@@ -216,7 +318,7 @@ footer,
216
  border: 1px solid var(--ov-border-faint);
217
  border-left: 4px solid var(--ov-border-light);
218
  border-radius: 8px;
219
- background: rgba(30, 28, 25, 0.72);
220
  }
221
 
222
  .archive-status strong {
@@ -261,7 +363,7 @@ footer,
261
  .gradio-container .box {
262
  border-color: var(--ov-border-faint) !important;
263
  border-radius: 8px !important;
264
- background: rgba(22, 21, 19, 0.52) !important;
265
  box-shadow: none !important;
266
  }
267
 
@@ -294,7 +396,7 @@ footer,
294
  min-height: 176px;
295
  border: 1px dashed var(--ov-border-light) !important;
296
  border-radius: 8px;
297
- background: rgba(22, 21, 19, 0.38) !important;
298
  color: var(--ov-text-muted) !important;
299
  text-align: center;
300
  }
@@ -330,7 +432,7 @@ footer,
330
  padding: 10px !important;
331
  border: 1px solid var(--ov-border-faint) !important;
332
  border-radius: 6px !important;
333
- background: rgba(22, 21, 19, 0.36) !important;
334
  text-align: center;
335
  cursor: pointer;
336
  }
@@ -401,7 +503,7 @@ button.example-card {
401
  padding: 11px 12px !important;
402
  border: 1px solid var(--ov-border-faint) !important;
403
  border-radius: 6px !important;
404
- background: rgba(22, 21, 19, 0.58) !important;
405
  color: var(--ov-text-main) !important;
406
  font-family: var(--font-typewriter) !important;
407
  text-align: left !important;
@@ -491,7 +593,7 @@ button.example-card * {
491
  border: 1px dashed var(--ov-border-light);
492
  border-radius: 8px;
493
  color: var(--ov-text-muted) !important;
494
- background: rgba(22, 21, 19, 0.32);
495
  text-align: center;
496
  }
497
 
@@ -591,7 +693,7 @@ button.example-card * {
591
  padding: 22px;
592
  border: 1px solid var(--ov-border-light);
593
  border-radius: 8px;
594
- background: rgba(22, 21, 19, 0.7);
595
  }
596
 
597
  .card-header {
@@ -621,7 +723,7 @@ button.example-card * {
621
  .quiet-button {
622
  border: 1px solid var(--ov-border-light) !important;
623
  border-radius: 6px !important;
624
- background: rgba(22, 21, 19, 0.45) !important;
625
  color: var(--ov-gold) !important;
626
  font-family: var(--font-typewriter) !important;
627
  }
@@ -629,7 +731,7 @@ button.example-card * {
629
  .developer-details {
630
  border: 1px solid var(--ov-border-faint) !important;
631
  border-radius: 8px !important;
632
- background: rgba(30, 28, 25, 0.58) !important;
633
  }
634
 
635
  .developer-json-grid {
@@ -645,7 +747,7 @@ button.example-card * {
645
  }
646
 
647
  .gradio-container :is(.chatbot, .json-holder, pre) {
648
- background: rgba(22, 21, 19, 0.5) !important;
649
  }
650
 
651
  @media (max-width: 980px) {
@@ -667,7 +769,12 @@ button.example-card * {
667
  }
668
 
669
  .top-controls {
670
- justify-content: space-between;
 
 
 
 
 
671
  }
672
  }
673
 
 
8
  --ov-bg: #161513;
9
  --ov-bg-panel: rgba(30, 28, 25, 0.82);
10
  --ov-bg-panel-solid: #1f1d1a;
11
+ --ov-bg-subtle: rgba(22, 21, 19, 0.52);
12
+ --ov-bg-field: rgba(22, 21, 19, 0.36);
13
+ --ov-bg-chip: rgba(212, 175, 55, 0.06);
14
  --ov-bg-input: #1b1a18;
15
  --ov-border-faint: rgba(212, 175, 55, 0.16);
16
  --ov-border-light: rgba(212, 175, 55, 0.34);
 
26
  --font-serif: Georgia, serif;
27
  }
28
 
29
+ html[data-ov-theme="light"] {
30
+ --ov-bg: #f4ecdd;
31
+ --ov-bg-panel: rgba(255, 250, 240, 0.86);
32
+ --ov-bg-panel-solid: #fff7e8;
33
+ --ov-bg-subtle: rgba(255, 247, 232, 0.72);
34
+ --ov-bg-field: rgba(255, 247, 232, 0.78);
35
+ --ov-bg-chip: rgba(142, 104, 29, 0.08);
36
+ --ov-bg-input: #fffaf0;
37
+ --ov-border-faint: rgba(142, 104, 29, 0.18);
38
+ --ov-border-light: rgba(142, 104, 29, 0.36);
39
+ --ov-text-main: #2f2a22;
40
+ --ov-text-soft: #453b2d;
41
+ --ov-text-muted: #756a58;
42
+ --ov-text-dark: #2a261f;
43
+ --ov-gold: #8e681d;
44
+ --ov-gold-bright: #6f4f0f;
45
+ --ov-red: #a94e3d;
46
+ }
47
+
48
  .lang-zh,
49
  .auto-zh {
50
  display: none !important;
 
170
  border: 1px solid var(--ov-border-light);
171
  border-radius: 999px;
172
  color: var(--ov-gold-bright) !important;
173
+ background: var(--ov-bg-chip);
174
  font-family: var(--font-typewriter);
175
  font-size: 12px;
176
  line-height: 1.2;
 
187
 
188
  .top-controls {
189
  display: flex;
190
+ flex-direction: column;
191
  align-items: center;
192
+ gap: 10px;
193
  min-width: 176px;
194
  color: var(--ov-text-muted);
195
  font-family: var(--font-typewriter);
196
  font-size: 12px;
197
  }
198
 
199
+ .top-actions,
200
+ .language-control {
201
+ display: flex;
202
+ align-items: center;
203
+ gap: 10px;
204
+ }
205
+
206
+ .top-actions {
207
+ justify-content: flex-end;
208
+ width: 100%;
209
+ }
210
+
211
+ .language-control {
212
+ justify-content: flex-end;
213
+ }
214
+
215
+ .icon-link {
216
+ display: inline-flex;
217
+ align-items: center;
218
+ justify-content: center;
219
+ width: 36px;
220
+ height: 36px;
221
+ border: 1px solid var(--ov-border-light);
222
+ border-radius: 8px;
223
+ background: var(--ov-bg-subtle);
224
+ color: var(--ov-gold) !important;
225
+ text-decoration: none !important;
226
+ cursor: pointer;
227
+ transition: border-color 0.2s, background 0.2s, color 0.2s;
228
+ }
229
+
230
+ .icon-link:hover,
231
+ .icon-link:focus {
232
+ border-color: var(--ov-gold);
233
+ background: var(--ov-bg-chip);
234
+ color: var(--ov-gold-bright) !important;
235
+ outline: none;
236
+ }
237
+
238
+ .icon-link svg {
239
+ width: 19px;
240
+ height: 19px;
241
+ fill: none;
242
+ stroke: currentColor;
243
+ stroke-width: 1.8;
244
+ stroke-linecap: round;
245
+ stroke-linejoin: round;
246
+ }
247
+
248
+ .icon-link svg[hidden] {
249
+ display: none !important;
250
+ }
251
+
252
+ .icon-link[href*="youtu"] svg rect {
253
+ fill: currentColor;
254
+ stroke: currentColor;
255
+ }
256
+
257
+ .icon-link[href*="youtu"] svg path {
258
+ fill: var(--ov-bg);
259
+ stroke: var(--ov-bg);
260
+ }
261
+
262
+ .x-mark {
263
+ font-family: var(--font-typewriter);
264
+ font-size: 16px;
265
+ font-weight: 700;
266
+ line-height: 1;
267
+ }
268
+
269
+ .theme-toggle {
270
+ padding: 0;
271
+ }
272
+
273
+ .theme-toggle.active {
274
+ background: var(--ov-gold);
275
+ color: var(--ov-text-dark) !important;
276
+ }
277
+
278
  .segmented-control {
279
  display: grid;
280
  grid-template-columns: 1fr 1fr;
 
282
  overflow: hidden;
283
  border: 1px solid var(--ov-border-light);
284
  border-radius: 6px;
285
+ background: var(--ov-bg-subtle);
286
  }
287
 
288
  .segmented-control button {
 
318
  border: 1px solid var(--ov-border-faint);
319
  border-left: 4px solid var(--ov-border-light);
320
  border-radius: 8px;
321
+ background: var(--ov-bg-panel);
322
  }
323
 
324
  .archive-status strong {
 
363
  .gradio-container .box {
364
  border-color: var(--ov-border-faint) !important;
365
  border-radius: 8px !important;
366
+ background: var(--ov-bg-subtle) !important;
367
  box-shadow: none !important;
368
  }
369
 
 
396
  min-height: 176px;
397
  border: 1px dashed var(--ov-border-light) !important;
398
  border-radius: 8px;
399
+ background: var(--ov-bg-field) !important;
400
  color: var(--ov-text-muted) !important;
401
  text-align: center;
402
  }
 
432
  padding: 10px !important;
433
  border: 1px solid var(--ov-border-faint) !important;
434
  border-radius: 6px !important;
435
+ background: var(--ov-bg-field) !important;
436
  text-align: center;
437
  cursor: pointer;
438
  }
 
503
  padding: 11px 12px !important;
504
  border: 1px solid var(--ov-border-faint) !important;
505
  border-radius: 6px !important;
506
+ background: var(--ov-bg-subtle) !important;
507
  color: var(--ov-text-main) !important;
508
  font-family: var(--font-typewriter) !important;
509
  text-align: left !important;
 
593
  border: 1px dashed var(--ov-border-light);
594
  border-radius: 8px;
595
  color: var(--ov-text-muted) !important;
596
+ background: var(--ov-bg-field);
597
  text-align: center;
598
  }
599
 
 
693
  padding: 22px;
694
  border: 1px solid var(--ov-border-light);
695
  border-radius: 8px;
696
+ background: var(--ov-bg-subtle);
697
  }
698
 
699
  .card-header {
 
723
  .quiet-button {
724
  border: 1px solid var(--ov-border-light) !important;
725
  border-radius: 6px !important;
726
+ background: var(--ov-bg-subtle) !important;
727
  color: var(--ov-gold) !important;
728
  font-family: var(--font-typewriter) !important;
729
  }
 
731
  .developer-details {
732
  border: 1px solid var(--ov-border-faint) !important;
733
  border-radius: 8px !important;
734
+ background: var(--ov-bg-panel) !important;
735
  }
736
 
737
  .developer-json-grid {
 
747
  }
748
 
749
  .gradio-container :is(.chatbot, .json-holder, pre) {
750
+ background: var(--ov-bg-subtle) !important;
751
  }
752
 
753
  @media (max-width: 980px) {
 
769
  }
770
 
771
  .top-controls {
772
+ align-items: flex-start;
773
+ }
774
+
775
+ .top-actions,
776
+ .language-control {
777
+ justify-content: flex-start;
778
  }
779
  }
780