benchkit-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- benchkit/__init__.py +1 -0
- benchkit/_pi_proxy.py +219 -0
- benchkit/answer_key_guard.ts +49 -0
- benchkit/artifacts.py +87 -0
- benchkit/benchmarks/__init__.py +105 -0
- benchkit/benchmarks/aider_polyglot.py +405 -0
- benchkit/benchmarks/arc.py +52 -0
- benchkit/benchmarks/banking77.py +149 -0
- benchkit/benchmarks/base.py +19 -0
- benchkit/benchmarks/boolq.py +49 -0
- benchkit/benchmarks/chartqa.py +133 -0
- benchkit/benchmarks/code_verifier.py +77 -0
- benchkit/benchmarks/downloads.py +166 -0
- benchkit/benchmarks/evalplus.py +288 -0
- benchkit/benchmarks/git_surgery.py +485 -0
- benchkit/benchmarks/gpqa.py +56 -0
- benchkit/benchmarks/gsm8k.py +97 -0
- benchkit/benchmarks/hellaswag.py +49 -0
- benchkit/benchmarks/humaneval.py +103 -0
- benchkit/benchmarks/ifeval.py +56 -0
- benchkit/benchmarks/ifeval_instructions.py +477 -0
- benchkit/benchmarks/mbpp.py +60 -0
- benchkit/benchmarks/mc_arena.py +479 -0
- benchkit/benchmarks/mcq.py +94 -0
- benchkit/benchmarks/medfailbench.py +537 -0
- benchkit/benchmarks/mmlu.py +54 -0
- benchkit/benchmarks/mmlu_pro.py +62 -0
- benchkit/benchmarks/mmvp.py +115 -0
- benchkit/benchmarks/ocrbench.py +96 -0
- benchkit/benchmarks/openbookqa.py +52 -0
- benchkit/benchmarks/patcheval.py +929 -0
- benchkit/benchmarks/piqa.py +52 -0
- benchkit/benchmarks/ruler.py +172 -0
- benchkit/benchmarks/ruler_generator.py +535 -0
- benchkit/benchmarks/sanity.py +174 -0
- benchkit/benchmarks/treejs_arena.py +268 -0
- benchkit/benchmarks/truthfulqa.py +52 -0
- benchkit/benchmarks/utils.py +25 -0
- benchkit/benchmarks/vision.py +230 -0
- benchkit/benchmarks/vstar.py +108 -0
- benchkit/benchmarks/winogrande.py +52 -0
- benchkit/benchmarks/xstest.py +116 -0
- benchkit/browser.py +429 -0
- benchkit/catalog.py +121 -0
- benchkit/cli.py +1000 -0
- benchkit/client.py +1413 -0
- benchkit/controls.py +87 -0
- benchkit/datasets/MMLU_LICENSE.txt +21 -0
- benchkit/datasets/README.md +142 -0
- benchkit/datasets/arc_challenge.jsonl +1172 -0
- benchkit/datasets/banking77.jsonl +3080 -0
- benchkit/datasets/banking77_intents.json +79 -0
- benchkit/datasets/boolq.jsonl +3270 -0
- benchkit/datasets/code_verifier.jsonl +500 -0
- benchkit/datasets/gpqa_diamond.jsonl +198 -0
- benchkit/datasets/gsm8k.jsonl +1319 -0
- benchkit/datasets/hellaswag.jsonl +1000 -0
- benchkit/datasets/humaneval.jsonl +164 -0
- benchkit/datasets/ifeval.jsonl +541 -0
- benchkit/datasets/mbpp.jsonl +500 -0
- benchkit/datasets/mc_blocks_1_20_1.jsonl +1003 -0
- benchkit/datasets/medfailbench_hard30.jsonl +30 -0
- benchkit/datasets/medfailbench_hard30.md +13 -0
- benchkit/datasets/openbookqa.jsonl +500 -0
- benchkit/datasets/piqa.jsonl +1838 -0
- benchkit/datasets/sanity_code.jsonl +6 -0
- benchkit/datasets/truthfulqa.jsonl +817 -0
- benchkit/datasets/winogrande.jsonl +1267 -0
- benchkit/datasets/xstest.csv +451 -0
- benchkit/decision.py +194 -0
- benchkit/demo.py +451 -0
- benchkit/dockerfiles.py +247 -0
- benchkit/engine.py +1270 -0
- benchkit/evaluation.py +195 -0
- benchkit/events.py +141 -0
- benchkit/executor.py +96 -0
- benchkit/git_surgery/backport-release-stack/setup.sh +328 -0
- benchkit/git_surgery/backport-release-stack/verify.sh +119 -0
- benchkit/git_surgery/bisect-the-regression/setup.sh +49 -0
- benchkit/git_surgery/bisect-the-regression/verify.sh +34 -0
- benchkit/git_surgery/rebase-conflict-chain/setup.sh +65 -0
- benchkit/git_surgery/rebase-conflict-chain/verify.sh +43 -0
- benchkit/git_surgery/recover-complex-stash/setup.sh +152 -0
- benchkit/git_surgery/recover-complex-stash/verify.sh +95 -0
- benchkit/git_surgery/recover-lost-work/setup.sh +40 -0
- benchkit/git_surgery/recover-lost-work/verify.sh +36 -0
- benchkit/git_surgery/repair-force-pushed-remote/setup.sh +171 -0
- benchkit/git_surgery/repair-force-pushed-remote/verify.sh +83 -0
- benchkit/git_surgery/revert-merge-with-followups/setup.sh +198 -0
- benchkit/git_surgery/revert-merge-with-followups/verify.sh +98 -0
- benchkit/git_surgery/secret-in-history/README.md +27 -0
- benchkit/git_surgery/secret-in-history/setup.sh +107 -0
- benchkit/git_surgery/secret-in-history/verify.sh +106 -0
- benchkit/git_surgery/split-the-mega-commit/setup.sh +50 -0
- benchkit/git_surgery/split-the-mega-commit/verify.sh +48 -0
- benchkit/git_surgery/untangle-nested-submodules/setup.sh +193 -0
- benchkit/git_surgery/untangle-nested-submodules/verify.sh +90 -0
- benchkit/history.py +474 -0
- benchkit/jobs.py +332 -0
- benchkit/leakage.py +51 -0
- benchkit/looping.py +873 -0
- benchkit/mc_render.py +274 -0
- benchkit/mc_viewer/dist/atlas.png +0 -0
- benchkit/mc_viewer/dist/blockStates.json.gz +0 -0
- benchkit/mc_viewer/dist/version.json +4 -0
- benchkit/mc_viewer/dist/viewer.html +23 -0
- benchkit/mc_viewer/dist/viewer.js +2 -0
- benchkit/mc_viewer/dist/viewer.js.LICENSE.txt +25 -0
- benchkit/mc_viewer/dist/worker.js +2 -0
- benchkit/mc_viewer/dist/worker.js.LICENSE.txt +19 -0
- benchkit/metrics.py +100 -0
- benchkit/ollama.py +60 -0
- benchkit/perf.py +353 -0
- benchkit/perf_report.py +207 -0
- benchkit/perturbations.py +208 -0
- benchkit/pi_agent.py +476 -0
- benchkit/pi_package/package-lock.json +1827 -0
- benchkit/pi_package/package.json +8 -0
- benchkit/report.py +142 -0
- benchkit/report_markdown.py +645 -0
- benchkit/results.py +688 -0
- benchkit/retry.py +163 -0
- benchkit/runner.py +769 -0
- benchkit/sandbox.py +1167 -0
- benchkit/template_check.py +291 -0
- benchkit/templates/arena.html +504 -0
- benchkit/templates/history.html +58 -0
- benchkit/templates/perf.html +509 -0
- benchkit/templates/report.html +769 -0
- benchkit/tui/__init__.py +5 -0
- benchkit/tui/app.py +87 -0
- benchkit/tui/app.tcss +512 -0
- benchkit/tui/formatting.py +84 -0
- benchkit/tui/screens/__init__.py +1 -0
- benchkit/tui/screens/connect.py +191 -0
- benchkit/tui/screens/detail.py +288 -0
- benchkit/tui/screens/help.py +84 -0
- benchkit/tui/screens/modals.py +544 -0
- benchkit/tui/screens/results.py +361 -0
- benchkit/tui/screens/run.py +988 -0
- benchkit/tui/screens/setup.py +733 -0
- benchkit/tui/theme.py +135 -0
- benchkit/tui/widgets.py +120 -0
- benchkit_cli-0.1.0.dist-info/METADATA +294 -0
- benchkit_cli-0.1.0.dist-info/RECORD +149 -0
- benchkit_cli-0.1.0.dist-info/WHEEL +5 -0
- benchkit_cli-0.1.0.dist-info/entry_points.txt +3 -0
- benchkit_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- benchkit_cli-0.1.0.dist-info/top_level.txt +1 -0
benchkit/mc_render.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Fixed-camera Minecraft build rendering for the mc-arena suite.
|
|
2
|
+
|
|
3
|
+
The renderer is prismarine-viewer, pinned to one Minecraft version and built
|
|
4
|
+
into ``mc_viewer/dist`` (see ``mc_viewer/build/README.md``). This module is the
|
|
5
|
+
Python half: it serves that bundle over a loopback HTTP server, hands one build
|
|
6
|
+
to the page, and photographs the result from three frozen camera positions.
|
|
7
|
+
|
|
8
|
+
Two builds are only comparable if they were photographed the same way, so the
|
|
9
|
+
volume, the camera rig, the lighting and the image size all live in the page's
|
|
10
|
+
own source and never vary per model or per run.
|
|
11
|
+
|
|
12
|
+
The page here is BenchKit's own code, not the model's. A model contributes a
|
|
13
|
+
list of block positions and nothing executable - its script already ran, under
|
|
14
|
+
Docker, in :func:`benchkit.sandbox.run_python_script`.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import atexit
|
|
20
|
+
import contextlib
|
|
21
|
+
import functools
|
|
22
|
+
import http.server
|
|
23
|
+
import json
|
|
24
|
+
import shutil
|
|
25
|
+
import socketserver
|
|
26
|
+
import tempfile
|
|
27
|
+
import threading
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from importlib.resources import files
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from benchkit.browser import chromium_executable, launch_args
|
|
33
|
+
|
|
34
|
+
# Frozen alongside the camera rig in ``mc_viewer/build/entry.js``.
|
|
35
|
+
VIEWS = ("iso", "side", "top")
|
|
36
|
+
BUILD_SIZE = 32
|
|
37
|
+
DEFAULT_TIMEOUT_S = 120.0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class MCRenderResult:
|
|
42
|
+
"""What one build's three photographs came back with."""
|
|
43
|
+
|
|
44
|
+
rendered: bool = False
|
|
45
|
+
views: dict[str, str] = field(default_factory=dict)
|
|
46
|
+
contact_sheet: str = ""
|
|
47
|
+
# Blocks the renderer placed, and ones its block table did not recognise.
|
|
48
|
+
placed: int = 0
|
|
49
|
+
unresolved: int = 0
|
|
50
|
+
page_errors: list[str] = field(default_factory=list)
|
|
51
|
+
console_errors: list[str] = field(default_factory=list)
|
|
52
|
+
# Set when this machine could not render at all (no browser, no WebGL).
|
|
53
|
+
# mc-arena scores the block list, not the picture, so this is a missing
|
|
54
|
+
# illustration rather than a failed task.
|
|
55
|
+
error: str = ""
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def diagnostics(self) -> str:
|
|
59
|
+
lines = [f"[uncaught] {message}" for message in self.page_errors]
|
|
60
|
+
lines += [f"[console] {message}" for message in self.console_errors]
|
|
61
|
+
if self.error:
|
|
62
|
+
lines.append(f"[renderer] {self.error}")
|
|
63
|
+
return "\n".join(lines)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def dist_dir() -> Path:
|
|
67
|
+
"""The vendored prismarine-viewer bundle shipped inside the package."""
|
|
68
|
+
return Path(str(files("benchkit").joinpath("mc_viewer/dist")))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def viewer_version() -> dict:
|
|
72
|
+
"""The pinned Minecraft version and prismarine-viewer commit."""
|
|
73
|
+
try:
|
|
74
|
+
return json.loads((dist_dir() / "version.json").read_text(encoding="utf-8"))
|
|
75
|
+
except (OSError, ValueError):
|
|
76
|
+
return {}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class _QuietHandler(http.server.SimpleHTTPRequestHandler):
|
|
80
|
+
"""The viewer's own asset requests are not run output."""
|
|
81
|
+
|
|
82
|
+
def log_message(self, *_args: object) -> None:
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
def do_GET(self) -> None:
|
|
86
|
+
# Chromium asks every page for a favicon; a 404 for one would be
|
|
87
|
+
# recorded as a console error against the build.
|
|
88
|
+
if self.path == "/favicon.ico":
|
|
89
|
+
self.send_response(204)
|
|
90
|
+
self.end_headers()
|
|
91
|
+
return
|
|
92
|
+
super().do_GET()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class _Site:
|
|
97
|
+
"""One loopback web server over a copy of the viewer bundle.
|
|
98
|
+
|
|
99
|
+
prismarine-viewer meshes chunks in a web worker, and Chromium refuses to
|
|
100
|
+
start a worker from a ``file://`` page, so the bundle is served over HTTP
|
|
101
|
+
on 127.0.0.1 instead. Nothing outside this machine can reach it.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
root: Path
|
|
105
|
+
port: int
|
|
106
|
+
server: socketserver.TCPServer
|
|
107
|
+
|
|
108
|
+
def url(self, build: str) -> str:
|
|
109
|
+
return f"http://127.0.0.1:{self.port}/viewer.html?build={build}"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
_SITE: _Site | None = None
|
|
113
|
+
_SITE_LOCK = threading.Lock()
|
|
114
|
+
_BUILD_COUNTER = 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _site() -> _Site:
|
|
118
|
+
"""Start this process's viewer server on first use."""
|
|
119
|
+
global _SITE
|
|
120
|
+
with _SITE_LOCK:
|
|
121
|
+
if _SITE is not None:
|
|
122
|
+
return _SITE
|
|
123
|
+
root = Path(tempfile.mkdtemp(prefix="benchkit-mc-viewer-"))
|
|
124
|
+
for asset in dist_dir().iterdir():
|
|
125
|
+
if asset.is_file():
|
|
126
|
+
shutil.copyfile(asset, root / asset.name)
|
|
127
|
+
handler = functools.partial(_QuietHandler, directory=str(root))
|
|
128
|
+
server = socketserver.ThreadingTCPServer(("127.0.0.1", 0), handler)
|
|
129
|
+
server.daemon_threads = True
|
|
130
|
+
threading.Thread(
|
|
131
|
+
target=server.serve_forever,
|
|
132
|
+
name="benchkit-mc-viewer",
|
|
133
|
+
daemon=True,
|
|
134
|
+
).start()
|
|
135
|
+
_SITE = _Site(root=root, port=server.server_address[1], server=server)
|
|
136
|
+
atexit.register(shutdown)
|
|
137
|
+
return _SITE
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def shutdown() -> None:
|
|
141
|
+
"""Stop the viewer server and remove its copy of the bundle."""
|
|
142
|
+
global _SITE
|
|
143
|
+
with _SITE_LOCK:
|
|
144
|
+
site, _SITE = _SITE, None
|
|
145
|
+
if site is None:
|
|
146
|
+
return
|
|
147
|
+
site.server.shutdown()
|
|
148
|
+
site.server.server_close()
|
|
149
|
+
shutil.rmtree(site.root, ignore_errors=True)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _stage_build(blocks: list[dict]) -> str:
|
|
153
|
+
"""Write one build next to the viewer and return the name to fetch it by."""
|
|
154
|
+
global _BUILD_COUNTER
|
|
155
|
+
site = _site()
|
|
156
|
+
with _SITE_LOCK:
|
|
157
|
+
_BUILD_COUNTER += 1
|
|
158
|
+
name = f"build-{_BUILD_COUNTER:05d}.json"
|
|
159
|
+
(site.root / name).write_text(json.dumps(blocks), encoding="utf-8")
|
|
160
|
+
return name
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _render(
|
|
164
|
+
blocks: list[dict],
|
|
165
|
+
out_dir: Path,
|
|
166
|
+
timeout_s: float,
|
|
167
|
+
result: MCRenderResult,
|
|
168
|
+
) -> None:
|
|
169
|
+
"""Photograph one build and fill ``result`` in place."""
|
|
170
|
+
try:
|
|
171
|
+
from playwright.sync_api import Error as PlaywrightError
|
|
172
|
+
from playwright.sync_api import sync_playwright
|
|
173
|
+
except ImportError:
|
|
174
|
+
result.error = (
|
|
175
|
+
"playwright is not installed; install the browser extra "
|
|
176
|
+
"(uv sync --extra browser) and run 'playwright install chromium'"
|
|
177
|
+
)
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
site = _site()
|
|
181
|
+
staged = _stage_build(blocks)
|
|
182
|
+
url = site.url(staged)
|
|
183
|
+
timeout_ms = timeout_s * 1000
|
|
184
|
+
try:
|
|
185
|
+
with sync_playwright() as playwright:
|
|
186
|
+
browser = playwright.chromium.launch(
|
|
187
|
+
args=launch_args(),
|
|
188
|
+
executable_path=chromium_executable(),
|
|
189
|
+
)
|
|
190
|
+
try:
|
|
191
|
+
context = browser.new_context(
|
|
192
|
+
# Wide enough for the three views side by side.
|
|
193
|
+
viewport={"width": 2000, "height": 760},
|
|
194
|
+
accept_downloads=False,
|
|
195
|
+
service_workers="block",
|
|
196
|
+
)
|
|
197
|
+
context.set_default_timeout(timeout_ms)
|
|
198
|
+
page = context.new_page()
|
|
199
|
+
page.on(
|
|
200
|
+
"console",
|
|
201
|
+
lambda message: (
|
|
202
|
+
result.console_errors.append(message.text)
|
|
203
|
+
if message.type == "error"
|
|
204
|
+
else None
|
|
205
|
+
),
|
|
206
|
+
)
|
|
207
|
+
page.on(
|
|
208
|
+
"pageerror", lambda error: result.page_errors.append(str(error))
|
|
209
|
+
)
|
|
210
|
+
page.goto(url, wait_until="load", timeout=timeout_ms)
|
|
211
|
+
page.wait_for_function(
|
|
212
|
+
"window.__mcarena !== undefined", timeout=timeout_ms
|
|
213
|
+
)
|
|
214
|
+
state = page.evaluate("window.__mcarena") or {}
|
|
215
|
+
if not state.get("ok"):
|
|
216
|
+
result.error = str(state.get("error") or "the viewer did not run")
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
result.placed = int(state.get("placed") or 0)
|
|
220
|
+
result.unresolved = int(state.get("unresolved") or 0)
|
|
221
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
222
|
+
for view in VIEWS:
|
|
223
|
+
shot = out_dir / f"{view}.png"
|
|
224
|
+
page.locator(f"#view-{view}").screenshot(
|
|
225
|
+
path=str(shot), timeout=timeout_ms
|
|
226
|
+
)
|
|
227
|
+
result.views[view] = str(shot)
|
|
228
|
+
sheet = out_dir / "views.png"
|
|
229
|
+
page.locator("#views").screenshot(path=str(sheet), timeout=timeout_ms)
|
|
230
|
+
result.contact_sheet = str(sheet)
|
|
231
|
+
result.rendered = True
|
|
232
|
+
finally:
|
|
233
|
+
browser.close()
|
|
234
|
+
except PlaywrightError as exc:
|
|
235
|
+
message = str(exc).strip().splitlines()[0] if str(exc).strip() else repr(exc)
|
|
236
|
+
if "executable doesn't exist" in message.lower():
|
|
237
|
+
result.error = (
|
|
238
|
+
"chromium is not installed for playwright (run "
|
|
239
|
+
"'playwright install chromium', or set BENCHKIT_RENDER_CHROMIUM "
|
|
240
|
+
f"to an existing build): {message}"
|
|
241
|
+
)
|
|
242
|
+
else:
|
|
243
|
+
result.error = message
|
|
244
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
245
|
+
result.error = f"{type(exc).__name__}: {exc}"
|
|
246
|
+
finally:
|
|
247
|
+
# The viewer has read it by now, and a long run would otherwise leave
|
|
248
|
+
# one of these behind per task until the process exits.
|
|
249
|
+
with contextlib.suppress(OSError):
|
|
250
|
+
(site.root / staged).unlink()
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def render_build(
|
|
254
|
+
blocks: list[dict],
|
|
255
|
+
out_dir: Path,
|
|
256
|
+
*,
|
|
257
|
+
timeout_s: float = DEFAULT_TIMEOUT_S,
|
|
258
|
+
) -> MCRenderResult:
|
|
259
|
+
"""Render one block list from three fixed cameras into ``out_dir``."""
|
|
260
|
+
result = MCRenderResult()
|
|
261
|
+
# Playwright's sync API refuses to run in a thread with a live asyncio
|
|
262
|
+
# loop, which is exactly where the TUI evaluates tasks.
|
|
263
|
+
worker = threading.Thread(
|
|
264
|
+
target=_render,
|
|
265
|
+
args=(blocks, out_dir, timeout_s, result),
|
|
266
|
+
name="benchkit-mc-render",
|
|
267
|
+
daemon=True,
|
|
268
|
+
)
|
|
269
|
+
worker.start()
|
|
270
|
+
worker.join(timeout=timeout_s + 60.0)
|
|
271
|
+
if worker.is_alive():
|
|
272
|
+
result.rendered = False
|
|
273
|
+
result.error = f"the build did not render within its {timeout_s:g}s budget"
|
|
274
|
+
return result
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>BenchKit mc-arena viewer</title>
|
|
6
|
+
<style>
|
|
7
|
+
:root { color-scheme: light; }
|
|
8
|
+
body { margin: 0; background: #ffffff; font: 13px "Avenir Next", Avenir, sans-serif; color: #0b0b0b; }
|
|
9
|
+
#views { display: flex; gap: 0; align-items: flex-start; }
|
|
10
|
+
figure { margin: 0; }
|
|
11
|
+
img { display: block; }
|
|
12
|
+
figcaption { padding: 6px 8px; color: #6b7280; text-transform: uppercase; letter-spacing: .08em; font-size: 11px; }
|
|
13
|
+
/* Interactive mode: one canvas, filling the window, no captions. */
|
|
14
|
+
body.interactive { overflow: hidden; background: #dfe6ee; }
|
|
15
|
+
body.interactive #views { display: block; }
|
|
16
|
+
body.interactive canvas { display: block; width: 100vw; height: 100vh; }
|
|
17
|
+
</style>
|
|
18
|
+
</head>
|
|
19
|
+
<body>
|
|
20
|
+
<div id="views"></div>
|
|
21
|
+
<script src="viewer.js"></script>
|
|
22
|
+
</body>
|
|
23
|
+
</html>
|