mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-15 03:36:48 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffd811d679 |
No files matched your search
@@ -22,6 +22,7 @@ mod image;
|
||||
mod options;
|
||||
pub mod orientation;
|
||||
pub mod resolution;
|
||||
pub mod source_settings;
|
||||
pub mod web;
|
||||
|
||||
pub use crate::codecs::exr_cryptomatte::{cryptomatte_id, cryptomatte_key, cryptomatte_preview_color};
|
||||
@@ -39,6 +40,7 @@ pub use crate::image::{
|
||||
pub use crate::options::{DecodeOptions, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression};
|
||||
pub use crate::orientation::{exif_orientation, upright_exif, upright_xmp};
|
||||
pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_resolution, xmp_resolution};
|
||||
pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding};
|
||||
pub use half::f16;
|
||||
|
||||
use crate::codecs::{exr, fits, heif, jpeg, png, pnm, tiff, via_image, webp};
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
//! The encoder settings a lossy file was written with, read back from its own data, so a save
|
||||
//! can write it again the way it came (File › Save keeps a JPEG's quality and a WebP's lossy or
|
||||
//! lossless encoding).
|
||||
//!
|
||||
//! * **JPEG**: the quality is estimated from the quantisation tables (DQT). Most encoders scale
|
||||
//! the example tables of ITU-T T.81 Annex K (K.1 luminance, K.2 chrominance) by the
|
||||
//! Independent JPEG Group's quality curve, so the quality whose scaled tables match the file's
|
||||
//! best is the one it was saved at (exact for such tables; the closest fit for others).
|
||||
//! * **WebP**: a `VP8L` image is lossless; a `VP8 ` image is lossy, and its base quantiser index
|
||||
//! (RFC 6386 §9.6) maps back to a quality on the scale our encoder uses.
|
||||
//!
|
||||
//! Everything here reads hostile bytes: every index is checked and a malformed file gives `None`.
|
||||
|
||||
use crate::codecs::vp8;
|
||||
|
||||
/// ITU-T T.81 Annex K.1, the example luminance quantisation table, in natural (row) order.
|
||||
const ANNEX_K_LUMA: [u16; 64] = [
|
||||
16, 11, 10, 16, 24, 40, 51, 61, //
|
||||
12, 12, 14, 19, 26, 58, 60, 55, //
|
||||
14, 13, 16, 24, 40, 57, 69, 56, //
|
||||
14, 17, 22, 29, 51, 87, 80, 62, //
|
||||
18, 22, 37, 56, 68, 109, 103, 77, //
|
||||
24, 35, 55, 64, 81, 104, 113, 92, //
|
||||
49, 64, 78, 87, 103, 121, 120, 101, //
|
||||
72, 92, 95, 98, 112, 100, 103, 99,
|
||||
];
|
||||
|
||||
/// ITU-T T.81 Annex K.2, the example chrominance quantisation table, in natural order.
|
||||
const ANNEX_K_CHROMA: [u16; 64] = [
|
||||
17, 18, 24, 47, 99, 99, 99, 99, //
|
||||
18, 21, 26, 66, 99, 99, 99, 99, //
|
||||
24, 26, 56, 99, 99, 99, 99, 99, //
|
||||
47, 66, 99, 99, 99, 99, 99, 99, //
|
||||
99, 99, 99, 99, 99, 99, 99, 99, //
|
||||
99, 99, 99, 99, 99, 99, 99, 99, //
|
||||
99, 99, 99, 99, 99, 99, 99, 99, //
|
||||
99, 99, 99, 99, 99, 99, 99, 99,
|
||||
];
|
||||
|
||||
/// The natural-order index of the k-th coefficient in zig-zag order (T.81 Figure A.6): DQT
|
||||
/// stores its 64 values in this order.
|
||||
const ZIGZAG: [usize; 64] = [
|
||||
0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, 12, 19, 26, 33, 40, 48, 41, 34, 27, 20, 13, 6, 7, 14, 21, 28, 35, 42, 49, 56, 57, 50, 43, 36, 29,
|
||||
22, 15, 23, 30, 37, 44, 51, 58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63,
|
||||
];
|
||||
|
||||
/// One quantisation table from a DQT segment, in natural order, with its precision.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Table {
|
||||
values: [u16; 64],
|
||||
/// 16-bit entries (Pq = 1) rather than 8-bit.
|
||||
wide: bool,
|
||||
}
|
||||
|
||||
/// The quality (1–100, the Independent JPEG Group's scale that Save As and Export As use) a
|
||||
/// JPEG was most likely saved at, from its quantisation tables; `None` when it has no usable
|
||||
/// luminance table (or isn't a JPEG).
|
||||
pub fn jpeg_quality(bytes: &[u8]) -> Option<u8> {
|
||||
let tables = jpeg_tables(bytes);
|
||||
let [luma, chroma, ..] = tables;
|
||||
let luma = luma?;
|
||||
let mut best: Option<(u64, u8)> = None;
|
||||
for q in 1..=100u8 {
|
||||
let mut err = table_error(&luma, &ANNEX_K_LUMA, q);
|
||||
if let Some(c) = chroma {
|
||||
err = err.saturating_add(table_error(&c, &ANNEX_K_CHROMA, q));
|
||||
}
|
||||
if best.is_none_or(|(e, _)| err < e) {
|
||||
best = Some((err, q));
|
||||
}
|
||||
}
|
||||
best.map(|(_, q)| q)
|
||||
}
|
||||
|
||||
/// The Independent JPEG Group's quality scaling of `base` (libjpeg's `jpeg_quality_scaling` and
|
||||
/// `jpeg_add_quant_table`, as published with its source): a percentage scale of 5000 / q below
|
||||
/// quality 50 and 200 − 2q from it, rounded, and kept within 1..=255 (8-bit) or 1..=32767.
|
||||
fn scaled(base: u16, q: u8, wide: bool) -> u32 {
|
||||
let q = u32::from(q.clamp(1, 100));
|
||||
let scale = if q < 50 { 5000 / q } else { 200 - 2 * q };
|
||||
let v = (u32::from(base) * scale + 50) / 100;
|
||||
v.clamp(1, if wide { 32767 } else { 255 })
|
||||
}
|
||||
|
||||
/// How far `t` is from `base` scaled to quality `q`: the summed absolute difference.
|
||||
fn table_error(t: &Table, base: &[u16; 64], q: u8) -> u64 {
|
||||
t.values.iter().zip(base).map(|(&v, &b)| u64::from(u32::from(v).abs_diff(scaled(b, q, t.wide)))).sum()
|
||||
}
|
||||
|
||||
/// The quantisation tables 0..=3 defined before the first scan (a later definition of the same
|
||||
/// slot replaces an earlier one).
|
||||
fn jpeg_tables(b: &[u8]) -> [Option<Table>; 4] {
|
||||
let mut tables = [None; 4];
|
||||
if b.get(..2) != Some(&[0xFF, 0xD8]) {
|
||||
return tables;
|
||||
}
|
||||
let mut i = 2usize;
|
||||
while let (Some(&0xFF), Some(&marker)) = (b.get(i), b.get(i.saturating_add(1))) {
|
||||
if marker == 0xFF {
|
||||
i = i.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
if marker == 0xD8 || (0xD0..=0xD7).contains(&marker) || marker == 0x01 {
|
||||
i = i.saturating_add(2);
|
||||
continue;
|
||||
}
|
||||
if marker == 0xDA || marker == 0xD9 {
|
||||
break;
|
||||
}
|
||||
let Some(len) = b.get(i + 2..i + 4).map(|l| usize::from(u16::from_be_bytes([l[0], l[1]]))) else { break };
|
||||
let Some(seg) = len.checked_sub(2).and_then(|n| b.get(i + 4..i + 4 + n)) else { break };
|
||||
if marker == 0xDB {
|
||||
read_dqt(seg, &mut tables);
|
||||
}
|
||||
i = i.saturating_add(2).saturating_add(len);
|
||||
}
|
||||
tables
|
||||
}
|
||||
|
||||
/// The tables of one DQT segment; a truncated or malformed table ends the segment.
|
||||
fn read_dqt(mut seg: &[u8], tables: &mut [Option<Table>; 4]) {
|
||||
while let Some((&pq_tq, rest)) = seg.split_first() {
|
||||
let wide = pq_tq >> 4 == 1;
|
||||
if pq_tq >> 4 > 1 {
|
||||
return;
|
||||
}
|
||||
let slot = usize::from(pq_tq & 0x0F);
|
||||
let n = if wide { 128 } else { 64 };
|
||||
let Some(data) = rest.get(..n) else { return };
|
||||
let mut values = [0u16; 64];
|
||||
for (k, &natural) in ZIGZAG.iter().enumerate() {
|
||||
let v = if wide { data.get(2 * k..2 * k + 2).map_or(0, |p| u16::from_be_bytes([p[0], p[1]])) } else { data.get(k).map_or(0, |&p| u16::from(p)) };
|
||||
if let Some(dst) = values.get_mut(natural) {
|
||||
*dst = v;
|
||||
}
|
||||
}
|
||||
if let Some(dst) = tables.get_mut(slot) {
|
||||
*dst = Some(Table { values, wide });
|
||||
}
|
||||
seg = rest.get(n..).unwrap_or(&[]);
|
||||
}
|
||||
}
|
||||
|
||||
/// How a WebP file's image is compressed.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WebpEncoding {
|
||||
/// VP8L.
|
||||
Lossless,
|
||||
/// VP8, with the quality (0–100, our encoder's scale) its quantiser maps back to, when the
|
||||
/// frame header could be read.
|
||||
Lossy { quality: Option<u8> },
|
||||
}
|
||||
|
||||
/// The compression of a WebP file's (first) image, or `None` when it isn't a WebP or holds no
|
||||
/// image chunk.
|
||||
pub fn webp_encoding(bytes: &[u8]) -> Option<WebpEncoding> {
|
||||
if bytes.get(..4) != Some(b"RIFF") || bytes.get(8..12) != Some(b"WEBP") {
|
||||
return None;
|
||||
}
|
||||
find_image_chunk(bytes.get(12..)?, 0)
|
||||
}
|
||||
|
||||
/// The first `VP8 ` or `VP8L` chunk among `chunks`, looking inside animation frames.
|
||||
fn find_image_chunk(mut chunks: &[u8], depth: u8) -> Option<WebpEncoding> {
|
||||
while let Some(head) = chunks.get(..8) {
|
||||
let fourcc = head.get(..4)?;
|
||||
let size = usize::try_from(u32::from_le_bytes([head[4], head[5], head[6], head[7]])).ok()?;
|
||||
let end = 8usize.checked_add(size)?;
|
||||
// A truncated last chunk still holds its header.
|
||||
let payload = chunks.get(8..end.min(chunks.len()))?;
|
||||
match fourcc {
|
||||
b"VP8L" => return Some(WebpEncoding::Lossless),
|
||||
b"VP8 " => return Some(WebpEncoding::Lossy { quality: vp8_quality(payload) }),
|
||||
// An animation frame: a 16-byte header, then the frame's own chunks.
|
||||
b"ANMF" if depth == 0 => {
|
||||
if let Some(found) = payload.get(16..).and_then(|c| find_image_chunk(c, 1)) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
chunks = chunks.get(end.checked_add(size % 2)?..).unwrap_or(&[]);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The quality a VP8 key frame's base quantiser index maps back to on our encoder's scale (the
|
||||
/// quality whose index is nearest; the middle one when several share it).
|
||||
fn vp8_quality(frame: &[u8]) -> Option<u8> {
|
||||
let qi = vp8_base_quantizer(frame)?;
|
||||
let distance = |q: u8| vp8::quantizer_index(q).abs_diff(qi);
|
||||
let best = (0..=100u8).map(distance).min()?;
|
||||
let matches: Vec<u8> = (0..=100u8).filter(|&q| distance(q) == best).collect();
|
||||
let mid = matches.get(matches.len() / 2)?;
|
||||
Some(*mid)
|
||||
}
|
||||
|
||||
/// The base quantiser index of a VP8 key frame: `y_ac_qi` from its frame header (RFC 6386
|
||||
/// §9.2–9.6, §19.2), averaged over the segments when segmentation sets absolute quantisers or
|
||||
/// adjusted by their mean delta otherwise.
|
||||
fn vp8_base_quantizer(frame: &[u8]) -> Option<u8> {
|
||||
let tag = frame.get(..3)?;
|
||||
// Bit 0 clear: a key frame, which carries the start code and dimensions.
|
||||
if tag[0] & 1 != 0 || frame.get(3..6)? != [0x9D, 0x01, 0x2A] {
|
||||
return None;
|
||||
}
|
||||
let mut d = BoolDecoder::new(frame.get(10..)?);
|
||||
d.literal(1); // color_space
|
||||
d.literal(1); // clamping_type
|
||||
let mut segments: Option<(bool, [i32; 4])> = None;
|
||||
if d.flag() {
|
||||
let update_map = d.flag();
|
||||
if d.flag() {
|
||||
let absolute = d.flag();
|
||||
let mut q = [0i32; 4];
|
||||
for v in &mut q {
|
||||
*v = if d.flag() { d.signed(7) } else { 0 };
|
||||
}
|
||||
for _ in 0..4 {
|
||||
if d.flag() {
|
||||
d.signed(6);
|
||||
}
|
||||
}
|
||||
segments = Some((absolute, q));
|
||||
}
|
||||
if update_map {
|
||||
for _ in 0..3 {
|
||||
if d.flag() {
|
||||
d.literal(8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
d.literal(1); // filter_type
|
||||
d.literal(6); // loop_filter_level
|
||||
d.literal(3); // sharpness_level
|
||||
if d.flag() && d.flag() {
|
||||
// loop_filter_adj_enable, mode_ref_lf_delta_update: 4 reference and 4 mode deltas.
|
||||
for _ in 0..8 {
|
||||
if d.flag() {
|
||||
d.signed(6);
|
||||
}
|
||||
}
|
||||
}
|
||||
d.literal(2); // log2_nbr_of_dct_partitions
|
||||
if d.exhausted() {
|
||||
return None;
|
||||
}
|
||||
let base = d.literal(7) as i32;
|
||||
let qi = match segments {
|
||||
Some((true, q)) => q.iter().sum::<i32>() / 4,
|
||||
Some((false, q)) => base + q.iter().sum::<i32>() / 4,
|
||||
None => base,
|
||||
};
|
||||
u8::try_from(qi.clamp(0, 127)).ok()
|
||||
}
|
||||
|
||||
/// RFC 6386 §7's boolean decoder, reading zeros past the end of its data.
|
||||
struct BoolDecoder<'a> {
|
||||
data: &'a [u8],
|
||||
pos: usize,
|
||||
value: u32,
|
||||
range: u32,
|
||||
bit_count: u32,
|
||||
/// Bytes read past the end of the data.
|
||||
overrun: usize,
|
||||
}
|
||||
|
||||
impl<'a> BoolDecoder<'a> {
|
||||
fn new(data: &'a [u8]) -> Self {
|
||||
let mut d = BoolDecoder { data, pos: 0, value: 0, range: 255, bit_count: 0, overrun: 0 };
|
||||
d.value = (d.next_byte() << 8) | d.next_byte();
|
||||
d
|
||||
}
|
||||
|
||||
fn next_byte(&mut self) -> u32 {
|
||||
match self.data.get(self.pos) {
|
||||
Some(&b) => {
|
||||
self.pos += 1;
|
||||
u32::from(b)
|
||||
}
|
||||
None => {
|
||||
self.overrun = self.overrun.saturating_add(1);
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The header was read from beyond the end of the data (a couple of bytes of look-ahead
|
||||
/// are normal).
|
||||
fn exhausted(&self) -> bool {
|
||||
self.overrun > 2
|
||||
}
|
||||
|
||||
fn bool(&mut self, prob: u32) -> bool {
|
||||
let split = 1 + (((self.range - 1) * prob) >> 8);
|
||||
let big_split = split << 8;
|
||||
let bit = if self.value >= big_split {
|
||||
self.range -= split;
|
||||
self.value -= big_split;
|
||||
true
|
||||
} else {
|
||||
self.range = split;
|
||||
false
|
||||
};
|
||||
while self.range < 128 {
|
||||
self.value <<= 1;
|
||||
self.range <<= 1;
|
||||
self.bit_count += 1;
|
||||
if self.bit_count == 8 {
|
||||
self.bit_count = 0;
|
||||
self.value |= self.next_byte();
|
||||
}
|
||||
}
|
||||
bit
|
||||
}
|
||||
|
||||
fn flag(&mut self) -> bool {
|
||||
self.bool(128)
|
||||
}
|
||||
|
||||
fn literal(&mut self, bits: u32) -> u32 {
|
||||
(0..bits).fold(0, |v, _| (v << 1) | u32::from(self.flag()))
|
||||
}
|
||||
|
||||
/// A magnitude of `bits` bits followed by a sign bit.
|
||||
fn signed(&mut self, bits: u32) -> i32 {
|
||||
let v = self.literal(bits) as i32;
|
||||
if self.flag() { -v } else { v }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn scaling_matches_the_published_curve() {
|
||||
// Quality 50 is the Annex K table itself; 100 is all ones; 75 halves it.
|
||||
assert_eq!(scaled(16, 50, false), 16);
|
||||
assert_eq!(scaled(121, 100, false), 1);
|
||||
assert_eq!(scaled(16, 75, false), 8);
|
||||
assert_eq!(scaled(121, 1, false), 255);
|
||||
assert_eq!(scaled(121, 1, true), 6050);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zigzag_is_a_permutation() {
|
||||
let mut seen = [false; 64];
|
||||
for &n in &ZIGZAG {
|
||||
seen[n] = true;
|
||||
}
|
||||
assert!(seen.iter().all(|&s| s));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//! Reading back the settings a lossy file was saved with: a JPEG's quality from its
|
||||
//! quantisation tables, a WebP's lossy or lossless encoding (and its quality).
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
use photocraft_codecs::*;
|
||||
|
||||
fn photo(w: u32, h: u32, layout: ChannelLayout) -> Image {
|
||||
synth(w, h, layout, SampleType::U8, 5, 0.03)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_quality_is_estimated_within_two() {
|
||||
for layout in [ChannelLayout::Rgb, ChannelLayout::Gray] {
|
||||
let img = photo(40, 24, layout);
|
||||
for q in [1u8, 5, 10, 25, 40, 50, 60, 70, 75, 80, 85, 90, 95, 98, 100] {
|
||||
let bytes = encode(&img, Format::Jpeg, &EncodeOptions { jpeg_quality: q, ..Default::default() }).unwrap();
|
||||
let est = jpeg_quality(&bytes).unwrap_or_else(|| panic!("{layout:?} q{q}: no estimate"));
|
||||
assert!(est.abs_diff(q) <= 2, "{layout:?}: saved at {q}, estimated {est}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_quality_of_hand_built_tables() {
|
||||
// A minimal JPEG prefix: SOI, one DQT holding the Annex K luminance table (quality 50) in
|
||||
// zig-zag order, then SOS.
|
||||
let luma: [u8; 64] = [
|
||||
16, 11, 12, 14, 12, 10, 16, 14, 13, 14, 18, 17, 16, 19, 24, 40, 26, 24, 22, 22, 24, 49, 35, 37, 29, 40, 58, 51, 61, 60, 57, 51, 56, 55, 64, 72, 92, 78,
|
||||
64, 68, 87, 69, 55, 56, 80, 109, 81, 87, 95, 98, 103, 104, 103, 62, 77, 113, 121, 112, 100, 120, 92, 101, 103, 99,
|
||||
];
|
||||
let mut b = vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 67, 0x00];
|
||||
b.extend_from_slice(&luma);
|
||||
b.extend_from_slice(&[0xFF, 0xDA, 0, 2]);
|
||||
assert_eq!(jpeg_quality(&b), Some(50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_jpeg_tables_give_no_estimate_and_never_panic() {
|
||||
let img = photo(16, 16, ChannelLayout::Rgb);
|
||||
let good = encode(&img, Format::Jpeg, &EncodeOptions { jpeg_quality: 70, ..Default::default() }).unwrap();
|
||||
// Every truncation of a real file.
|
||||
for n in 0..good.len().min(800) {
|
||||
let _ = jpeg_quality(&good[..n]);
|
||||
}
|
||||
let cases: Vec<Vec<u8>> = vec![
|
||||
vec![],
|
||||
vec![0xFF],
|
||||
vec![0xFF, 0xD8],
|
||||
// A DQT whose length runs past the end.
|
||||
vec![0xFF, 0xD8, 0xFF, 0xDB, 0xFF, 0xFF, 0x00, 1, 2, 3],
|
||||
// A DQT of length 0 and 1 (less than its own length field).
|
||||
vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 0x00],
|
||||
vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 0x01],
|
||||
// A table cut short, a bad precision, a slot past 3, a 16-bit table cut mid-entry.
|
||||
[vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 12, 0x00], vec![9; 9]].concat(),
|
||||
[vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 67, 0x70], vec![9; 64]].concat(),
|
||||
[vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 67, 0x0F], vec![9; 64]].concat(),
|
||||
[vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 68, 0x10], vec![0xFF; 65]].concat(),
|
||||
// All-zero and all-0xFF tables.
|
||||
[vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 67, 0x00], vec![0; 64]].concat(),
|
||||
[vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 131, 0x10], vec![0xFF; 128]].concat(),
|
||||
vec![0xFF; 64],
|
||||
];
|
||||
for c in &cases {
|
||||
let _ = jpeg_quality(c);
|
||||
}
|
||||
assert_eq!(jpeg_quality(&[0xFF, 0xD8, 0xFF, 0xDB, 0x00, 0x00]), None);
|
||||
assert_eq!(jpeg_quality(b"not a jpeg at all"), None);
|
||||
let png = encode(&img, Format::Png, &EncodeOptions::default()).unwrap();
|
||||
assert_eq!(jpeg_quality(&png), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webp_lossless_and_lossy_are_told_apart() {
|
||||
let img = photo(33, 21, ChannelLayout::Rgb);
|
||||
let lossless = encode(&img, Format::WebP, &EncodeOptions::default()).unwrap();
|
||||
assert_eq!(webp_encoding(&lossless), Some(WebpEncoding::Lossless));
|
||||
for q in [10u8, 30, 50, 75, 80, 90, 100] {
|
||||
let bytes = encode(&img, Format::WebP, &EncodeOptions { webp_lossless: false, webp_quality: q, ..Default::default() }).unwrap();
|
||||
let Some(WebpEncoding::Lossy { quality: Some(est) }) = webp_encoding(&bytes) else { panic!("q{q}: {:?}", webp_encoding(&bytes)) };
|
||||
assert!(est.abs_diff(q) <= 2, "lossy WebP saved at {q}, estimated {est}");
|
||||
}
|
||||
// With alpha and metadata (the extended container).
|
||||
let rgba = photo(20, 20, ChannelLayout::Rgba);
|
||||
let bytes = encode(&rgba, Format::WebP, &EncodeOptions { webp_lossless: false, webp_quality: 60, ..Default::default() }).unwrap();
|
||||
assert!(matches!(webp_encoding(&bytes), Some(WebpEncoding::Lossy { quality: Some(58..=62) })), "{:?}", webp_encoding(&bytes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_webp_never_panics() {
|
||||
let img = photo(24, 24, ChannelLayout::Rgb);
|
||||
for opts in [EncodeOptions::default(), EncodeOptions { webp_lossless: false, ..Default::default() }] {
|
||||
let good = encode(&img, Format::WebP, &opts).unwrap();
|
||||
for n in 0..good.len().min(400) {
|
||||
let _ = webp_encoding(&good[..n]);
|
||||
}
|
||||
let mut flipped = good.clone();
|
||||
for i in 12..flipped.len().min(64) {
|
||||
flipped[i] ^= 0xA5;
|
||||
let _ = webp_encoding(&flipped);
|
||||
}
|
||||
}
|
||||
let mut huge = b"RIFF\0\0\0\0WEBPVP8 ".to_vec();
|
||||
huge.extend_from_slice(&u32::MAX.to_le_bytes());
|
||||
let _ = webp_encoding(&huge);
|
||||
let mut anmf = b"RIFF\0\0\0\0WEBPANMF".to_vec();
|
||||
anmf.extend_from_slice(&3u32.to_le_bytes());
|
||||
let _ = webp_encoding(&anmf);
|
||||
assert_eq!(webp_encoding(b"RIFF\0\0\0\0WAVE"), None);
|
||||
}
|
||||
@@ -875,6 +875,32 @@ pub struct Document {
|
||||
pub variables: Variables,
|
||||
/// Window › Timeline (None until a video timeline is created).
|
||||
pub timeline: Option<Timeline>,
|
||||
/// How the document's lossy file was encoded (its JPEG quality, WebP lossy or lossless), so
|
||||
/// a save writes it back the same way rather than at fixed defaults.
|
||||
pub file_encoding: FileEncoding,
|
||||
}
|
||||
|
||||
/// The encoder settings a document's file was opened or saved with: what File › Save and the
|
||||
/// Save As defaults reuse for that format. Empty for a new document or a file without lossy
|
||||
/// options. Qualities are on the codecs' 1–100 scale.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct FileEncoding {
|
||||
/// JPEG quality (estimated from the file's quantisation tables on open).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub jpeg_quality: Option<u8>,
|
||||
/// WebP: `Some(true)` lossless (VP8L), `Some(false)` lossy (VP8 at [`Self::webp_quality`]).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub webp_lossless: Option<bool>,
|
||||
/// Lossy WebP quality.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub webp_quality: Option<u8>,
|
||||
}
|
||||
|
||||
impl FileEncoding {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
*self == FileEncoding::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a layer lives in the tree: indices from the root down.
|
||||
@@ -913,6 +939,7 @@ impl Document {
|
||||
slices: Slices::default(),
|
||||
variables: Variables::default(),
|
||||
timeline: None,
|
||||
file_encoding: FileEncoding::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -285,6 +285,9 @@ pub(crate) fn encode(doc: &Document, path: &str, save: impl Into<SaveOpts>) -> R
|
||||
let save = save.into();
|
||||
let mut opts =
|
||||
photocraft_io::ExportOptions { tiff_layers: save.tiff_layers, tga_bits: save.tga_bits, image_previews: save.image_previews, ..Default::default() };
|
||||
// No quality chosen: write the file the way it was opened (a JPEG at its own quality, a WebP
|
||||
// lossy or lossless as it was), as Photoshop's Save does.
|
||||
photocraft_io::apply_file_encoding(doc, &mut opts.encode);
|
||||
if let Some(q) = save.quality {
|
||||
let q = (q.clamp(0.0, 12.0) / 12.0 * 99.0 + 1.0).round() as u8;
|
||||
opts.encode.jpeg_quality = q;
|
||||
|
||||
@@ -597,6 +597,7 @@ fn doc_pixels_equal(a: &Document, b: &Document) -> bool {
|
||||
slices: _,
|
||||
variables: _,
|
||||
timeline: _,
|
||||
file_encoding: _,
|
||||
} = a;
|
||||
*size == b.size
|
||||
&& resolution_dpi.to_bits() == b.resolution_dpi.to_bits()
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! #2399: a save writes a JPEG or WebP back the way it was opened. A JPEG keeps the quality
|
||||
//! estimated from its quantisation tables, a lossy WebP stays lossy and a lossless one lossless,
|
||||
//! unless the save asks for a quality; the remembered settings survive a .pcraft round trip.
|
||||
|
||||
use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType, WebpEncoding, jpeg_quality, webp_encoding};
|
||||
use photocraft_engine::Session;
|
||||
use photocraft_engine::file_cmds::open_bytes_as;
|
||||
use serde_json::json;
|
||||
|
||||
fn photo() -> Image {
|
||||
let (w, h) = (48u32, 32u32);
|
||||
let px = (0..w * h).flat_map(|i| {
|
||||
let (x, y) = (i % w, i / w);
|
||||
[(x * 5) as u8, (y * 7) as u8, ((x * y) % 251) as u8]
|
||||
});
|
||||
Image::from_raw(w, h, ChannelLayout::Rgb, SampleType::U8, px.collect()).unwrap()
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-2399-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir.join(name)
|
||||
}
|
||||
|
||||
/// Opens `bytes` as `name`, saves a copy to `out` with `params`, and returns the saved file.
|
||||
fn open_and_save(name: &str, bytes: &[u8], out: &str, params: serde_json::Value) -> (Session, Vec<u8>) {
|
||||
let mut s = Session::new();
|
||||
open_bytes_as(&mut s, name, bytes, None, None).unwrap();
|
||||
let path = tmp(out);
|
||||
let mut p = params;
|
||||
p["path"] = json!(path.to_string_lossy());
|
||||
s.execute("file.saveACopy", p).unwrap();
|
||||
let saved = std::fs::read(&path).unwrap();
|
||||
(s, saved)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_jpeg_saves_at_its_own_quality() {
|
||||
for q in [35u8, 60, 77, 95] {
|
||||
let src = photocraft_codecs::encode(&photo(), Format::Jpeg, &EncodeOptions { jpeg_quality: q, ..Default::default() }).unwrap();
|
||||
let (s, saved) = open_and_save("in.jpg", &src, &format!("q{q}.jpg"), json!({}));
|
||||
let remembered = s.active().unwrap().doc.file_encoding.jpeg_quality.unwrap();
|
||||
assert!(remembered.abs_diff(q) <= 2, "opened at {q}, remembered {remembered}");
|
||||
let est = jpeg_quality(&saved).unwrap();
|
||||
assert!(est.abs_diff(q) <= 2, "opened at {q}, saved at {est}");
|
||||
}
|
||||
// A quality the save asks for still wins (Photoshop's 0–12 scale: 12 is the top).
|
||||
let src = photocraft_codecs::encode(&photo(), Format::Jpeg, &EncodeOptions { jpeg_quality: 40, ..Default::default() }).unwrap();
|
||||
let (_, saved) = open_and_save("in.jpg", &src, "explicit.jpg", json!({"quality": 12}));
|
||||
assert_eq!(jpeg_quality(&saved), Some(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_webp_stays_lossy_or_lossless() {
|
||||
let lossy = photocraft_codecs::encode(&photo(), Format::WebP, &EncodeOptions { webp_lossless: false, webp_quality: 60, ..Default::default() }).unwrap();
|
||||
let (s, saved) = open_and_save("in.webp", &lossy, "lossy.webp", json!({}));
|
||||
let e = s.active().unwrap().doc.file_encoding;
|
||||
assert_eq!(e.webp_lossless, Some(false));
|
||||
let Some(WebpEncoding::Lossy { quality: Some(q) }) = webp_encoding(&saved) else { panic!("saved {:?}", webp_encoding(&saved)) };
|
||||
assert!(q.abs_diff(60) <= 2, "lossy WebP at 60 saved at {q}");
|
||||
|
||||
let lossless = photocraft_codecs::encode(&photo(), Format::WebP, &EncodeOptions::default()).unwrap();
|
||||
let (s, saved) = open_and_save("in.webp", &lossless, "lossless.webp", json!({}));
|
||||
assert_eq!(s.active().unwrap().doc.file_encoding.webp_lossless, Some(true));
|
||||
assert_eq!(webp_encoding(&saved), Some(WebpEncoding::Lossless));
|
||||
assert_eq!(photocraft_codecs::decode(&saved).unwrap().data(), photocraft_codecs::decode(&lossless).unwrap().data(), "lossless round trip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_document_and_a_png_save_at_the_defaults() {
|
||||
let png = photocraft_codecs::encode(&photo(), Format::Png, &EncodeOptions::default()).unwrap();
|
||||
let (s, saved) = open_and_save("in.png", &png, "from-png.jpg", json!({}));
|
||||
assert!(s.active().unwrap().doc.file_encoding.is_empty());
|
||||
assert_eq!(jpeg_quality(&saved), Some(EncodeOptions::default().jpeg_quality));
|
||||
let (_, saved) = open_and_save("in.png", &png, "from-png.webp", json!({}));
|
||||
assert_eq!(webp_encoding(&saved), Some(WebpEncoding::Lossless));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_remembered_encoding_survives_pcraft() {
|
||||
let src = photocraft_codecs::encode(&photo(), Format::Jpeg, &EncodeOptions { jpeg_quality: 70, ..Default::default() }).unwrap();
|
||||
let mut s = Session::new();
|
||||
open_bytes_as(&mut s, "in.jpg", &src, None, None).unwrap();
|
||||
let doc = &s.active().unwrap().doc;
|
||||
let bytes = photocraft_format::save_to_bytes(doc, &photocraft_format::SaveOptions::default()).unwrap();
|
||||
let back = photocraft_format::load_from_bytes(&bytes).unwrap();
|
||||
assert_eq!(back.file_encoding, doc.file_encoding);
|
||||
assert!(back.file_encoding.jpeg_quality.is_some());
|
||||
}
|
||||
@@ -253,6 +253,7 @@ pub(crate) fn doc_m(d: &Document, sink: &mut dyn Sink) -> DocM {
|
||||
duotone: d.duotone.clone(),
|
||||
variables: d.variables.clone(),
|
||||
timeline: d.timeline.clone(),
|
||||
file_encoding: d.file_encoding,
|
||||
layer_comps: d.layer_comps.iter().map(|c| comp_m(c, sink)).collect(),
|
||||
last_applied_comp: d.last_applied_comp,
|
||||
last_document_state: d.last_document_state.as_ref().map(|c| comp_m(c, sink)),
|
||||
@@ -631,6 +632,7 @@ impl Loader<'_> {
|
||||
duotone: m.duotone.clone(),
|
||||
variables: m.variables.clone(),
|
||||
timeline: m.timeline.clone(),
|
||||
file_encoding: m.file_encoding,
|
||||
layer_comps,
|
||||
last_applied_comp: m.last_applied_comp,
|
||||
last_document_state,
|
||||
|
||||
@@ -84,6 +84,9 @@ pub struct DocM {
|
||||
/// Window › Timeline.
|
||||
#[serde(default)]
|
||||
pub timeline: Option<photocraft_doc::Timeline>,
|
||||
/// The JPEG quality / WebP encoding File › Save reuses.
|
||||
#[serde(default, skip_serializing_if = "photocraft_doc::FileEncoding::is_empty")]
|
||||
pub file_encoding: photocraft_doc::FileEncoding,
|
||||
/// Image › Analysis: measurement scale, count groups, ruler.
|
||||
#[serde(default)]
|
||||
pub measurement: photocraft_doc::Measurement,
|
||||
|
||||
@@ -21,6 +21,7 @@ pub fn import_flat(name: &str, bytes: &[u8]) -> Result<ImportResult, IoError> {
|
||||
}
|
||||
let img = codecs::decode(bytes)?;
|
||||
let mut r = image_to_document(name, &img)?;
|
||||
r.document.file_encoding = file_encoding(bytes);
|
||||
if codecs::detect(bytes) == Some(Format::Tga) && img.layout() == ChannelLayout::Rgba {
|
||||
tga_alpha_to_channel(&mut r.document)?;
|
||||
}
|
||||
@@ -50,6 +51,45 @@ pub fn import_gif_frames(name: &str, bytes: &[u8], each: &mut dyn FnMut(Document
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// The lossy settings `bytes` were encoded with, for File › Save to keep: a JPEG's quality
|
||||
/// (estimated from its quantisation tables) and a WebP's lossless or lossy encoding with its
|
||||
/// quality. A lossy WebP whose quality can't be read gets [`DEFAULT_LOSSY_WEBP_QUALITY`].
|
||||
pub fn file_encoding(bytes: &[u8]) -> photocraft_doc::FileEncoding {
|
||||
let mut e = photocraft_doc::FileEncoding::default();
|
||||
match codecs::detect(bytes) {
|
||||
Some(Format::Jpeg) => e.jpeg_quality = codecs::jpeg_quality(bytes),
|
||||
Some(Format::WebP) => match codecs::webp_encoding(bytes) {
|
||||
Some(codecs::WebpEncoding::Lossless) => e.webp_lossless = Some(true),
|
||||
Some(codecs::WebpEncoding::Lossy { quality }) => {
|
||||
e.webp_lossless = Some(false);
|
||||
e.webp_quality = Some(quality.unwrap_or(DEFAULT_LOSSY_WEBP_QUALITY));
|
||||
}
|
||||
None => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
/// The quality a lossy WebP is saved at when its own can't be read.
|
||||
pub const DEFAULT_LOSSY_WEBP_QUALITY: u8 = 75;
|
||||
|
||||
/// Applies the settings `doc`'s file was encoded with (see [`file_encoding`]) to `opts`: what a
|
||||
/// save that chose no quality writes, so a JPEG keeps its quality and a WebP stays lossy or
|
||||
/// lossless.
|
||||
pub fn apply_file_encoding(doc: &Document, opts: &mut codecs::EncodeOptions) {
|
||||
let e = &doc.file_encoding;
|
||||
if let Some(q) = e.jpeg_quality {
|
||||
opts.jpeg_quality = q.clamp(1, 100);
|
||||
}
|
||||
if let Some(lossless) = e.webp_lossless {
|
||||
opts.webp_lossless = lossless;
|
||||
}
|
||||
if let Some(q) = e.webp_quality {
|
||||
opts.webp_quality = q.min(100);
|
||||
}
|
||||
}
|
||||
|
||||
/// Photoshop opens a 32-bit Targa as an opaque Background plus an "Alpha 1" channel holding the
|
||||
/// fourth byte, even when the header declares no alpha bits, and keeps the stored colours (#2225;
|
||||
/// measured on Photoshop 27.11). Layer transparency would hide those colours and lose the channel
|
||||
|
||||
@@ -64,7 +64,10 @@ use photocraft_doc::Document;
|
||||
use photocraft_psd::{PsdError, PsdFile};
|
||||
|
||||
pub use adjust_map::ADJUSTMENT_KEYS;
|
||||
pub use flat::{document_to_image, import_gif_frames, import_tiff_page, tga_alpha_channel_to_transparency, tga_default_bits};
|
||||
pub use flat::{
|
||||
DEFAULT_LOSSY_WEBP_QUALITY, apply_file_encoding, document_to_image, file_encoding, import_gif_frames, import_tiff_page, tga_alpha_channel_to_transparency,
|
||||
tga_default_bits,
|
||||
};
|
||||
pub use psd_export::{PsdExportOptions, document_to_psd, document_to_psd_with};
|
||||
pub use psd_import::{psd_to_document, psd_to_document_with};
|
||||
|
||||
|
||||
@@ -1284,10 +1284,19 @@ impl PhotocraftApp {
|
||||
|
||||
/// What a save writes by default under the preferences: File Handling › Image Previews
|
||||
/// decides whether a PSD/PSB gets its thumbnail. Ask When Saving has no Save As checkbox
|
||||
/// here yet, so it saves the preview, as Photoshop's checkbox starts on.
|
||||
/// here yet, so it saves the preview, as Photoshop's checkbox starts on. A JPEG or WebP
|
||||
/// is written the way the active document's file was (its JPEG quality, a WebP lossy at
|
||||
/// its quality or lossless), as Photoshop's Save and Save As options start there.
|
||||
pub(crate) fn save_settings(&self) -> ExportSettings {
|
||||
let previews = self.session.prefs().file_handling.image_previews != photocraft_engine::prefs::Ask::Never;
|
||||
ExportSettings { image_previews: previews, ..ExportSettings::default() }
|
||||
let mut settings = ExportSettings { image_previews: previews, ..ExportSettings::default() };
|
||||
if let Some(st) = self.session.active() {
|
||||
let e = &st.doc.file_encoding;
|
||||
settings.jpeg_quality = e.jpeg_quality.map(|q| q.clamp(1, 100));
|
||||
settings.webp_lossless = e.webp_lossless.unwrap_or(settings.webp_lossless);
|
||||
settings.webp_quality = e.webp_quality.map(|q| q.min(100));
|
||||
}
|
||||
settings
|
||||
}
|
||||
|
||||
/// Whether saving the active document to `path` writes a copy. `tiff_layers` is the TIFF
|
||||
|
||||
@@ -204,3 +204,30 @@ fn image_previews_preference_reaches_the_exporter() {
|
||||
assert_eq!(*seen.borrow(), [expect; 3], "{value}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #2399: Save and Save As start at the opened file's own encoding: a JPEG's estimated quality,
|
||||
/// a WebP lossy (at its quality) or lossless; a new document keeps the codec defaults.
|
||||
#[test]
|
||||
fn save_settings_start_at_the_opened_files_encoding() {
|
||||
use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType};
|
||||
let px: Vec<u8> = (0..32u32 * 24).flat_map(|i| [(i * 3) as u8, (i / 32 * 9) as u8, (i % 97) as u8]).collect();
|
||||
let img = Image::from_raw(32, 24, ChannelLayout::Rgb, SampleType::U8, px).unwrap();
|
||||
let mut app = app();
|
||||
assert_eq!(app.save_settings().jpeg_quality, None);
|
||||
assert!(app.save_settings().webp_lossless);
|
||||
|
||||
let jpeg = photocraft_codecs::encode(&img, Format::Jpeg, &EncodeOptions { jpeg_quality: 65, ..Default::default() }).unwrap();
|
||||
app.open_bytes("photo.jpg", &jpeg).unwrap();
|
||||
let q = app.save_settings().jpeg_quality.unwrap();
|
||||
assert!(q.abs_diff(65) <= 2, "estimated {q}");
|
||||
|
||||
let lossy = photocraft_codecs::encode(&img, Format::WebP, &EncodeOptions { webp_lossless: false, webp_quality: 80, ..Default::default() }).unwrap();
|
||||
app.open_bytes("photo.webp", &lossy).unwrap();
|
||||
let s = app.save_settings();
|
||||
assert!(!s.webp_lossless);
|
||||
assert!(s.webp_quality.unwrap().abs_diff(80) <= 2, "{:?}", s.webp_quality);
|
||||
|
||||
let lossless = photocraft_codecs::encode(&img, Format::WebP, &EncodeOptions::default()).unwrap();
|
||||
app.open_bytes("art.webp", &lossless).unwrap();
|
||||
assert!(app.save_settings().webp_lossless);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (D5100 crop and Camera Raw tuning validation) · **Target:** Adobe Photoshop 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Save keeps a JPEG's quality and a WebP's lossy/lossless encoding) · **Target:** Adobe Photoshop 2026
|
||||
|
||||
Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and
|
||||
tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write
|
||||
@@ -25,11 +25,11 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity.
|
||||
| PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) |
|
||||
| Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) |
|
||||
| TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) |
|
||||
| JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; no JPEG Options dialog (quality 0–12, baseline/progressive) (FILE-215-19) |
|
||||
| JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; Save keeps the opened file's quality (estimated from its quantisation tables, #2399); no JPEG Options dialog (quality 0–12, baseline/progressive) (FILE-215-19) |
|
||||
| PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225) |
|
||||
| GIF | R/W | yes | yes | File › Open keeps the first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the delays (#572). Per-frame delays and keeping the frames in `.pcraft` are not done |
|
||||
| BMP | R/W | yes | yes | 8-bit |
|
||||
| WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP |
|
||||
| WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP; Save keeps an opened file lossy (at its quality) or lossless (#2399) |
|
||||
| AVIF | R/W | no (#463) | optional feature, off by default | |
|
||||
| JPEG XL | R/W | inside DNG only | no | |
|
||||
| HEIF/HEIC | R/W(HEIF) | yes (pure-Rust heic-rs, `heif` feature, on in releases) | no | 8/16-bit |
|
||||
@@ -140,5 +140,6 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Save writes a JPEG at its own estimated quality and a WebP lossy or lossless as opened (#2399) |
|
||||
| 2026-10-11 | minor | D5100 image area and fitted-look preservation in Camera Raw tuning |
|
||||
| 2026-10-10 | major | First version: Photoshop 2026 27.11.0's formats from its bundle, against our codecs; Affinity notes moved from `roadmap.md` |
|
||||
Reference in new issue
Block a user