Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,58 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

---

## [1.2.0] — 2026-06-30 (release)

**JPEG-XL transfer-syntax completion + lossless ratio.** Three additions: the
reverse half of JPEG recompression is now public API, the lossless Modular path
gains the YCoCg-R reversible colour transform (a large ratio win on RGB/RGBA),
and the `async` facade reaches parity for the recompression pair. **Encoded
bytes change for RGB/RGBA lossless** (RCT — see *Changed*); grayscale, the JPEG
bridge, and every decode path are unchanged. RCT output is `djxl 0.11.2`
byte-exact (reference-verified), and the JPEG-recompression round trip is
byte-identical against `cjxl`/`djxl` in both directions.

### Added

- **Public reverse JPEG recompression** — `JXLDecoder.decodeLosslessJPEG(_:)
throws -> Data`, the symmetric mirror of the existing public
`encodeLosslessJPEG(_:)`. Reconstructs the byte-identical original JPEG from a
JPEG-bridge JXL (baseline + common progressive; 4:4:4 / 4:2:2 / 4:2:0 /
grayscale; APP / ICC metadata). The reverse pipeline's adapter/bridge types
stay `package`; only the one-call wrapper is public. `jxl transcode --mode
reverse` is now a thin client over it.
- **`async` overloads** for `encodeLosslessJPEG` / `decodeLosslessJPEG`,
completing the J2KSwift async-facade parity for the recompression syntax.

### Changed

- **Lossless RGB/RGBA now uses the YCoCg-R reversible colour transform**
(§C.7.7), single- and multi-group. R, G, B are decorrelated to Y, Co, Cg
before prediction and inverted by the decoder from the global-header
transform list. **Encoded bytes change for RGB/RGBA lossless** (smaller);
grayscale and all decode output are unchanged. Measured **21–66 % smaller**
on correlated RGB/RGBA versus the previous independent-channel coding.
- **RCT is cost-gated** — applied only when its estimated residual cost beats
raw R, G, B, so it can never inflate a frame (already-decorrelated /
independent-channel content keeps the identity path). Round-trips stay
pixel-exact either way.

### Fixed

- The modular gradient predictor's redundant `[0, sampleHi]` clamp is dropped in
the residual paths — a no-op for in-range channels (existing byte-exact output
preserved) but it diverged from the decoder's ClampedGradient for the
RCT-transformed (signed / over-range) chroma channels, which would otherwise
break the round trip.

### Verification

- RCT lossless output decodes **byte-exact** through reference `djxl 0.11.2`
across single/multi-group, 8/16-bit RGB/RGBA, correlated + random content.
- JPEG recompression: `cjxl --lossless_jpeg=1` → `decodeLosslessJPEG` →
byte-identical original, and forward bridge → `djxl` → byte-identical, across
4:4:4 / 4:2:2 / 4:2:0 / grayscale.

## [1.1.0] — 2026-06-12 (release)

**Performance-programme release.** Default-effort lossless encode is
Expand Down
12 changes: 6 additions & 6 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import PackageDescription
// amended 2026-05) — currently scaffolding only; functions added there must
// be byte-equivalent to a scalar Swift reference and gate-tested.
//
// Status: v1.1.0 production-ready lossless JPEG XL codec — medical-grade
// (DICOM + CID22 byte-exact via djxl), API frozen, Swift-first with the
// `JXLPerfC` boundary scaffolded for future C/C++ hot-path work. v1.1.0
// is the performance-programme release (7.1× default-effort encode,
// parallel batch/groups, retuned effort ladder — see CHANGELOG.md and
// Documentation/OPTIMISATION-PLAN-2026-06.md).
// Status: v1.2.0 production-ready lossless JPEG XL codec — medical-grade
// (DICOM + CID22 byte-exact via djxl), Swift-first with the `JXLPerfC`
// boundary scaffolded for future C/C++ hot-path work. v1.2.0 adds the
// public reverse JPEG-recompression API (`JXLDecoder.decodeLosslessJPEG`)
// and the YCoCg-R lossless colour transform (21–66 % smaller RGB/RGBA,
// cost-gated, djxl-byte-exact — see CHANGELOG.md).

