Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
b58ae9e
Refine generation→repair pipeline: resilience, latency, converging re…
Jun 13, 2026
b426c44
Harden server: catch-all 500 + client-disconnect guard
Jun 14, 2026
c50b05b
Faster + more reliable generation: validator, auto-fixer, library, cache
Maxpeng59 Jun 14, 2026
7bdf7cb
Validator catches off-screen draws (data-vs-pixel coordinate mixups)
Maxpeng59 Jun 14, 2026
6cd20c7
Integrate 50-scene verified STEM library + smarter retrieval
Maxpeng59 Jun 14, 2026
4116ad6
Validator catches motion that drifts off-screen and never loops
Maxpeng59 Jun 15, 2026
f27a2fe
Merge main into refine-generation-repair-pipeline (resolve squash-mer…
Jun 15, 2026
883202f
Catch drifting-subject motion + steer the model away from it up front
Maxpeng59 Jun 15, 2026
191e34f
Pedagogy: teach the mechanism, don't solve the student's problem
Jun 15, 2026
64e8118
Run VisualLM as an app: one-click launcher + native-window option
Jun 15, 2026
8af5deb
Fix Pylance type error: narrow node path before subprocess.run
Jun 15, 2026
82df16d
Build VisualLM as a real macOS app (double-click .app + custom icon)
Jun 16, 2026
ef63a25
App foundation: in-process entry point + frozen-aware asset paths
Jun 16, 2026
ad559a3
Self-contained app: PyInstaller bundle (dist/VisualLM.app)
Jun 16, 2026
c5e5692
Test coverage for the app launcher (launch.py + app_main)
Jun 16, 2026
c01b413
Fix auto-fixer corrupting scenes that declare their own PI/TAU/math-f…
Jun 16, 2026
0a34690
Fix sanitize_code corrupting t/H/ctx used as call or array values
Jun 16, 2026
46434de
Lock sanitize_code unwrap behavior with edge-case tests
Jun 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Copy this file to ".env" (same folder) and fill in your key(s).
# The launcher (launch.py / VisualLM.command / desktop.py) loads it automatically.
# Format is one KEY=VALUE per line. Anything already set in your shell wins.

# Best-quality animations (recommended). Get a key at https://console.anthropic.com
ANTHROPIC_API_KEY=sk-ant-...

# Optional alternatives / fallbacks (used only if Anthropic isn't set):
# OPENAI_API_KEY=sk-...
# GEMINI_API_KEY=...

# No cloud key? VisualLM falls back to a local Ollama model if one is running
# (install from https://ollama.com, then e.g. ollama pull qwen2.5:7b).

# Optional: pin the port (default 4173).
# VISUALLM_PORT=4173

# Optional: require a shared access code to generate (for a published instance).
# VISUALLM_ACCESS_CODE=letmein
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,12 @@ __pycache__/
.env
.venv/
venv/

# macOS app build artifacts (run ./make_app.sh to regenerate)
VisualLM.app/
VisualLM.iconset/
VisualLM.png
VisualLM.icns
build/
dist/
*.spec.bak
47 changes: 37 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,22 +101,43 @@ Generation tries providers in this order — the first one with a key wins:

All cloud providers are called over plain REST — no extra SDKs required.

## Run locally
## Run it (as an app)

The easy way — after a one-time setup, **no terminal needed**:

1. **Set a key (once).** Copy `.env.example` to `.env` and paste your key:
`ANTHROPIC_API_KEY=sk-ant-...`. For Claude also run `pip install -r requirements.txt`.
No cloud key? Install [Ollama](https://ollama.com) and `ollama pull qwen2.5:7b`
for a local fallback.
2. **Launch it.**
- **macOS:** double-click **`VisualLM.command`** in Finder (first time: right-click → Open to clear the macOS warning).
- **Any OS:** `python3 launch.py`

The launcher loads `.env`, starts the server on a free port, waits until it's
healthy, and opens VisualLM in its own app-style window. Keep that window open;
Ctrl+C (or closing it) stops everything.

**Prefer a true native window** (no browser chrome at all)? `pip install pywebview`
then `python3 desktop.py`.

**Want a real, self-contained app** (bundles Python — no terminal, no repo needed
to run)? `./build_app.sh` produces **`dist/VisualLM.app`** via PyInstaller; move it
to /Applications and double-click. It runs the server in-process (`app_main.py`)
and serves bundled assets. For Claude inside the bundle, `pip install anthropic`
before building; for a native window, `pip install pywebview` before building.
The packaged app reads a `.env` placed next to `VisualLM.app` or in `~/.visuallm/`.

### Plain manual start

Equivalent to what the launcher does, if you'd rather drive it yourself:

```bash
# 1. (Recommended) enable at least one cloud generator
pip install -r requirements.txt # only needed for Claude
export ANTHROPIC_API_KEY=sk-ant-... # and/or OPENAI_API_KEY / GEMINI_API_KEY

# 2. (Optional) local fallback + tutor: run Ollama with a model
ollama pull qwen2.5:7b

# 3. Start the app
python3 main.py
ollama pull qwen2.5:7b # optional local fallback + tutor
python3 main.py # then open http://127.0.0.1:4173
```

Then open <http://127.0.0.1:4173>.

## Deploy to the web

The repo is deploy-ready for any Docker host. The server reads `PORT` and
Expand Down Expand Up @@ -165,6 +186,12 @@ HTTP round-trips against every endpoint (with stubbed generators).
- `app.js` — orchestration: sandbox runner, generate→run→repair loop, orbit
controls, playback, tutor chat, resources, status.
- `index.html` / `styles.css` — app structure and visual design.
- `launch.py` / `VisualLM.command` / `desktop.py` — run VisualLM as an app:
the one-click launcher (loads `.env`, starts the server on a free port, opens
an app-style window), the macOS double-click wrapper, and the optional
native-window version (pywebview). `.env.example` is the key template.
- `stem-viz-plugin/` — the scene-generation capability packaged as a portable
Claude skill + plugin (drop into any AI); see its own README.
- `Dockerfile` / `render.yaml` — production deployment (Docker image bundles
Node for the validator).
- `tests/` — stdlib-only test suite (covers the validator, auto-fixer,
Expand Down
5 changes: 5 additions & 0 deletions VisualLM.command
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/bin/bash
# Double-click this file in Finder to launch VisualLM like an app.
# (macOS may ask once to confirm running it — right-click → Open the first time.)
cd "$(dirname "$0")" || exit 1
exec python3 launch.py
74 changes: 74 additions & 0 deletions VisualLM.spec
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# -*- mode: python ; coding: utf-8 -*-
# PyInstaller spec — freezes VisualLM into a self-contained, double-click macOS app.
#
# ./build_app.sh # regenerates the icon, then runs `pyinstaller VisualLM.spec`
# open dist/VisualLM.app
#
# The frozen app runs the server in-process (app_main.py) and serves the bundled
# static assets via main.py's BASE_DIR -> sys._MEIPASS. It opens a native
# pywebview window if pywebview was importable at build time, else an app-style
# browser window. To include Claude, `pip install anthropic` before building;
# otherwise the bundle uses OpenAI / Gemini / Ollama (all stdlib HTTP).
from pathlib import Path

# Static + data files the server reads at runtime, placed at the bundle root.
_ASSETS = [
"index.html", "app.js", "sandbox-worker.js", "styles.css",
"validate_scene.js", "scene_library.py", "scene_library_generated.json",
]
datas = [(a, ".") for a in _ASSETS if Path(a).exists()]

# pywebview is optional: include its backend only if it's installed, so the
# build works without it (app_main.py falls back to a browser window).
hiddenimports = ["main", "launch"]
try:
import webview # noqa: F401
hiddenimports.append("webview")
except ImportError:
pass

icon = "VisualLM.icns" if Path("VisualLM.icns").exists() else None

a = Analysis(
["app_main.py"],
pathex=["."],
binaries=[],
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
runtime_hooks=[],
excludes=[],
noarchive=False,
)
pyz = PYZ(a.pure)

exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name="VisualLM",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
console=False, # windowed app — no terminal
argv_emulation=False,
icon=icon,
)
coll = COLLECT(exe, a.binaries, a.datas, strip=False, upx=False, name="VisualLM")

app = BUNDLE(
coll,
name="VisualLM.app",
icon=icon,
bundle_identifier="com.visuallm.app",
info_plist={
"CFBundleName": "VisualLM",
"CFBundleDisplayName": "VisualLM",
"CFBundleShortVersionString": "1.0",
"CFBundleVersion": "1.0",
"NSHighResolutionCapable": True,
"LSMinimumSystemVersion": "10.13",
},
)
104 changes: 104 additions & 0 deletions app_main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""VisualLM native-app entry point — runs the server IN-PROCESS and opens a window.

This is what PyInstaller freezes into VisualLM.app, and it also works unfrozen
(`python3 app_main.py`). Unlike launch.py / desktop.py — which spawn `python
main.py` as a subprocess for the dev workflow — this imports the server and runs
it in a background thread, because a frozen app's `sys.executable` is the app
binary, not a Python interpreter, so spawning a subprocess can't work once bundled.

Flags:
--no-window start the server only (print the URL, keep serving)
--smoke start, confirm /api/health, stop, exit 0 (for testing)
"""
from __future__ import annotations

import os
import sys
import threading
from http.server import ThreadingHTTPServer
from pathlib import Path

import launch # reuse load_dotenv / free_port / wait_healthy / open_app_window

HERE = Path(__file__).resolve().parent


def _env_files() -> list[Path]:
"""Where to look for a .env. When frozen (inside VisualLM.app) __file__ is
in the bundle, so also check next to the .app and a per-user config dir —
that's where a user can drop ANTHROPIC_API_KEY=... for the packaged app."""
if getattr(sys, "frozen", False):
out: list[Path] = []
exe = Path(sys.executable).resolve()
# .../VisualLM.app/Contents/MacOS/VisualLM -> the folder holding the .app
if len(exe.parents) >= 4:
out.append(exe.parents[3] / ".env")
out.append(Path.home() / ".visuallm" / ".env")
return out
return [HERE / ".env"]


def main() -> int:
for env_file in _env_files():
launch.load_dotenv(env_file)
forced = os.environ.get("VISUALLM_PORT")
port = int(forced) if forced else launch.free_port(4173)
os.environ["VISUALLM_PORT"] = str(port)
os.environ.setdefault("VISUALLM_HOST", "127.0.0.1")
url = f"http://127.0.0.1:{port}"

# Importing the server module is side-effect-free (its server start is under
# an `if __name__ == "__main__"` guard); we drive it ourselves below.
import main as server_mod

httpd = ThreadingHTTPServer(("127.0.0.1", port), server_mod.VisualLMHandler)
threading.Thread(target=httpd.serve_forever, daemon=True).start()

smoke = "--smoke" in sys.argv
no_window = smoke or "--no-window" in sys.argv
try:
if not launch.wait_healthy(f"{url}/api/health"):
print("✗ Server did not become healthy.")
return 1
print(f"✓ VisualLM is up on {url}")
if smoke:
# Also confirm static assets serve — the real test that bundled data
# files (index.html etc.) resolve via BASE_DIR when frozen.
import urllib.request
try:
with urllib.request.urlopen(url + "/", timeout=3) as r:
served = r.status == 200 and b"VisualLM" in r.read()
except Exception: # noqa: BLE001
served = False
print("✓ static assets served (index.html)" if served else "✗ static assets NOT served")
print("✓ Smoke check passed." if served else "✗ Smoke check FAILED.")
return 0 if served else 1
if no_window:
print(f" Open {url} in your browser. Ctrl+C to stop.")
_block()
return 0
try:
import webview # pywebview → true native window
except ImportError:
print("• pywebview not installed — opening an app-style browser window.")
print(" (pip install pywebview for a true native window.)")
launch.open_app_window(url)
_block()
return 0
webview.create_window("VisualLM", url, width=1280, height=820, min_size=(900, 600))
webview.start() # blocks on the main thread until the window closes
return 0
finally:
httpd.shutdown()


def _block() -> None:
try:
threading.Event().wait()
except KeyboardInterrupt:
pass


if __name__ == "__main__":
raise SystemExit(main())
38 changes: 38 additions & 0 deletions build_app.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/bin/bash
# Build a SELF-CONTAINED, double-click VisualLM.app with PyInstaller.
#
# ./build_app.sh
# open dist/VisualLM.app
#
# Unlike make_app.sh (a lightweight wrapper that needs the repo + system Python),
# this produces a standalone bundle that includes Python and all dependencies.
# To bundle Claude support, `pip install anthropic` first; otherwise the app
# uses OpenAI / Gemini / Ollama. For a true native window, `pip install pywebview`
# before building; otherwise it opens an app-style browser window.
#
# macOS only (uses sips/iconutil for the icon). Output: dist/VisualLM.app
set -euo pipefail
cd "$(dirname "$0")"

echo "• Ensuring PyInstaller is available …"
python3 -c "import PyInstaller" 2>/dev/null || python3 -m pip install --disable-pip-version-check pyinstaller

echo "• Generating icon …"
python3 make_icon.py VisualLM.png >/dev/null
ICONSET="VisualLM.iconset"; rm -rf "$ICONSET"; mkdir "$ICONSET"
gen() { sips -z "$2" "$2" VisualLM.png --out "$ICONSET/$1" >/dev/null; }
gen icon_16x16.png 16; gen icon_16x16@2x.png 32
gen icon_32x32.png 32; gen icon_32x32@2x.png 64
gen icon_128x128.png 128; gen icon_128x128@2x.png 256
gen icon_256x256.png 256; gen icon_256x256@2x.png 512
gen icon_512x512.png 512; gen icon_512x512@2x.png 1024
iconutil -c icns "$ICONSET" -o VisualLM.icns
rm -rf "$ICONSET" VisualLM.png

echo "• Freezing app with PyInstaller …"
python3 -m PyInstaller --noconfirm --clean VisualLM.spec

rm -f VisualLM.icns
echo "✓ Built dist/VisualLM.app"
echo " Verify: dist/VisualLM.app/Contents/MacOS/VisualLM --smoke"
echo " Run: open dist/VisualLM.app"
59 changes: 59 additions & 0 deletions desktop.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Run VisualLM as a NATIVE desktop window (no browser chrome at all).

This is the most "real app" option. It needs one extra package:

pip install pywebview
python3 desktop.py

It starts the local server in the background and opens VisualLM in a native OS
window (WKWebView on macOS, WebView2 on Windows, GTK/Qt on Linux). API keys via
.env work exactly as in launch.py. If you don't want the extra dependency, use
`python3 launch.py` instead — it opens an app-style browser window.
"""
from __future__ import annotations

import os
import subprocess
import sys
from pathlib import Path

import launch # reuse .env loading, free_port, and the health check

HERE = Path(__file__).resolve().parent


def main() -> int:
try:
import webview # pywebview
except ImportError:
print("This needs pywebview: pip install pywebview")
print("(or just run: python3 launch.py — opens an app-style browser window)")
return 1

launch.load_dotenv(HERE / ".env")
forced = os.environ.get("VISUALLM_PORT")
port = int(forced) if forced else launch.free_port(4173)
os.environ["VISUALLM_PORT"] = str(port)
os.environ.setdefault("VISUALLM_HOST", "127.0.0.1")
url = f"http://127.0.0.1:{port}"

server = subprocess.Popen([sys.executable, str(HERE / "main.py")], cwd=str(HERE))
try:
if not launch.wait_healthy(f"{url}/api/health"):
print("✗ The server did not start — see output above.")
return 1
webview.create_window("VisualLM", url, width=1280, height=820, min_size=(900, 600))
webview.start() # blocks until the window closes (must run on main thread)
finally:
if server.poll() is None:
server.terminate()
try:
server.wait(timeout=5)
except subprocess.TimeoutExpired:
server.kill()
return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading