Skip to content
Merged
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
8 changes: 8 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
[workspace]
members = ["tellur-core", "tellur-macros", "tellur-renderer"]
members = ["tellur-core", "tellur-live", "tellur-macros", "tellur-renderer"]
resolver = "2"
17 changes: 17 additions & 0 deletions tellur-live/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[package]
name = "tellur-live"
version = "0.1.0"
edition = "2021"
build = "build.rs"

[dependencies]
tellur-core = { path = "../tellur-core" }
tellur-renderer = { path = "../tellur-renderer" }

[[example]]
name = "demo_timeline_plugin"
crate-type = ["cdylib"]

[[example]]
name = "demo_timeline_mp4"
path = "examples/demo_timeline_mp4.rs"
112 changes: 112 additions & 0 deletions tellur-live/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# tellur-live

`tellur-live` is a local preview host for editing tellur timelines. It loads a
timeline plugin from a Rust `cdylib`, keeps the render process alive across
frame requests, and reuses one `CachingRenderContext` for the session.

The dynamic-library boundary is a Rust-internal ABI. Build the host and plugin
from the same workspace/toolchain; this is not intended as a stable C ABI.

## Build a Plugin

```rust
use tellur_core::timeline::{timeline, Timeline};

fn build_timeline() -> impl Timeline {
timeline(5.0, move |t, target, ctx| {
// build and render a RasterComponent here
todo!()
})
}

tellur_live::export_timeline!("main", "Main", build_timeline);
```

The bundled demo plugin can be built with:

```sh
cargo build --release -p tellur-live --example demo_timeline_plugin
```

Cargo writes it to:

```text
target/release/examples/libdemo_timeline_plugin.so
```

## Run the Preview Host

```sh
cargo run -p tellur-live -- serve \
-p tellur-live \
--example demo_timeline_plugin \
--host 127.0.0.1 \
--port 4317 \
--fps 30
```

Open `http://127.0.0.1:4317/` for the minimal browser client.
Use `--host 0.0.0.0` when the preview server should be reachable from other
devices on the network.
Pass `--verbose` to print per-frame timing and cache statistics to stdout.

Passing `-p <package> --example <example>` makes `tellur-live` infer the release
cdylib path (`target/release/examples/lib<example>.so`) and run
`cargo build --release -p <package> --example <example>` when watched source
files change. `--examples` is accepted as an alias for `--example`.

```sh
cargo run -p tellur-live -- serve \
-p tellur-live \
--examples demo_timeline_plugin
```

By default, watch paths are inferred from the package: its `Cargo.toml`, `src`,
the selected example file, the workspace lockfile/manifest, and local `path`
dependencies. Use `--plugin <path>` or repeated `--watch-path <path>` arguments
to override those inferred values.

When a release build succeeds and the cdylib contents change, `tellur-live`
reloads the plugin, clears the server render cache, and publishes a new
`cacheKey` to the browser. The browser uses that key in image/video URLs,
stores media responses as blobs in IndexedDB, and records the green cache
ranges separately. Old IndexedDB media entries and green ranges are revoked
only after a successful cdylib update. Failed builds leave the previous plugin
and cache key in place. Video cache entries are variable-length ranges. Starting
playback inside a cached range seeks within that blob instead of creating a
duplicate cache entry. Missing video ranges fall back to direct streaming
immediately; playback does not wait for IndexedDB cache fill. During playback
the client scans the continuous cached range from the current position and
starts one background stream from the next cache gap. When that stream finishes,
its full range is saved to IndexedDB. When stopped, it fills only the next
three seconds from the current position.

The browser UI is intentionally a thin validation client. It requests
coalesced PNG frames for still previews and seeking, and fragmented MP4/H.264
for playback. The Size and FPS controls lower the request resolution and frame
rate when full-resolution playback is too expensive. The Size control sends an
explicit `width` and `height` selected from browser presets, including low,
HD, 4K, and vertical variants. While idle, the client
preloads the beginning of the MP4 stream for the current position so the play
button can reuse already-buffered video data.

## HTTP Endpoints

