diff --git a/AGENTS.md b/AGENTS.md index 241a51d..89908d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,8 @@ Commands registered on the **`docgen`** CLI include: - **`init`** — scaffold bundle layout and `docgen.yaml`. - **`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`). -- **`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``. +- **`gui`** — desktop window over the same Vue/Flask UI (`pip install 'docgen[gui]'` for pywebview). ``--smoke`` is a headless HTTP check. PyInstaller spec: ``packaging/docgen-gui.spec``. Frozen apps resolve templates/static/benchmark JSON via ``docgen.resources``. +- **`freeze`** — ``docgen freeze`` builds the **`docgen-gui`** onedir (`pip install 'docgen[packaging]'`). Optional ``--smoke`` runs the binary headless. Do not run a full freeze in routine pytest; set ``DOCGEN_FREEZE_SMOKE=1`` for the optional test. - **`tts`** — text-to-speech for segment files. - **`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. - **`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`). @@ -85,7 +86,7 @@ String assertions on compiled ``scenes.py`` and ``simulate_reveal_timeline`` are ## Testing (downstream relevance) -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. +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`** (``--smoke`` only in default pytest), 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. ## Cursor Cloud specific instructions @@ -95,4 +96,4 @@ Tests should cover **CLI-visible behavior** and contracts that adopters rely on: - **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`. - **`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. - **No in-repo dogfood bundle:** exercise the pipeline against a scratch bundle (`docgen init /tmp/ --defaults` in a throwaway git dir). Do not hand-edit consumer generated assets (see `.cursor/rules/no-asset-edits.mdc`). -- **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. +- **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. `docgen gui --smoke` is the headless HTTP check (default pytest). `docgen freeze` builds the GUI onedir; do not run a full PyInstaller freeze in routine CI/pytest. Keep `packaging/docgen-gui.spec` and `docgen.gui.packaging` in sync. diff --git a/README.md b/README.md index 8913a17..963d737 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,8 @@ docgen validate --pre-push | `docgen --version` | Show installed version + recommended `pip install` line (external tool) | | `docgen init [TARGET_DIR] [--defaults] [--segments-file FILE]` | Scaffold a bundle: `docgen.yaml`, `requirements-docgen.txt`, wrapper scripts, directories | | `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` | -| `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` | +| `docgen gui [--view benchmark] [--browser] [--smoke]` | Desktop GUI (Vue + Flask). Install `docgen[gui]` for a pywebview window; `--browser` uses the system browser; `--smoke` is a headless HTTP check | +| `docgen freeze [--dist DIR] [--smoke]` | PyInstaller onedir for **`docgen-gui` only** (`pip install 'docgen[packaging]'`). Not the full Manim CLI | | `docgen tts [--segment 01] [--dry-run]` | Generate TTS audio | | `docgen timestamps [--engine local\|whisper]` | Extract word/segment timestamps from TTS audio → `timing.json` (default `local`: offline narration-text alignment; `whisper`: OpenAI transcription) | | `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 | diff --git a/packaging/README.md b/packaging/README.md index 8a5ef3e..91981d6 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -6,8 +6,9 @@ wizard (benchmark view first). It is **not** a frozen copy of the full ```bash pip install -e '.[packaging]' -pyinstaller packaging/docgen-gui.spec -# dist/docgen-gui/docgen-gui +docgen freeze # writes dist/docgen-gui/docgen-gui +docgen freeze --smoke # freeze, then headless GET / and /api/benchmark +# equivalent: pyinstaller packaging/docgen-gui.spec ``` From a source checkout you can run the same entry without freezing: diff --git a/packaging/docgen-gui.spec b/packaging/docgen-gui.spec index df8ce2e..52e51c4 100644 --- a/packaging/docgen-gui.spec +++ b/packaging/docgen-gui.spec @@ -6,11 +6,32 @@ From the repo root, after ``pip install -e '.[packaging]'``: pyinstaller packaging/docgen-gui.spec """ +import os import sys from pathlib import Path -SPECDIR = Path(SPECPATH).resolve().parent -ROOT = SPECDIR.parent + +def _repo_root() -> Path: + env = os.environ.get("DOCGEN_FREEZE_ROOT") + if env: + root = Path(env).resolve() + if (root / "src" / "docgen" / "gui" / "__main__.py").is_file(): + return root + here = Path(os.path.abspath(str(SPECPATH))).resolve() + for candidate in ( + here.parent.parent, + here.parent, + Path.cwd(), + Path.cwd().parent, + ): + if (candidate / "src" / "docgen" / "gui" / "__main__.py").is_file(): + return candidate + raise SystemExit( + f"cannot locate docgen repo root from SPECPATH={SPECPATH!r} cwd={Path.cwd()}" + ) + + +ROOT = _repo_root() sys.path.insert(0, str(ROOT / "src")) from docgen.gui.packaging import pyinstaller_datas, pyinstaller_hiddenimports # noqa: E402 diff --git a/pyproject.toml b/pyproject.toml index bf2b7cf..4bd52c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,7 @@ docgen = [ testpaths = ["tests"] markers = [ "integration: optional network/OpenAI; skipped when credentials unavailable", + "freeze: optional PyInstaller onedir smoke; skipped unless DOCGEN_FREEZE_SMOKE=1", ] [tool.ruff] diff --git a/src/docgen/cli.py b/src/docgen/cli.py index 0c088bc..9704614 100644 --- a/src/docgen/cli.py +++ b/src/docgen/cli.py @@ -187,8 +187,26 @@ def init( is_flag=True, help="Open the system browser instead of a pywebview window.", ) +@click.option( + "--smoke", + is_flag=True, + help="Headless HTTP check of / and /api/benchmark; do not open a window.", +) +@click.option( + "--smoke-output", + default=None, + type=click.Path(dir_okay=False, path_type=Path), + help="Write the --smoke JSON report to this path.", +) @click.pass_context -def gui(ctx: click.Context, port: int, view: str, browser: bool) -> None: +def gui( + ctx: click.Context, + port: int, + view: str, + browser: bool, + smoke: bool, + smoke_output: Path | None, +) -> None: """Desktop GUI (Vue). Prefer `pip install 'docgen[gui]'` for a native window. This is the entry PyInstaller freezes (see packaging/docgen-gui.spec). @@ -200,12 +218,72 @@ def gui(ctx: click.Context, port: int, view: str, browser: bool) -> None: args.extend(["--port", str(port)]) if browser: args.append("--browser") + if smoke: + args.append("--smoke") + if smoke_output is not None: + args.extend(["--smoke-output", str(smoke_output)]) cfg = ctx.obj.get("config") if ctx.obj else None if cfg is not None and getattr(cfg, "yaml_path", None) and Path(cfg.yaml_path).is_file(): args.extend(["--config", str(cfg.yaml_path)]) gui_main(args) +@main.command("freeze") +@click.option( + "--dist", + "distpath", + default=None, + type=click.Path(file_okay=False, path_type=Path), + help="PyInstaller dist directory (default: ./dist).", +) +@click.option( + "--work", + "workpath", + default=None, + type=click.Path(file_okay=False, path_type=Path), + help="PyInstaller work directory (default: ./build).", +) +@click.option( + "--smoke", + is_flag=True, + help="After the freeze, run the binary with --smoke (headless HTTP check).", +) +@click.option( + "--smoke-output", + default=None, + type=click.Path(dir_okay=False, path_type=Path), + help="JSON report path for --smoke (default: /docgen-gui-smoke.json).", +) +def freeze( + distpath: Path | None, + workpath: Path | None, + smoke: bool, + smoke_output: Path | None, +) -> None: + """Freeze the Vue desktop GUI with PyInstaller (not the full Manim CLI). + + Requires ``pip install 'docgen[packaging]'``. Writes ``dist/docgen-gui/``. + """ + from docgen.gui.freeze import run_freeze, smoke_frozen_binary + + dist = (distpath or Path.cwd() / "dist").resolve() + work = (workpath or Path.cwd() / "build").resolve() + try: + binary = run_freeze(distpath=dist, workpath=work) + except RuntimeError as exc: + raise click.ClickException(str(exc)) from exc + click.echo(f"wrote {binary}") + if not smoke: + return + report = smoke_output or (dist / "docgen-gui-smoke.json") + try: + payload = smoke_frozen_binary(binary, output=report) + except RuntimeError as exc: + raise click.ClickException(str(exc)) from exc + click.echo(f"smoke ok {report}") + click.echo(json.dumps(payload, indent=2)) + + @main.command() @click.option("--port", default=8501, help="Port for the wizard web server.") @click.pass_context diff --git a/src/docgen/gui/__main__.py b/src/docgen/gui/__main__.py index aee142e..03da9b9 100644 --- a/src/docgen/gui/__main__.py +++ b/src/docgen/gui/__main__.py @@ -3,6 +3,9 @@ from __future__ import annotations import argparse +import json +import sys +from pathlib import Path def _load_config(config_path: str | None): @@ -31,10 +34,27 @@ def main(argv: list[str] | None = None) -> None: default=None, help="Optional path to a consumer docgen.yaml (Setup / Production).", ) + parser.add_argument( + "--smoke", + action="store_true", + help="Headless HTTP check of / and /api/benchmark; do not open a window.", + ) + parser.add_argument( + "--smoke-output", + default=None, + help="Write the --smoke JSON report to this path.", + ) args = parser.parse_args(argv) path = f"/?view={args.view}" port = args.port or None config = _load_config(args.config) + if args.smoke: + from docgen.gui.freeze import smoke_session + + out = Path(args.smoke_output) if args.smoke_output else None + result = smoke_session(config, output=out) + print(json.dumps(result, indent=2)) + return if args.browser: import webbrowser @@ -50,4 +70,8 @@ def main(argv: list[str] | None = None) -> None: if __name__ == "__main__": - main() + try: + main() + except Exception as exc: + print(f"docgen-gui failed: {exc}", file=sys.stderr) + raise diff --git a/src/docgen/gui/desktop.py b/src/docgen/gui/desktop.py index 1d489a2..6c83d23 100644 --- a/src/docgen/gui/desktop.py +++ b/src/docgen/gui/desktop.py @@ -64,12 +64,24 @@ def launch_desktop( _wait_until_interrupt(httpd) return url + class _GuiBridge: + def pick_bundle(self) -> str | None: + wins = webview.windows + if not wins: + return None + result = wins[0].create_file_dialog(webview.FOLDER_DIALOG) + if not result: + return None + chosen = result[0] if isinstance(result, (list, tuple)) else result + return str(chosen) + webview.create_window( "docgen", url, width=width, height=height, min_size=(800, 560), + js_api=_GuiBridge(), ) webview.start() httpd.shutdown() diff --git a/src/docgen/gui/freeze.py b/src/docgen/gui/freeze.py new file mode 100644 index 0000000..9ae6250 --- /dev/null +++ b/src/docgen/gui/freeze.py @@ -0,0 +1,172 @@ +"""Build and smoke-test the desktop GUI freeze (not the full Manim CLI).""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import Any +from urllib.request import urlopen + +from docgen.gui.packaging import pyinstaller_datas, pyinstaller_hiddenimports, spec_path + +GUI_NAME = "docgen-gui" +_EXCLUDES = ("manim", "cv2", "torch", "IPython") + + +def gui_entry_script() -> Path: + import docgen.gui.__main__ as entry + + return Path(entry.__file__).resolve() + + +def frozen_binary(distpath: Path) -> Path: + """Onedir binary: ``dist/docgen-gui/docgen-gui`` (``.exe`` on Windows).""" + folder = Path(distpath) / GUI_NAME + win = folder / f"{GUI_NAME}.exe" + if win.is_file(): + return win + return folder / GUI_NAME + + +def _pyinstaller_run(args: list[str]) -> None: + try: + from PyInstaller.__main__ import run as pyi_run + except ImportError as exc: + raise RuntimeError( + "PyInstaller is not installed. pip install 'docgen[packaging]'" + ) from exc + pyi_run(args) + + +def run_freeze( + *, + distpath: Path, + workpath: Path | None = None, + noconfirm: bool = True, +) -> Path: + """Run PyInstaller and return the onedir binary path.""" + distpath = Path(distpath) + distpath.mkdir(parents=True, exist_ok=True) + spec = spec_path() + if spec.is_file(): + spec = spec.resolve() + args = ["--distpath", str(distpath.resolve())] + if noconfirm: + args.append("--noconfirm") + if workpath is not None: + args.extend(["--workpath", str(Path(workpath).resolve())]) + args.append(str(spec)) + old_cwd = os.getcwd() + old_root = os.environ.get("DOCGEN_FREEZE_ROOT") + os.environ["DOCGEN_FREEZE_ROOT"] = str(spec.parent.parent) + try: + os.chdir(spec.parent.parent) + _pyinstaller_run(args) + finally: + os.chdir(old_cwd) + if old_root is None: + os.environ.pop("DOCGEN_FREEZE_ROOT", None) + else: + os.environ["DOCGEN_FREEZE_ROOT"] = old_root + else: + args = [ + "--onedir", + "--noconsole", + "--name", + GUI_NAME, + "--distpath", + str(distpath), + ] + if noconfirm: + args.append("--noconfirm") + if workpath is not None: + args.extend(["--workpath", str(workpath)]) + sep = ";" if os.name == "nt" else ":" + for src, dest in pyinstaller_datas(): + args.extend(["--add-data", f"{src}{sep}{dest}"]) + for name in pyinstaller_hiddenimports(): + args.extend(["--hidden-import", name]) + for mod in _EXCLUDES: + args.extend(["--exclude-module", mod]) + args.append(str(gui_entry_script())) + _pyinstaller_run(args) + binary = frozen_binary(distpath) + if not binary.is_file(): + raise RuntimeError(f"freeze produced no binary at {binary}") + return binary + + +def smoke_session( + config: Any | None = None, + *, + output: Path | None = None, + timeout: float = 20, +) -> dict[str, Any]: + """Start the local Flask GUI and GET ``/``, ``/api/session``, ``/api/benchmark``.""" + from docgen.gui.desktop import serve_url + + url, httpd = serve_url(config, path="/?view=benchmark") + try: + base = url.split("?", 1)[0].rstrip("/") + with urlopen(base + "/", timeout=timeout) as page: + html = page.read() + page_status = page.status + with urlopen(base + "/api/session", timeout=timeout) as resp: + session = json.loads(resp.read().decode("utf-8")) + with urlopen(base + "/api/benchmark?case=early_title", timeout=timeout) as resp: + report = json.loads(resp.read().decode("utf-8")) + result: dict[str, Any] = { + "ok": page_status == 200 + and report.get("ok") is True + and session.get("pipeline_available") is not None, + "url": url, + "session": session, + "case_id": (report.get("cases") or [{}])[0].get("case_id"), + "html_has_benchmark": b"benchmark-app" in html, + "meets_baseline": report.get("meets_baseline"), + } + if not result["html_has_benchmark"]: + result["ok"] = False + finally: + httpd.shutdown() + if output is not None: + Path(output).write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + if not result["ok"]: + raise RuntimeError(f"gui smoke failed: {result}") + return result + + +def smoke_frozen_binary( + binary: Path, + *, + output: Path, + timeout: float = 180, +) -> dict[str, Any]: + """Run ``docgen-gui --smoke`` and read the JSON report.""" + output = Path(output) + output.parent.mkdir(parents=True, exist_ok=True) + proc = subprocess.run( + [str(binary), "--smoke", "--smoke-output", str(output)], + check=False, + capture_output=True, + text=True, + timeout=timeout, + ) + if proc.returncode != 0: + err = (proc.stderr or proc.stdout or "").strip() + raise RuntimeError( + f"frozen smoke exited {proc.returncode}: {err or 'no output'}" + ) + if not output.is_file(): + raise RuntimeError(f"frozen smoke wrote no report at {output}") + return json.loads(output.read_text(encoding="utf-8")) + + +def smoke_current(*, output: Path | None = None, config: Any | None = None) -> dict[str, Any]: + """Smoke this interpreter (editable install or frozen ``sys.executable``).""" + if getattr(sys, "frozen", False): + return smoke_session(config, output=output) + return smoke_session(config, output=output) diff --git a/src/docgen/gui/packaging.py b/src/docgen/gui/packaging.py index 0269519..294be33 100644 --- a/src/docgen/gui/packaging.py +++ b/src/docgen/gui/packaging.py @@ -24,13 +24,14 @@ def pyinstaller_datas() -> list[tuple[str, str]]: def pyinstaller_hiddenimports() -> list[str]: - return [ + names = [ "docgen", "docgen.cli", "docgen.config", "docgen.gui", "docgen.gui.desktop", "docgen.gui.__main__", + "docgen.gui.freeze", "docgen.resources", "docgen.scene_benchmark", "docgen.scene_clock_harness", @@ -43,8 +44,13 @@ def pyinstaller_hiddenimports() -> list[str]: "jinja2", "click", "yaml", - "webview", ] + try: + import webview # noqa: F401 + except ImportError: + return names + names.append("webview") + return names def spec_path() -> Path: diff --git a/src/docgen/manim_scene_support.py b/src/docgen/manim_scene_support.py index e681e19..7c87641 100644 --- a/src/docgen/manim_scene_support.py +++ b/src/docgen/manim_scene_support.py @@ -23,8 +23,6 @@ if TYPE_CHECKING: from docgen.config import Config -from docgen.validate import lint_manim_timing_stub_antipattern - _TITLE_DOWN_OVERLAP_RE = re.compile(r"\.next_to\(\s*title\s*,\s*DOWN\b") _SHIFT_LEFT_RE = re.compile(r"\.shift\(\s*LEFT\s*\*") _SHIFT_RIGHT_RE = re.compile(r"\.shift\(\s*RIGHT\s*\*") @@ -1042,6 +1040,8 @@ def lint_generated_block( f"line {node.lineno}: Text() font_size={int(val)} is below " f"minimum {min_font_size}; small text is unreadable in video" ) + from docgen.validate import lint_manim_timing_stub_antipattern + issues.extend(lint_manim_timing_stub_antipattern(tree, "generated")) issues.extend(lint_manim_title_down_row_collision_risk(code)) issues.extend(lint_set_opacity_then_fadein(code)) diff --git a/src/docgen/static/wizard.css b/src/docgen/static/wizard.css index 216f66c..6cba448 100644 --- a/src/docgen/static/wizard.css +++ b/src/docgen/static/wizard.css @@ -7,6 +7,21 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,san .nav-btn{background:transparent;border:1px solid rgba(255,255,255,.3);color:#fff;padding:.4rem 1rem;border-radius:6px;cursor:pointer;font-size:.85rem;transition:all .2s} .nav-btn:hover,.nav-btn.active{background:rgba(255,255,255,.15);border-color:rgba(255,255,255,.6)} .tool-version-badge{font-size:.75rem;opacity:.85;margin-left:.25rem;padding:.2rem .55rem;border:1px solid rgba(255,255,255,.25);border-radius:999px} +.frozen-badge{font-size:.7rem;background:#e11d48;border-radius:999px;padding:.15rem .5rem;letter-spacing:.04em;text-transform:uppercase} +#bundle-bar{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;padding:.45rem 1.5rem;background:#16213e;color:#fff;font-size:.8rem} +#bundle-bar label{font-weight:600} +#bundle-bar input{flex:1;min-width:180px;border:1px solid rgba(255,255,255,.25);border-radius:6px;padding:.3rem .5rem;background:#0f3460;color:#fff} +#bundle-bar .btn{padding:.3rem .7rem;font-size:.78rem} +#bundle-bar .status-text{color:#c5d0e6} +.pipeline-shell-note{margin:0;padding:.55rem 1.5rem;background:#fef3c7;color:#92400e;font-size:.82rem} +body.gui-frozen .video-actions button, +body.gui-frozen #btn-rebuild-from, +body.gui-frozen #btn-redo-all, +body.gui-frozen #btn-generate, +body.gui-frozen #btn-revise-narration, +body.gui-frozen #btn-regen-narration, +body.gui-frozen #btn-redo-tts, +body.gui-frozen #btn-tool-update{opacity:.45} .tool-layout{display:grid;grid-template-columns:1fr 1.1fr;gap:1.5rem;align-items:start;max-width:1100px} @media(max-width:900px){.tool-layout{grid-template-columns:1fr}} .tool-info{display:grid;gap:.65rem;margin:1rem 0 0} diff --git a/src/docgen/static/wizard.js b/src/docgen/static/wizard.js index d7ef993..c7052bc 100644 --- a/src/docgen/static/wizard.js +++ b/src/docgen/static/wizard.js @@ -16,6 +16,95 @@ let scanExtensions = null; let filterText = ""; + // ---- Session (bundle + frozen shell) ---- + async function loadSession() { + try { + const res = await fetch("/api/session"); + const data = await res.json(); + applySession(data); + } catch (err) { + const status = document.getElementById("bundle-status"); + if (status) status.textContent = "session unavailable"; + } + } + + function applySession(data) { + document.body.classList.toggle("gui-frozen", !!data.frozen); + const badge = document.getElementById("frozen-badge"); + if (badge) badge.classList.toggle("hidden", !data.frozen); + const note = document.getElementById("pipeline-shell-note"); + if (note) note.classList.toggle("hidden", !data.frozen); + const input = document.getElementById("bundle-path"); + if (input && data.config_path) input.value = data.config_path; + const status = document.getElementById("bundle-status"); + if (status) { + status.textContent = data.has_bundle ? (data.bundle_dir || "bundle open") : "no bundle"; + } + const disable = !!data.frozen; + [ + "btn-generate", + "btn-revise-narration", + "btn-regen-narration", + "btn-redo-tts", + "btn-redo-all", + "btn-rebuild-from", + "btn-tool-update", + ].forEach((id) => { + const el = document.getElementById(id); + if (el) el.disabled = disable; + }); + document.querySelectorAll(".video-actions button").forEach((el) => { + el.disabled = disable; + }); + } + + document.getElementById("btn-bundle-open")?.addEventListener("click", async () => { + const input = document.getElementById("bundle-path"); + const status = document.getElementById("bundle-status"); + const path = (input && input.value || "").trim(); + if (!path) { + alert("Enter a path to docgen.yaml or a bundle directory."); + return; + } + try { + const res = await fetch("/api/open-bundle", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path }), + }); + const data = await res.json(); + if (!res.ok || data.ok === false) { + if (status) status.textContent = data.error || "open failed"; + alert("Open bundle failed: " + (data.error || res.status)); + return; + } + applySession(data); + if (document.getElementById("view-production") && + !document.getElementById("view-production").classList.contains("hidden")) { + loadProductionView(); + } + } catch (err) { + if (status) status.textContent = "open failed"; + alert("Open bundle error: " + err.message); + } + }); + + document.getElementById("btn-bundle-browse")?.addEventListener("click", async () => { + const input = document.getElementById("bundle-path"); + let picked = null; + try { + if (window.pywebview && window.pywebview.api && window.pywebview.api.pick_bundle) { + picked = await window.pywebview.api.pick_bundle(); + } + } catch (err) { + picked = null; + } + if (picked && input) input.value = picked; + else if (input) input.focus(); + }); + + loadSession(); + // ---- View switching ---- document.querySelectorAll(".nav-btn").forEach((btn) => { btn.addEventListener("click", () => { diff --git a/src/docgen/templates/wizard.html b/src/docgen/templates/wizard.html index d13312d..b6ced6c 100644 --- a/src/docgen/templates/wizard.html +++ b/src/docgen/templates/wizard.html @@ -14,8 +14,20 @@ + +
+ + + + + no bundle +
+ diff --git a/src/docgen/wizard.py b/src/docgen/wizard.py index 6b53051..4e509e9 100644 --- a/src/docgen/wizard.py +++ b/src/docgen/wizard.py @@ -12,6 +12,57 @@ STATE_FILENAME = ".docgen-state.json" +def session_payload(config: Any | None) -> dict[str, Any]: + """GUI session: frozen shell vs pip CLI, and whether a bundle is attached.""" + from docgen.resources import is_frozen + + frozen = is_frozen() + yaml_path = None + bundle_dir = None + has_bundle = False + if config is not None: + yp = getattr(config, "yaml_path", None) + if yp is not None: + path = Path(yp) + if path.is_file(): + has_bundle = True + yaml_path = str(path) + bundle_dir = str(config.base_dir) + return { + "frozen": frozen, + "pipeline_available": not frozen, + "has_bundle": has_bundle, + "bundle_dir": bundle_dir, + "config_path": yaml_path, + } + + +def _frozen_pipeline_response(): + from docgen.resources import is_frozen + + if not is_frozen(): + return None + return jsonify({ + "ok": False, + "error": "pipeline steps need the pip CLI; this packaged app is a GUI shell", + }), 400 + + +def open_bundle_config(raw_path: str): + """Load ``docgen.yaml`` from a file or bundle directory path.""" + from docgen.config import Config + + text = (raw_path or "").strip() + if not text: + raise ValueError("path is required") + path = Path(text).expanduser().resolve() + if path.is_dir(): + path = path / "docgen.yaml" + if not path.is_file(): + raise ValueError(f"no docgen.yaml at {path}") + return Config.from_yaml(path) + + # --------------------------------------------------------------------------- # File tree scanner # --------------------------------------------------------------------------- @@ -366,6 +417,22 @@ def api_benchmark_update_baseline(): report["wrote"] = str(path) return jsonify(report) + @app.route("/api/session") + def api_session(): + return jsonify(session_payload(_cfg())) + + @app.route("/api/open-bundle", methods=["POST"]) + def api_open_bundle(): + data = request.get_json(silent=True) or {} + try: + cfg = open_bundle_config(str(data.get("path") or "")) + except ValueError as exc: + return jsonify({"ok": False, "error": str(exc)}), 400 + app.config["DOCGEN"] = cfg + payload = session_payload(cfg) + payload["ok"] = True + return jsonify(payload) + # -- API: tool version / upgrade (external install) ------------------------ @app.route("/api/tool") @@ -389,6 +456,9 @@ def api_tool_update(): """ from docgen.install_spec import update_docgen_install + blocked = _frozen_pipeline_response() + if blocked is not None: + return blocked cfg = _cfg() data = request.json or {} ref = str(data.get("ref") or "main") @@ -449,6 +519,9 @@ def api_file(): @app.route("/api/generate-narration", methods=["POST"]) def api_generate_narration(): + blocked = _frozen_pipeline_response() + if blocked is not None: + return blocked cfg = _cfg() data = request.json or {} source_paths: list[str] = list(data.get("source_paths") or []) @@ -846,6 +919,9 @@ def _run_segment_step(cfg: Any, step: str, segment_id: str) -> dict[str, Any]: @app.route("/api/run//", methods=["POST"]) def api_run_step(step: str, segment_id: str): """Run a single pipeline step for one segment. Returns result or error.""" + blocked = _frozen_pipeline_response() + if blocked is not None: + return blocked cfg = _cfg() if not cfg: return jsonify({"error": "no config"}), 400 @@ -863,6 +939,9 @@ def api_run_from(step: str, segment_id: str): Body (optional): ``{"llm_scene_spec": false}`` — when true, the cascade uses LLM ``scene-spec`` instead of offline ``scene-retime``. """ + blocked = _frozen_pipeline_response() + if blocked is not None: + return blocked cfg = _cfg() if not cfg: return jsonify({"error": "no config"}), 400 diff --git a/tests/test_gui_packaging.py b/tests/test_gui_packaging.py index daa11d1..c9b9f0a 100644 --- a/tests/test_gui_packaging.py +++ b/tests/test_gui_packaging.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os import sys from pathlib import Path @@ -12,7 +13,7 @@ from docgen.cli import main from docgen.gui.packaging import pyinstaller_datas, pyinstaller_hiddenimports, spec_path from docgen.resources import benchmark_data_dir, is_frozen, package_root, static_dir, templates_dir -from docgen.wizard import create_app +from docgen.wizard import create_app, open_bundle_config, session_payload def test_package_root_uses_meipass(tmp_path: Path, monkeypatch) -> None: @@ -26,6 +27,19 @@ def test_package_root_uses_meipass(tmp_path: Path, monkeypatch) -> None: assert (templates_dir() / "wizard.html").read_text(encoding="utf-8") == "ok" +def test_manim_scene_support_has_no_toplevel_validate_import() -> None: + """The GUI freeze excludes cv2; BOOTSTRAP_HEADER must not import validate.""" + import ast + + src = (package_root() / "manim_scene_support.py").read_text(encoding="utf-8") + tree = ast.parse(src) + for node in tree.body: + if isinstance(node, ast.ImportFrom) and node.module == "docgen.validate": + raise AssertionError( + "top-level docgen.validate import pulls cv2 into the GUI freeze" + ) + + def test_package_root_has_gui_assets() -> None: root = package_root() assert (root / "templates" / "wizard.html").is_file() @@ -41,8 +55,10 @@ def test_pyinstaller_datas_include_vue_and_baseline() -> None: assert "docgen/static" in dests assert "docgen/templates" in dests assert "docgen/benchmark_data" in dests - assert "webview" in pyinstaller_hiddenimports() - assert "docgen.gui.desktop" in pyinstaller_hiddenimports() + hidden = pyinstaller_hiddenimports() + assert "flask" in hidden + assert "docgen.gui.desktop" in hidden + assert "docgen.gui.freeze" in hidden def test_pyinstaller_spec_exists_and_points_at_gui_entry() -> None: @@ -52,6 +68,7 @@ def test_pyinstaller_spec_exists_and_points_at_gui_entry() -> None: assert "docgen.gui" in text or "__main__.py" in text assert "pyinstaller_datas" in text assert "excludes" in text + assert "DOCGEN_FREEZE_ROOT" in text def test_wizard_html_is_vue_benchmark_shell() -> None: @@ -61,6 +78,9 @@ def test_wizard_html_is_vue_benchmark_shell() -> None: assert "vue.global.prod.js" in html assert "benchmark-app.js" in html assert "createApp" in (static_dir() / "benchmark-app.js").read_text(encoding="utf-8") + assert 'id="bundle-path"' in html + assert 'id="frozen-badge"' in html + assert "pipeline-shell-note" in html def test_api_benchmark_returns_corpus(tmp_path: Path) -> None: @@ -117,10 +137,15 @@ def test_update_baseline_disabled_when_frozen(monkeypatch) -> None: def test_cli_registers_gui() -> None: assert "gui" in main.commands + assert "freeze" in main.commands runner = CliRunner() result = runner.invoke(main, ["gui", "--help"]) assert result.exit_code == 0 assert "pywebview" in result.output or "desktop" in result.output.lower() + assert "--smoke" in result.output + freeze_help = runner.invoke(main, ["freeze", "--help"]) + assert freeze_help.exit_code == 0 + assert "PyInstaller" in freeze_help.output or "docgen-gui" in freeze_help.output from docgen.gui.__main__ import main as gui_main with pytest.raises(SystemExit) as exc: @@ -149,3 +174,74 @@ def test_serve_url_answers_benchmark(monkeypatch) -> None: assert data["cases"][0]["case_id"] == "early_title" finally: httpd.shutdown() + + +def test_session_and_open_bundle(tmp_path: Path) -> None: + app = create_app(None) + client = app.test_client() + res = client.get("/api/session") + assert res.status_code == 200 + data = res.get_json() + assert data["frozen"] is False + assert data["pipeline_available"] is True + assert data["has_bundle"] is False + (tmp_path / "docgen.yaml").write_text("segments:\n default: ['01']\n", encoding="utf-8") + opened = client.post("/api/open-bundle", json={"path": str(tmp_path)}) + assert opened.status_code == 200, opened.get_json() + body = opened.get_json() + assert body["ok"] is True + assert body["has_bundle"] is True + assert body["config_path"].endswith("docgen.yaml") + missing = client.post("/api/open-bundle", json={"path": str(tmp_path / "missing")}) + assert missing.status_code == 400 + empty = client.post("/api/open-bundle", json={"path": ""}) + assert empty.status_code == 400 + + +def test_open_bundle_config_helper(tmp_path: Path) -> None: + yaml_path = tmp_path / "docgen.yaml" + yaml_path.write_text("segments:\n default: ['01']\n", encoding="utf-8") + cfg = open_bundle_config(str(tmp_path)) + assert cfg.yaml_path == yaml_path + assert session_payload(cfg)["has_bundle"] is True + with pytest.raises(ValueError): + open_bundle_config("") + + +def test_frozen_blocks_pipeline(monkeypatch) -> None: + app = create_app(None) + monkeypatch.setattr("docgen.resources.is_frozen", lambda: True) + client = app.test_client() + assert client.post("/api/generate-narration", json={}).status_code == 400 + assert client.post("/api/run/tts/01").status_code == 400 + assert client.post("/api/run-from/tts/01").status_code == 400 + assert client.post("/api/tool/update", json={"ref": "main"}).status_code == 400 + sess = client.get("/api/session").get_json() + assert sess["frozen"] is True + assert sess["pipeline_available"] is False + + +def test_gui_smoke_headless(tmp_path: Path) -> None: + out = tmp_path / "smoke.json" + runner = CliRunner() + result = runner.invoke(main, ["gui", "--smoke", "--smoke-output", str(out)]) + assert result.exit_code == 0, result.output + data = json.loads(out.read_text(encoding="utf-8")) + assert data["ok"] is True + assert data["case_id"] == "early_title" + assert data["html_has_benchmark"] is True + + +@pytest.mark.freeze +def test_optional_pyinstaller_freeze_smoke(tmp_path: Path) -> None: + if not os.environ.get("DOCGEN_FREEZE_SMOKE"): + pytest.skip("set DOCGEN_FREEZE_SMOKE=1 to run the PyInstaller onedir smoke") + from docgen.gui.freeze import run_freeze, smoke_frozen_binary + + dist = tmp_path / "dist" + work = tmp_path / "build" + binary = run_freeze(distpath=dist, workpath=work) + assert binary.is_file() + report = smoke_frozen_binary(binary, output=tmp_path / "frozen-smoke.json") + assert report["ok"] is True + assert report["case_id"] == "early_title"