use std::collections::{BTreeMap, BTreeSet};
use std::fmt::{self, Display};
use std::path::{Component, Path, PathBuf};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::runtime::{
read_scene_document, scene_revision, SceneDocument, SceneMaterial,
SceneMesh,
};
pub const META_EXTENSION: &str = "rmeta";
pub const META_FORMAT_VERSION: u32 = 1;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ImportedKind {
Image,
Gltf,
Audio,
}
impl ImportedKind {
#[must_use]
pub fn from_path(path: &Path) -> Option<Self> {
let extension = path.extension()?.to_str()?.to_ascii_lowercase();
match extension.as_str() {
"png" | "jpg" | "jpeg" | "bmp" | "tga" => Some(Self::Image),
"gltf" | "glb" => Some(Self::Gltf),
"wav" | "ogg" => Some(Self::Audio),
_ => None,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct AssetProvenance {
pub original: String,
pub author: Option<String>,
pub license: Option<String>,
pub url: Option<String>,
pub generator: Option<String>,
pub notes: Option<String>,
}
impl AssetProvenance {
fn merge(&mut self, other: &AssetProvenance) {
if !other.original.is_empty() {
self.original.clone_from(&other.original);
}
for (mine, theirs) in [
(&mut self.author, &other.author),
(&mut self.license, &other.license),
(&mut self.url, &other.url),
(&mut self.generator, &other.generator),
(&mut self.notes, &other.notes),
] {
if theirs.is_some() {
mine.clone_from(theirs);
}
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ImportSettings {
pub max_size: Option<u32>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetMeta {
pub format_version: u32,
pub id: Uuid,
pub kind: ImportedKind,
#[serde(default)]
pub settings: ImportSettings,
#[serde(default)]
pub dependencies: Vec<PathBuf>,
pub content_hash: String,
#[serde(default)]
pub source: AssetProvenance,
}
#[derive(Debug)]
pub enum AssetImportError {
SourceMissing(PathBuf),
Unsupported(PathBuf),
Invalid {
path: PathBuf,
message: String,
},
Exists(PathBuf),
NotFound(String),
Io {
path: PathBuf,
error: std::io::Error,
},
GeneratorUnknown(String),
GeneratorFailed {
hook: String,
message: String,
},
}
impl AssetImportError {
#[must_use]
pub fn code(&self) -> &'static str {
match self {
Self::SourceMissing(_) => "ASSET_SOURCE_MISSING",
Self::Unsupported(_) => "ASSET_UNSUPPORTED",
Self::Invalid { .. } => "ASSET_INVALID",
Self::Exists(_) => "ASSET_EXISTS",
Self::NotFound(_) => "ASSET_NOT_FOUND",
Self::Io { .. } => "ASSET_IO",
Self::GeneratorUnknown(_) => "GENERATOR_UNKNOWN",
Self::GeneratorFailed { .. } => "GENERATOR_FAILED",
}
}
#[must_use]
pub fn path(&self) -> Option<&Path> {
match self {
Self::SourceMissing(path)
| Self::Unsupported(path)
| Self::Exists(path)
| Self::Invalid { path, .. }
| Self::Io { path, .. } => Some(path),
Self::NotFound(_)
| Self::GeneratorUnknown(_)
| Self::GeneratorFailed { .. } => None,
}
}
}
impl Display for AssetImportError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::SourceMissing(path) => {
write!(formatter, "`{}` does not exist", path.display())
}
Self::Unsupported(path) => write!(
formatter,
"`{}` is not a png, jpeg, bmp, tga, gltf, glb, wav or ogg file",
path.display()
),
Self::Invalid { path, message } => {
write!(
formatter,
"`{}` is not valid: {message}",
path.display()
)
}
Self::Exists(path) => write!(
formatter,
"`{}` already exists; use `asset reimport` to replace it",
path.display()
),
Self::NotFound(name) => {
write!(formatter, "no imported asset matches `{name}`")
}
Self::Io { path, error } => {
write!(formatter, "`{}`: {error}", path.display())
}
Self::GeneratorUnknown(hook) => write!(
formatter,
"project.json has no generator hook named `{hook}`"
),
Self::GeneratorFailed { hook, message } => {
write!(formatter, "generator `{hook}`: {message}")
}
}
}
}
impl std::error::Error for AssetImportError {}
fn io(path: &Path) -> impl FnOnce(std::io::Error) -> AssetImportError + '_ {
move |error| AssetImportError::Io {
path: path.to_owned(),
error,
}
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct ImportReport {
pub id: Uuid,
pub kind: ImportedKind,
pub path: String,
pub reference: String,
pub dependencies: Vec<PathBuf>,
pub size: [u32; 2],
pub dry_run: bool,
pub settings: ImportSettings,
pub source: AssetProvenance,
pub referenced_by: Vec<String>,
pub warnings: Vec<String>,
}
#[must_use]
pub fn meta_path(asset: &Path) -> PathBuf {
let mut name = asset.file_name().unwrap_or_default().to_os_string();
name.push(".");
name.push(META_EXTENSION);
asset.with_file_name(name)
}
fn slash(path: &Path) -> String {
path.components()
.map(|component| component.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/")
}
fn is_plain_relative(path: &Path) -> bool {
path.components()
.all(|component| matches!(component, Component::Normal(_)))
}
#[cfg(feature = "gltf")]
fn gltf_dependencies(path: &Path) -> Result<Vec<PathBuf>, AssetImportError> {
let document =
gltf::Gltf::open(path).map_err(|error| AssetImportError::Invalid {
path: path.to_owned(),
message: error.to_string(),
})?;
let buffers =
document
.buffers()
.filter_map(|buffer| match buffer.source() {
gltf::buffer::Source::Uri(uri) => Some(uri.to_owned()),
gltf::buffer::Source::Bin => None,
});
let images = document.images().filter_map(|image| match image.source() {
gltf::image::Source::Uri { uri, .. } => Some(uri.to_owned()),
gltf::image::Source::View { .. } => None,
});
let mut dependencies = BTreeSet::new();
for uri in buffers.chain(images) {
if uri.starts_with("data:") {
continue;
}
let dependency = PathBuf::from(&uri);
if !is_plain_relative(&dependency) {
return Err(AssetImportError::Invalid {
path: path.to_owned(),
message: format!(
"URI `{uri}` must stay inside the glTF's folder"
),
});
}
dependencies.insert(dependency);
}
Ok(dependencies.into_iter().collect())
}
#[cfg(not(feature = "gltf"))]
fn gltf_dependencies(path: &Path) -> Result<Vec<PathBuf>, AssetImportError> {
Err(AssetImportError::Invalid {
path: path.to_owned(),
message: "this build has no glTF support (feature `gltf`)".into(),
})
}
fn validate(
kind: ImportedKind,
path: &Path,
) -> Result<[u32; 2], AssetImportError> {
let invalid = |message: String| AssetImportError::Invalid {
path: path.to_owned(),
message,
};
match kind {
ImportedKind::Image => image::image_dimensions(path)
.map(|(width, height)| [width, height])
.map_err(|error| invalid(error.to_string())),
ImportedKind::Gltf => {
#[cfg(feature = "gltf")]
{
let mut server = crate::assets::AssetServer::default();
let primitives = server
.import_gltf(path)
.map_err(|error| invalid(error.to_string()))?;
Ok([u32::try_from(primitives.len()).unwrap_or(u32::MAX), 0])
}
#[cfg(not(feature = "gltf"))]
gltf_dependencies(path).map(|_| [0, 0])
}
ImportedKind::Audio => {
let bytes = std::fs::read(path).map_err(io(path))?;
audio_size(&bytes).map_err(|message| invalid(message.into()))
}
}
}
fn audio_size(bytes: &[u8]) -> Result<[u32; 2], &'static str> {
if bytes.starts_with(b"OggS") {
return Ok([0, 0]);
}
if bytes.len() < 12 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WAVE" {
return Err("not a RIFF/WAVE or Ogg file");
}
let u16_at = |at: usize| u16::from_le_bytes([bytes[at], bytes[at + 1]]);
let u32_at = |at: usize| {
u32::from_le_bytes([
bytes[at],
bytes[at + 1],
bytes[at + 2],
bytes[at + 3],
])
};
let (mut format, mut data) = (None, None);
let mut at = 12;
while at + 8 <= bytes.len() {
let length = u32_at(at + 4) as usize;
let body = at + 8;
match &bytes[at..at + 4] {
b"fmt " if length >= 16 && body + 16 <= bytes.len() => {
format = Some((
u16_at(body + 2),
u32_at(body + 4),
u16_at(body + 12),
));
}
b"data" => data = Some(length.min(bytes.len() - body)),
_ => {}
}
at = body.saturating_add(length + (length & 1));
}
match (format, data) {
(Some((channels, rate, frame)), Some(data))
if channels > 0 && rate > 0 && frame > 0 =>
{
let frames = (data / usize::from(frame)) as u64;
let milliseconds = frames * 1000 / u64::from(rate);
Ok([rate, u32::try_from(milliseconds).unwrap_or(u32::MAX)])
}
_ => Err("WAV file has no usable `fmt ` and `data` chunks"),
}
}
fn apply_settings(
kind: ImportedKind,
path: &Path,
settings: &ImportSettings,
) -> Result<(), AssetImportError> {
let (ImportedKind::Image, Some(max_size)) = (kind, settings.max_size)
else {
return Ok(());
};
let invalid = |error: image::ImageError| AssetImportError::Invalid {
path: path.to_owned(),
message: error.to_string(),
};
let image = image::open(path).map_err(invalid)?;
let longest = image.width().max(image.height());
if longest <= max_size.max(1) {
return Ok(());
}
image
.resize(max_size, max_size, image::imageops::FilterType::Lanczos3)
.save(path)
.map_err(invalid)
}
fn write_meta(asset: &Path, meta: &AssetMeta) -> Result<(), AssetImportError> {
let path = meta_path(asset);
let mut bytes =
serde_json::to_vec_pretty(meta).expect("asset metadata serializes");
bytes.push(b'\n');
crate::runtime::write_atomic(&path, &bytes).map_err(io(&path))
}
fn read_meta(path: &Path) -> Result<AssetMeta, AssetImportError> {
let bytes = std::fs::read(path).map_err(io(path))?;
serde_json::from_slice(&bytes).map_err(|error| AssetImportError::Invalid {
path: path.to_owned(),
message: error.to_string(),
})
}
fn copy_in(
source: &Path,
destination: &Path,
settings: &ImportSettings,
) -> Result<(ImportedKind, Vec<PathBuf>, [u32; 2], String), AssetImportError> {
if !source.is_file() {
return Err(AssetImportError::SourceMissing(source.to_owned()));
}
let kind = ImportedKind::from_path(source)
.ok_or_else(|| AssetImportError::Unsupported(source.to_owned()))?;
let dependencies = match kind {
ImportedKind::Image | ImportedKind::Audio => Vec::new(),
ImportedKind::Gltf => gltf_dependencies(source)?,
};
let size = validate(kind, source)?;
let from = source.parent().unwrap_or(Path::new("."));
let to = destination.parent().unwrap_or(Path::new("."));
let same = |a: &Path, b: &Path| {
a.canonicalize()
.ok()
.is_some_and(|a| b.canonicalize().ok() == Some(a))
};
for file in dependencies
.iter()
.map(|dependency| (from.join(dependency), to.join(dependency)))
.chain([(source.to_owned(), destination.to_owned())])
{
if same(&file.0, &file.1) {
continue;
}
if let Some(parent) = file.1.parent() {
std::fs::create_dir_all(parent).map_err(io(parent))?;
}
std::fs::copy(&file.0, &file.1).map_err(io(&file.0))?;
}
apply_settings(kind, destination, settings)?;
let size = if settings.max_size.is_some() {
validate(kind, destination)?
} else {
size
};
let bytes = std::fs::read(destination).map_err(io(destination))?;
Ok((kind, dependencies, size, scene_revision(&bytes)))
}
fn report(
project_root: &Path,
asset: &Path,
meta: AssetMeta,
size: [u32; 2],
dry_run: bool,
) -> ImportReport {
let relative = asset.strip_prefix(project_root).unwrap_or(asset);
let mut warnings = Vec::new();
if meta.source.license.is_none() {
warnings.push(
"no license recorded; add one with --license before shipping"
.into(),
);
}
ImportReport {
id: meta.id,
kind: meta.kind,
path: slash(relative),
reference: format!("../{}", slash(relative)),
dependencies: meta.dependencies,
size,
dry_run,
settings: meta.settings,
source: meta.source,
referenced_by: scenes_referencing(project_root, asset),
warnings,
}
}
struct Staging(PathBuf);
impl Staging {
fn new(purpose: &str) -> Result<Self, AssetImportError> {
let path = std::env::temp_dir()
.join(format!("rusting-{purpose}-{}", Uuid::new_v4()));
std::fs::create_dir_all(&path).map_err(io(&path))?;
Ok(Self(path))
}
}
impl Drop for Staging {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn copy_in_or_preview(
source: &Path,
destination: &Path,
settings: &ImportSettings,
dry_run: bool,
) -> Result<(ImportedKind, Vec<PathBuf>, [u32; 2], String), AssetImportError> {
if !dry_run {
return copy_in(source, destination, settings);
}
let staging = Staging::new("asset-preview")?;
copy_in(
source,
&staging.0.join(destination.file_name().unwrap_or_default()),
settings,
)
}
pub fn import_asset(
project_root: &Path,
source: &Path,
folder: &Path,
provenance: &AssetProvenance,
settings: &ImportSettings,
dry_run: bool,
) -> Result<ImportReport, AssetImportError> {
if !is_plain_relative(folder) {
return Err(AssetImportError::Invalid {
path: folder.to_owned(),
message: "the folder must be relative to `assets`".into(),
});
}
let name = source
.file_name()
.ok_or_else(|| AssetImportError::SourceMissing(source.to_owned()))?;
let destination = project_root.join("assets").join(folder).join(name);
for path in [destination.clone(), meta_path(&destination)] {
if path.exists() {
return Err(AssetImportError::Exists(path));
}
}
let (kind, dependencies, size, content_hash) =
copy_in_or_preview(source, &destination, settings, dry_run)?;
let mut recorded = AssetProvenance {
original: source.display().to_string(),
..AssetProvenance::default()
};
recorded.merge(provenance);
let meta = AssetMeta {
format_version: META_FORMAT_VERSION,
id: Uuid::new_v4(),
kind,
settings: settings.clone(),
dependencies,
content_hash,
source: recorded,
};
if !dry_run {
write_meta(&destination, &meta)?;
}
Ok(report(project_root, &destination, meta, size, dry_run))
}
pub fn reimport_asset(
project_root: &Path,
asset: &str,
from: Option<&Path>,
provenance: &AssetProvenance,
settings: Option<&ImportSettings>,
dry_run: bool,
) -> Result<ImportReport, AssetImportError> {
let (path, mut meta) = find_asset(project_root, asset)?;
if let Some(settings) = settings {
meta.settings = settings.clone();
}
let original = PathBuf::from(&meta.source.original);
let source = match from {
Some(from) => from.to_owned(),
None if original.is_file() => original,
None => path.clone(),
};
if ImportedKind::from_path(&source) != Some(meta.kind) {
return Err(AssetImportError::Invalid {
path: source,
message: format!(
"a {:?} asset must be replaced by the same kind",
meta.kind
),
});
}
let (_, dependencies, size, content_hash) =
copy_in_or_preview(&source, &path, &meta.settings, dry_run)?;
if from.is_some() {
meta.source.original = source.display().to_string();
}
meta.source.merge(provenance);
meta.dependencies = dependencies;
meta.content_hash = content_hash;
if !dry_run {
write_meta(&path, &meta)?;
}
Ok(report(project_root, &path, meta, size, dry_run))
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct GeneratorHook {
pub command: Vec<String>,
pub license: Option<String>,
pub settings: ImportSettings,
}
#[derive(Deserialize)]
struct GeneratedFile {
file: PathBuf,
#[serde(flatten)]
provenance: AssetProvenance,
}
#[derive(Clone, Copy, Debug)]
pub enum GenerateTarget<'a> {
Import { folder: &'a Path },
Replace { asset: &'a str },
}
pub fn generate_asset(
project_root: &Path,
hooks: &BTreeMap<String, GeneratorHook>,
hook: &str,
prompt: &str,
target: GenerateTarget<'_>,
dry_run: bool,
) -> Result<ImportReport, AssetImportError> {
let config = hooks
.get(hook)
.ok_or_else(|| AssetImportError::GeneratorUnknown(hook.to_owned()))?;
let failed = |message: String| AssetImportError::GeneratorFailed {
hook: hook.to_owned(),
message,
};
let (program, arguments) = config
.command
.split_first()
.ok_or_else(|| failed("`command` is empty".into()))?;
let output_dir = Staging::new("generated")?;
let output = std::process::Command::new(program)
.args(arguments)
.current_dir(project_root)
.env("RUSTING_PROMPT", prompt)
.env("RUSTING_OUTPUT_DIR", &output_dir.0)
.stdin(std::process::Stdio::null())
.output()
.map_err(|error| {
failed(format!("could not run `{program}`: {error}"))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(failed(format!(
"exited with {}: {}",
output.status,
stderr.trim()
)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let last = stdout.lines().rev().find(|line| !line.trim().is_empty());
let generated: GeneratedFile = last
.and_then(|line| serde_json::from_str(line).ok())
.ok_or_else(|| {
failed(
"the last output line must be JSON like {\"file\": \"x.png\"}"
.into(),
)
})?;
let mut provenance = generated.provenance;
if provenance.license.is_none() {
provenance.license.clone_from(&config.license);
}
if provenance.license.is_none() {
return Err(failed(
"no license: print one or set `license` on the hook".into(),
));
}
provenance.generator.get_or_insert_with(|| hook.to_owned());
provenance
.notes
.get_or_insert_with(|| format!("prompt: {prompt}"));
provenance.original = format!("generator:{hook}");
let file = output_dir.0.join(generated.file);
match target {
GenerateTarget::Import { folder } => import_asset(
project_root,
&file,
folder,
&provenance,
&config.settings,
dry_run,
),
GenerateTarget::Replace { asset } => reimport_asset(
project_root,
asset,
Some(&file),
&provenance,
(config.settings != ImportSettings::default())
.then_some(&config.settings),
dry_run,
),
}
}
fn metas(
project_root: &Path,
) -> Vec<(PathBuf, Result<AssetMeta, AssetImportError>)> {
let mut found = Vec::new();
let mut folders = vec![project_root.join("assets")];
while let Some(folder) = folders.pop() {
let Ok(entries) = std::fs::read_dir(&folder) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
folders.push(path);
} else if path.extension().is_some_and(|e| e == META_EXTENSION) {
found.push((path.with_extension(""), read_meta(&path)));
}
}
}
found.sort_by(|a, b| a.0.cmp(&b.0));
found
}
fn find_asset(
project_root: &Path,
asset: &str,
) -> Result<(PathBuf, AssetMeta), AssetImportError> {
let id = Uuid::parse_str(asset).ok();
let by_path = project_root.join(asset);
metas(project_root)
.into_iter()
.filter_map(|(path, meta)| Some((path, meta.ok()?)))
.find(|(path, meta)| Some(meta.id) == id || *path == by_path)
.ok_or_else(|| AssetImportError::NotFound(asset.to_owned()))
}
#[must_use]
pub fn asset_references(document: &SceneDocument) -> Vec<(PathBuf, usize)> {
let mut references = Vec::new();
for (index, entity) in document.entities.iter().enumerate() {
if let Some(renderer) = &entity.mesh_renderer {
if let SceneMesh::AssetPath(path) = &renderer.mesh {
references.push((path.clone(), index));
}
if let SceneMaterial::Inline(material) = &renderer.material {
for path in [
&material.base_color_texture,
&material.normal_texture,
&material.metallic_roughness_texture,
&material.occlusion_texture,
&material.emissive_texture,
]
.into_iter()
.flatten()
{
references.push((path.clone(), index));
}
}
}
}
references
}
fn scenes_referencing(project_root: &Path, asset: &Path) -> Vec<String> {
let Ok(asset) = asset.canonicalize() else {
return Vec::new();
};
let scenes = project_root.join("scenes");
let Ok(entries) = std::fs::read_dir(&scenes) else {
return Vec::new();
};
let mut found: Vec<String> = entries
.flatten()
.map(|entry| entry.path())
.filter(|path| path.extension().is_some_and(|e| e == "rscene"))
.filter(|scene| {
read_scene_document(scene).is_ok_and(|document| {
asset_references(&document).iter().any(|(path, _)| {
scenes.join(path).canonicalize().ok().as_ref()
== Some(&asset)
})
})
})
.map(|scene| slash(scene.strip_prefix(project_root).unwrap_or(&scene)))
.collect();
found.sort();
found
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AssetIssue {
pub code: &'static str,
pub severity: &'static str,
pub message: String,
pub file: PathBuf,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct AssetCatalog {
pub assets: Vec<ImportReport>,
pub issues: Vec<AssetIssue>,
}
#[must_use]
pub fn list_assets(project_root: &Path) -> AssetCatalog {
let mut catalog = AssetCatalog::default();
let mut issue = |code, severity, message: String, file: &Path| {
catalog.issues.push(AssetIssue {
code,
severity,
message,
file: file.to_owned(),
});
};
let mut known = BTreeSet::new();
let mut ids: BTreeMap<Uuid, PathBuf> = BTreeMap::new();
let mut assets = Vec::new();
for (path, meta) in metas(project_root) {
let meta = match meta {
Ok(meta) => meta,
Err(error) => {
issue(
"ASSET_META_INVALID",
"error",
error.to_string(),
&meta_path(&path),
);
continue;
}
};
known.insert(path.clone());
let folder = path.parent().unwrap_or(Path::new("."));
known.extend(meta.dependencies.iter().map(|d| folder.join(d)));
if let Some(first) = ids.insert(meta.id, path.clone()) {
issue(
"ASSET_DUPLICATE_ID",
"error",
format!("ID {} is also used by `{}`", meta.id, first.display()),
&meta_path(&path),
);
}
let Ok(bytes) = std::fs::read(&path) else {
issue(
"ASSET_MISSING",
"error",
"the imported file is missing".into(),
&path,
);
continue;
};
if scene_revision(&bytes) != meta.content_hash {
issue(
"ASSET_CHANGED",
"warning",
"changed since import; run `asset reimport` to record it"
.into(),
&path,
);
}
for dependency in &meta.dependencies {
let file = folder.join(dependency);
if !file.is_file() {
issue(
"ASSET_DEPENDENCY_MISSING",
"error",
format!("needs `{}`", dependency.display()),
&file,
);
}
}
let size = validate(meta.kind, &path).unwrap_or_else(|error| {
issue("ASSET_INVALID", "error", error.to_string(), &path);
[0, 0]
});
assets.push((path, meta, size));
}
let mut folders = vec![project_root.join("assets")];
while let Some(folder) = folders.pop() {
for path in std::fs::read_dir(&folder)
.into_iter()
.flatten()
.flatten()
.map(|entry| entry.path())
{
if path.is_dir() {
folders.push(path);
} else if ImportedKind::from_path(&path).is_some()
&& !known.contains(&path)
{
issue(
"ASSET_NOT_IMPORTED",
"warning",
"has no .rmeta; import it to record an ID and license"
.into(),
&path,
);
}
}
}
for (path, meta, size) in assets {
let entry = report(project_root, &path, meta, size, false);
if entry.source.license.is_none() {
issue(
"ASSET_NO_LICENSE",
"warning",
"no license recorded".into(),
&path,
);
}
catalog.assets.push(ImportReport {
warnings: Vec::new(),
..entry
});
}
catalog
.issues
.sort_by(|a, b| (&a.file, a.code).cmp(&(&b.file, b.code)));
catalog
}
#[cfg(test)]
mod tests {
use super::*;
fn project() -> PathBuf {
let root = std::env::temp_dir()
.join(format!("rusting-asset-import-{}", Uuid::new_v4()));
std::fs::create_dir_all(root.join("assets")).unwrap();
std::fs::create_dir_all(root.join("scenes")).unwrap();
root
}
fn png(path: &Path, width: u32, height: u32, value: u8) {
image::RgbaImage::from_pixel(
width,
height,
image::Rgba([value, 0, 0, 255]),
)
.save(path)
.unwrap();
}
#[test]
fn images_import_with_settings_provenance_and_a_stable_id_on_reimport() {
let root = project();
let source = root.join("incoming.png");
png(&source, 64, 32, 10);
let provenance = AssetProvenance {
author: Some("Ada".into()),
..AssetProvenance::default()
};
let settings = ImportSettings { max_size: Some(16) };
let imported = import_asset(
&root,
&source,
Path::new("ui"),
&provenance,
&settings,
false,
)
.unwrap();
assert_eq!(imported.path, "assets/ui/incoming.png");
assert_eq!(imported.reference, "../assets/ui/incoming.png");
assert_eq!(imported.size, [16, 8], "longest edge scaled to max_size");
assert_eq!(imported.source.author.as_deref(), Some("Ada"));
assert_eq!(
imported.warnings.len(),
1,
"no license: {:?}",
imported.warnings
);
let meta = read_meta(&meta_path(&root.join(&imported.path))).unwrap();
assert_eq!(meta.id, imported.id);
assert!(matches!(
import_asset(
&root,
&source,
Path::new("ui"),
&provenance,
&settings,
false
),
Err(AssetImportError::Exists(_))
));
let scene = serde_json::json!({
"format_version": crate::runtime::SCENE_FORMAT_VERSION,
"name": "Main",
"entities": [{
"id": Uuid::new_v4(), "name": "Sign",
"transform": {"position": [0.0, 0.0, 0.0], "rotation": [0.0, 0.0, 0.0], "scale": [1.0, 1.0, 1.0]},
"mesh_renderer": {"mesh": {"BuiltinPrimitive": "Quad"}, "material": {"Inline": {
"model": "Unlit", "base_color": [1.0, 1.0, 1.0, 1.0], "emissive": [0.0, 0.0, 0.0],
"metallic": 0.0, "roughness": 1.0,
"base_color_texture": imported.reference,
"normal_texture": null, "metallic_roughness_texture": null,
"occlusion_texture": null, "emissive_texture": null}},
"cast_shadows": false, "receive_shadows": false}
}]
});
std::fs::write(root.join("scenes/main.rscene"), scene.to_string())
.unwrap();
read_scene_document(root.join("scenes/main.rscene")).unwrap();
let replacement = root.join("better.png");
png(&replacement, 8, 8, 200);
let license = AssetProvenance {
license: Some("CC0-1.0".into()),
..AssetProvenance::default()
};
let replaced = reimport_asset(
&root,
&imported.id.to_string(),
Some(&replacement),
&license,
None,
false,
)
.unwrap();
assert_eq!(replaced.id, imported.id);
assert_eq!(replaced.size, [8, 8]);
assert_eq!(replaced.source.author.as_deref(), Some("Ada"));
assert_eq!(replaced.source.license.as_deref(), Some("CC0-1.0"));
assert!(replaced.source.original.ends_with("better.png"));
assert_eq!(replaced.referenced_by, ["scenes/main.rscene"]);
assert!(replaced.warnings.is_empty());
let pixel = image::open(root.join(&replaced.path)).unwrap().to_rgba8();
assert_eq!(pixel.get_pixel(0, 0)[0], 200);
let catalog = list_assets(&root);
assert_eq!(catalog.assets.len(), 1);
assert!(catalog.issues.is_empty(), "{:?}", catalog.issues);
png(&root.join(&replaced.path), 8, 8, 99);
png(&root.join("assets/stray.png"), 2, 2, 1);
std::fs::copy(
meta_path(&root.join(&replaced.path)),
root.join("assets/stray.png.rmeta"),
)
.unwrap();
let codes: Vec<_> = list_assets(&root)
.issues
.iter()
.map(|issue| issue.code)
.collect();
assert_eq!(
codes,
["ASSET_CHANGED", "ASSET_CHANGED", "ASSET_DUPLICATE_ID"]
);
assert!(matches!(
import_asset(
&root,
&root.join("notes.txt"),
Path::new(""),
&provenance,
&settings,
false
),
Err(AssetImportError::SourceMissing(_))
));
std::fs::write(root.join("broken.png"), b"not a png").unwrap();
assert_eq!(
import_asset(
&root,
&root.join("broken.png"),
Path::new(""),
&provenance,
&settings,
false
)
.unwrap_err()
.code(),
"ASSET_INVALID"
);
assert!(!root.join("assets/broken.png").exists());
std::fs::remove_dir_all(root).unwrap();
}
fn wav(rate: u32, frames: u32) -> Vec<u8> {
let data = frames * 2;
let mut bytes = b"RIFF".to_vec();
bytes.extend((36 + data).to_le_bytes());
bytes.extend(b"WAVEfmt ");
bytes.extend(16u32.to_le_bytes());
bytes.extend(1u16.to_le_bytes()); bytes.extend(1u16.to_le_bytes()); bytes.extend(rate.to_le_bytes());
bytes.extend((rate * 2).to_le_bytes());
bytes.extend(2u16.to_le_bytes());
bytes.extend(16u16.to_le_bytes());
bytes.extend(b"data");
bytes.extend(data.to_le_bytes());
bytes.resize(bytes.len() + data as usize, 0);
bytes
}
#[test]
fn audio_imports_and_dry_runs_write_nothing() {
let root = project();
let source = root.join("hit.wav");
std::fs::write(&source, wav(8000, 4000)).unwrap();
let none = AssetProvenance::default();
let settings = ImportSettings::default();
let preview = import_asset(
&root,
&source,
Path::new("sfx"),
&none,
&settings,
true,
)
.unwrap();
assert!(preview.dry_run);
assert_eq!(preview.kind, ImportedKind::Audio);
assert_eq!(preview.size, [8000, 500], "sample rate and milliseconds");
assert!(
!root.join("assets/sfx").exists(),
"a preview writes nothing"
);
let imported = import_asset(
&root,
&source,
Path::new("sfx"),
&none,
&settings,
false,
)
.unwrap();
assert!(!imported.dry_run);
std::fs::write(&source, wav(8000, 8000)).unwrap();
let replace = reimport_asset(
&root,
"assets/sfx/hit.wav",
None,
&none,
None,
true,
)
.unwrap();
assert_eq!((replace.id, replace.size), (imported.id, [8000, 1000]));
assert_eq!(
std::fs::read(root.join("assets/sfx/hit.wav"))
.unwrap()
.len(),
44 + 8000,
"the previewed replacement left the old file"
);
std::fs::write(root.join("bad.wav"), b"RIFF\0\0\0\0WAVE").unwrap();
let bad = import_asset(
&root,
&root.join("bad.wav"),
Path::new(""),
&none,
&settings,
false,
)
.unwrap_err();
assert_eq!(bad.code(), "ASSET_INVALID", "{bad}");
std::fs::write(root.join("music.ogg"), b"OggS and more").unwrap();
let ogg = import_asset(
&root,
&root.join("music.ogg"),
Path::new(""),
&none,
&settings,
true,
)
.unwrap();
assert_eq!(ogg.size, [0, 0]);
std::fs::remove_dir_all(root).unwrap();
}
#[cfg(unix)]
#[test]
fn generator_hooks_import_and_replace_through_the_importer() {
let root = project();
let fixture = root.join("fixture.png");
png(&fixture, 32, 32, 7);
let hook = |print: &str, license: Option<&str>| {
GeneratorHook {
command: vec![
"sh".into(),
"-c".into(),
format!(
"cp \"$0\" \"$RUSTING_OUTPUT_DIR/$RUSTING_PROMPT.png\" && echo '{print}'"
),
fixture.display().to_string(),
],
license: license.map(Into::into),
settings: ImportSettings { max_size: Some(16) },
}
};
let hooks = BTreeMap::from([
(
"icons".to_owned(),
hook(r#"{"file": "crate.png"}"#, Some("CC0-1.0")),
),
(
"unlicensed".to_owned(),
hook(r#"{"file": "crate.png"}"#, None),
),
("quiet".to_owned(), hook("done", Some("CC0-1.0"))),
(
"broken".to_owned(),
GeneratorHook {
command: vec![
"sh".into(),
"-c".into(),
"echo nope >&2; exit 3".into(),
],
..GeneratorHook::default()
},
),
]);
let folder = Path::new("props");
let generate = |hook: &str, target, dry_run| {
generate_asset(&root, &hooks, hook, "crate", target, dry_run)
};
let preview =
generate("icons", GenerateTarget::Import { folder }, true).unwrap();
assert!(preview.dry_run);
assert!(!root.join("assets/props").exists());
let made = generate("icons", GenerateTarget::Import { folder }, false)
.unwrap();
assert_eq!(made.path, "assets/props/crate.png");
assert_eq!(made.size, [16, 16], "hook settings apply");
assert_eq!(made.source.original, "generator:icons");
assert_eq!(made.source.license.as_deref(), Some("CC0-1.0"));
assert_eq!(made.source.generator.as_deref(), Some("icons"));
assert_eq!(made.source.notes.as_deref(), Some("prompt: crate"));
assert!(made.warnings.is_empty());
let id = made.id.to_string();
let replaced =
generate("icons", GenerateTarget::Replace { asset: &id }, false)
.unwrap();
assert_eq!(replaced.id, made.id);
assert!(list_assets(&root).issues.is_empty());
let codes: Vec<_> = ["unlicensed", "quiet", "broken", "missing"]
.map(|hook| {
generate(hook, GenerateTarget::Import { folder }, true)
.unwrap_err()
.code()
})
.into();
assert_eq!(
codes,
[
"GENERATOR_FAILED",
"GENERATOR_FAILED",
"GENERATOR_FAILED",
"GENERATOR_UNKNOWN"
]
);
std::fs::remove_dir_all(root).unwrap();
}
#[cfg(feature = "gltf")]
#[test]
fn gltf_import_copies_and_reports_dependencies() {
let root = project();
let incoming = root.join("incoming");
std::fs::create_dir_all(incoming.join("textures")).unwrap();
png(&incoming.join("textures/pixel.png"), 1, 1, 5);
let positions: Vec<u8> =
[0.0f32, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0]
.iter()
.flat_map(|value| value.to_le_bytes())
.collect();
std::fs::write(incoming.join("tri.bin"), &positions).unwrap();
let gltf = incoming.join("tri.gltf");
let json = |uri: &str| {
format!(
r#"{{"asset": {{"version": "2.0"}},
"buffers": [{{"uri": "{uri}", "byteLength": 36}}],
"bufferViews": [{{"buffer": 0, "byteLength": 36}}],
"accessors": [{{"bufferView": 0, "componentType": 5126, "count": 3,
"type": "VEC3", "min": [0, 0, 0], "max": [1, 1, 0]}}],
"images": [{{"uri": "textures/pixel.png"}}],
"textures": [{{"source": 0}}],
"materials": [{{"pbrMetallicRoughness": {{"baseColorTexture": {{"index": 0}}}}}}],
"meshes": [{{"primitives": [{{"attributes": {{"POSITION": 0}}, "material": 0}}]}}]}}"#
)
};
std::fs::write(&gltf, json("tri.bin")).unwrap();
let imported = import_asset(
&root,
&gltf,
Path::new("models"),
&AssetProvenance {
license: Some("CC-BY-4.0".into()),
..AssetProvenance::default()
},
&ImportSettings::default(),
false,
)
.unwrap();
assert_eq!(imported.kind, ImportedKind::Gltf);
assert_eq!(imported.size, [1, 0]);
assert_eq!(
imported.dependencies,
[
PathBuf::from("textures/pixel.png"),
PathBuf::from("tri.bin")
]
);
assert!(root.join("assets/models/textures/pixel.png").is_file());
assert!(list_assets(&root).issues.is_empty());
std::fs::remove_file(root.join("assets/models/tri.bin")).unwrap();
let codes: Vec<_> = list_assets(&root)
.issues
.iter()
.map(|issue| issue.code)
.collect();
assert_eq!(codes, ["ASSET_DEPENDENCY_MISSING", "ASSET_INVALID"]);
std::fs::write(&gltf, json("../outside.bin")).unwrap();
let escaped = reimport_asset(
&root,
"assets/models/tri.gltf",
None,
&AssetProvenance::default(),
None,
false,
)
.unwrap_err();
assert_eq!(escaped.code(), "ASSET_INVALID", "{escaped}");
std::fs::remove_dir_all(root).unwrap();
}
}