Files
photocraft/crates/ops/src/lib.rs
T
47cf5af9fd perf: keep effect-cache pins and History snapshots inside the memory budgets (#5336)
* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))

Aligning the decoded D90 sensor plane with the camera's own JPEG puts the
image at (10, 10) in the 4352x2868 sensor plane, giving the camera's
4288x2848 frame (10-pixel margins; the right border reads the camera's flat
255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the
camera's frame instead of the uncropped plane, and make a measured profile
leave a file that carries its own colour calibration (a DNG) untouched.

* Codecs: open Cineon (.cin) film frames read-only

Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from
the same Kodak header family: the generic header (big-endian, orientation at
192, channel count at 193, the channel-1 bits/dimensions), one or three
channels, three 10-bit samples packed into each 32-bit word on a word
boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the
file's log black/white points are a display choice and are not applied. A
depth other than 10 bits or a channel count other than 1/3 is Unsupported;
truncation errors rather than panicking. Read-only (no Cineon writer), so
.cin joins the open filters and Cineon read closes part of #1049's G10 gap.

* PSD: write tiff blocks without copies, inflate merged ZIP once

- ImageSourceData::to_bytes writes the global mask and global blocks
  straight from the model with 4-byte alignment instead of cloning each
  block (data included) to set its padding. Same bytes.
- The tiff transcoder's nested regions and blocks write into the parent's
  buffer (taken and handed back, error or not) instead of building their
  own output and copying it up per nesting level. Bodies that fail or are
  not transcoded are truncated before being copied as stored or dropped.
  Same bytes.
- PsdFile::from_bytes_and_merged decodes the merged image with the parse
  when asked: a ZIP stream is inflated once (the decode's stream check is
  the parse's); the parse-only check runs only when the decode fails, to
  keep from_bytes' errors. PSD import uses it with merged_needed.
- PsdBuilder::composite documents that Gray/CMYK composites stay straight.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Content-Aware Fill: measure a thin hole by its thickness, not its bounds (#1366)

Removing long, thin objects (coiled guitar strings, wires) left their
outline as darker and lighter blotches and copied pieces of other strings
into the fill. The pyramid shrank until the hole's bounding box was four
patches across: for a string across the image that is a level where the
hole has swallowed its surroundings, so the matches found there copy
whatever looks like the blur, and the finer levels (refine-only for wide
bounds) keep them.

A hole at most a quarter as thick as its bounds are long now counts as
thin: the pyramid stops while it is still about two patches thick, and
that level starts from a membrane across it (over a few pixels, the
surroundings themselves) instead of the onion peel, whose exhaustive
search is what made a shallow pyramid unaffordable. Compact holes are
unchanged bit for bit.

Refs #1366

* perf(compose,gpu): effect caches reserve tile addresses instead of pinning layers

Effect map cache entries kept a clone of their layer (a deep clone for groups) so the tile addresses the cache key hashes couldn't be reused. That kept an edited or deleted layer's pixels alive, uncounted, until the entry was evicted. Keep a Weak to each hashed tile instead: it reserves the address without keeping the pixels. The GPU compositor's styled-group pins and shape-source tile snapshots do the same. Part of #4530.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(history): count snapshot-only tiles against the history budget

History panel snapshots outlived every budget: a Merged Layers snapshot is a full flattened copy that neither the history budget, the GPU budget's document bytes nor Purge accounted for. Trim now counts the tiles only snapshots hold as fixed cost, the GPU budget includes them, and Purge > Histories no longer reports tiles a kept snapshot still holds as freed. Part of #4530.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Content-Aware Fill: every part of a thin selection keeps its surroundings (#1366)

Two more causes of ghosts when removing strings and the small knobs
selected with them:

* Auto sampling summarises the selection's surroundings by a few
  clusters. Around a long selection those come from the strings'
  surroundings, so a small part elsewhere (a tuning knob in a differently
  coloured corner) lost its own surroundings and was filled from far away
  in another colour. Around a thin selection the Auto area now always
  reaches as far as the selection is thick.
* The membrane that starts a thin hole now starts a patch radius out, past
  the anti-aliased fringe or halo that a selection drawn close to the
  object leaves outside it.

Refs #1366

* Content-Aware Fill: peel a thin hole where that stays affordable (#1366)

Starting every thin hole from a membrane smoothed Spot Healing on film
grain (spot_healing_keeps_film_grain_through_overlapping_strokes: grain
9.0 against 12.1 around). The onion peel copies the grain; it was only
given up because its exhaustive search is too slow on the big level a
long thin hole stops at. It now starts the thin hole whenever hole cells
times sources stay within 2^28 comparisons, and the membrane only beyond.

Refs #1366

* Select Subject: keep subject parts coloured like the backdrop, leave enclosed gaps open

Users on #2788 reported Select Subject dropping clothes that are close in
colour to a plain wall and bridging the gap between an arm and the torso.
Both come from the same place: the saliency pass labels low-contrast parts
of the subject as background, the background colour model then learns
them and explains them away, and the final blind hole fill closes every
enclosed gap up to 5 % of the mask.

Two changes in crates/algo:

- A second GrabCut pass whose background model only knows the backdrop. A
  colour model fitted to the image frame (minus a margin around the
  salient blob, in case the subject runs out of the picture) marks every
  pixel it explains as probable background; the blob and whatever the
  backdrop cannot account for start as probable foreground. Only
  components that touch the first pass's result survive.
- Hole filling that consults the colour models: specks (<= 0.2 % of the
  mask) always fill, larger holes up to 5 % fill only when their pixels
  fit the foreground model better than the background's.

Tests: two regressions (a cream-shouldered figure on a near-white wall
keeps its shoulders; a ring keeps its backdrop-coloured hole open), plus
ignored timing and picture-dump helpers. The 6 MP first run goes from
about 100 ms to about 200 ms on one core; the P29 repeat path is untouched.

* Keyboard shortcut sets: Set menu, New Set, Save, Delete, .kys Export and Summarize (K2)

Edit › Keyboard Shortcuts gets Photoshop's set management. A set is a saved copy of the
bindings (Preferences.shortcutSets, the one in use in shortcutSet; "" is PhotoCraft Defaults),
and the Set menu shows "(modified)" when the bindings differ from it. New Set…, Save and
Delete wait for OK like the rest of the dialog. Export… writes the dialog's bindings as a .kys
file (commands then tools, Photoshop's macOS key spelling), each row carrying our id as pc-id
so PhotoCraft imports its own sets back exactly, whatever the label; Summarize… writes
Photoshop's HTML list of every bound command by menu.

edit.keyboardShortcuts takes useSet (before the call's edits), saveSet, deleteSet, sets and
activeSet (after them), exportKys and summarize; every reply reports the sets, the set in use
and whether it is modified. Bad names, keys or counts are errors that change nothing.

* .kys import: tool keys and sets saved on Windows (K3)

Photoshop's <tool> rows now import: each matches our tool by name (our tool names are
Photoshop's) or by pc-id in sets PhotoCraft exported, and a letter other than the tool's own
becomes its override, while its own letter keeps the tool cycling with the others (#5036).

A set saved on Windows spells the primary modifier Ctrl where a Mac set writes Cmd and keeps
Control for the Control key. A set with no Cmd or Opt anywhere but some Ctrl is read as a
Windows set, its Ctrl as our Cmd: Ctrl+L is the Cmd+L default, not a Control-key override that
would also mean Control on a Mac. The import reports `windows`.

* cargo fmt the extension lists

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Vikram Bhaskaran <vikram.bhaskaran@outlook.com>
Co-authored-by: vangel76 <vangel1976@googlemail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Gert Pellin <gert@pellin.be>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Diego Chagas <drochachagas@gmail.com>
Co-authored-by: Tanish Manku <tmanku.dev@gmail.com>
2026-10-15 00:44:55 -04:00

853 lines
37 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Undo/redo history.
//!
//! Because pixel tiles are `Arc`-shared copy-on-write (see `photocraft-raster`), a full
//! [`Document`] clone costs O(layers + tiles) pointer copies, not pixel copies. History therefore
//! stores whole-document snapshots per transaction, which is simple, obviously correct, and the
//! same approach Photoshop's History panel exposes to users (one state per step).
//!
//! Memory is bounded by `max_states` plus an approximate byte budget. The current document's
//! tiles and tiles only history holds count against it, including selection masks.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::collections::{HashSet, VecDeque};
use std::sync::Arc;
use photocraft_doc::{Document, LayerId};
mod tiles;
/// The layers a state targeted: the active ("key") layer and every selected layer. Undo and redo
/// bring them back with the state's document, as the reference app does. Selecting layers is not
/// a step of its own, so the session updates the current state's target with
/// [`History::set_current_layers`] just before recording the next step (#1356).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct LayerTarget {
pub active: Option<LayerId>,
pub selected: Vec<LayerId>,
}
#[derive(Clone, Debug)]
pub struct HistoryState {
pub label: String,
pub doc: Arc<Document>,
/// The layers targeted when this state was created.
pub layers: LayerTarget,
}
#[derive(Clone, Debug)]
pub struct History {
/// Past states; the last one is the state *before* the current document.
undo: VecDeque<HistoryState>,
redo: Vec<HistoryState>,
/// Lazily initialized when a byte budget is used; unlimited histories pay no counting cost.
tiles: Option<tiles::Tiles>,
pub max_states: usize,
/// Pixel memory budget in bytes for the current document plus the tiles only history holds
/// (0 = unlimited). [`History::trim`] drops the oldest states beyond it.
pub max_bytes: usize,
/// Label of the step that produced the current document.
current_label: String,
/// The layers targeted when the current document was created (opened or edited).
current_layers: LayerTarget,
}
impl Default for History {
fn default() -> Self {
Self::new(50)
}
}
impl History {
pub fn new(max_states: usize) -> Self {
Self {
undo: VecDeque::new(),
redo: Vec::new(),
tiles: None,
max_states: max_states.max(1),
max_bytes: 0,
current_label: "Open".into(),
current_layers: LayerTarget::default(),
}
}
/// Record that `before` was replaced by a new current document via step `label`, which left
/// `layers` targeted.
pub fn record(&mut self, label: impl Into<String>, before: Arc<Document>, layers: LayerTarget) {
if self.max_bytes == 0 {
self.tiles = None;
}
if let Some(tiles) = &mut self.tiles {
tiles.add(&before);
}
let prev_label = std::mem::replace(&mut self.current_label, label.into());
let prev_layers = std::mem::replace(&mut self.current_layers, layers);
self.undo.push_back(HistoryState { label: prev_label, doc: before, layers: prev_layers });
self.clear_redo();
while self.undo.len() > self.max_states {
if let Some(state) = self.undo.pop_front()
&& let Some(tiles) = &mut self.tiles
{
tiles.remove(&state.doc);
}
}
}
pub fn can_undo(&self) -> bool {
!self.undo.is_empty()
}
pub fn can_redo(&self) -> bool {
!self.redo.is_empty()
}
pub fn undo_label(&self) -> Option<&str> {
self.can_undo().then_some(self.current_label.as_str())
}
pub fn redo_label(&self) -> Option<&str> {
self.redo.last().map(|s| s.label.as_str())
}
/// Labels of the redo states, the next redo first (the History panel's greyed rows).
pub fn redo_labels(&self) -> impl Iterator<Item = &str> {
self.redo.iter().rev().map(|s| s.label.as_str())
}
/// Set the layers the current document targets from its creation on: when it is first
/// opened, or when its step coalesces or changes the selection after recording.
pub fn set_current_layers(&mut self, layers: LayerTarget) {
self.current_layers = layers;
}
/// Undo: `current` becomes redoable; returns the document to make current and the layers it
/// targeted when it was created.
pub fn undo(&mut self, current: Arc<Document>) -> Option<(Arc<Document>, LayerTarget)> {
let prev = self.undo.pop_back()?;
if let Some(tiles) = &mut self.tiles {
tiles.remove(&prev.doc);
tiles.add(&current);
}
let label = std::mem::replace(&mut self.current_label, prev.label);
let layers = std::mem::replace(&mut self.current_layers, prev.layers.clone());
self.redo.push(HistoryState { label, doc: current, layers });
Some((prev.doc, prev.layers))
}
/// Redo: the mirror of [`Self::undo`].
pub fn redo(&mut self, current: Arc<Document>) -> Option<(Arc<Document>, LayerTarget)> {
let next = self.redo.pop()?;
if let Some(tiles) = &mut self.tiles {
tiles.remove(&next.doc);
tiles.add(&current);
}
let label = std::mem::replace(&mut self.current_label, next.label);
let layers = std::mem::replace(&mut self.current_layers, next.layers.clone());
self.undo.push_back(HistoryState { label, doc: current, layers });
Some((next.doc, next.layers))
}
/// Entries for a History panel: past labels oldest→newest, then the current label.
pub fn entries(&self) -> Vec<String> {
self.undo.iter().map(|s| s.label.clone()).chain(std::iter::once(self.current_label.clone())).collect()
}
/// Document of past entry `i`, indexed like [`History::entries`] (0 = oldest). The last entry is
/// the current document, which the history does not hold, so it (and any index past it) is `None`.
pub fn state(&self, i: usize) -> Option<Arc<Document>> {
self.undo.get(i).map(|s| s.doc.clone())
}
/// Number of past states (entries before the current one).
pub fn past_len(&self) -> usize {
self.undo.len()
}
/// Rename the current step (e.g. after folding several steps into one with [`Self::purge_last`]).
pub fn set_current_label(&mut self, label: impl Into<String>) {
self.current_label = label.into();
}
/// Forget the most recent undo state and every redo state (Edit › Purge › Undo): the
/// last step can no longer be undone and its pixels are released.
pub fn purge_last(&mut self) -> bool {
self.clear_redo();
let Some(state) = self.undo.pop_back() else { return false };
if let Some(tiles) = &mut self.tiles {
tiles.remove(&state.doc);
}
true
}
/// Forget the redo states, e.g. after undoing half of a compound step that failed.
pub fn clear_redo(&mut self) {
if let Some(tiles) = &mut self.tiles {
for state in &self.redo {
tiles.remove(&state.doc);
}
}
self.redo.clear();
}
pub fn clear(&mut self) {
self.undo.clear();
self.redo.clear();
self.tiles = None;
}
/// Approximate unique pixel bytes held by history (tiles not shared with `current`).
pub fn unique_bytes(&self, current: &Document) -> usize {
let mut seen = HashSet::new();
tile_bytes(current, &mut seen);
self.undo.iter().chain(self.redo.iter()).map(|s| tile_bytes(&s.doc, &mut seen)).sum()
}
/// Pixel bytes of `current` plus the tiles only history holds (what [`History::trim`]
/// bounds by [`History::max_bytes`]).
pub fn pixel_bytes(&self, current: &Document) -> usize {
if let Some(tiles) = &self.tiles {
return usize::try_from(tiles.bytes_with(current)).unwrap_or(usize::MAX);
}
let mut seen = HashSet::new();
let own = tile_bytes(current, &mut seen);
self.undo.iter().chain(self.redo.iter()).fold(own, |n, s| n.saturating_add(tile_bytes(&s.doc, &mut seen)))
}
/// Bytes of the tiles only `extra` documents hold (History panel snapshots, #4530): not
/// `current`'s and not any state's. 0 without `extra`, without walking the states.
pub fn extra_bytes(&self, current: &Document, extra: &[&Document]) -> usize {
if extra.is_empty() {
return 0;
}
let mut seen = HashSet::new();
tile_bytes(current, &mut seen);
for s in self.undo.iter().chain(self.redo.iter()) {
tile_bytes(&s.doc, &mut seen);
}
extra.iter().fold(0, |n, d| n.saturating_add(tile_bytes(d, &mut seen)))
}
/// [`History::unique_bytes`] less the tiles `keep` documents also hold: what clearing the
/// history frees while they stay (Edit › Purge › Histories keeps the snapshots).
pub fn unique_bytes_besides(&self, current: &Document, keep: &[&Document]) -> usize {
let mut seen = HashSet::new();
tile_bytes(current, &mut seen);
for d in keep {
tile_bytes(d, &mut seen);
}
self.undo.iter().chain(self.redo.iter()).fold(0, |n, s| n.saturating_add(tile_bytes(&s.doc, &mut seen)))
}
/// Keep pixel memory within [`History::max_bytes`]: the current document's tiles plus the
/// tiles only history holds (newest states first). The oldest undo states that don't fit are
/// dropped; the most recent one is always kept so the last step can be undone. Returns how
/// many states were dropped.
pub fn trim(&mut self, current: &Document) -> usize {
self.trim_with(current, &[])
}
/// [`History::trim`] counting the tiles only `pinned` documents hold (History panel
/// snapshots, which outlive the states) against the budget too, so they can't take memory
/// beyond it unseen (#4530). The most recent state is still always kept.
pub fn trim_with(&mut self, current: &Document, pinned: &[&Document]) -> usize {
if self.max_bytes == 0 || self.undo.len() <= 1 {
return 0;
}
// An upper bound of what only `pinned` holds (some of it may be in states too): if the
// states fit beside it, they fit.
let pinned_bytes = if pinned.is_empty() {
0
} else {
let mut seen = HashSet::new();
tile_bytes(current, &mut seen);
pinned.iter().fold(0usize, |n, d| n.saturating_add(tile_bytes(d, &mut seen)))
};
let tiles = self.tiles.get_or_insert_with(|| {
let mut tiles = tiles::Tiles::default();
for state in self.undo.iter().chain(&self.redo) {
tiles.add(&state.doc);
}
tiles
});
if tiles.fits(current, self.max_bytes.saturating_sub(pinned_bytes)) {
return 0;
}
let mut seen = HashSet::new();
let mut total = tile_bytes(current, &mut seen);
for d in pinned {
total = total.saturating_add(tile_bytes(d, &mut seen));
}
for s in self.redo.iter().rev() {
total = total.saturating_add(tile_bytes(&s.doc, &mut seen));
}
let mut keep = 0;
for s in self.undo.iter().rev() {
total = total.saturating_add(tile_bytes(&s.doc, &mut seen));
if total > self.max_bytes && keep >= 1 {
break;
}
keep += 1;
}
let drop = self.undo.len() - keep;
for state in self.undo.drain(..drop) {
tiles.remove(&state.doc);
}
drop
}
}
/// Bytes of the pixel tiles of `doc` (layers, masks, fill caches, smart filter masks, alpha channels, selection) not already in `seen`.
fn tile_bytes(doc: &Document, seen: &mut HashSet<usize>) -> usize {
let mut n = 0usize;
tiles::surfaces(doc, |surface| {
for (_, tile) in surface.tiles() {
if seen.insert(Arc::as_ptr(tile) as usize) {
n = n.saturating_add(tile.bytes().len());
}
}
});
n
}
#[cfg(test)]
mod tests {
use super::*;
use photocraft_doc::{Color, ColorMode, Layer, SampleType, Size};
fn base() -> Document {
Document::with_background("h", Size::new(64, 64), ColorMode::Rgb, SampleType::U8, Color::WHITE)
}
/// Apply an edit the way the engine does: snapshot, mutate a clone, record.
fn edit(h: &mut History, cur: &mut Arc<Document>, label: &str, f: impl FnOnce(&mut Document)) {
let before = cur.clone();
let mut d = (**cur).clone();
f(&mut d);
*cur = Arc::new(d);
h.record(label, before, LayerTarget::default());
}
#[test]
fn undo_redo_cycle() {
let mut h = History::default();
let mut cur = Arc::new(base());
edit(&mut h, &mut cur, "New Layer", |d| {
d.insert_above(None, Layer::raster("L", d.pixel_format()));
});
assert_eq!(cur.layers.len(), 2);
assert_eq!(h.undo_label(), Some("New Layer"));
cur = h.undo(cur).unwrap().0;
assert_eq!(cur.layers.len(), 1);
assert_eq!(h.redo_label(), Some("New Layer"));
cur = h.redo(cur).unwrap().0;
assert_eq!(cur.layers.len(), 2);
assert!(h.redo(cur.clone()).is_none());
}
#[test]
fn undo_and_redo_return_the_layers_each_state_targeted_when_created() {
let mut h = History::default();
let open = Arc::new(base());
let bg = open.layers[0].id;
let target = |id: LayerId| LayerTarget { active: Some(id), selected: vec![id] };
h.set_current_layers(target(bg));
// New Layer targets the new layer; the background is then selected (not a step) and filled.
let mut d = (*open).clone();
let new = d.insert_above(None, Layer::raster("L", d.pixel_format()));
let layered = Arc::new(d);
h.record("New Layer", open, target(new));
let mut d = (*layered).clone();
d.name = "filled".into();
h.record("Fill", layered, target(bg));
let (d, layers) = h.undo(Arc::new(d)).unwrap();
assert_eq!(layers, target(new), "New Layer's state targeted the new layer when created");
let (d, layers) = h.undo(d).unwrap();
assert_eq!(layers, target(bg), "the opened document's target");
let (d, layers) = h.redo(d).unwrap();
assert_eq!(layers, target(new));
let (_, layers) = h.redo(d).unwrap();
assert_eq!(layers, target(bg), "Fill's state targeted the background");
}
#[test]
fn state_accessor_matches_entries() {
let mut h = History::default();
let mut cur = Arc::new(base());
let open = cur.clone();
edit(&mut h, &mut cur, "A", |d| d.name = "a".into());
edit(&mut h, &mut cur, "B", |d| d.name = "b".into());
assert_eq!(h.entries().len(), h.past_len() + 1);
assert!(Arc::ptr_eq(&h.state(0).unwrap(), &open));
assert_eq!(h.state(1).unwrap().name, "a");
assert!(h.state(2).is_none(), "the current document is not held");
cur = h.undo(cur).unwrap().0;
h.undo(cur).unwrap();
assert_eq!(h.redo_labels().collect::<Vec<_>>(), ["A", "B"], "next redo first");
}
#[test]
fn new_edit_clears_redo() {
let mut h = History::default();
let mut cur = Arc::new(base());
edit(&mut h, &mut cur, "A", |d| d.name = "a".into());
cur = h.undo(cur).unwrap().0;
edit(&mut h, &mut cur, "B", |d| d.name = "b".into());
assert!(!h.can_redo());
assert_eq!(h.entries(), vec!["Open".to_string(), "B".to_string()]);
}
#[test]
fn max_states_bound() {
let mut h = History::new(3);
let mut cur = Arc::new(base());
for i in 0..10 {
edit(&mut h, &mut cur, &format!("step {i}"), |d| d.name = format!("{i}"));
}
let mut n = 0;
while let Some((d, _)) = h.undo(cur.clone()) {
cur = d;
n += 1;
}
assert_eq!(n, 3);
assert_eq!(cur.name, "6");
}
#[test]
fn byte_budget_drops_oldest_states_but_keeps_one() {
let mut h = History::new(50);
let mut cur = Arc::new(base());
let tile = 256 * 256 * 4;
// Each step repaints the background's tile: one unique tile per state.
for i in 0..6 {
edit(&mut h, &mut cur, &format!("paint {i}"), |d| {
let id = d.layers[0].id;
d.layer_mut(id).unwrap().surface_mut().unwrap().write_pixel(1, 1, &[i as f32 / 8.0, 0.0, 0.0, 1.0]);
});
}
assert_eq!(h.trim(&cur), 0, "unlimited by default");
assert_eq!(h.past_len(), 6);
// The current tile plus three history tiles fit.
h.max_bytes = 4 * tile;
assert_eq!(h.trim(&cur), 3);
assert_eq!(h.past_len(), 3);
assert_eq!(h.unique_bytes(&cur), 3 * tile);
assert_eq!(h.pixel_bytes(&cur), 4 * tile, "the current tile plus history's");
assert_eq!(h.entries()[0], "paint 2");
// A budget smaller than one state still keeps the last step undoable.
h.max_bytes = 1;
h.trim(&cur);
assert_eq!(h.past_len(), 1);
assert!(h.undo(cur.clone()).is_some());
}
/// Snapshot-only tiles count against the budget (#4530); tiles a snapshot shares with the
/// document or a state count once, and a purge frees only what no snapshot keeps.
#[test]
fn snapshot_tiles_count_against_the_budget() {
let mut h = History::new(50);
let mut cur = Arc::new(base());
let tile = 256 * 256 * 4;
let paint = |d: &mut Document, v: f32| {
let id = d.layers[0].id;
d.layer_mut(id).unwrap().surface_mut().unwrap().write_pixel(1, 1, &[v, 0.0, 0.0, 1.0]);
};
// A snapshot holding a tile nothing else does, then one sharing the first state's tile.
let mut own = (*cur).clone();
paint(&mut own, 0.9);
edit(&mut h, &mut cur, "paint 0", |d| paint(d, 0.1));
let shared = h.state(0).unwrap();
for i in 1..6 {
edit(&mut h, &mut cur, &format!("paint {i}"), |d| paint(d, i as f32 / 8.0));
}
let snaps = [&own, &*shared];
assert_eq!(h.extra_bytes(&cur, &snaps), tile, "the shared tile is history's");
assert_eq!(h.extra_bytes(&cur, &[]), 0);
assert_eq!(h.unique_bytes(&cur), 6 * tile);
assert_eq!(h.unique_bytes_besides(&cur, &snaps), 5 * tile, "clearing keeps the shared tile");
// Both snapshot tiles stay whatever is trimmed (the shared one too), so beside the
// current tile they leave room for two history tiles; without them, four would fit.
h.max_bytes = 5 * tile;
assert_eq!(h.trim_with(&cur, &snaps), 4);
assert_eq!(h.past_len(), 2);
assert_eq!(h.pixel_bytes(&cur) + h.extra_bytes(&cur, &snaps), 5 * tile);
// Still keeps the last step undoable.
h.max_bytes = 1;
h.trim_with(&cur, &snaps);
assert_eq!(h.past_len(), 1);
}
#[test]
fn history_shares_untouched_tiles() {
let mut h = History::default();
let mut cur = Arc::new(base());
assert_eq!(h.unique_bytes(&cur), 0);
// rename only: no pixel changes, history holds no unique tiles
edit(&mut h, &mut cur, "Rename", |d| d.name = "x".into());
assert_eq!(h.unique_bytes(&cur), 0);
// paint one pixel: exactly one tile becomes unique to history
edit(&mut h, &mut cur, "Paint", |d| {
let id = d.layers[0].id;
d.layer_mut(id).unwrap().surface_mut().unwrap().write_pixel(1, 1, &[0.0, 0.0, 0.0, 1.0]);
});
assert_eq!(h.unique_bytes(&cur), 256 * 256 * 4);
}
#[test]
fn fill_caches_and_smart_filter_masks_count_toward_history_bytes() {
use photocraft_doc::{Affine, Fill, FillCache, LayerContent, LayerMask, PixelFormat, SmartObject, SmartSource, Surface};
let mut doc = Document::new("fills", Size::new(64, 64), ColorMode::Rgb, SampleType::U8);
let mut fill = Layer::new("Fill", LayerContent::Fill(Fill::Solid(Color::WHITE)));
let mut cache = Surface::new(PixelFormat::GRAY8);
cache.write_pixel(1, 1, &[1.0]);
fill.fill_cache = Some(FillCache { fill: Fill::Solid(Color::WHITE), surface: cache });
doc.insert_above(None, fill);
let mut smart_obj = SmartObject::new(SmartSource::Linked { path: "a.png".into() }, Affine::IDENTITY, None);
let mut mask = LayerMask::reveal_all();
mask.surface.write_pixel(1, 1, &[0.5]);
smart_obj.filter_mask = Some(mask);
doc.insert_above(None, Layer::new("Smart", LayerContent::Smart(smart_obj)));
let tile = 256 * 256;
let mut cur = Arc::new(doc);
let mut h = History { max_bytes: usize::MAX, ..History::default() };
assert_eq!(h.pixel_bytes(&cur), 2 * tile, "fill cache and smart filter mask are counted");
edit(&mut h, &mut cur, "Rename", |doc| doc.name = "renamed".into());
assert_eq!(h.pixel_bytes(&cur), 2 * tile, "shared tiles count once");
edit(&mut h, &mut cur, "Fill cache", |doc| {
let id = doc.layers[0].id;
if let Some(cache) = doc.layer_mut(id).and_then(|l| l.fill_cache.as_mut()) {
cache.surface.write_pixel(1, 1, &[0.0]);
}
});
assert_eq!(h.pixel_bytes(&cur), 3 * tile, "the copied fill cache tile is history-only");
assert_eq!(h.unique_bytes(&cur), tile);
h.trim(&cur);
assert_counter(&h, &cur);
h.max_bytes = 2 * tile;
assert_eq!(h.trim(&cur), 1, "the copied fill cache tile pushes history over budget");
assert_eq!(h.pixel_bytes(&cur), 3 * tile, "the most recent undo state is always kept");
assert_counter(&h, &cur);
}
#[test]
fn selection_tiles_count_current_and_history_without_double_counting() {
use photocraft_doc::{PixelFormat, Surface};
for depth in SampleType::ALL {
let mut doc = Document::new("selection", Size::new(300, 64), ColorMode::Rgb, depth);
let mut selection = Surface::new(PixelFormat::new(ColorMode::Grayscale, depth, false));
selection.write_pixel(1, 1, &[1.0]);
selection.write_pixel(257, 1, &[1.0]);
doc.selection = Some(selection);
let mut cur = Arc::new(doc);
let tile = match depth {
SampleType::U8 => 65_536,
SampleType::U16 => 131_072,
SampleType::F32 => 262_144,
};
let mut h = History { max_bytes: usize::MAX, ..History::default() };
assert_eq!(h.pixel_bytes(&cur), 2 * tile, "current selection counts without any history");
assert_eq!(h.unique_bytes(&cur), 0);
edit(&mut h, &mut cur, "Rename", |doc| doc.name = "renamed".into());
assert_eq!(h.pixel_bytes(&cur), 2 * tile, "the shared selection tiles count once");
edit(&mut h, &mut cur, "Selection", |doc| {
doc.selection.as_mut().unwrap().write_pixel(1, 1, &[0.5]);
});
assert_eq!(h.pixel_bytes(&cur), 3 * tile, "only the changed selection tile is copied");
assert_eq!(h.unique_bytes(&cur), tile);
h.trim(&cur);
assert_counter(&h, &cur);
let selected_value = cur.selection.as_ref().unwrap().sample_channel(1, 1, 0);
edit(&mut h, &mut cur, "Deselect", |doc| doc.selection = None);
assert_eq!(h.pixel_bytes(&cur), 3 * tile, "deselecting leaves the previous masks in history");
assert_eq!(h.unique_bytes(&cur), 3 * tile);
assert_counter(&h, &cur);
cur = h.undo(cur).unwrap().0;
assert_eq!(cur.selection.as_ref().unwrap().sample_channel(1, 1, 0), selected_value);
assert_eq!(h.pixel_bytes(&cur), 3 * tile);
assert_eq!(h.unique_bytes(&cur), tile);
assert_counter(&h, &cur);
// Coalescing can change the current mask without recording another state.
Arc::make_mut(&mut cur).selection.as_mut().unwrap().write_pixel(257, 1, &[0.25]);
assert_eq!(h.pixel_bytes(&cur), 4 * tile);
assert_eq!(h.trim(&cur), 0);
assert_counter(&h, &cur);
cur = h.redo(cur).unwrap().0;
assert!(cur.selection.is_none());
assert_eq!(h.pixel_bytes(&cur), 4 * tile);
assert_counter(&h, &cur);
}
}
#[test]
fn selection_byte_budget_drops_old_masks_and_keeps_the_last_undo() {
use photocraft_doc::{PixelFormat, Surface};
for depth in SampleType::ALL {
let mut doc = Document::new("selection", Size::new(64, 64), ColorMode::Rgb, depth);
doc.selection = Some(Surface::new(PixelFormat::new(ColorMode::Grayscale, depth, false)));
let mut cur = Arc::new(doc);
let tile = 256 * 256 * depth.bytes();
let mut h = History::default();
for i in 1..=6 {
edit(&mut h, &mut cur, "Selection", |doc| {
doc.selection.as_mut().unwrap().write_pixel(1, 1, &[i as f32 / 8.0]);
});
}
assert_eq!(h.pixel_bytes(&cur), 6 * tile);
h.max_bytes = 3 * tile;
assert_eq!(h.trim(&cur), 4);
assert_eq!(h.past_len(), 2);
assert_eq!(h.pixel_bytes(&cur), 3 * tile);
assert_eq!(h.unique_bytes(&cur), 2 * tile);
assert_counter(&h, &cur);
h.max_bytes = 1;
assert_eq!(h.trim(&cur), 1);
assert_eq!(h.past_len(), 1, "the most recent undo survives even below one mask's size");
assert_eq!(h.pixel_bytes(&cur), 2 * tile);
cur = h.undo(cur).unwrap().0;
assert_counter(&h, &cur);
}
}
fn assert_counter(h: &History, current: &Document) {
let counter = h.tiles.as_ref().expect("budget initializes the counter");
let total = tile_bytes(current, &mut HashSet::new()).saturating_add(h.unique_bytes(current));
assert_eq!(counter.bytes_with(current), total as u128);
assert_eq!(h.pixel_bytes(current), total);
let mut seen = HashSet::new();
let retained: usize = h.undo.iter().chain(&h.redo).map(|s| tile_bytes(&s.doc, &mut seen)).sum();
let empty = Document::new("empty", Size::new(1, 1), ColorMode::Rgb, SampleType::U8);
assert_eq!(counter.bytes_with(&empty), retained as u128);
}
fn video_document(depth: SampleType, frames: usize) -> Document {
let mut doc = Document::new("video", Size::new(64, 64), ColorMode::Rgb, depth);
let mut layer = Layer::raster("Video", doc.pixel_format());
let footage: Vec<_> = (0..frames)
.map(|i| {
let mut surface = photocraft_doc::Surface::new(doc.pixel_format());
surface.write_pixel(1, 1, &[i as f32 / frames as f32, 0.0, 0.0, 1.0]);
surface
})
.collect();
layer.content = photocraft_doc::LayerContent::Raster(footage[0].clone());
layer.video = Some(photocraft_doc::VideoData::new(footage, 24.0));
doc.layers.push(layer);
doc
}
#[test]
fn video_memory_counts_originals_and_deduplicates_displayed_frames() {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut doc = video_document(depth, 3);
let tile = doc.layers[0].surface().unwrap().tiles().next().unwrap().1.bytes().len();
let video = doc.layers[0].video.as_mut().unwrap();
video.original_frames = Some(video.frames.clone());
// Original footage and the displayed frame alias the stack, except this painted frame.
video.frames[2].write_pixel(1, 1, &[0.0, 1.0, 0.0, 1.0]);
let mut cur = Arc::new(doc);
let mut h = History { max_bytes: usize::MAX, ..History::default() };
assert_eq!(h.pixel_bytes(&cur), 4 * tile);
for name in ["A", "B"] {
edit(&mut h, &mut cur, "Rename", |doc| doc.name = name.into());
h.trim(&cur);
}
assert_counter(&h, &cur);
assert_eq!(h.pixel_bytes(&cur), 4 * tile);
assert_eq!(h.unique_bytes(&cur), 0);
// Rasterizing keeps the displayed frame; the rest of the clip survives only in undo.
edit(&mut h, &mut cur, "Rasterize", |doc| doc.layers[0].video = None);
assert_eq!(h.unique_bytes(&cur), 3 * tile);
assert_eq!(h.pixel_bytes(&cur), 4 * tile);
assert_counter(&h, &cur);
cur = h.undo(cur).unwrap().0;
assert_eq!(h.unique_bytes(&cur), 0);
assert_counter(&h, &cur);
cur = h.redo(cur).unwrap().0;
assert_eq!(h.unique_bytes(&cur), 3 * tile);
assert_counter(&h, &cur);
h.clear();
assert_eq!(h.pixel_bytes(&cur), tile);
}
}
#[test]
fn video_memory_budget_evicts_deleted_off_playhead_frames() {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut cur = Arc::new(video_document(depth, 6));
let tile = cur.layers[0].surface().unwrap().tiles().next().unwrap().1.bytes().len();
let mut h = History::default();
for _ in 0..4 {
edit(&mut h, &mut cur, "Delete Frame", |doc| {
doc.layers[0].video.as_mut().unwrap().frames.remove(1);
});
}
assert_eq!(h.pixel_bytes(&cur), 6 * tile);
assert_eq!(h.unique_bytes(&cur), 4 * tile);
h.max_bytes = 3 * tile;
assert_eq!(h.trim(&cur), 3);
assert_eq!(h.past_len(), 1);
assert_eq!(h.pixel_bytes(&cur), 3 * tile);
assert_eq!(h.unique_bytes(&cur), tile);
assert_counter(&h, &cur);
cur = h.undo(cur).unwrap().0;
assert_eq!(cur.layers[0].video.as_ref().unwrap().frames.len(), 3);
assert_counter(&h, &cur);
cur = h.redo(cur).unwrap().0;
assert_eq!(cur.layers[0].video.as_ref().unwrap().frames.len(), 2);
assert_counter(&h, &cur);
// A budget below the remaining clip still keeps the latest step undoable.
h.max_bytes = 1;
assert_eq!(h.trim(&cur), 0);
assert!(h.can_undo());
}
}
/// A pattern fill or layer style copies its pattern into the document, so the pattern's tiles
/// count like layer pixels, shared ones once; undoing that step leaves them to the redo states.
#[test]
fn document_patterns_count_as_document_pixels() {
use photocraft_doc::{Pattern, Surface};
for depth in SampleType::ALL {
let tile = 4 * 256 * 256 * depth.bytes();
let mut cur = Arc::new(Document::new("patterns", Size::new(64, 64), ColorMode::Rgb, depth));
let mut h = History { max_bytes: usize::MAX, ..History::default() };
for name in ["A", "B"] {
edit(&mut h, &mut cur, "Rename", |doc| doc.name = name.into());
h.trim(&cur);
}
let mut surface = Surface::new(cur.pixel_format());
surface.write_pixel(1, 1, &[0.0, 0.0, 1.0, 1.0]);
let pattern = Pattern::new("Pattern", surface, 4, 4);
edit(&mut h, &mut cur, "Pattern Fill", |doc| doc.patterns.push(pattern));
assert_eq!(h.pixel_bytes(&cur), tile, "the document's pattern");
assert_eq!(h.unique_bytes(&cur), 0);
assert_counter(&h, &cur);
edit(&mut h, &mut cur, "Rename", |doc| doc.name = "C".into());
assert_eq!(h.pixel_bytes(&cur), tile, "shared with history, it counts once");
assert_counter(&h, &cur);
for _ in 0..2 {
cur = h.undo(cur).unwrap().0;
assert_counter(&h, &cur);
}
assert!(cur.patterns.is_empty());
assert_eq!(h.unique_bytes(&cur), tile, "only the redo states hold the pattern");
assert_eq!(h.pixel_bytes(&cur), tile);
cur = h.redo(cur).unwrap().0;
assert_eq!(h.unique_bytes(&cur), 0);
assert_counter(&h, &cur);
h.clear();
assert_eq!(h.pixel_bytes(&cur), tile);
}
}
#[test]
fn counter_tracks_eviction_undo_redo_branching_purge_and_clones() {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut cur = Arc::new(Document::with_background("h", Size::new(300, 64), ColorMode::Rgb, depth, Color::WHITE));
Arc::make_mut(&mut cur).selection = Some(photocraft_doc::Surface::new(photocraft_doc::PixelFormat::GRAY8));
let mut h = History::new(4);
h.max_bytes = usize::MAX;
for i in 0..8 {
edit(&mut h, &mut cur, "Paint", |doc| {
doc.layers[0].surface_mut().unwrap().write_pixel(1, 1, &[i as f32 / 10.0, 0.0, 0.0, 1.0]);
doc.selection.as_mut().unwrap().write_pixel(1, 1, &[i as f32 / 10.0]);
});
h.trim(&cur);
if i >= 1 {
assert_counter(&h, &cur);
}
}
cur = h.undo(cur).unwrap().0;
assert_counter(&h, &cur);
cur = h.undo(cur).unwrap().0;
assert_counter(&h, &cur);
cur = h.redo(cur).unwrap().0;
assert_counter(&h, &cur);
let copy = h.clone();
edit(&mut h, &mut cur, "Branch", |doc| doc.name = "branch".into());
assert!(!h.can_redo());
assert_counter(&h, &cur);
assert_counter(&copy, &cur);
cur = h.undo(cur).unwrap().0;
h.clear_redo();
assert_counter(&h, &cur);
h.purge_last();
assert_counter(&h, &cur);
h.clear();
assert!(h.tiles.is_none());
assert_eq!(h.pixel_bytes(&cur), tile_bytes(&cur, &mut HashSet::new()));
}
}
#[test]
fn shared_masks_channels_and_repeated_snapshot_references_count_once() {
use photocraft_doc::{AlphaChannel, LayerMask, PixelFormat, Surface};
let mut doc = base();
let mut gray = Surface::new(PixelFormat::GRAY8);
gray.write_pixel(1, 1, &[1.0]);
doc.layers[0].mask = Some(LayerMask { surface: gray.clone(), ..LayerMask::reveal_all() });
doc.channels.push(AlphaChannel::new("alpha", gray.clone()));
doc.selection = Some(gray.clone());
doc.quick_mask = Some(AlphaChannel::new("quick", gray));
doc.layers.push(doc.layers[0].clone());
let mut cur = Arc::new(doc);
let bytes = tile_bytes(&cur, &mut HashSet::new());
let mut h = History::new(50);
// Counting current separately would double count its tiles; the exact union fits.
h.max_bytes = bytes;
for _ in 0..6 {
h.record("Same", cur.clone(), LayerTarget::default());
assert_eq!(h.trim(&cur), 0);
}
assert_counter(&h, &cur);
for _ in 0..3 {
cur = h.undo(cur).unwrap().0;
assert_counter(&h, &cur);
}
h.clear_redo();
h.purge_last();
assert_counter(&h, &cur);
assert_eq!(h.pixel_bytes(&cur), bytes);
}
#[test]
fn threshold_rechecks_coalesced_current_and_changed_budget() {
let mut h = History::new(50);
let mut cur = Arc::new(base());
h.max_bytes = usize::MAX;
for i in 0..6 {
edit(&mut h, &mut cur, "Paint", |doc| {
doc.layers[0].surface_mut().unwrap().write_pixel(1, 1, &[i as f32 / 10.0, 0.0, 0.0, 1.0]);
});
h.trim(&cur);
}
// A coalesced edit may change current without recording another history state.
Arc::make_mut(&mut cur).layers[0].surface_mut().unwrap().write_pixel(300, 1, &[1.0; 4]);
let tile = 256 * 256 * 4;
h.max_bytes = 4 * tile;
assert_eq!(h.trim(&cur), 4);
assert_eq!(h.past_len(), 2);
assert_counter(&h, &cur);
cur = h.undo(cur).unwrap().0;
assert_counter(&h, &cur);
h.max_bytes = 0;
edit(&mut h, &mut cur, "Unlimited", |doc| doc.name = "u".into());
assert!(h.tiles.is_none());
h.max_bytes = 1;
h.trim(&cur);
assert_eq!(h.past_len(), 1, "always retain the most recent undo");
assert_counter(&h, &cur);
}
#[test]
fn transferred_tiles_cannot_make_the_budget_check_underestimate_current() {
use photocraft_doc::{PixelFormat, Surface};
let before = Arc::new(base());
let mut current = (*before).clone();
let mut large = Surface::new(PixelFormat::new(ColorMode::Cmyk, SampleType::F32, true));
large.write_pixel(256, 0, &[1.0; 5]);
current.layers[0].surface_mut().unwrap().put_tiles(large.take_tiles(photocraft_doc::Rect::from_xywh(256, 0, 1, 1)));
let mut h = History::new(50);
h.max_bytes = tile_bytes(&current, &mut HashSet::new()) - 1;
h.record("A", before.clone(), LayerTarget::default());
h.record("B", before, LayerTarget::default());
assert_eq!(h.trim(&current), 1, "current alone exceeds the budget; only the mandatory undo survives");
assert_counter(&h, &current);
}
}