Skip to content

Commit e0fe581

Browse files
jmjavacursoragent
andauthored
Vue desktop GUI and PyInstaller freeze path (#71)
* Add Vue desktop GUI and PyInstaller freeze path Introduce docgen gui / docgen-gui as a Vue + Flask shell over the wizard, with a Benchmark view that runs the standard scene-timing corpus. Asset paths go through docgen.resources so templates, vendor Vue, and benchmark_data resolve in an editable install and under sys._MEIPASS. The freeze target is packaging/docgen-gui.spec (not the full Manim CLI). Co-authored-by: jmjava <jmjava@gmail.com> * Fix Vue/Jinja clash and frozen-path lookup Wrap the Benchmark view in a Jinja raw block so Vue mustaches are not compiled as templates. Make meipass_dir tolerate a missing _MEIPASS so is_frozen patches in tests cannot crash create_app. Co-authored-by: jmjava <jmjava@gmail.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 55fb792 commit e0fe581

20 files changed

Lines changed: 885 additions & 10 deletions

AGENTS.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ The Playwright/VHS/demo-function/per-function/discover-tests/catalog surface are
4141
Commands registered on the **`docgen`** CLI include:
4242

4343
- **`init`** — scaffold bundle layout and `docgen.yaml`.
44-
- **`wizard`** — local web UI for narration/bootstrap workflows (focus files, **in-place narration revise**, per-segment **asset freshness** + **rebuild-from-here**, **Tool** tab to pip-upgrade docgen and pin `requirements-docgen.txt`).
44+
- **`wizard`** — local web UI for narration/bootstrap workflows (focus files, **in-place narration revise**, per-segment **asset freshness** + **rebuild-from-here**, Vue **Benchmark** view, **Tool** tab to pip-upgrade docgen and pin `requirements-docgen.txt`).
45+
- **`gui`** — desktop window over the same Vue/Flask UI (`pip install 'docgen[gui]'` for pywebview). PyInstaller spec: ``packaging/docgen-gui.spec`` (`pip install 'docgen[packaging]'`). Frozen apps resolve templates/static/benchmark JSON via ``docgen.resources``.
4546
- **`tts`** — text-to-speech for segment files.
4647
- **`timestamps`** — word/segment timing (`timing.json`). Default engine **`local`** aligns the known narration text against the mp3 offline (ffmpeg silencedetect, no API); **`--engine whisper`** keeps OpenAI whisper-1 transcription. Both emit the same Whisper-shaped blocks.
4748
- **`image-generate`** — render scene-spec **image elements** (`image:` + `prompt:` boxes) via the OpenAI Images API into the bundle (also runs for missing assets inside `generate-all`).
@@ -84,7 +85,7 @@ String assertions on compiled ``scenes.py`` and ``simulate_reveal_timeline`` are
8485

8586
## Testing (downstream relevance)
8687

87-
Tests should cover **CLI-visible behavior** and contracts that adopters rely on: **`yaml-generate`**, **`scene-spec-generate`**, **`scene-compile`**, **`validate`**, **`compose`**, **`generate-all`**, **`pages`**, **`init`**, **`benchmark`**, **config** loading (`repo_root`, `env_file`), and package exports. Use small in-tree fixtures; this library does not ship a dogfood bundle. Clock / compile changes must keep ``docgen benchmark`` at or above ``benchmark_data/baseline.json`` — string assertions on ``scenes.py`` are not enough.
88+
Tests should cover **CLI-visible behavior** and contracts that adopters rely on: **`yaml-generate`**, **`scene-spec-generate`**, **`scene-compile`**, **`validate`**, **`compose`**, **`generate-all`**, **`pages`**, **`init`**, **`benchmark`**, **`gui`** / freeze-safe ``docgen.resources`` paths, **config** loading (`repo_root`, `env_file`), and package exports. Use small in-tree fixtures; this library does not ship a dogfood bundle. Clock / compile changes must keep ``docgen benchmark`` at or above ``benchmark_data/baseline.json`` — string assertions on ``scenes.py`` are not enough. Do not run a full PyInstaller freeze in routine pytest.
8889

8990
## Cursor Cloud specific instructions
9091

@@ -94,4 +95,4 @@ Tests should cover **CLI-visible behavior** and contracts that adopters rely on:
9495
- **OpenAI-gated vs offline commands:** `tts`, `timestamps --engine whisper`, `image-generate`, `narration-generate`, `scene-spec-generate`, and `yaml-generate --llm` call OpenAI and need `OPENAI_API_KEY` (integration tests auto-skip without it). Fully offline: `init`, `scene-compile`, `manim`, `compose`, `validate`, `lint`, `pages`, `concat`, `yaml-generate` (no `--llm`), `timestamps` (default `local` engine), and `benchmark`.
9596
- **`scene-compile` gotcha:** paced specs (`wait_word`) need a `timing.json` entry for that stem (`docgen timestamps` after TTS). Prefer `scene-compile --retime` after fresh timestamps; for a fully offline smoke render, author rows without wait indices only if you accept unpaced reveals.
9697
- **No in-repo dogfood bundle:** exercise the pipeline against a scratch bundle (`docgen init /tmp/<name> --defaults` in a throwaway git dir). Do not hand-edit consumer generated assets (see `.cursor/rules/no-asset-edits.mdc`).
97-
- **Wizard:** `docgen wizard --port 8501` is an optional local Flask app (long-lived); run it from a bundle directory that contains `docgen.yaml`.
98+
- **Wizard / desktop GUI:** `docgen wizard --port 8501` is the Flask UI (bundle optional for the Benchmark view). `docgen gui` / `docgen benchmark --gui` open the Vue benchmark view in a desktop window when `pywebview` is installed. Do not run a full PyInstaller freeze in routine CI; keep `packaging/docgen-gui.spec` and `docgen.gui.packaging` in sync instead.

README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,10 @@ pip install 'docgen @ git+https://github.com/jmjava/documentation-generator.git'
8080
# Prefer a SHA pin in CI:
8181
# pip install 'docgen @ git+https://github.com/jmjava/documentation-generator.git@<sha>'
8282

83-
# Optional Manim extra
83+
# Optional extras
8484
pip install 'docgen[manim] @ git+https://github.com/jmjava/documentation-generator.git'
85+
pip install 'docgen[gui] @ git+https://github.com/jmjava/documentation-generator.git' # pywebview desktop window
86+
# Freeze the Vue GUI only (not the full Manim CLI): pip install 'docgen[packaging]' && pyinstaller packaging/docgen-gui.spec
8587

8688
# Isolated global CLI (no project venv)
8789
pipx install 'docgen @ git+https://github.com/jmjava/documentation-generator.git'
@@ -100,6 +102,8 @@ cd documentation-generator
100102
python3 -m venv .venv
101103
source .venv/bin/activate
102104
pip install -e ".[dev]"
105+
# Optional: pip install -e ".[gui]" # pywebview window for `docgen gui`
106+
# Optional: pip install -e ".[packaging]" # PyInstaller (see packaging/README.md)
103107
pytest
104108
docgen benchmark # required: scene-timing corpus vs committed baseline
105109
```
@@ -125,7 +129,8 @@ docgen validate --pre-push
125129
|---------|-------------|
126130
| `docgen --version` | Show installed version + recommended `pip install` line (external tool) |
127131
| `docgen init [TARGET_DIR] [--defaults] [--segments-file FILE]` | Scaffold a bundle: `docgen.yaml`, `requirements-docgen.txt`, wrapper scripts, directories |
128-
| `docgen wizard [--port 8501]` | Local web GUI: focus files, **revise narration**, asset freshness / rebuild-from-here, and a **Tool** tab to upgrade the installed `docgen` package (pip) + rewrite `requirements-docgen.txt` |
132+
| `docgen wizard [--port 8501]` | Local web GUI: focus files, **revise narration**, asset freshness / rebuild-from-here, Vue **Benchmark** view, and a **Tool** tab to upgrade the installed `docgen` package (pip) + rewrite `requirements-docgen.txt` |
133+
| `docgen gui [--view benchmark] [--browser]` | Desktop GUI (Vue + Flask). Install `docgen[gui]` for a pywebview window; `--browser` uses the system browser. Freeze with `pyinstaller packaging/docgen-gui.spec` |
129134
| `docgen tts [--segment 01] [--dry-run]` | Generate TTS audio |
130135
| `docgen timestamps [--engine local\|whisper]` | Extract word/segment timestamps from TTS audio → `timing.json` (default `local`: offline narration-text alignment; `whisper`: OpenAI transcription) |
131136
| `docgen image-generate [--segment 01 \| --all \| --spec PATH] [--force] [--dry-run] [--model …] [--size …]` | Generate scene-spec image assets (`image:` + `prompt:` boxes) via the OpenAI Images API into the bundle |
@@ -142,7 +147,7 @@ docgen validate --pre-push
142147
| `docgen yaml-generate [--merge-defaults] [--llm] [--dry-run] [--list-gaps]` | Merge defaults into `docgen.yaml`; optional OpenAI refresh of `tts.instructions` / `wizard.system_prompt` (rewrites the file — review in Git) |
143148
| `docgen scene-compile [SPEC.scene.yaml \| --all] [--retime] [--dry-run]` | Compile declarative scene YAML into `animations/scenes.py`. **`--all --retime`** re-derives `wait_word` from current `timing.json` with no OpenAI; unmatched labels fail closed (or set `pace: none`) |
144149
| `docgen scene-spec-generate [--segment 01 \| --all] [--compile] [--print-only] [--output PATH] [--hint …] [--model …]` | Call OpenAI to emit YAML only (same schema as `scene-compile`); rejects frame-budget overflow and **subject-beat coverage** failures (hold board on same topic; cover topic shifts; no invented labels — not a blind count); auto-paginate + word-alignment; optionally writes `animations/specs/<stem>.scene.yaml` and `--compile`s into `scenes.py` |
145-
| `docgen benchmark [--case ID] [--format text\|json] [--update-baseline]` | Score the **standard scene-timing corpus** (execute compiled `construct()` on the real `_TimedScene` clock, no Manim). Diffs a committed baseline so clock changes are measured, not guessed |
150+
| `docgen benchmark [--case ID] [--format text\|json] [--update-baseline] [--gui]` | Score the **standard scene-timing corpus** (execute compiled `construct()` on the real `_TimedScene` clock, no Manim). Diffs a committed baseline so clock changes are measured, not guessed. `--gui` opens the Vue view |
146151

147152
## Configuration
148153

packaging/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Packaging the desktop GUI
2+
3+
The freeze target is **`docgen-gui`**: a Vue window over the local Flask
4+
wizard (benchmark view first). It is **not** a frozen copy of the full
5+
`docgen` CLI (Manim / ffmpeg / OpenAI stay on the pip tool).
6+
7+
```bash
8+
pip install -e '.[packaging]'
9+
pyinstaller packaging/docgen-gui.spec
10+
# dist/docgen-gui/docgen-gui
11+
```
12+
13+
From a source checkout you can run the same entry without freezing:
14+
15+
```bash
16+
pip install -e '.[gui]'
17+
docgen gui # pywebview window if the extra is installed
18+
docgen gui --browser # system browser fallback
19+
docgen gui --config path/to/docgen.yaml
20+
docgen benchmark --gui
21+
python -m docgen.gui
22+
```
23+
24+
Asset paths use ``docgen.resources.package_root()`` so templates, static
25+
Vue files, and ``benchmark_data/`` resolve both in an editable install and
26+
under ``sys._MEIPASS`` after PyInstaller.

packaging/docgen-gui.spec

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# -*- mode: python ; coding: utf-8 -*-
2+
"""PyInstaller spec for the Vue desktop GUI (not the full Manim CLI).
3+
4+
From the repo root, after ``pip install -e '.[packaging]'``:
5+
6+
pyinstaller packaging/docgen-gui.spec
7+
"""
8+
9+
import sys
10+
from pathlib import Path
11+
12+
SPECDIR = Path(SPECPATH).resolve().parent
13+
ROOT = SPECDIR.parent
14+
sys.path.insert(0, str(ROOT / "src"))
15+
16+
from docgen.gui.packaging import pyinstaller_datas, pyinstaller_hiddenimports # noqa: E402
17+
18+
a = Analysis(
19+
[str(ROOT / "src" / "docgen" / "gui" / "__main__.py")],
20+
pathex=[str(ROOT / "src")],
21+
binaries=[],
22+
datas=pyinstaller_datas(),
23+
hiddenimports=pyinstaller_hiddenimports(),
24+
hookspath=[],
25+
hooksconfig={},
26+
runtime_hooks=[],
27+
excludes=["manim", "cv2", "torch", "IPython"],
28+
noarchive=False,
29+
)
30+
pyz = PYZ(a.pure)
31+
exe = EXE(
32+
pyz,
33+
a.scripts,
34+
[],
35+
exclude_binaries=True,
36+
name="docgen-gui",
37+
debug=False,
38+
bootloader_ignore_signals=False,
39+
strip=False,
40+
upx=False,
41+
console=False,
42+
)
43+
coll = COLLECT(
44+
exe,
45+
a.binaries,
46+
a.datas,
47+
strip=False,
48+
upx=False,
49+
name="docgen-gui",
50+
)

pyproject.toml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ dependencies = [
2424

2525
[project.optional-dependencies]
2626
manim = ["manim>=0.18"]
27+
gui = ["pywebview>=5.0"]
28+
packaging = [
29+
"pyinstaller>=6.0",
30+
"pywebview>=5.0",
31+
]
2732
dev = [
2833
"pytest>=7.0",
2934
"pytest-cov",
@@ -32,12 +37,19 @@ dev = [
3237

3338
[project.scripts]
3439
docgen = "docgen.cli:main"
40+
docgen-gui = "docgen.gui.__main__:main"
3541

3642
[tool.setuptools.packages.find]
3743
where = ["src"]
3844

3945
[tool.setuptools.package-data]
40-
docgen = ["templates/*.html", "static/*.css", "static/*.js", "benchmark_data/*"]
46+
docgen = [
47+
"templates/*.html",
48+
"static/*.css",
49+
"static/*.js",
50+
"static/vendor/*",
51+
"benchmark_data/*",
52+
]
4153

4254
[tool.pytest.ini_options]
4355
testpaths = ["tests"]

src/docgen/cli.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,38 @@ def init(
174174
print_summary(plan, created)
175175

176176

177+
@main.command("gui")
178+
@click.option("--port", default=0, help="Bind port (0 = ephemeral).")
179+
@click.option(
180+
"--view",
181+
default="benchmark",
182+
show_default=True,
183+
help="Initial view: benchmark, setup, production, or tool.",
184+
)
185+
@click.option(
186+
"--browser",
187+
is_flag=True,
188+
help="Open the system browser instead of a pywebview window.",
189+
)
190+
@click.pass_context
191+
def gui(ctx: click.Context, port: int, view: str, browser: bool) -> None:
192+
"""Desktop GUI (Vue). Prefer `pip install 'docgen[gui]'` for a native window.
193+
194+
This is the entry PyInstaller freezes (see packaging/docgen-gui.spec).
195+
"""
196+
from docgen.gui.__main__ import main as gui_main
197+
198+
args: list[str] = ["--view", view]
199+
if port:
200+
args.extend(["--port", str(port)])
201+
if browser:
202+
args.append("--browser")
203+
cfg = ctx.obj.get("config") if ctx.obj else None
204+
if cfg is not None and getattr(cfg, "yaml_path", None) and Path(cfg.yaml_path).is_file():
205+
args.extend(["--config", str(cfg.yaml_path)])
206+
gui_main(args)
207+
208+
177209
@main.command()
178210
@click.option("--port", default=8501, help="Port for the wizard web server.")
179211
@click.pass_context
@@ -1174,12 +1206,20 @@ def rebuild_after_audio(ctx: click.Context, regen_scene_specs: bool) -> None:
11741206
type=click.Path(dir_okay=False, path_type=Path),
11751207
help="Write the full JSON report to this path.",
11761208
)
1209+
@click.option(
1210+
"--gui",
1211+
is_flag=True,
1212+
help="Open the Vue benchmark view (desktop window if pywebview is installed).",
1213+
)
1214+
@click.pass_context
11771215
def benchmark(
1216+
ctx: click.Context,
11781217
case_id: str | None,
11791218
fmt: str,
11801219
update_baseline: bool,
11811220
baseline_path: Path | None,
11821221
output_path: Path | None,
1222+
gui: bool,
11831223
) -> None:
11841224
"""Score the standard scene-timing corpus (no bundle, no Manim, no OpenAI).
11851225
@@ -1198,6 +1238,16 @@ def benchmark(
11981238
write_baseline,
11991239
)
12001240

1241+
if gui:
1242+
from docgen.gui.desktop import launch_desktop
1243+
1244+
path = "/?view=benchmark"
1245+
if case_id:
1246+
path += "&case=" + case_id
1247+
cfg = ctx.obj.get("config") if ctx.obj else None
1248+
launch_desktop(cfg, path=path)
1249+
return
1250+
12011251
try:
12021252
scores = run_benchmark(case_id=case_id)
12031253
except ValueError as exc:

src/docgen/gui/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Desktop GUI entry (Vue + Flask, packable with PyInstaller)."""
2+
3+
from __future__ import annotations
4+
5+
from docgen.gui.desktop import launch_desktop, serve_url
6+
7+
__all__ = ["launch_desktop", "serve_url"]

src/docgen/gui/__main__.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""PyInstaller / ``python -m docgen.gui`` entry point."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
7+
8+
def _load_config(config_path: str | None):
9+
if not config_path:
10+
return None
11+
from docgen.config import Config
12+
13+
return Config.from_yaml(config_path)
14+
15+
16+
def main(argv: list[str] | None = None) -> None:
17+
parser = argparse.ArgumentParser(description="docgen desktop GUI (Vue + Flask).")
18+
parser.add_argument("--port", type=int, default=0, help="Bind port (0 = ephemeral).")
19+
parser.add_argument(
20+
"--view",
21+
default="benchmark",
22+
help="Initial wizard view (benchmark, setup, production, tool).",
23+
)
24+
parser.add_argument(
25+
"--browser",
26+
action="store_true",
27+
help="Force the system browser instead of a pywebview window.",
28+
)
29+
parser.add_argument(
30+
"--config",
31+
default=None,
32+
help="Optional path to a consumer docgen.yaml (Setup / Production).",
33+
)
34+
args = parser.parse_args(argv)
35+
path = f"/?view={args.view}"
36+
port = args.port or None
37+
config = _load_config(args.config)
38+
if args.browser:
39+
import webbrowser
40+
41+
from docgen.gui.desktop import _wait_until_interrupt, serve_url
42+
43+
url, httpd = serve_url(config, port=port, path=path)
44+
webbrowser.open(url)
45+
_wait_until_interrupt(httpd)
46+
return
47+
from docgen.gui.desktop import launch_desktop
48+
49+
launch_desktop(config, port=port, path=path)
50+
51+
52+
if __name__ == "__main__":
53+
main()

src/docgen/gui/desktop.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Launch the Vue wizard in a desktop window (or a local browser fallback)."""
2+
3+
from __future__ import annotations
4+
5+
import socket
6+
import threading
7+
import time
8+
import webbrowser
9+
from typing import Any
10+
from wsgiref.simple_server import make_server
11+
12+
13+
def _free_port() -> int:
14+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
15+
sock.bind(("127.0.0.1", 0))
16+
return int(sock.getsockname()[1])
17+
18+
19+
def serve_url(
20+
config: Any | None = None,
21+
*,
22+
host: str = "127.0.0.1",
23+
port: int | None = None,
24+
path: str = "/?view=benchmark",
25+
) -> tuple[str, Any]:
26+
"""Start Flask in a daemon thread. Returns ``(url, httpd)``."""
27+
from docgen.wizard import create_app
28+
29+
app = create_app(config)
30+
bind_port = int(port) if port else _free_port()
31+
httpd = make_server(host, bind_port, app)
32+
thread = threading.Thread(target=httpd.serve_forever, name="docgen-gui", daemon=True)
33+
thread.start()
34+
time.sleep(0.05)
35+
url = f"http://{host}:{bind_port}{path}"
36+
return url, httpd
37+
38+
39+
def _wait_until_interrupt(httpd: Any) -> None:
40+
try:
41+
while True:
42+
time.sleep(0.4)
43+
except KeyboardInterrupt:
44+
httpd.shutdown()
45+
46+
47+
def launch_desktop(
48+
config: Any | None = None,
49+
*,
50+
port: int | None = None,
51+
path: str = "/?view=benchmark",
52+
width: int = 1100,
53+
height: int = 760,
54+
) -> str:
55+
"""Open the GUI. Prefer ``pywebview``; fall back to the system browser.
56+
57+
Returns the URL that was opened.
58+
"""
59+
url, httpd = serve_url(config, port=port, path=path)
60+
try:
61+
import webview
62+
except ImportError:
63+
webbrowser.open(url)
64+
_wait_until_interrupt(httpd)
65+
return url
66+
67+
webview.create_window(
68+
"docgen",
69+
url,
70+
width=width,
71+
height=height,
72+
min_size=(800, 560),
73+
)
74+
webview.start()
75+
httpd.shutdown()
76+
return url

0 commit comments

Comments
 (0)