// JXLSwift is fully self-contained: no shared-protocol package. The only
// dependency is swift-argument-parser, and that is CLI-only (the JXLSwift
Expand Down
18 changes: 18 additions & 0 deletions Sources/JXLSwift/Codec/AsyncOverloads.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ extension JXLEncoder {
let syncEncode: ([ImageFrame]) throws -> EncodedImage = self.encode
return try syncEncode(frames)
}

/// Async overload of ``encodeLosslessJPEG(_:)`` — forward JPEG
/// recompression (JPEG → JXL). Completes the `async` family-parity
/// surface for the recompression transfer syntax; thin wrapper
/// around the synchronous implementation.
public func encodeLosslessJPEG(_ jpegBytes: Data) async throws -> EncodedImage {
let sync: (Data) throws -> EncodedImage = self.encodeLosslessJPEG
return try sync(jpegBytes)
}
}

extension JXLDecoder {
Expand All @@ -54,4 +63,13 @@ extension JXLDecoder {
let syncDecodeAll: (Data) throws -> [ImageFrame] = self.decodeAll
return try syncDecodeAll(data)
}

/// Async overload of ``decodeLosslessJPEG(_:)`` — reverse JPEG
/// recompression (JXL → byte-identical JPEG). Completes the `async`
/// family-parity surface for the recompression transfer syntax;
/// thin wrapper around the synchronous implementation.
public func decodeLosslessJPEG(_ jxlBytes: Data) async throws -> Data {
let sync: (Data) throws -> Data = self.decodeLosslessJPEG
return try sync(jxlBytes)
}
}
142 changes: 142 additions & 0 deletions Sources/JXLSwift/Codec/JXLDecoder+LosslessJPEG.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// JXLDecoder+LosslessJPEG.swift
//
// Public reverse JPEG-recompression entry point — the symmetric mirror
// of `JXLEncoder.encodeLosslessJPEG(_:)`. Reconstructs the byte-identical
// original JPEG from a JPEG-XL file produced by lossless JPEG
// recompression (Phase J capstone, ISO/IEC 18181-1 Annex J / the `jbrd`
// JPEG-bitstream-reconstruction box of ISO/IEC 18181-2).
//
// The reverse pipeline itself lives in `package`-scoped types
// (`JXLToJPEGAdapter`, `JXLJPEGBridgeData`, the container/jbrd/Brotli
// helpers); this file is the single thin `public` wrapper that chains
// them so an external `import JXLSwift` consumer can reverse-transcode
// without reaching into package internals. The same sequence was
// previously assembled only inside `JXLTool/Transcode.swift`.

import Foundation

