use std::io::Write;
use std::sync::{Arc, Mutex, mpsc};
use std::time::{Duration, Instant};
use axum::Router;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use image::ExtendedColorType;
use image::codecs::jpeg::JpegEncoder;
use oxframe_core::Image;
use oxframe_protocol::{Command, FrameFormat};
use tokio::sync::mpsc::{UnboundedSender, unbounded_channel};
use crate::{Encoder, Output, PreviewEngine, Session};
#[derive(Debug, Clone)]
pub struct ServerOptions {
pub port: u16,
pub token: Option<String>,
pub cache_mb: usize,
pub jpeg_quality: u8,
}
impl Default for ServerOptions {
fn default() -> Self {
Self {
port: 0,
token: std::env::var("OXFRAME_PREVIEW_TOKEN")
.ok()
.filter(|t| !t.is_empty()),
cache_mb: 256,
jpeg_quality: 80,
}
}
}
pub fn jpeg_encoder(quality: u8) -> Encoder {
Box::new(move |img: &Image| {
let mut rgb = Vec::with_capacity((img.width * img.height * 3) as usize);
for px in img.data.chunks_exact(4) {
let a = u16::from(px[3]);
rgb.extend(
px[..3]
.iter()
.map(|&c| ((u16::from(c) * a + 127) / 255) as u8),
);
}
let mut out = Vec::new();
JpegEncoder::new_with_quality(&mut out, quality)
.encode(&rgb, img.width, img.height, ExtendedColorType::Rgb8)
.expect("JPEG encoding into memory");
(FrameFormat::Jpeg, out)
})
}
enum Inbound {
Command(Command),
}
#[derive(Clone)]
struct AppState {
commands: mpsc::Sender<Inbound>,
client: Arc<Mutex<Option<UnboundedSender<Output>>>>,
token: Option<String>,
}
pub fn serve<E, F>(make_engine: F, opts: ServerOptions) -> std::io::Result<()>
where
E: PreviewEngine + 'static,
F: FnOnce() -> Result<E, String> + Send + 'static,
{
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()?;
rt.block_on(async move {
let listener = tokio::net::TcpListener::bind(("127.0.0.1", opts.port)).await?;
let port = listener.local_addr()?.port();
let (cmd_tx, cmd_rx) = mpsc::channel::<Inbound>();
let client: Arc<Mutex<Option<UnboundedSender<Output>>>> = Arc::default();
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<(), String>>();
let engine_client = client.clone();
let (cache_mb, quality) = (opts.cache_mb, opts.jpeg_quality);
std::thread::Builder::new()
.name("oxframe-engine".into())
.spawn(move || {
let engine = match make_engine() {
Ok(e) => e,
Err(e) => {
let _ = ready_tx.send(Err(e));
return;
}
};
let _ = ready_tx.send(Ok(()));
let session = Session::new(engine, jpeg_encoder(quality), cache_mb);
engine_loop(session, cmd_rx, engine_client);
})?;
match ready_rx.recv() {
Ok(Ok(())) => {}
Ok(Err(e)) => return Err(std::io::Error::other(e)),
Err(_) => return Err(std::io::Error::other("engine thread died")),
}
let mut stdout = std::io::stdout();
writeln!(stdout, "{port}")?;
stdout.flush()?;
let state = AppState {
commands: cmd_tx,
client,
token: opts.token,
};
let app = Router::new()
.route("/ws", get(ws_handler))
.route("/healthz", get(|| async { "ok" }))
.with_state(state);
axum::serve(listener, app).await
})
}
fn engine_loop<E: PreviewEngine>(
mut session: Session<E>,
rx: mpsc::Receiver<Inbound>,
client: Arc<Mutex<Option<UnboundedSender<Output>>>>,
) {
let clock = Instant::now();
let send = |outputs: Vec<Output>| {
if outputs.is_empty() {
return;
}
if let Some(tx) = client.lock().expect("client lock").as_ref() {
for o in outputs {
let _ = tx.send(o);
}
}
};
loop {
let now = clock.elapsed().as_secs_f64();
let wait = session
.next_tick_in(now)
.map_or(Duration::from_secs(3600), Duration::from_secs_f64);
let mut batch = Vec::new();
match rx.recv_timeout(wait) {
Ok(Inbound::Command(c)) => batch.push(c),
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => return,
}
while let Ok(Inbound::Command(c)) = rx.try_recv() {
batch.push(c);
}
let last_seek = batch
.iter()
.rposition(|c| matches!(c, Command::Seek { .. }));
for (i, cmd) in batch.into_iter().enumerate() {
if matches!(cmd, Command::Seek { .. }) && Some(i) != last_seek {
continue;
}
if matches!(cmd, Command::Shutdown) {
std::process::exit(0);
}
let now = clock.elapsed().as_secs_f64();
send(session.handle(cmd, now));
}
send(session.tick(clock.elapsed().as_secs_f64()));
}
}
#[derive(serde::Deserialize)]
struct WsQuery {
token: Option<String>,
}
fn origin_allowed(headers: &HeaderMap) -> bool {
let Some(origin) = headers.get("origin").and_then(|o| o.to_str().ok()) else {
return true;
};
let host = origin
.strip_prefix("http://")
.unwrap_or("")
.split(':')
.next()
.unwrap_or("");
matches!(host, "127.0.0.1" | "localhost")
}
async fn ws_handler(
ws: WebSocketUpgrade,
headers: HeaderMap,
Query(q): Query<WsQuery>,
State(state): State<AppState>,
) -> Response {
if !origin_allowed(&headers) {
return (StatusCode::FORBIDDEN, "origin not allowed").into_response();
}
if state.token.is_some() && q.token != state.token {
return (StatusCode::UNAUTHORIZED, "bad token").into_response();
}
ws.on_upgrade(move |socket| client(socket, state))
}
async fn client(mut socket: WebSocket, state: AppState) {
let (tx, mut rx) = unbounded_channel::<Output>();
*state.client.lock().expect("client lock") = Some(tx);
loop {
tokio::select! {
out = rx.recv() => {
let Some(out) = out else { break };
let msg = match out {
Output::Event(e) => Message::Text(serde_json::to_string(&e).expect("event json").into()),
Output::Frame(bytes) => Message::Binary(bytes.into()),
};
if socket.send(msg).await.is_err() {
break;
}
}
msg = socket.recv() => {
match msg {
Some(Ok(Message::Text(t))) => match serde_json::from_str::<Command>(&t) {
Ok(cmd) => {
if state.commands.send(Inbound::Command(cmd)).is_err() {
break;
}
}
Err(e) => {
let err = oxframe_protocol::Event::Error {
kind: oxframe_protocol::ErrorKind::Runtime,
message: format!("bad command: {e}"),
spans: Vec::new(),
};
let _ = socket.send(Message::Text(serde_json::to_string(&err).expect("json").into())).await;
}
},
Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
Some(Ok(_)) => {}
}
}
}
}
}