Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down Expand Up @@ -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

Expand All @@ -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/<name> --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.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
5 changes: 3 additions & 2 deletions packaging/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
25 changes: 23 additions & 2 deletions packaging/docgen-gui.spec
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
80 changes: 79 additions & 1 deletion src/docgen/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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: <dist>/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
Expand Down
26 changes: 25 additions & 1 deletion src/docgen/gui/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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

Expand All @@ -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
12 changes: 12 additions & 0 deletions src/docgen/gui/desktop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading