This repository has no description
Rust 56%
Typst 15%
JavaScript 13%
HTML 9%
CSS 4%
Nix 3%
Shell <1%

README.md

presentypst #

Point it at a Typst file and it serves the presentation over HTTP: a clean fullscreen display to cast to a screen, and a presenter console to drive it from.

$ presentypst talk.typ
presentypst — 14 slides
  display   http://192.168.1.24:8080/
  presenter http://localhost:8080/present

Cast the display URL (with castt, a browser, a TV, whatever), open the presenter URL on your laptop, and drive the talk from there. Navigation is authoritative on the server, so both views stay in lockstep and either one can drive.

Writing a deck #

#import "@presentypst/lib:0.1.0": *

#show: presentation.with(
  title: "My talk",
  author: "Me",
  date: datetime.today(),
)

#title-slide()

= A section

== A slide

First point #pause
Second point

#notes[Say the thing about the second point.]

== With a video

#video("clips/demo.mp4", start: "0:30", end: "0:55", width: 70%)

The library is built on touying, so #pause, #meanwhile, #only, #uncover and the rest work as documented there. Nothing needs installing: the @presentypst package namespace is served from inside the binary.

Slides #

presentation(title:, subtitle:, author:, date:, aspect-ratio:, primary:) show rule setting up the deck
title-slide() opening slide, built from the deck's front matter
slide[…] a content slide (also what a == heading produces)
centered-slide[…] content centred both ways
focus-slide[…] full-bleed, one statement
notes[…] speaker notes, visible only in the presenter console

A = heading starts a section and gets a divider slide; a == heading titles a slide. Both show up in the presenter console's slide list.

Speaker notes keep their markup: lists, *emphasis*, *bold*, raw, links and headings all render as such in the console, so a note can be structured rather than one long paragraph.

Video #

Typst cannot render video, so #video reserves a correctly-sized box and records where it landed on the page. The server reads that geometry and floats a real HTML <video> over the slide at exactly that rectangle — placement stays authored in Typst, playback is the browser's.

#video(
  "https://www.youtube.com/watch?v=…",  // or a path relative to the deck
  start: "1:30",       // optional; see "Timestamps" below
  end: "2:00",         // optional
  width: 70%,          // height defaults to 16:9
  loop: true,          // restart at `start` on reaching `end`
  poster: "still.png", // optional
  name: "The demo",    // shown in the presenter console
)

Timestamps. Typst has no 30s literal, so start and end take a number of seconds (start: 90), a clock string (start: "1:30" or "1:02:03"), or a duration (start: duration(minutes: 1, seconds: 30)).

A video counts as one overlay step. Landing on the slide shows the first frame and nothing plays. The next Space starts it; the press after that moves on and stops it. Stepping back onto the slide re-arms it. With several videos on a slide they arm in document order.

Scrubbing. The presenter console's copy of the clip has ordinary player controls, and driving them drives the display too: seek, pause or resume there and the cast screen lands on the same frame. Pausing holds the position rather than rewinding — only leaving the slide, or stopping the clip, sends it back to the start. Scrubbing outside a clipped start/end window suspends the loop, so you can look at the rest of the footage; playing it from the top restores it.

Enlarging. v, or the presenter console's enlarge button, blows the current slide's video up to fill the whole screen, and again puts it back. It is driven from the presenter and applies to the display, so the audience sees the clip full-size without you touching that screen. Leaving the slide, or stopping the clip, restores it on its own.

YouTube URLs are fetched with yt-dlp in the background and cached under $XDG_CACHE_HOME/presentypst/media, then served locally — so once a clip is cached, presenting needs no network. Clips are downloaded whole and trimmed in the browser, so adjusting start/end never costs another download.

When YouTube breaks yt-dlp. It does, regularly, and the symptom on the slide is HTTP Error 403: Forbidden or Requested format is not available. presentypst already asks through player clients that work around the current breakage, but that default will rot. Update yt-dlp first; if that is not enough, override the arguments:

$ presentypst talk.typ \
    --yt-dlp-arg --extractor-args --yt-dlp-arg youtube:player_client=web_safari

--yt-dlp-arg is repeatable and replaces the built-in default entirely. To find a client that works, try yt-dlp --extractor-args youtube:player_client=X -F <url> by hand.

Controls #

Both views answer to the same keys.

→ ↓ Space PgDn Enter next step
← ↑ PgUp Backspace previous
Home / End first / last
b blank the display
v enlarge this slide's video to fill the screen
f fullscreen the browser window
r reset the talk timer (presenter)

The display view waits for one gesture — any key, click or scroll — before it starts. That is not decoration: browsers refuse to play audio on a page nobody has interacted with, so without it a video started from the presenter console would come up silent.

A presentation remote sends ordinary key events, so clicking it once clears the prompt without anyone touching the cast screen. That first press only dismisses the prompt — it does not advance, so the talk starts on slide one rather than skipping straight past it.

Live reload #

The deck and everything it imports are watched. On save it recompiles and pushes the new slides to both views, holding your position. A deck that fails to compile leaves the previous one on screen and shows the diagnostic in a box — a typo mid-talk costs nothing.

Pass --no-watch to compile once and serve.

Options #

presentypst <DECK>
      --root <DIR>    resolve imports and media against DIR (default: the deck's directory)
      --host <ADDR>   bind address (default: 0.0.0.0, so other devices can reach the display)
  -p, --port <PORT>   (default: 8080)
      --no-watch      compile once, do not watch for changes
      --yt-dlp-arg <ARG>  extra argument for yt-dlp; repeatable, replaces the default

Building #

$ nix build            # the packaged binary, with yt-dlp and the Typst packages wired in
$ nix run . -- examples/demo.typ
$ nix develop          # a shell with the toolchain, typst, yt-dlp and ffmpeg

The flake vendors the Typst Universe packages the library needs as fixed-output derivations, so nix build is hermetic and the packaged binary resolves @preview/… imports without network access.

When something looks wrong #

$ RUST_LOG=presentypst=debug,tower_http=debug presentypst talk.typ

That logs every request, so when a slide does not appear on the cast screen you can see whether that screen actually asked for it — which is almost always the first thing worth knowing.

How it fits together #

The typst crates are embedded, so there is no typst subprocess and no temp files — a recompile happens in memory. Metadata (speaker notes, slide titles, video geometry) is read straight out of the compiled document's introspector rather than by running a second query pass over it.

typst/presentypst/ the Typst library, embedded into the binary
src/world.rs the Typst World: bundled-package namespace, fonts, dependency tracking
src/compile.rs compile → SVG pages + metadata
src/state.rs navigation rules, including the video-as-overlay-step rule
src/media.rs local files and yt-dlp downloads
src/server.rs HTTP routes and the websocket
web/ the two views