- `GET /api/info` returns resolution, fps, the current media `cacheKey`,
compile status (`compiled`, `compiling`, or `failed`), hot-reload errors, and
timeline metadata.
- `GET /api/events` streams the same info payload as Server-Sent Events. The
browser client uses this instead of polling `/api/info`.
- `GET /api/frame?time=1.25&timeline=main` returns one PNG frame.
- `GET /api/frame?frame=42&timeline=main` returns one PNG frame by frame index.
- `GET /api/frame?time=1.25&timeline=main&format=rgba` returns raw RGBA8 bytes
with `X-Tellur-Width` / `X-Tellur-Height` headers.
- `GET /api/video.mp4?time=1.25&timeline=main&fps=60&gop=12&crf=23`
streams fragmented MP4/H.264 through `ffmpeg`. The browser client uses this
path for playback so `<video>` handles decode and presentation timing.
`duration=<seconds>` limits the generated stream length and is used for
IndexedDB video cache segments.
Frame and stream endpoints also accept `width=<pixels>&height=<pixels>` or
`scale=<ratio>` to override the default preview resolution.
- `GET /api/stream?time=0&timeline=main&fps=30` returns a simple multipart PNG
stream. This endpoint is useful for experiments.
110 changes: 110 additions & 0 deletions tellur-live/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// Ensure the Vite-built web bundle exists before compiling the Rust
// server, which embeds index.html / assets/index.js / assets/index.css
// via include_bytes!. When the bundle is missing we run `npm install`
// (if needed) and `npm run build` in tellur-live/web. The opt-out
// variable TELLUR_SKIP_WEB_BUILD=1 skips the npm step entirely; in that
// case we synthesise placeholder files so include_bytes! still compiles.
use std::path::{Path, PathBuf};
use std::process::Command;

fn main() {
let manifest_dir = PathBuf::from(env_var("CARGO_MANIFEST_DIR"));
let web_dir = manifest_dir.join("web");
let dist_dir = web_dir.join("dist");

println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-env-changed=TELLUR_SKIP_WEB_BUILD");
println!("cargo:rerun-if-changed={}", web_dir.join("package.json").display());
println!("cargo:rerun-if-changed={}", web_dir.join("vite.config.ts").display());
println!("cargo:rerun-if-changed={}", web_dir.join("index.html").display());
track_dir(&web_dir.join("src"));

if has_required_assets(&dist_dir) {
return;
}

if std::env::var_os("TELLUR_SKIP_WEB_BUILD").is_some() {
ensure_placeholder_bundle(&dist_dir);
return;
}

build_web(&web_dir);

if !has_required_assets(&dist_dir) {
panic!(
"web build did not produce expected files under {}. \
Set TELLUR_SKIP_WEB_BUILD=1 to bypass and embed placeholders.",
dist_dir.display()
);
}
}

fn env_var(name: &str) -> String {
std::env::var(name)
.unwrap_or_else(|e| panic!("missing required env var {name}: {e}"))
}

fn has_required_assets(dist: &Path) -> bool {
dist.join("index.html").is_file()
&& dist.join("assets/index.js").is_file()
&& dist.join("assets/index.css").is_file()
}

fn ensure_placeholder_bundle(dist: &Path) {
let assets = dist.join("assets");
std::fs::create_dir_all(&assets).expect("create dist/assets");
write_if_missing(
&dist.join("index.html"),
"<!doctype html><meta charset=utf-8><title>tellur-live</title>\
<body><p>web bundle missing; rebuild without TELLUR_SKIP_WEB_BUILD.</p></body>",
);
write_if_missing(&assets.join("index.js"), "");
write_if_missing(&assets.join("index.css"), "");
}

fn write_if_missing(path: &Path, contents: &str) {
if path.exists() {
return;
}
std::fs::write(path, contents).unwrap_or_else(|e| {
panic!("failed to write placeholder {}: {e}", path.display())
});
}

fn build_web(web_dir: &Path) {
if !web_dir.join("node_modules").exists() {
run("npm", &["install", "--no-audit", "--no-fund"], web_dir);
}
run("npm", &["run", "build"], web_dir);
}

fn run(program: &str, args: &[&str], cwd: &Path) {
let status = Command::new(program)
.args(args)
.current_dir(cwd)
.status()
.unwrap_or_else(|e| {
panic!(
"failed to spawn {program} {}: {e}. \
Install node/npm or set TELLUR_SKIP_WEB_BUILD=1 to skip.",
args.join(" ")
)
});
if !status.success() {
panic!("{program} {} failed with status {status}", args.join(" "));
}
}

fn track_dir(dir: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
track_dir(&path);
} else {
println!("cargo:rerun-if-changed={}", path.display());
}
}
}
Loading
Loading