extension JXLDecoder {

/// Reverse JPEG recompression: reconstruct the **byte-identical**
/// original JPEG from a JPEG-XL file that was produced by lossless
/// JPEG recompression.
///
/// This is the public mirror of ``JXLEncoder/encodeLosslessJPEG(_:)``
/// — the round trip `encodeLosslessJPEG` → `decodeLosslessJPEG`
/// returns the source JPEG bit-for-bit (no generational loss). It
/// also reverses files produced by `cjxl --lossless_jpeg=1`.
///
/// The reconstruction is **autonomous**: every input is recovered
/// from the JXL itself — the DCT coefficient planes, the RAW slot-0
/// quant table, the chroma subsampling, the colour transform and the
/// codestream ICC profile (all via the VarDCT bridge decode) — plus
/// the container's `jbrd` box (marker order, Huffman tables, scan
/// structure, APP/COM metadata). No reference to the original JPEG
/// is required.
///
/// Supported today: baseline and the common progressive JPEGs, with
/// 4:4:4 / 4:2:2 / 4:2:0 / 4:4:0 chroma, restart intervals, grayscale,
/// non-MCU-aligned dimensions, and small / uncompressed APP metadata
/// (incl. an APP2 `ICC_PROFILE` recovered from the codestream).
///
/// - Parameter jxlBytes: an ISOBMFF-wrapped JPEG-XL file carrying a
/// `jbrd` box. The box is present only when the file was produced
/// by a JPEG-bridge encoder; a naked codestream or an ordinary
/// (non-recompressed) JXL has none.
/// - Returns: the reconstructed JPEG bytes — byte-identical to the
/// source for the supported feature set.
/// - Throws:
/// - ``DecoderError/container(_:)`` if the JXL container is
/// malformed.
/// - ``DecoderError/notImplemented(_:)`` if the input is a naked
/// codestream, carries no `jbrd` box (i.e. is not a recompressed
/// JPEG and so cannot be turned back into one), uses a
/// Brotli-*compressed* metadata payload (large EXIF/XMP/ICC —
/// not yet supported), or contains a frame the autonomous
/// coefficient decoder can't handle. The decoder fails safe: it
/// throws rather than emitting wrong bytes.
public func decodeLosslessJPEG(_ jxlBytes: Data) throws -> Data {
// 1. Parse the container — the `jbrd` box only exists in ISOBMFF.
let form: JXLContainerForm
do { form = try parseJXLContainer(jxlBytes) }
catch let e as ContainerError { throw DecoderError.container(e) }
guard case .iso(let boxes) = form else {
throw DecoderError.notImplemented(
"decodeLosslessJPEG: input is a naked codestream — reverse "
+ "JPEG recompression needs the `jbrd` box from an ISOBMFF "
+ "container.")
}

// 2. Locate + read the jbrd box, then Brotli-decode its trailing
// payload (APP/COM/inter-marker/tail data).
let jbrdPayload: Data
do {
guard let p = try extractJBRDBox(from: boxes, in: jxlBytes) else {
throw DecoderError.notImplemented(
"decodeLosslessJPEG: JXL container has no `jbrd` box — "
+ "this file was not produced by JPEG recompression and "
+ "cannot be reconstructed to a JPEG.")
}
jbrdPayload = p
} catch let e as ContainerError {
throw DecoderError.container(e)
}

var r = BitReader(jbrdPayload)
var box: JBRDBox
do { box = try JBRDBoxReader.read(from: &r) }
catch let e as JBRDError {
throw DecoderError.notImplemented(
"decodeLosslessJPEG: jbrd Bundle parse failed: \(e)")
}
let brotliStart = (r.position + 7) / 8
let brotliBytes = jbrdPayload.suffix(from: brotliStart)
let decoded: Data
do { decoded = try BrotliDecoder.decode(Data(brotliBytes)) }
catch let e as BrotliError {
throw DecoderError.notImplemented(
"decodeLosslessJPEG: jbrd Brotli payload — \(e). Compressed "
+ "payloads (large EXIF/XMP/ICC metadata) are not supported "
+ "yet; common-case JPEGs (small APP0) decode today.")
}

// 3. Extract optional metadata boxes (Exif / xml — direct or
// `brob`-wrapped) so kExif / kXMP markers reconstruct exactly.
let exifBox: Data?
let xmpBox: Data?
do {
exifBox = try extractMetadataBox(
type: "Exif", from: boxes, in: jxlBytes)
xmpBox = try extractMetadataBox(
type: "xml ", from: boxes, in: jxlBytes)
} catch let e as BrotliError {
throw DecoderError.notImplemented(
"decodeLosslessJPEG: metadata box (Exif/xml) uses unsupported "
+ "Brotli-compressed encoding — \(e).")
}

// 4. Autonomous bridge decode: coefficient planes + RAW quant
// table + chroma subsampling + colour transform + codestream
// ICC. Done BEFORE distributing the payload so the recovered
// ICC can be spliced into the APP2 `ICC_PROFILE` marker.
let bridge = try decodeJPEGBridgeData(jxlBytes)

// 5. Distribute the Brotli payload across the jbrd's app/com/
// inter-marker/tail slots, splicing the external metadata.
let external = JBRDBox.ExternalMetadata(
exif: exifBox, xmp: xmpBox, icc: bridge.icc)
do { try box.distributeBrotliPayload(decoded, external: external) }
catch let e as JBRDError {
throw DecoderError.notImplemented(
"decodeLosslessJPEG: jbrd payload distribution failed: \(e)")
}

// 6. Reconstruct the byte-identical JPEG.
do {
return try JXLToJPEGAdapter.reconstruct(bridgeData: bridge, jbrd: box)
} catch let e as JXLToJPEGAdapterError {
throw DecoderError.notImplemented(
"decodeLosslessJPEG: JPEG reconstruction failed: \(e)")
}
}
}
Loading
Loading