use std::io::{Cursor, Read, Write};
use serde::{Deserialize, Serialize};
use crate::brush::stabilizer::StabilizerConfig;
use crate::brush::wire::BrushWireType;
use crate::nodegraph::Graph;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BrushMetadata {
pub name: String,
#[serde(default = "default_engine_version")]
pub engine_version: String,
#[serde(default)]
pub category: String,
#[serde(default)]
pub author: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub tags: Vec<String>,
pub graph: Graph<BrushWireType>,
#[serde(default)]
pub stabilizer: StabilizerConfig,
}
#[derive(Clone, Debug)]
pub struct Brush {
pub metadata: BrushMetadata,
pub thumbnail_png: Option<Vec<u8>>,
}
fn default_engine_version() -> String {
crate::VERSION.to_string()
}
impl BrushMetadata {
pub fn from_graph(name: impl Into<String>, graph: Graph<BrushWireType>) -> Self {
BrushMetadata {
name: name.into(),
engine_version: default_engine_version(),
category: String::new(),
author: String::new(),
description: String::new(),
tags: Vec::new(),
graph,
stabilizer: StabilizerConfig::default(),
}
}
}
impl Brush {
pub fn from_metadata(metadata: BrushMetadata) -> Self {
Brush {
metadata,
thumbnail_png: None,
}
}
const METADATA_JSON_PATH: &'static str = "brush.json";
const PREVIEW_PNG_PATH: &'static str = "preview.png";
pub fn to_bytes(&self) -> Result<Vec<u8>, String> {
let buf = Vec::new();
let cursor = Cursor::new(buf);
let mut zip = zip::ZipWriter::new(cursor);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
let json = serde_json::to_string_pretty(&self.metadata)
.map_err(|e| format!("failed to serialize brush metadata: {e}"))?;
zip.start_file(Self::METADATA_JSON_PATH, options)
.map_err(|e| format!("zip write error: {e}"))?;
zip.write_all(json.as_bytes())
.map_err(|e| format!("zip write error: {e}"))?;
if let Some(png) = &self.thumbnail_png {
zip.start_file(Self::PREVIEW_PNG_PATH, options)
.map_err(|e| format!("zip write error: {e}"))?;
zip.write_all(png)
.map_err(|e| format!("zip write error: {e}"))?;
}
let cursor = zip
.finish()
.map_err(|e| format!("zip finalize error: {e}"))?;
Ok(cursor.into_inner())
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, String> {
let cursor = Cursor::new(bytes);
let mut archive =
zip::ZipArchive::new(cursor).map_err(|e| format!("invalid ZIP archive: {e}"))?;
let metadata: BrushMetadata = {
let mut file = archive
.by_name(Self::METADATA_JSON_PATH)
.map_err(|e| format!("missing {}: {e}", Self::METADATA_JSON_PATH))?;
let mut json = String::new();
file.read_to_string(&mut json)
.map_err(|e| format!("failed to read {}: {e}", Self::METADATA_JSON_PATH))?;
serde_json::from_str(&json)
.map_err(|e| format!("invalid {}: {e}", Self::METADATA_JSON_PATH))?
};
let thumbnail_png = match archive.by_name(Self::PREVIEW_PNG_PATH) {
Ok(mut file) => {
let mut data = Vec::with_capacity(file.size() as usize);
file.read_to_end(&mut data)
.map_err(|e| format!("failed to read preview.png: {e}"))?;
Some(data)
}
Err(_) => None,
};
Ok(Brush {
metadata,
thumbnail_png,
})
}
#[cfg(not(target_arch = "wasm32"))]
pub fn save(&self, path: &std::path::Path) -> Result<(), String> {
let bytes = self.to_bytes()?;
std::fs::write(path, bytes).map_err(|e| format!("failed to write brush: {e}"))
}
#[cfg(not(target_arch = "wasm32"))]
pub fn load(path: &std::path::Path) -> Result<Self, String> {
let bytes = std::fs::read(path).map_err(|e| format!("failed to read brush file: {e}"))?;
Self::from_bytes(&bytes)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::brush;
#[test]
fn engine_version_default_is_crate_version() {
assert_eq!(default_engine_version(), crate::VERSION);
}
#[test]
fn round_trip_no_resources() {
let graph = brush::default_graph();
let metadata = BrushMetadata::from_graph("Test Brush", graph.clone());
let brush = Brush::from_metadata(metadata);
let bytes = brush.to_bytes().unwrap();
let loaded = Brush::from_bytes(&bytes).unwrap();
assert_eq!(loaded.metadata.name, "Test Brush");
let orig_val = serde_json::to_value(&brush.metadata.graph).unwrap();
let loaded_val = serde_json::to_value(&loaded.metadata.graph).unwrap();
assert_eq!(orig_val, loaded_val);
}
#[test]
fn corrupt_zip_returns_error() {
let err = Brush::from_bytes(b"not a zip").unwrap_err();
assert!(err.contains("invalid ZIP"), "got: {err}");
}
#[test]
fn missing_metadata_json_returns_error() {
let buf = Vec::new();
let cursor = Cursor::new(buf);
let mut zip = zip::ZipWriter::new(cursor);
let opts = zip::write::SimpleFileOptions::default();
zip.start_file("dummy.txt", opts).unwrap();
zip.write_all(b"hello").unwrap();
let cursor = zip.finish().unwrap();
let bytes = cursor.into_inner();
let err = Brush::from_bytes(&bytes).unwrap_err();
assert!(err.contains("missing"), "got: {err}");
}
#[test]
fn thumbnail_png_round_trip() {
let graph = brush::default_graph();
let metadata = BrushMetadata::from_graph("Thumbnailed", graph);
let png = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 1, 2, 3];
let mut brush = Brush::from_metadata(metadata);
brush.thumbnail_png = Some(png.clone());
let bytes = brush.to_bytes().unwrap();
let loaded = Brush::from_bytes(&bytes).unwrap();
assert_eq!(loaded.thumbnail_png, Some(png));
}
#[test]
fn thumbnail_absent_loads_as_none() {
let graph = brush::default_graph();
let metadata = BrushMetadata::from_graph("Bare", graph);
let brush = Brush::from_metadata(metadata);
let bytes = brush.to_bytes().unwrap();
let loaded = Brush::from_bytes(&bytes).unwrap();
assert!(loaded.thumbnail_png.is_none());
}
#[test]
fn unknown_fields_ignored() {
let graph = brush::default_graph();
let metadata = BrushMetadata::from_graph("Compat", graph);
let mut json_val: serde_json::Value = serde_json::to_value(&metadata).unwrap();
json_val["unknown_field"] = serde_json::json!("should be ignored");
json_val["nested_unknown"] = serde_json::json!({"a": 1, "b": [2,3]});
let json_str = serde_json::to_string_pretty(&json_val).unwrap();
let buf = Vec::new();
let cursor = Cursor::new(buf);
let mut zip = zip::ZipWriter::new(cursor);
let opts = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
zip.start_file(Brush::METADATA_JSON_PATH, opts).unwrap();
zip.write_all(json_str.as_bytes()).unwrap();
let cursor = zip.finish().unwrap();
let bytes = cursor.into_inner();
let loaded = Brush::from_bytes(&bytes).unwrap();
assert_eq!(loaded.metadata.name, "Compat");
}
}