Author your talk in Google Slides (or any tool that exports slide images),
then present it as a reveal.js deck where chosen slides get a live,
interactive <iframe> window into a local HTML app or an external website.
Google Slides can't put live HTML on a slide (it flattens everything to an image). This project bridges that gap without asking you to abandon your normal authoring workflow: keep the deck in Slides, and a small conversion step turns the exported images into an interactive HTML deck.
Everything runs locally, so it's fully interactive during a talk with no cloud service in the loop.
Left / Right arrows always change slides. Every other input is interactivity.
That contract is what makes embedded viewers usable inside a deck. It is
enforced by a tiny shim (serve.py) injected into each viewer at serve time
(your viewer files are never modified on disk). The shim captures only
ArrowLeft / ArrowRight in the capture phase - so it beats things like
three.js OrbitControls - and posts the parent deck to navigate. Mouse drag,
wheel, and every other key stay inside the viewer. The deck side
(preventIframeAutoFocus + a message listener, both set up by build_deck.py)
completes the loop.
Because the proxy injects the shim server-side, this even works for external
websites you don't control (and it bypasses X-Frame-Options, since the
browser sees a localhost origin).
python make_demo.py
python launch.py --manifest demo/manifest.json --buildThis generates four SVG "slides" and opens the deck. Slide 3 is a local three.js cube (drag to orbit); slide 4 is the live OpenStreetMap website (drag to pan). On both, Left/Right still change slides.
(The two interactive demo slides need internet - three.js CDN + OSM tiles. Your own local viewers can run fully offline.)
-
Author your deck in Google Slides as usual.
-
Export: File -> Download -> PDF (one file), or PNG per slide into a folder (e.g.
slides/). -
Write a manifest (
manifest.json, copy frommanifest.example.json): pointimages/pdfat your export, setstatic_rootto where your local viewer HTML lives, and add an embed per interactive slide. -
Present:
python launch.py --manifest manifest.json --build
This (re)builds
deck.html, starts the viewer servers, and opens the deck.Ctrl-Cstops the servers. Re-run after any Slides edit.
No Drive API or OAuth required. Google serves any link-viewable deck as a PDF
at https://docs.google.com/presentation/d/<ID>/export/pdf. Share your deck as
"Anyone with the link -> Viewer", then:
# re-pull + rebuild whenever the deck changes
python sync_slides.py --url "<slides url>" --manifest manifest.json --build --watch 30Set your manifest's "pdf": "deck.pdf" so build_deck.py splits the synced PDF
into slide backgrounds (needs pip install pymupdf).
The PDF/PNG export flattens everything — an animated GIF becomes one frame, a video becomes its poster still. If you connect to the Google Slides API instead, you can rip the media out and keep it live: each slide is still rendered to a flat background, but every image/GIF and every YouTube/Drive video is re-attached as a live overlay at its exact on-slide position, using the same interaction-contract shim as any other embed.
# Friendliest: one tab does sign-in + file picker + a live progress bar, then the
# finished interactive deck opens right there (no separate build/launch step).
python slides_import.py --studio --out mydeck --client client_secret.json
# Or the plain picker (build + launch yourself):
python slides_import.py --picker --out mydeck --build
python launch.py --manifest mydeck/manifest.jsonThe --picker flow uses the non-sensitive drive.file scope, so there's no
scary permission warning and no Google verification needed - you just sign in
and pick your deck. Other auth models (service account for headless use, or
broader-scope OAuth) are also supported. Auth is one-time; later runs refresh
silently. See IMPORT.md for setup and how it maps Slides
elements to overlays.
serve: "static"- for viewers that only reference relative files. Served fromstatic_root(port 9100) with the shim injected.serve: "proxy"- for viewers that use root-relativefetch('/...'), or for external sites.launch.pystarts the backend (ifcmdgiven), then a shim-injecting proxy in front of it, so the viewer sees a normal origin.rectis a percent box within the slide; omit it for a full-bleed viewer.
flowchart LR
slides["Slide images (Slides export / PDF)"] --> build["build_deck.py"]
manifest["manifest.json"] --> build
build --> deck["deck.html (reveal.js, vendored locally)"]
launch["launch.py"] --> serve["serve.py (static + proxy, shim injection)"]
serve --> viewers["local viewers / external sites"]
deck -->|"iframe -> localhost"| serve
viewers -.->|"postMessage on Arrow keys"| deck
| File | Role |
|---|---|
build_deck.py |
Turns exported slide images + manifest into deck.html. |
serve.py |
Shim-injecting static server / reverse proxy. |
launch.py |
Starts the servers a manifest needs and opens the deck. |
sync_slides.py |
Pulls a link-viewable Google Slides deck as PDF (optional watch). |
slides_import.py |
Imports a deck via the Slides API, keeping GIFs/videos live (IMPORT.md). |
slides_auth_picker.py |
No-warning drive.file + Google Picker auth used by slides_import.py. |
slides_studio.py |
--studio server: sign-in + Picker + live progress bar, then serves the finished deck in the same tab. |
deckcommon.py |
Shared manifest parsing + port assignment. |
make_demo.py |
Generates the self-contained demo. |
manifest.example.json |
Copy to manifest.json and edit. |
- Python 3.10+ (core tooling is standard-library only).
pymupdfonly if you split an exported PDF (pip install -r requirements.txt).google-api-python-client,google-auth,google-auth-oauthlibonly for the live-media Slides API import (slides_import.py; see IMPORT.md).ffmpeg, installed by default via theimageio-ffmpegpackage (inrequirements.txt). Used byslides_import.pyto transcode animated GIFs into small, GPU-decoded looping videos (H.264 MP4, or VP9 WebM with alpha). A systemffmpegonPATHis preferred when present; the bundled one is the fallback. If neither is available, GIFs are served as-is - correct, just heavier.- A modern browser. reveal.js is vendored locally into
vendor/on first build, so the deck framework has no CDN dependency (pass--cdnto opt out).
The Google-import deps are commented out in requirements.txt because most
users only need the core (PDF/PNG) flow. If you're using slides_import.py,
uncomment those lines before installing.
On modern macOS (Homebrew Python) and most Linux distros, the system Python is
"externally managed" (PEP 668) and refuses a plain pip install. Use a venv:
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt # after uncommenting the google-* lines for the import flowThen run every command with the venv active (or prefix with .venv/bin/python).
- Some external sites block framing via
Content-Security-Policy: frame-ancestors; those cannot be embedded even through the proxy. - Heavy viewers lazy-load (
data-src+viewDistance: 1) so only the viewer near the current slide boots. - Proxied backends still need whatever runtime the viewer itself requires;
launch.pystarts the process but does not install its dependencies.
MIT - see LICENSE.
{ "title": "My Talk", "width": 1280, "height": 720, // match your slide aspect ratio "reveal_theme": "black", // any reveal.js theme name "images": "slides", // dir of PNG/JPG/SVG, OR "pdf": "deck.pdf" "static_root": "viewers", // where local viewer HTML lives (default: manifest dir) "embeds": [ // A self-contained local viewer, served with the shim injected: { "slide": 3, "serve": "static", "path": "my_viewer.html", "rect": { "x": 8, "y": 14, "w": 60, "h": 72 } }, // A local app with its own server (started for you, then proxied): { "slide": 6, "serve": "proxy", "path": "index.html", "backend": { "cmd": ["python", "-m", "http.server", "8000"], "port": 8000, "cwd": "my_app" }, "rect": { "x": 5, "y": 10, "w": 90, "h": 80 } }, // A live external website, proxied so the shim can be injected: { "slide": 8, "serve": "proxy", "path": "export/embed.html?bbox=-0.13,51.50,-0.11,51.51&layer=mapnik", "backend": { "base": "https://www.openstreetmap.org" } } ] }