| """Capture real Objectverse Diary UI assets for the HyperFrames demo video. |
| |
| This script starts the local Gradio app with explicit mock runtimes, drives a |
| system Chrome instance through the Chrome DevTools Protocol, and saves |
| screenshots into ``video/objectverse-diary-demo/assets``. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import os |
| import shutil |
| import signal |
| import socket |
| import subprocess |
| import textwrap |
| import time |
| import urllib.request |
| from pathlib import Path |
|
|
|
|
| PROJECT_ROOT = Path(__file__).resolve().parents[1] |
| DEFAULT_ASSET_DIR = PROJECT_ROOT / "video" / "objectverse-diary-demo" / "assets" |
| DEFAULT_TRACE_DIR = PROJECT_ROOT / ".tmp" / "demo-video-traces" |
| DEFAULT_CHROME_PATHS = [ |
| Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"), |
| Path("/Applications/Chromium.app/Contents/MacOS/Chromium"), |
| ] |
|
|
|
|
| NODE_CAPTURE_SCRIPT = r""" |
| const fs = require("fs"); |
| const path = require("path"); |
| const http = require("http"); |
| const { spawn } = require("child_process"); |
| |
| const options = JSON.parse(process.argv[1] || process.argv[2]); |
| const chromeArgs = [ |
| "--headless=new", |
| "--disable-gpu", |
| "--hide-scrollbars", |
| "--mute-audio", |
| "--no-first-run", |
| "--no-default-browser-check", |
| "--disable-background-networking", |
| "--disable-sync", |
| "--disable-extensions", |
| "--disable-features=Translate", |
| "--remote-debugging-port=0", |
| `--user-data-dir=${options.profileDir}`, |
| "about:blank", |
| ]; |
| |
| function sleep(ms) { |
| return new Promise((resolve) => setTimeout(resolve, ms)); |
| } |
| |
| function requestJson(url) { |
| return new Promise((resolve, reject) => { |
| http.get(url, (res) => { |
| let body = ""; |
| res.setEncoding("utf8"); |
| res.on("data", (chunk) => (body += chunk)); |
| res.on("end", () => { |
| try { |
| resolve(JSON.parse(body)); |
| } catch (error) { |
| reject(error); |
| } |
| }); |
| }).on("error", reject); |
| }); |
| } |
| |
| async function openWebSocket(url) { |
| if (typeof WebSocket === "undefined") { |
| throw new Error("Node WebSocket global is unavailable. Node 22+ is required."); |
| } |
| return new Promise((resolve, reject) => { |
| const socket = new WebSocket(url); |
| socket.addEventListener("open", () => resolve(socket), { once: true }); |
| socket.addEventListener("error", (event) => reject(event.error || new Error("WebSocket error")), { once: true }); |
| }); |
| } |
| |
| function makeClient(socket) { |
| let id = 0; |
| const pending = new Map(); |
| socket.addEventListener("message", (event) => { |
| const message = JSON.parse(event.data); |
| if (message.id && pending.has(message.id)) { |
| const { resolve, reject } = pending.get(message.id); |
| pending.delete(message.id); |
| if (message.error) reject(new Error(`${message.error.message || "CDP error"} ${message.error.data || ""}`)); |
| else resolve(message.result || {}); |
| } |
| }); |
| return { |
| send(method, params = {}) { |
| const commandId = ++id; |
| socket.send(JSON.stringify({ id: commandId, method, params })); |
| return new Promise((resolve, reject) => { |
| pending.set(commandId, { resolve, reject }); |
| setTimeout(() => { |
| if (pending.has(commandId)) { |
| pending.delete(commandId); |
| reject(new Error(`Timed out waiting for ${method}`)); |
| } |
| }, 15000); |
| }); |
| }, |
| close() { |
| socket.close(); |
| }, |
| }; |
| } |
| |
| async function waitForExpression(client, expression, timeoutMs = 20000) { |
| const start = Date.now(); |
| while (Date.now() - start < timeoutMs) { |
| const result = await client.send("Runtime.evaluate", { |
| expression, |
| returnByValue: true, |
| awaitPromise: true, |
| }); |
| if (result.result && result.result.value) return true; |
| await sleep(250); |
| } |
| let debugValue = {}; |
| try { |
| const debug = await client.send("Runtime.evaluate", { |
| 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) : '' }))()", |
| returnByValue: true, |
| }); |
| debugValue = debug.result ? debug.result.value : {}; |
| } catch (error) { |
| debugValue = { debugError: String(error) }; |
| } |
| throw new Error(`Timed out waiting for expression: ${expression}\nCurrent page: ${JSON.stringify(debugValue, null, 2)}`); |
| } |
| |
| async function evaluate(client, expression) { |
| return client.send("Runtime.evaluate", { |
| expression, |
| returnByValue: true, |
| awaitPromise: true, |
| }); |
| } |
| |
| function withGradioRoot(source) { |
| return ` |
| (() => { |
| const app = document.querySelector('gradio-app'); |
| const root = app && app.shadowRoot ? app.shadowRoot : document; |
| const pageText = document.body ? document.body.innerText : ''; |
| ${source} |
| })() |
| `; |
| } |
| |
| async function screenshot(client, name) { |
| await sleep(650); |
| const result = await client.send("Page.captureScreenshot", { |
| format: "png", |
| captureBeyondViewport: false, |
| fromSurface: true, |
| }); |
| const output = path.join(options.assetDir, `${name}.png`); |
| fs.writeFileSync(output, Buffer.from(result.data, "base64")); |
| console.log(output); |
| } |
| |
| async function main() { |
| fs.mkdirSync(options.assetDir, { recursive: true }); |
| fs.mkdirSync(options.profileDir, { recursive: true }); |
| const chrome = spawn(options.chromePath, chromeArgs, { stdio: ["ignore", "ignore", "pipe"] }); |
| let stderr = ""; |
| chrome.stderr.on("data", (chunk) => { |
| stderr += chunk.toString(); |
| }); |
| |
| let debuggerUrl = ""; |
| const start = Date.now(); |
| while (Date.now() - start < 12000) { |
| const match = stderr.match(/DevTools listening on (ws:\/\/[^\s]+)/); |
| if (match) { |
| debuggerUrl = match[1]; |
| break; |
| } |
| await sleep(100); |
| } |
| if (!debuggerUrl) { |
| chrome.kill("SIGTERM"); |
| throw new Error("Could not find Chrome DevTools WebSocket URL."); |
| } |
| |
| const debugPort = new URL(debuggerUrl).port; |
| const browserSocket = await openWebSocket(debuggerUrl); |
| const browserClient = makeClient(browserSocket); |
| const createdTarget = await browserClient.send("Target.createTarget", { |
| url: options.appUrl, |
| }); |
| browserClient.close(); |
| await sleep(500); |
| |
| const targets = await requestJson(`http://127.0.0.1:${debugPort}/json/list`); |
| const pageTarget = targets.find((target) => target.id === createdTarget.targetId) || targets.find( |
| (target) => |
| target.type === "page" && |
| target.webSocketDebuggerUrl && |
| String(target.url || "").startsWith(options.appUrl) |
| ); |
| if (!pageTarget) { |
| chrome.kill("SIGTERM"); |
| throw new Error("Could not find a Chrome page target."); |
| } |
| |
| const socket = await openWebSocket(pageTarget.webSocketDebuggerUrl); |
| const client = makeClient(socket); |
| try { |
| await client.send("Page.enable"); |
| await client.send("Runtime.enable"); |
| await client.send("Emulation.setDeviceMetricsOverride", { |
| width: 1920, |
| height: 1080, |
| deviceScaleFactor: 1, |
| mobile: false, |
| }); |
| await client.send("Page.navigate", { url: options.appUrl }); |
| await waitForExpression(client, withGradioRoot("return Boolean(root.querySelector('#objectverse-app') || pageText.includes('Objectverse Diary'));"), 60000); |
| await sleep(3000); |
| await screenshot(client, "01-hero"); |
| |
| await evaluate(client, withGradioRoot(` |
| const button = [...root.querySelectorAll('button')].find((el) => el.textContent.includes('OVD-001')); |
| if (!button) throw new Error('Coffee mug example button not found'); |
| button.click(); |
| return true; |
| `)); |
| await waitForExpression(client, withGradioRoot("return pageText.includes('CoffeeMug worth is awake');"), 35000); |
| await sleep(1300); |
| await screenshot(client, "02-intake-awake"); |
| |
| const captureScroll = async (name, selector, offset = -120) => { |
| await evaluate(client, withGradioRoot(` |
| const el = document.querySelector(${JSON.stringify(selector)}); |
| const shadowEl = root.querySelector(${JSON.stringify(selector)}); |
| const target = shadowEl || el; |
| if (!target) throw new Error('Missing selector ${selector}'); |
| target.scrollIntoView({ block: 'start', inline: 'nearest' }); |
| window.scrollBy(0, ${offset}); |
| return true; |
| `)); |
| await screenshot(client, name); |
| }; |
| |
| await captureScroll("03-object-file", "#results", -80); |
| await captureScroll("04-diary", "#diary-output", -190); |
| await captureScroll("05-share-card", "#share-chat", -80); |
| |
| await evaluate(client, withGradioRoot(` |
| const input = root.querySelector('#chat-panel textarea'); |
| if (!input) throw new Error('Chat textarea not found'); |
| input.value = "What have you been hiding on my desk?"; |
| input.dispatchEvent(new Event('input', { bubbles: true })); |
| const button = [...root.querySelectorAll('#chat-panel button')].find((el) => el.textContent.trim() === 'Ask'); |
| if (!button) throw new Error('Ask button not found'); |
| button.click(); |
| return true; |
| `)); |
| await waitForExpression(client, withGradioRoot("return pageText.includes('unlimited office hours');"), 20000); |
| await evaluate(client, withGradioRoot(` |
| const input = root.querySelector('#chat-panel textarea'); |
| if (input) { |
| input.value = ""; |
| input.dispatchEvent(new Event('input', { bubbles: true })); |
| input.blur(); |
| } |
| return true; |
| `)); |
| await captureScroll("06-chat", "#chat-panel", -140); |
| |
| await evaluate(client, withGradioRoot(` |
| const label = [...root.querySelectorAll('button, summary, [role="button"], .label-wrap, .accordion')].find((el) => el.textContent.includes('Developer details')); |
| const trigger = label ? (label.closest('button, summary, [role="button"]') || label) : null; |
| if (!trigger) throw new Error('Developer details trigger not found'); |
| const expanded = trigger.getAttribute('aria-expanded'); |
| if (expanded !== 'true') trigger.click(); |
| window.scrollTo({ top: document.body.scrollHeight, behavior: 'instant' }); |
| return true; |
| `)); |
| await waitForExpression(client, withGradioRoot("return pageText.includes('Trace saved') || pageText.includes('sample-01');"), 10000); |
| await sleep(1200); |
| await screenshot(client, "07-trace"); |
| } finally { |
| client.close(); |
| chrome.kill("SIGTERM"); |
| } |
| } |
| |
| main().catch((error) => { |
| console.error(error.stack || String(error)); |
| process.exit(1); |
| }); |
| """ |
|
|
|
|
| def parse_args() -> argparse.Namespace: |
| parser = argparse.ArgumentParser(description=__doc__) |
| parser.add_argument("--asset-dir", type=Path, default=DEFAULT_ASSET_DIR) |
| parser.add_argument("--trace-dir", type=Path, default=DEFAULT_TRACE_DIR) |
| parser.add_argument("--port", type=int, default=7860) |
| parser.add_argument("--chrome-path", type=Path, default=None) |
| parser.add_argument("--python", type=Path, default=PROJECT_ROOT / ".venv" / "bin" / "python") |
| parser.add_argument( |
| "--use-uv", |
| action="store_true", |
| help="Run the app in a temporary uv environment instead of the project virtualenv.", |
| ) |
| return parser.parse_args() |
|
|
|
|
| def find_free_port(preferred: int) -> int: |
| for port in range(preferred, preferred + 50): |
| with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: |
| probe.settimeout(0.2) |
| if probe.connect_ex(("127.0.0.1", port)) != 0: |
| return port |
| raise RuntimeError("Could not find a free local port for Gradio.") |
|
|
|
|
| def wait_for_http(url: str, timeout: float = 45.0) -> None: |
| deadline = time.time() + timeout |
| last_error: Exception | None = None |
| while time.time() < deadline: |
| try: |
| with urllib.request.urlopen(url, timeout=3) as response: |
| if response.status < 500: |
| return |
| except Exception as exc: |
| last_error = exc |
| time.sleep(0.5) |
| raise RuntimeError(f"Timed out waiting for {url}: {last_error}") |
|
|
|
|
| def chrome_path(explicit_path: Path | None) -> Path: |
| candidates = [explicit_path] if explicit_path else [] |
| candidates.extend(DEFAULT_CHROME_PATHS) |
| for candidate in candidates: |
| if candidate and candidate.exists(): |
| return candidate |
| raise RuntimeError("Could not find Chrome. Pass --chrome-path with a Chrome or Chromium executable.") |
|
|
|
|
| def start_gradio( |
| python_path: Path, |
| port: int, |
| trace_dir: Path, |
| *, |
| use_uv: bool = False, |
| ) -> subprocess.Popen[str]: |
| env = os.environ.copy() |
| env.update( |
| { |
| "OBJECTVERSE_VISION_BACKEND": "mock", |
| "OBJECTVERSE_TEXT_BACKEND": "mock", |
| "TRACE_OUTPUT_DIR": str(trace_dir), |
| "GRADIO_SERVER_NAME": "127.0.0.1", |
| "GRADIO_SERVER_PORT": str(port), |
| } |
| ) |
| return subprocess.Popen( |
| _app_command(python_path, use_uv), |
| cwd=PROJECT_ROOT, |
| env=env, |
| text=True, |
| stdout=subprocess.PIPE, |
| stderr=subprocess.STDOUT, |
| ) |
|
|
|
|
| def _app_command(python_path: Path, use_uv: bool) -> list[str]: |
| if not use_uv: |
| return [str(python_path), "app.py"] |
|
|
| return [ |
| "uv", |
| "run", |
| "--no-project", |
| "--with", |
| "gradio>=4.44,<6", |
| "--with", |
| "pydantic>=2.7,<3", |
| "--with", |
| "spaces>=0.30", |
| "--python", |
| str(python_path), |
| "python", |
| "app.py", |
| ] |
|
|
|
|
| def run_node_capture(app_url: str, asset_dir: Path, chrome: Path) -> None: |
| profile_dir = PROJECT_ROOT / ".tmp" / "demo-video-chrome-profile" |
| if profile_dir.exists(): |
| shutil.rmtree(profile_dir) |
| options = { |
| "appUrl": app_url, |
| "assetDir": str(asset_dir), |
| "profileDir": str(profile_dir), |
| "chromePath": str(chrome), |
| } |
| node = subprocess.run( |
| ["node", "-e", NODE_CAPTURE_SCRIPT, json.dumps(options)], |
| cwd=PROJECT_ROOT, |
| text=True, |
| check=False, |
| stdout=subprocess.PIPE, |
| stderr=subprocess.STDOUT, |
| ) |
| print(node.stdout, end="") |
| if node.returncode != 0: |
| raise RuntimeError(f"Node UI capture failed with exit code {node.returncode}.") |
|
|
|
|
| def main() -> int: |
| args = parse_args() |
| asset_dir = args.asset_dir.resolve() |
| trace_dir = args.trace_dir.resolve() |
| python_path = args.python.resolve() |
| if not args.use_uv and not python_path.exists(): |
| raise RuntimeError(f"Python interpreter not found: {python_path}") |
| if args.use_uv and shutil.which("uv") is None: |
| raise RuntimeError("uv is required when --use-uv is set.") |
| chrome = chrome_path(args.chrome_path) |
| port = find_free_port(args.port) |
| app_url = f"http://127.0.0.1:{port}" |
| asset_dir.mkdir(parents=True, exist_ok=True) |
| trace_dir.mkdir(parents=True, exist_ok=True) |
|
|
| print( |
| textwrap.dedent( |
| f""" |
| Capturing Objectverse Diary demo assets |
| app: {app_url} |
| assets: {asset_dir} |
| traces: {trace_dir} |
| chrome: {chrome} |
| """ |
| ).strip() |
| ) |
|
|
| server = start_gradio(python_path, port, trace_dir, use_uv=args.use_uv) |
| try: |
| wait_for_http(app_url) |
| run_node_capture(app_url, asset_dir, chrome) |
| finally: |
| if server.poll() is None: |
| server.send_signal(signal.SIGINT) |
| try: |
| server.wait(timeout=8) |
| except subprocess.TimeoutExpired: |
| server.terminate() |
| server.wait(timeout=8) |
| if server.stdout: |
| output = server.stdout.read() |
| if output: |
| print(output) |
|
|
| expected = [ |
| "01-hero.png", |
| "02-intake-awake.png", |
| "03-object-file.png", |
| "04-diary.png", |
| "05-share-card.png", |
| "06-chat.png", |
| "07-trace.png", |
| ] |
| missing = [name for name in expected if not (asset_dir / name).exists()] |
| if missing: |
| raise RuntimeError(f"Missing captured assets: {', '.join(missing)}") |
| print("Captured assets:") |
| for name in expected: |
| print(f"- {asset_dir / name}") |
| return 0 |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(main()) |
|
|