use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Generation {
pub prompt: String,
pub negative: String,
pub ckpt: String,
pub width: u32,
pub height: u32,
pub steps: u32,
pub cfg: f32,
pub sampler: String,
pub scheduler: String,
pub seed: u64,
pub n: u32,
pub index: u32,
pub software: String,
pub created_unix: u64,
}
impl Generation {
pub fn parameters_text(&self) -> String {
format!(
"{}\nNegative prompt: {}\nSteps: {}, Sampler: {}, Schedule: {}, CFG: {}, Seed: {}, Size: {}x{}, Model: {}, Batch: {}/{}, Software: {}",
self.prompt,
self.negative,
self.steps,
self.sampler,
self.scheduler,
self.cfg,
self.seed,
self.width,
self.height,
self.ckpt,
self.index + 1,
self.n,
self.software,
)
}
pub fn save_sidecar(&self, json_path: &Path) -> Result<()> {
let text = serde_json::to_string_pretty(self)?;
std::fs::write(json_path, text)
.with_context(|| format!("write {}", json_path.display()))?;
Ok(())
}
pub fn load_sidecar(json_path: &Path) -> Result<Generation> {
let text = std::fs::read_to_string(json_path)
.with_context(|| format!("read {}", json_path.display()))?;
Ok(serde_json::from_str(&text)?)
}
pub fn embed_png(&self, png_bytes: &[u8]) -> Vec<u8> {
match embed_fallible(png_bytes, &self.parameters_text()) {
Ok(out) => out,
Err(_) => png_bytes.to_vec(),
}
}
}
fn embed_fallible(src: &[u8], params: &str) -> Result<Vec<u8>> {
const SIG: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
anyhow::ensure!(src.len() > 8 && &src[..8] == SIG, "not a PNG");
let mut data = b"parameters\0".to_vec();
data.extend_from_slice(&[0, 0]); data.extend_from_slice(b"\0"); data.extend_from_slice(b"\0"); data.extend_from_slice(params.as_bytes());
let chunk = make_chunk(b"iTXt", &data);
let mut out = Vec::with_capacity(src.len() + chunk.len());
out.extend_from_slice(&src[..8]);
let mut pos = 8;
let mut inserted = false;
let mut first_idat_at: Option<usize> = None;
let mut scan = 8;
while scan + 8 <= src.len() {
let len = u32::from_be_bytes(src[scan..scan + 4].try_into().unwrap()) as usize;
let typ = &src[scan + 4..scan + 8];
let end = scan + 8 + len + 4;
anyhow::ensure!(end <= src.len(), "truncated PNG chunk");
if typ == b"IDAT" && first_idat_at.is_none() {
first_idat_at = Some(scan);
}
if typ == b"IEND" {
break;
}
scan = end;
}
let at = first_idat_at.unwrap_or(scan);
while pos + 8 <= src.len() {
let len = u32::from_be_bytes(src[pos..pos + 4].try_into().unwrap()) as usize;
let typ = &src[pos + 4..pos + 8];
let end = pos + 8 + len + 4;
anyhow::ensure!(end <= src.len(), "truncated PNG chunk");
if pos == at && !inserted {
out.extend_from_slice(&chunk);
inserted = true;
}
out.extend_from_slice(&src[pos..end]);
pos = end;
if typ == b"IEND" {
break;
}
}
anyhow::ensure!(inserted, "no insertion point found");
Ok(out)
}
fn make_chunk(typ: &[u8; 4], data: &[u8]) -> Vec<u8> {
let mut v = Vec::with_capacity(12 + data.len());
v.extend_from_slice(&(data.len() as u32).to_be_bytes());
v.extend_from_slice(typ);
v.extend_from_slice(data);
v.extend_from_slice(&crc32(&[typ.as_slice(), data].concat()).to_be_bytes());
v
}
fn crc32(data: &[u8]) -> u32 {
let mut table = [0u32; 256];
for (i, slot) in table.iter_mut().enumerate() {
let mut c = i as u32;
for _ in 0..8 {
c = if c & 1 == 1 {
0xEDB88320 ^ (c >> 1)
} else {
c >> 1
};
}
*slot = c;
}
let mut crc = 0xFFFF_FFFFu32;
for b in data {
crc = table[((crc ^ (*b as u32)) & 0xFF) as usize] ^ (crc >> 8);
}
crc ^ 0xFFFF_FFFF
}
pub fn store_rendered(
dir: &Path,
stem: &str,
png_bytes: &[u8],
generation: &Generation,
) -> Result<(PathBuf, PathBuf)> {
std::fs::create_dir_all(dir).with_context(|| format!("mkdir {}", dir.display()))?;
let png_path = dir.join(format!("{stem}.png"));
let json_path = dir.join(format!("{stem}.json"));
std::fs::write(&png_path, generation.embed_png(png_bytes))
.with_context(|| format!("write {}", png_path.display()))?;
generation.save_sidecar(&json_path)?;
Ok((png_path, json_path))
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Generation {
Generation {
prompt: "a fox".into(),
negative: "blurry".into(),
ckpt: "m.safetensors".into(),
width: 512,
height: 512,
steps: 8,
cfg: 1.5,
sampler: "euler".into(),
scheduler: "normal".into(),
seed: 42,
n: 2,
index: 1,
software: "ccti 0.1.2".into(),
created_unix: 1_700_000_000,
}
}
fn tiny_png() -> Vec<u8> {
let img = image::RgbImage::from_pixel(4, 4, image::Rgb([200, 100, 50]));
let dyn_img = image::DynamicImage::ImageRgb8(img);
let mut buf = Vec::new();
let mut cur = std::io::Cursor::new(&mut buf);
dyn_img
.write_to(&mut cur, image::ImageFormat::Png)
.expect("encode");
buf
}
#[test]
fn parameters_text_carries_everything_human() {
let t = sample().parameters_text();
for token in [
"a fox",
"blurry",
"m.safetensors",
"512x512",
"8",
"42",
"euler",
"2/2",
] {
assert!(t.contains(token), "parameters missing {token}:\n{t}");
}
}
#[test]
fn sidecar_roundtrips() {
let dir = std::env::temp_dir().join("ccti-prov-test");
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join("x.json");
sample().save_sidecar(&p).unwrap();
assert_eq!(Generation::load_sidecar(&p).unwrap(), sample());
std::fs::remove_file(p).ok();
}
#[test]
fn embed_adds_parameters_and_keeps_existing_chunks() {
let raw = tiny_png();
let mut enc_src = Vec::new();
{
let dec = png::Decoder::new(std::io::Cursor::new(raw.as_slice()));
let mut r = dec.read_info().unwrap();
let mut buf = vec![0; r.output_buffer_size().unwrap()];
let info = r.next_frame(&mut buf).unwrap();
let mut enc = png::Encoder::new(&mut enc_src, info.width, info.height);
enc.set_color(info.color_type);
enc.set_depth(info.bit_depth);
enc.add_text_chunk("prompt".to_string(), "{\"1\":{}}".to_string())
.unwrap();
enc.write_header().unwrap().write_image_data(&buf).unwrap();
}
let out = sample().embed_png(&enc_src);
let kinds: Vec<String> = {
let mut v = Vec::new();
let mut pos = 8;
while pos + 8 <= out.len() {
let len = u32::from_be_bytes(out[pos..pos + 4].try_into().unwrap()) as usize;
v.push(String::from_utf8_lossy(&out[pos + 4..pos + 8]).into_owned());
pos += 8 + len + 4;
if v.len() > 20 {
break;
}
}
v
};
assert!(
kinds.contains(&"iTXt".to_string()),
"no iTXt spliced, chunks: {kinds:?}"
);
let dec = png::Decoder::new(std::io::Cursor::new(out.as_slice()));
let mut r = dec.read_info().unwrap();
let info = r.info();
let get = |k: &str| {
info.uncompressed_latin1_text
.iter()
.find(|t| t.keyword == k)
.map(|t| t.text.clone())
.or_else(|| {
info.utf8_text
.iter()
.find(|t| t.keyword == k)
.and_then(|t| t.get_text().ok())
})
.unwrap_or_default()
};
assert_eq!(get("prompt"), "{\"1\":{}}");
assert!(
get("parameters").contains("a fox"),
"parameters chunk missing"
);
let mut buf = vec![0; r.output_buffer_size().unwrap()];
r.next_frame(&mut buf).unwrap();
let img = image::load_from_memory(&out).unwrap().to_rgb8();
assert_eq!(img.get_pixel(0, 0), &image::Rgb([200, 100, 50]));
}
#[test]
fn embed_never_loses_the_image() {
let garbage = b"definitely not a png";
assert_eq!(sample().embed_png(garbage), garbage);
}
#[test]
fn store_writes_png_plus_sidecar() {
let dir = std::env::temp_dir().join("ccti-store-test");
let (png, json) = store_rendered(&dir, "s", &tiny_png(), &sample()).unwrap();
assert!(png.exists() && json.exists());
assert_eq!(Generation::load_sidecar(&json).unwrap(), sample());
std::fs::remove_file(png).ok();
std::fs::remove_file(json).ok();
}
}