use std::collections::{HashMap, HashSet};
use super::{DarklyEngine, ReadbackContext};
use crate::document::filter;
use crate::document::layer_kind::{self, PixelBlobSpec};
use crate::document::Entity;
use crate::format::manifest::{
Manifest, ManifestCanvas, ManifestEntry, ManifestRequires, ManifestVeil, ManifestWriter,
SaveBlob, SaveBundle, CONTAINER_VERSION, FORMAT_TAG,
};
use crate::format::registry_io::InstancePayload;
use crate::gpu::readback;
use crate::layer::LayerId;
#[derive(Debug)]
pub enum SaveError {
InProgress,
}
impl std::fmt::Display for SaveError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SaveError::InProgress => write!(f, "a save is already in flight"),
}
}
}
impl std::error::Error for SaveError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SavePurpose {
File,
Snapshot,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SaveReadbackKind {
BlobBytes { key: String },
Composite,
}
pub struct SaveJob {
manifest: Manifest,
#[allow(dead_code)] pinned_textures: Vec<wgpu::Texture>,
pending_blobs: HashMap<String, Option<Vec<u8>>>,
composite: Option<(u32, u32, Vec<u8>)>,
purpose: SavePurpose,
}
impl SaveJob {
fn is_complete(&self) -> bool {
self.composite.is_some() && self.pending_blobs.values().all(Option::is_some)
}
}
impl DarklyEngine {
pub fn start_save_document(&mut self, purpose: SavePurpose) -> Result<(), SaveError> {
if self.active_save_job.is_some() {
return Err(SaveError::InProgress);
}
let (manifest, pixel_blobs) = build_manifest(self);
self.compositor
.render_offscreen(&self.gpu.device, &self.gpu.queue, &mut self.doc);
let canvas_w = self.compositor.canvas_width();
let canvas_h = self.compositor.canvas_height();
let mut pinned_textures = Vec::new();
let mut pending_blobs: HashMap<String, Option<Vec<u8>>> = HashMap::new();
for spec in pixel_blobs {
queue_pixel_readback(self, &spec, &mut pinned_textures, &mut pending_blobs);
}
let composite_tex = self.compositor.composited_texture().clone();
pinned_textures.push(composite_tex.clone());
self.gpu.encode("save-composite", |encoder| {
let request = readback::request_readback(
&self.gpu.device,
encoder,
&composite_tex,
wgpu::TextureFormat::Rgba8Unorm,
crate::coord::LayerRect::from_xywh(0, 0, canvas_w, canvas_h),
);
self.readbacks.submit(
request,
ReadbackContext::SaveDocument {
kind: SaveReadbackKind::Composite,
width: canvas_w,
height: canvas_h,
},
);
});
self.active_save_job = Some(SaveJob {
manifest,
pinned_textures,
pending_blobs,
composite: None,
purpose,
});
Ok(())
}
pub fn poll_save_result(&mut self) -> Option<SaveBundle> {
if self.active_save_job.is_some() {
self.drain_readbacks();
}
let job = self.active_save_job.as_ref()?;
if !job.is_complete() {
return None;
}
let job = self.active_save_job.take().unwrap();
let manifest_json = serde_json::to_vec_pretty(&job.manifest).ok()?;
let (composite_width, composite_height, composite_rgba) = job.composite.unwrap();
let mut blobs: Vec<SaveBlob> = job
.pending_blobs
.into_iter()
.map(|(path, bytes)| SaveBlob {
path,
bytes: bytes.unwrap_or_default(),
})
.collect();
blobs.sort_by(|a, b| a.path.cmp(&b.path));
if job.purpose == SavePurpose::File {
self.doc.dirty = false;
}
Some(SaveBundle {
manifest_json,
composite_width,
composite_height,
composite_rgba,
blobs,
})
}
pub(crate) fn complete_save_readback(
&mut self,
kind: SaveReadbackKind,
width: u32,
height: u32,
mut pixels: Vec<u8>,
) {
let Some(job) = self.active_save_job.as_mut() else {
return;
};
match kind {
SaveReadbackKind::Composite => {
pixels.truncate((width * height * 4) as usize);
job.composite = Some((width, height, pixels));
}
SaveReadbackKind::BlobBytes { key } => {
if let Some(slot) = job.pending_blobs.get_mut(&key) {
*slot = Some(pixels);
}
}
}
}
}
fn build_manifest(engine: &DarklyEngine) -> (Manifest, Vec<PixelBlobSpec>) {
let doc = &engine.doc;
let mut nodes: Vec<ManifestEntry> = Vec::new();
let mut filters: Vec<ManifestEntry> = Vec::new();
let mut blobs: Vec<PixelBlobSpec> = Vec::new();
let layer_kind_registry = layer_kind::registry();
let modifier_registry = filter::registry();
for (_id, entity) in doc.entities.iter() {
match entity {
Entity::Node(node) => {
let reg = layer_kind_registry
.get(node.type_id())
.expect("layer kind registration missing for type_id from doc");
let serialized = (reg.serialize)(node);
nodes.push(ManifestEntry {
id: node.id().to_ffi(),
type_id: reg.type_id.to_string(),
body: serialized.body,
});
blobs.extend(serialized.pixel_blobs);
}
Entity::Filter(m) => {
let reg = modifier_registry
.get(m.type_id())
.expect("filter registration missing for type_id from doc");
let serialized = (reg.serialize)(m);
filters.push(ManifestEntry {
id: m.id.to_ffi(),
type_id: reg.type_id.to_string(),
body: serialized.body,
});
blobs.extend(serialized.pixel_blobs);
}
}
}
nodes.sort_by_key(|e| e.id);
filters.sort_by_key(|e| e.id);
let veils = build_manifest_veils(engine);
let requires = requires_from_doc(engine);
let manifest = Manifest {
format: FORMAT_TAG.to_string(),
container_version: CONTAINER_VERSION,
writer: ManifestWriter::current(),
name: doc.name.clone(),
canvas: ManifestCanvas {
width: doc.width,
height: doc.height,
origin_x: doc.canvas_origin.x,
origin_y: doc.canvas_origin.y,
},
requires,
composite: "composite.png".to_string(),
root: doc.root_id().to_ffi(),
nodes,
modifiers: filters,
selection_id: doc.selection_id().map(LayerId::to_ffi),
veils,
};
(manifest, blobs)
}
fn build_manifest_veils(engine: &DarklyEngine) -> Vec<ManifestVeil> {
let chain = engine.compositor.veil_chain();
let count = chain.count();
let mut veils = Vec::with_capacity(count);
for i in 0..count {
let Some((type_id, visible)) = chain.info(i) else {
continue;
};
let params = chain.param_values(i).unwrap_or_default();
veils.push(ManifestVeil {
instance: InstancePayload::new(type_id.to_string(), params),
visible,
});
}
veils
}
pub fn requires_from_doc(engine: &DarklyEngine) -> ManifestRequires {
let mut layer_kinds = HashSet::new();
let mut blend_modes = HashSet::new();
let mut filter_kinds = HashSet::new();
let mut veil_types = HashSet::new();
for entity in engine.doc.entities.values() {
match entity {
Entity::Node(node) => {
layer_kinds.insert(node.type_id().to_string());
blend_modes.insert(node.blend().blend_mode.type_id.to_string());
}
Entity::Filter(m) => {
filter_kinds.insert(m.type_id().to_string());
}
}
}
let chain = engine.compositor.veil_chain();
for i in 0..chain.count() {
if let Some(id) = chain.type_id(i) {
veil_types.insert(id.to_string());
}
}
let mut layer_kind: Vec<String> = layer_kinds.into_iter().collect();
let mut blend_mode: Vec<String> = blend_modes.into_iter().collect();
let mut filter: Vec<String> = filter_kinds.into_iter().collect();
let mut veil: Vec<String> = veil_types.into_iter().collect();
layer_kind.sort();
blend_mode.sort();
filter.sort();
veil.sort();
ManifestRequires {
veil,
blend_mode,
layer_kind,
modifier: filter,
}
}
fn queue_pixel_readback(
engine: &mut DarklyEngine,
spec: &PixelBlobSpec,
pinned: &mut Vec<wgpu::Texture>,
blobs: &mut HashMap<String, Option<Vec<u8>>>,
) {
let Some(data) = engine.compositor.pixel_data_for(spec.source_node_id) else {
return;
};
let texture = data.texture.clone();
let format = data.format;
let width = data.width;
let height = data.height;
let key = spec.blob_key.clone();
pinned.push(texture.clone());
blobs.insert(key.clone(), None);
engine.gpu.encode("save-pixel-readback", |encoder| {
let request = readback::request_readback(
&engine.gpu.device,
encoder,
&texture,
format,
crate::coord::LayerRect::from_xywh(0, 0, width, height),
);
engine.readbacks.submit(
request,
ReadbackContext::SaveDocument {
kind: SaveReadbackKind::BlobBytes { key },
width,
height,
},
);
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gpu::context::GpuContext;
use crate::gpu::test_utils::test_device;
fn headless_engine(w: u32, h: u32) -> DarklyEngine {
let (device, queue) = test_device();
let gpu = GpuContext::new_headless(device, queue);
DarklyEngine::new(gpu, w, h)
}
#[test]
fn save_in_progress_returns_err() {
let mut engine = headless_engine(32, 32);
let _layer = engine.add_raster_layer(None);
engine
.start_save_document(SavePurpose::File)
.expect("first save kicks off");
let err = engine
.start_save_document(SavePurpose::File)
.expect_err("second save must refuse");
assert!(matches!(err, SaveError::InProgress));
}
#[test]
fn requires_inventory_collects_used_modules() {
let mut engine = headless_engine(32, 32);
let _layer = engine.add_raster_layer(None);
engine
.compositor
.veil_chain_mut()
.resize(&engine.gpu.device, &engine.gpu.queue, 32, 32);
let defaults: Vec<crate::gpu::params::ParamValue> = engine
.veil_param_defs("grain")
.iter()
.map(crate::gpu::params::ParamDef::default_value)
.collect();
engine.add_veil("grain", &defaults);
let requires = requires_from_doc(&engine);
assert!(
requires.veil.iter().any(|v| v == "grain"),
"requires.veil should list grain (got {:?})",
requires.veil
);
assert!(
requires.layer_kind.iter().any(|k| k == "raster"),
"requires.layer_kind should list raster (got {:?})",
requires.layer_kind
);
assert!(
requires.layer_kind.iter().any(|k| k == "group"),
"requires.layer_kind should list group (got {:?})",
requires.layer_kind
);
assert!(
requires.blend_mode.iter().any(|m| m == "normal"),
"requires.blend_mode should list normal (got {:?})",
requires.blend_mode
);
}
#[test]
fn dirty_flag_cleared_by_save() {
let mut engine = headless_engine(32, 32);
let _layer = engine.add_raster_layer(None);
assert!(engine.is_dirty(), "add_raster_layer must flip dirty");
engine
.start_save_document(SavePurpose::File)
.expect("save kicks off");
let mut bundle = None;
for _ in 0..16 {
engine.test_flush_readbacks();
engine.render(0.0);
if let Some(b) = engine.poll_save_result() {
bundle = Some(b);
break;
}
}
bundle.expect("save should complete within 16 frames");
assert!(
!engine.is_dirty(),
"successful save must clear dirty — bundle handoff matches disk"
);
}
#[test]
fn snapshot_save_does_not_clear_dirty() {
let mut engine = headless_engine(32, 32);
let _layer = engine.add_raster_layer(None);
assert!(engine.is_dirty(), "add_raster_layer must flip dirty");
engine
.start_save_document(SavePurpose::Snapshot)
.expect("snapshot save kicks off");
let mut bundle = None;
for _ in 0..16 {
engine.test_flush_readbacks();
engine.render(0.0);
if let Some(b) = engine.poll_save_result() {
bundle = Some(b);
break;
}
}
bundle.expect("snapshot should complete within 16 frames");
assert!(
engine.is_dirty(),
"autosave snapshot must NOT clear dirty — nothing reached the user's file"
);
}
#[test]
fn snapshot_completes_without_render() {
let mut engine = headless_engine(32, 32);
let _layer = engine.add_raster_layer(None);
engine
.start_save_document(SavePurpose::Snapshot)
.expect("snapshot save kicks off");
let mut bundle = None;
for _ in 0..32 {
engine.test_wait_gpu();
if let Some(b) = engine.poll_save_result() {
bundle = Some(b);
break;
}
}
let bundle = bundle.expect("snapshot must complete via poll alone, without render()");
assert!(
bundle.composite_width == 32 && bundle.composite_height == 32,
"composite dimensions should match the canvas"
);
}
#[test]
fn save_concurrent_edit_does_not_corrupt() {
let mut engine = headless_engine(32, 32);
let _baseline = engine.add_raster_layer(None);
engine
.start_save_document(SavePurpose::File)
.expect("save kicks off");
let _added_mid_save = engine.add_raster_layer(None);
let mut bundle = None;
for _ in 0..16 {
engine.test_flush_readbacks();
engine.render(0.0);
if let Some(b) = engine.poll_save_result() {
bundle = Some(b);
break;
}
}
let bundle = bundle.expect("save should complete within 16 frames");
let manifest: Manifest = serde_json::from_slice(&bundle.manifest_json).unwrap();
let raster_count = manifest
.nodes
.iter()
.filter(|e| e.type_id == crate::document::layer_kinds::raster::TYPE_ID)
.count();
assert_eq!(
raster_count, 1,
"snapshot must reflect doc state at start_save_document time, \
not the post-mutation state — found {raster_count} rasters"
);
}
}