mirror of
https://codeberg.org/icewind/vbsp-to-gltf.git
synced 2026-06-03 18:24:07 +02:00
server
This commit is contained in:
parent
23f9215481
commit
ecb7d5720b
9 changed files with 1343 additions and 49 deletions
|
|
@ -1,13 +1,4 @@
|
|||
mod bsp;
|
||||
pub mod convert;
|
||||
mod error;
|
||||
pub mod gltf_builder;
|
||||
mod materials;
|
||||
mod prop;
|
||||
|
||||
use crate::convert::export;
|
||||
use clap::Parser;
|
||||
pub use error::Error;
|
||||
use miette::Context;
|
||||
use std::fs::{read, File};
|
||||
use std::path::PathBuf;
|
||||
|
|
@ -17,6 +8,7 @@ use tracing_subscriber::util::SubscriberInitExt;
|
|||
use tracing_subscriber::EnvFilter;
|
||||
use tracing_tree::HierarchicalLayer;
|
||||
use vbsp::Bsp;
|
||||
use vbsp_to_gltf::{export, Error};
|
||||
|
||||
fn setup() {
|
||||
miette::set_panic_hook();
|
||||
9
src/lib.rs
Normal file
9
src/lib.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
mod bsp;
|
||||
pub mod convert;
|
||||
mod error;
|
||||
pub mod gltf_builder;
|
||||
mod materials;
|
||||
mod prop;
|
||||
|
||||
pub use convert::export;
|
||||
pub use error::Error;
|
||||
24
src/server/index.html
Normal file
24
src/server/index.html
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>TF2 map converter</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>TF2 map converter</h1>
|
||||
|
||||
<p>
|
||||
Download <span title="that is available on serveme.tf">any<sup>*</sup></span> tf2 map as gltf/glb
|
||||
</p>
|
||||
|
||||
<h2>Usage</h2>
|
||||
<p>
|
||||
Simply point your browser (or any other http client) at <code>/gltf/<map name>.glb</code> to download the map
|
||||
in glb format.
|
||||
</p>
|
||||
<p>
|
||||
For example: <a href="/gltf/cp_steel_f12.glb">/gltf/cp_steel_f12.glb</a>
|
||||
</p>
|
||||
<aside>
|
||||
Note that map conversion can take 10 to 30 seconds, once a map has been converted once it is cached for subsequent requests.
|
||||
</aside>
|
||||
</body>
|
||||
</html>
|
||||
31
src/server/pack.rs
Normal file
31
src/server/pack.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
use crate::{Result, ServerError};
|
||||
use async_tempfile::TempFile;
|
||||
use tokio::fs::read;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::process::Command;
|
||||
|
||||
pub async fn pack(map: &str, data: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut input = TempFile::new_with_name(map.to_string()).await?;
|
||||
let output = TempFile::new_with_name(format!("out_{map}")).await?;
|
||||
|
||||
input.write_all(data).await?;
|
||||
|
||||
let out = Command::new("gltfpack")
|
||||
.arg("-kn")
|
||||
.arg("-mm")
|
||||
.arg("-tc")
|
||||
.arg("-i")
|
||||
.arg(input.file_path())
|
||||
.arg("-o")
|
||||
.arg(output.file_path())
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
if !out.status.success() {
|
||||
return Err(ServerError::GltfPack(
|
||||
String::from_utf8_lossy(&out.stderr).into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(read(output.file_path()).await?)
|
||||
}
|
||||
256
src/server/server.rs
Normal file
256
src/server/server.rs
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
mod pack;
|
||||
|
||||
use crate::pack::pack;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use clap::Parser;
|
||||
use http::Method;
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use std::fs::{read, read_to_string, write};
|
||||
use std::net::Ipv4Addr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tf_asset_loader::{Loader, LoaderError};
|
||||
use thiserror::Error;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::signal;
|
||||
use toml::from_str;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tracing::{error, info};
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use tracing_tree::HierarchicalLayer;
|
||||
use url::Url;
|
||||
use vbsp::{Bsp, BspError};
|
||||
use vbsp_to_gltf::{export, Error};
|
||||
|
||||
type Result<T, E = ServerError> = std::result::Result<T, E>;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Config {
|
||||
cache_dir: PathBuf,
|
||||
map_server: Url,
|
||||
#[serde(default = "default_port")]
|
||||
port: u16,
|
||||
}
|
||||
|
||||
fn default_port() -> u16 {
|
||||
3030
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
enum ServerError {
|
||||
#[error(transparent)]
|
||||
Convert(#[from] vbsp_to_gltf::Error),
|
||||
#[error(transparent)]
|
||||
Toml(#[from] toml::de::Error),
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error(transparent)]
|
||||
Loader(#[from] LoaderError),
|
||||
#[error(transparent)]
|
||||
Req(#[from] reqwest::Error),
|
||||
#[error("invalid map name {0}")]
|
||||
InvalidMapName(String),
|
||||
#[error("failed to optimize output: {0}")]
|
||||
GltfPack(String),
|
||||
#[error(transparent)]
|
||||
TmpFile(#[from] async_tempfile::Error),
|
||||
}
|
||||
|
||||
impl ServerError {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
ServerError::InvalidMapName(_) => StatusCode::UNPROCESSABLE_ENTITY,
|
||||
// unexpected header means the dl wasn't a map
|
||||
ServerError::Convert(Error::Bsp(BspError::UnexpectedHeader(_))) => {
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
ServerError::Req(e)
|
||||
if e.status()
|
||||
.map(|status| status.is_client_error())
|
||||
.unwrap_or_default() =>
|
||||
{
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ServerError {
|
||||
fn into_response(self) -> Response {
|
||||
error!(error = ?self, "error during request");
|
||||
(self.status_code(), self.to_string()).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
/// View a demo file
|
||||
#[derive(Parser, Debug)]
|
||||
#[clap(author, version, about, long_about = None)]
|
||||
struct Args {
|
||||
/// Config url
|
||||
config: PathBuf,
|
||||
}
|
||||
|
||||
fn setup() -> Result<Config> {
|
||||
miette::set_panic_hook();
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(EnvFilter::from_default_env())
|
||||
.with(
|
||||
HierarchicalLayer::new(2)
|
||||
.with_targets(true)
|
||||
.with_bracketed_fields(true),
|
||||
)
|
||||
.init();
|
||||
|
||||
let args = Args::parse();
|
||||
let toml = read_to_string(&args.config)?;
|
||||
Ok(from_str(&toml)?)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let config = setup()?;
|
||||
|
||||
let app = App {
|
||||
cache_dir: config.cache_dir,
|
||||
map_server: config.map_server,
|
||||
client: Client::default(),
|
||||
loader: Loader::new()?,
|
||||
};
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_methods([Method::GET, Method::POST])
|
||||
.allow_origin(Any);
|
||||
|
||||
let app = Router::new()
|
||||
.route("/gltf/:map", get(convert))
|
||||
.route("/", get(index))
|
||||
.layer(cors)
|
||||
.with_state(Arc::new(app));
|
||||
|
||||
// Run our app with hyper
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, config.port))
|
||||
.await
|
||||
.unwrap();
|
||||
tracing::info!("listening on {}", listener.local_addr()?);
|
||||
let serve = async {
|
||||
if let Err(e) = axum::serve(listener, app).await {
|
||||
eprintln!("{e:#?}");
|
||||
}
|
||||
};
|
||||
let shutdown = shutdown_signal();
|
||||
tokio::select! {
|
||||
_ = serve => {},
|
||||
_ = shutdown => {},
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct App {
|
||||
cache_dir: PathBuf,
|
||||
map_server: Url,
|
||||
client: Client,
|
||||
loader: Loader,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn cached(&self, map: &str) -> Result<Option<Vec<u8>>> {
|
||||
let path = self.cache_dir.join(map);
|
||||
if path.exists() {
|
||||
Ok(Some(read(path)?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn cache(&self, map: &str, data: &[u8]) -> Result<()> {
|
||||
let path = self.cache_dir.join(map);
|
||||
Ok(write(path, data)?)
|
||||
}
|
||||
|
||||
async fn download(&self, map: &str) -> Result<Vec<u8>> {
|
||||
info!(map = map, "downloading map");
|
||||
Ok(self
|
||||
.client
|
||||
.get(
|
||||
self.map_server
|
||||
.join(map)
|
||||
.map_err(|_| ServerError::InvalidMapName(map.into()))?,
|
||||
)
|
||||
.send()
|
||||
.await?
|
||||
.bytes()
|
||||
.await?
|
||||
.to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
async fn index() -> impl IntoResponse {
|
||||
Html(include_str!("./index.html"))
|
||||
}
|
||||
|
||||
async fn convert(State(app): State<Arc<App>>, Path(map): Path<String>) -> impl IntoResponse {
|
||||
if !map.is_ascii() || map.contains('/') || !map.ends_with(".glb") {
|
||||
return Err(ServerError::InvalidMapName(map));
|
||||
}
|
||||
if let Some(cached) = app.cached(&map)? {
|
||||
info!(map = map, "serving cached model");
|
||||
return Ok(cached);
|
||||
}
|
||||
|
||||
let bsp_name = format!("{}.bsp", map.strip_suffix(".glb").unwrap());
|
||||
let bsp_data = app.download(&bsp_name).await?;
|
||||
|
||||
let mut loader = app.loader.clone();
|
||||
|
||||
let bsp = Bsp::read(&bsp_data).map_err(Error::from)?;
|
||||
loader.add_source(bsp.pack.clone().into_zip());
|
||||
|
||||
let glb = export(bsp, &loader)?;
|
||||
let glb = glb.to_vec().map_err(Error::from)?;
|
||||
let packed = pack(&map, &glb).await?;
|
||||
|
||||
info!(
|
||||
unoptimized = glb.len(),
|
||||
optimized = packed.len(),
|
||||
map = map,
|
||||
"optimized model"
|
||||
);
|
||||
|
||||
app.cache(&map, &packed)?;
|
||||
|
||||
Ok(packed)
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
let ctrl_c = async {
|
||||
signal::ctrl_c()
|
||||
.await
|
||||
.expect("failed to install Ctrl+C handler");
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
signal::unix::signal(signal::unix::SignalKind::terminate())
|
||||
.expect("failed to install signal handler")
|
||||
.recv()
|
||||
.await;
|
||||
};
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {},
|
||||
_ = terminate => {},
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue