Stage is Telex's transient text-cell video surface. A publisher converts video outside the IRC daemon, then sends complete Unicode/xterm256 frames and optional AAC audio through the separate Stage WebSocket. Frames are not chat messages and never become history, unread state, search results, reactions, or Telex database rows.
The bundled publisher supports two paths:
publishconverts a local media file while broadcasting it;encodeconverts once into a.tstagearchive, whileplayandstationbroadcast that archive later without FFmpeg, Chafa, or the source media.
Use the second path for a recurring channel, a library of clips, or a host where predictable playback matters more than saving local disk.
Create a bot automation and one app key in the web client's automation management view. Save both one-time values:
- the authentication ID, shaped like
StageBot/<server-generated-key-uuid>; - the
ak1...app key.
The authentication ID suffix is the server-generated key UUID, not the key's human label.
In the target channel's automation view, add the account with a custom grant containing only:
stage.publish
Set invocation context to zero. Stage publication needs no message, history, roster, or invocation permission. That grant lets the key occupy the channel's single transient Stage and nothing else. Removing the grant or revoking the key ends a live publisher.
Node.js 22 or newer is the only playback dependency. Conversion and preview
also need FFmpeg, FFprobe, Chafa development headers, pkg-config, and a C
compiler.
On Ubuntu:
sudo apt install \
nodejs ffmpeg chafa libchafa-dev libglib2.0-dev pkg-config build-essential
examples/stage/build-projector.sh
examples/stage/telex-stage doctorOn macOS with Homebrew:
brew install node ffmpeg chafa pkg-config
examples/stage/build-projector.sh
examples/stage/telex-stage doctorThe repository release process can build a self-contained source kit:
packaging/package-stage-publisher.shThe resulting tar archive keeps the small shared Stage protocol/session
modules, publisher, projector source, documentation, license, revision, and
per-file checksums together. It also contains a deterministic, silent
examples/stage/sample.tstage test pattern. Unpack it, run
examples/stage/build-projector.sh, and use
examples/stage/telex-stage as below.
For a downloaded release, verify the checksum published beside the outer archive before extraction. Then verify every packaged file and inspect the included sample:
tar -xzf telex-stage-publisher-0.2.0-REVISION.tar.gz
cd telex-stage-publisher-0.2.0
sha256sum -c SHA256SUMS
examples/stage/telex-stage inspect \
--input examples/stage/sample.tstageThe included sample needs only Node.js 22 or newer. It can be published with
play, placed in a station playlist, or regenerated exactly:
examples/stage/telex-stage sample \
--output ./telex-stage-sample.tstageOptional --columns, --rows, --fps, --seconds, and --title values
produce a custom synthetic test pattern. The canonical no-option sample is
64×18 cells, 12 fps, four seconds, silent, and contains no third-party media.
Build the checked-in container from the repository or unpacked kit:
docker build \
--file examples/stage/Containerfile \
--build-arg TELEX_STAGE_VERSION=0.2.0 \
--build-arg TELEX_STAGE_REVISION="$(git rev-parse HEAD 2>/dev/null || printf unknown)" \
--tag telex-stage-publisher:0.2.0 \
--tag telex-stage-publisher:latest \
.The image compiles its projector in a disposable build stage, retains only the
runtime media libraries, and runs as the unprivileged node user. The
:latest tag below is a local alias; no registry account or image publication
is required.
Check the finished image before using it:
docker run --rm telex-stage-publisher:latest doctorMount media read-only and an output directory read-write:
docker run --rm -it \
--volume "$PWD/media:/media:ro" \
--volume "$PWD/stages:/stages" \
telex-stage-publisher:latest \
encode \
--input /media/clip.mp4 \
--output /stages/clip.tstage \
--title "Clip"For publication, export IRC_CLIENT_PASSWORD in the invoking shell and pass
only the variable name into the container:
docker run --rm -it \
--env IRC_CLIENT_PASSWORD \
--volume "$PWD/stages:/stages:ro" \
telex-stage-publisher:latest \
play \
--url https://irc.example.net:8098 \
--auth-id 'StageBot/019f0000-0000-7000-8000-000000000000' \
--channel '#movie-night' \
--input /stages/clip.tstageDo not place the app key in an image, URL, playlist, or command argument.
The normal baseline is 160 columns, automatic rows, and 24 frames per second. Preview one source frame locally:
examples/stage/telex-stage preview \
--input ./clip.mp4 \
--preset balanced \
--time 8 \
--output /tmp/telex-stage-preview.htmlOpen the HTML file in a browser. It is self-contained and never contacts the server.
Publish the source directly:
examples/stage/telex-stage publish \
--url https://irc.example.net:8098 \
--auth-id 'StageBot/019f0000-0000-7000-8000-000000000000' \
--channel '#movie-night' \
--input ./clip.mp4 \
--preset balancedThe app key comes from IRC_CLIENT_PASSWORD or a hidden terminal prompt.
--raw-audio preserves source levels instead of normalizing to -16 LUFS.
--silent omits audio.
Available visual presets are:
| Preset | Columns | Intended use |
|---|---|---|
compact |
120 | small displays or constrained links |
balanced |
160 | default |
detail |
200 | deliberate higher detail |
Every preset uses automatic rows and 24 fps. Explicit
--columns, --rows, --fps, --sample-scale, --profile, and
--color-mode overrides remain available.
Encode once:
examples/stage/telex-stage encode \
--input ./clip.mp4 \
--output ./clip.tstage \
--title "Opening clip" \
--preset balancedEncoding never connects to Telex and refuses to replace an existing output
file. A .tstage archive contains:
- the validated codec, glyph dictionary, palette model, geometry, rate, and optional audio declaration;
- the exact encoded Stage video and audio packet timeline;
- playback lead and bounded packet statistics;
- a gzip-compressed record stream; and
- a SHA-256 checksum covering the manifest and compressed packet body.
It does not contain the source video, Telex credentials, channel name, account name, or IRC history. It is not encrypted. Treat private or licensed material inside the rendered frames and audio accordingly.
Inspect and fully verify an archive:
examples/stage/telex-stage inspect --input ./clip.tstageUse --json for a machine-readable manifest. Verification checks the complete
archive checksum, bounded decompression, exact packet lengths, contiguous
transport sequence, per-track timing, audio initialization, and declared
statistics.
Publish it:
examples/stage/telex-stage play \
--url https://irc.example.net:8098 \
--auth-id 'StageBot/019f0000-0000-7000-8000-000000000000' \
--channel '#movie-night' \
--input ./clip.tstageplay verifies the entire archive before it requests the app key or opens a
Stage. Playback then streams and decompresses records in bounded memory.
Required audio packets retry briefly under backpressure; late video frames are
disposable so transport congestion cannot grow delay without bound.
The v1 archive is deliberately sequential. It supports deterministic playback and playlists, not seeking or mid-clip resume.
The 0.1.x publisher kit targets Stage API 1, archive
telex.stage.archive version 1, and cell codec telex.cells version 1.
inspect, play, and station reject unknown versions rather than guessing.
While Telex remains pre-1.0, distribute the publisher kit or its exact revision
beside archives instead of assuming an arbitrary future kit will accept them.
.tstage files are ordinary opaque artifacts and fit the external-content/XDCC
interface without giving the content provider Stage authority. A catalogue
entry should use:
{
"package_id": "opening-clip",
"display_name": "Opening clip",
"description": "Telex Stage archive v1",
"filename": "opening-clip.tstage",
"path": "./files/opening-clip.tstage",
"media_type": "application/vnd.telex.stage",
"visibility": "human_members",
"consumption": "human_members"
}The ArtifactProvider computes the descriptor's byte size and whole-file
SHA-256. The digest printed by telex-stage inspect is the archive's internal
digest over its header, manifest, and compressed packet body; it is not the
same representation as the provider's whole-file digest. Verify the external
transfer digest first, then run inspect before playback.
The provider needs only its normal external-content permissions. A recipient
who wants to broadcast the downloaded archive uses a separate automation key
with stage.publish in the destination channel. Neither the .tstage file nor
its catalogue descriptor carries credentials or grants publication authority.
Create a playlist beside its archives:
{
"format": "telex.stage.playlist",
"version": 1,
"loop": true,
"intermission_ms": 1000,
"items": [
{ "path": "./opening.tstage" },
{ "path": "./feature.tstage" }
]
}Relative item paths resolve from the playlist directory. Start the station:
examples/stage/telex-stage station \
--url https://irc.example.net:8098 \
--auth-id 'StageBot/019f0000-0000-7000-8000-000000000000' \
--channel '#movie-night' \
--playlist ./playlist.json \
--state ./station-state.jsonBefore prompting for the key, the station reads the bounded playlist and fully verifies every archive. It then:
- opens one Stage for the current item;
- plays the item to completion;
- ends that Stage;
- atomically advances the private checkpoint; and
- starts the next item after the configured intermission.
Each item may have different geometry or audio. A new Stage epoch makes that transition explicit to viewers. If the process dies during an item, the checkpoint intentionally restarts that item from its beginning. Changing the playlist invalidates the old cursor and starts at item one. A non-looping list records completion and does not replay on service restart.
The station never publishes a chat announcement and therefore still needs only
stage.publish. A future announcer should use a separate key or an explicitly
reviewed message.publish grant rather than silently widening this process.
examples/stage/telex-stage-station.service.example shows a minimal systemd
service using LoadCredential. Replace every example path, account, host, and
channel before installing it. The credential source should be a root-readable
file with restrictive permissions; systemd exposes it to the service through
the private credentials directory rather than a process argument or ordinary
environment file.
The reference full-frame format favors simplicity and resilience over delta compression. Typical balanced video is roughly 4–6 Mbit/s before small WebSocket/TLS overhead, plus about 0.128 Mbit/s for AAC. Relay egress is that rate per viewing client. A pre-rendered archive is often much smaller on disk because gzip can exploit similarity between neighboring text-cell frames, but network playback still sends complete frames.
Only one publisher can occupy a channel Stage. A second receives an occupied
error. Frames remain outside chat retention and Telex database backups;
.tstage, playlist, and station-state files belong to the publisher host's own
storage and backup policy.
Troubleshooting:
authentication_required: use the returnedaccount/server-generated-key-uuidauthentication ID and matchingak1...key, not the key label.forbiddenorstage_unavailable: confirm the exact channel grant containsstage.publishand the account/key are active.occupied: another publisher owns the channel Stage.- missing projector: install Chafa development headers, then run
examples/stage/build-projector.sh. - no audible playback: confirm source audio exists, browser audio is unmuted, and the browser accepted its explicit play gesture.
From a clean source checkout, build the publisher archive twice, compare it for reproducibility, verify all packaged checksums, regenerate the canonical sample, inspect it, and exercise the packaged projector toolchain with:
packaging/test-stage-publisher-package.shPass an existing release archive to test it without rebuilding:
packaging/test-stage-publisher-package.sh \
/path/to/telex-stage-publisher-0.2.0-REVISION.tar.gzIf conversion dependencies are unavailable, the pure-Node package and sample
checks still run and the projector build is reported as skipped. Set
TELEX_STAGE_SMOKE_CONVERSION=required to make that skip fail.
Two acceptance checks remain deliberately external to the automated suite:
- build the
Containerfilewith Docker or Podman on a container-capable host; - run a real two-item station, observe the item transition from multiple viewers, stop it mid-item, and confirm systemd restart begins that item again.
The wire, timing, Canvas, authority, and retention boundaries are specified in Stage text-cell video.