A JPEG encoder that agrees with Skia, byte for byte #140
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
TrickplayInfos.Bandwidthisceil(tile_bytes * 8 / interval_seconds / tiles_wide / tiles_high)— a pure function of the encoded tile's byte length. Measured on the fixture: a 110,967-byte tile gives exactly the 888 upstream stored. So a one-byte difference in the JPEG changes a column the harness diffs.Upstream encodes through SkiaSharp at quality 90, which is libjpeg-turbo underneath. Matching that means an encoder whose output is byte-identical: the same quantisation tables, the same Huffman tables, the same subsampling, the same restart-marker and progressive decisions.
For scale,
crates/jellyfin-images/src/jpeg.rsis the comparable decoder and runs to 1,804 lines. The project has already met this shape once — M10's blur hash needed Skia's resampler reproduced exactly, and the notes there record that the scale factor is applied twice, that the sampling is nearest with a float reciprocal nudged one unit in the last place, and that fancy chroma upsampling is off below a two-pixel block. An encoder is the same kind of problem, larger.Why it is worth doing at all
Without it, trickplay sheets can be generated and served — a client gets working scrub previews — but one column of one table cannot agree and needs an ignore entry. #99 proposes exactly that as its first step. This issue is what retires the entry.
It would also close the position
collage.rscurrently records: that a picture this server draws is not upstream's bytes. Genre collages have the same property, so this is not trickplay's alone.Where to start
crates/jellyfin-images/src/jpeg.rsfor the decoder's conventions and the project's existing habits about matching a C++ library exactly. Upstream's side is SkiaSharp'sSKImage.Encode(SKEncodedImageFormat.Jpeg, 90).Settle first, before writing anything: what does Skia actually emit at quality 90 — tables, subsampling, and whether it writes progressive — measured off a tile upstream produced, rather than read out of libjpeg's documentation.
How to know it is done
A tile this server encodes is byte-identical to the one upstream encoded from the same frames, and #99's
Bandwidthignore entry is deleted rather than re-argued.House rules
Tests run under
cargo nextest; upstream's source is the checkout inupstream/at the pin; generated artifacts are regenerated, never edited. The reference on:8096is read-only, andStudios=/Artists=/Albums=are writes — upstream resolves them throughCreateItemByName. Anything needing a write gets its own container from the pinned image over acp -acopy of a snapshot. Kill a server by the PIDss -lntpH 'sport = :NNNN'reports; neverpkill,killall, orpgrep -fwith a pattern your own command line contains.Done, on
main, and it converged — this was the batch's designated drop and didnot have to be dropped.
crates/jellyfin-images/src/jpeg_encode.rsis theencoder beside the 1,804-line decoder.
What Skia emits at quality 90, measured off upstream's own tile
SOF0), not progressive.APP0v1.1, density unit 0, 1×1, no thumbnail. No AdobeAPP14, noCOM.h2 v2luma,1 1chroma), component ids 1/2/3, quantisationtables 0/1/1.
200 - 2*quality= 20, i.e.(base*20 + 50)/100.Luma zig-zag reads
3,2,2,3,2,2,3,3,…, chroma3,4,4,5,4,5,9,5,…— checkedagainst the DQT markers of upstream's sheet.
(
optimize_coding = TRUE).Ss=0 Se=63 Ah=Al=0. Marker orderSOI, APP0, DQT(0), DQT(1), SOF0, DHT(DC0), DHT(AC0), DHT(DC1), DHT(AC1), SOS, …, EOI.Two rules that are not what libjpeg's documentation says
Both were found by sweeping sizes rather than by reading, and both were then
confirmed against libjpeg's own sources.
in the MCU, whatever edge it sits on — not the documented "copy the block to
the left, or the block above". Decoding both files' entropy streams and
pulling the DC per block gives
-168, -62, -62, -62where the documented rulegives
-168, -62, -168, -62. Every visible pixel is identical either way; theDC symbol histogram is not, which changes the optimised table, which changes
the file. 33×17 and 101×7 decoded to a maximum pixel difference of 0 and still
differed by 4 and 9 bytes.
then repeat the last averaged row — not average repeated source rows, which
is a different thing when the height is even. 160×90 was the case that showed
it: 12,426 bytes against 12,384, maximum pixel difference 31.
Verification
The nine frames upstream's own run cut are committed beside the test
(
crates/jellyfin-images/tests/sheet.rs); laid out and encoded at 90 they give110,967 bytes,
sha256 0f1d74b1…d9c3d782— upstream's sheet exactly. End toend, from the real
.mkvthrough our ffmpeg, the file our server writes iscmp-identical to upstream's andBandwidthis 888.The review round swept 61 images × 13 qualities = 793 encodes against real
libjpeg-turbo 3.2.0 (
cjpeg -baseline -optimize -sample 2x2 -dct int): all 793byte-identical. That covers 1×1, 1×2, 2×1, 100×1, 1×100, odd width and odd
height, grayscale content fed as RGB, flat colour, a black/white checkerboard,
and qualities 1, 2, 5, 10, 25, 49, 50, 51 — both sides of the
5000/q↔200-2qbranch — 75, 90, 95, 99 and 100. Both quantiser clamps are right,including that
force_baselinecaps at 255.It also read the encoder line by line against
jcparam.c,jccolor.c,jcsample.c,jcprepct.c,jcdctmgr.c,jcdctint.c,jchuff.candjcmarker.c: marker order, DQT/DHTsent_tablede-duplication, thesi-vs-bitsdifference in code assignment, the guarded
flush_bits, and theFIX()truncations all agree.
Two things worth writing down
The answer keys are generated with
-baseline.cjpegdefaults toforce_baseline=FALSEand emits 16-bit quantisation tables below quality ~24,which is not what Skia asks for. Regenerating without it will produce a phantom
disagreement. This is in
tests/edges.rs's module doc.Rule (2) had no coverage until the review round. It is a no-op unless the
height is even, not a multiple of 16, and the last two rows differ — and all six
originally committed pictures failed one of those, so mutating the rule left both
answer-key tests green while failing 482 of the 793 sweep.
tests/edges/24x18was added for exactly that shape, and the mutation now goes red.
The
BandwidthentryOpened in
7f31074and deleted in3cd112d, rather than argued —db-ignore-list.jsonis byte-for-byte unchanged across the batch.Bandwidthitself is now derived in one place,
jellyfin_core::image::sheet_bandwidth.Filed forward
#149 — the encoder is exact and only trickplay uses it. The served-image path
still encodes through
image::codecs::jpeg, so the positioncollage.rsrecordsis not yet closed; what stands between them is the resampler, not the encoder.
Not a defect but worth knowing before someone bumps ffmpeg: the host's n9.0.1 and
upstream's jellyfin-ffmpeg 8.1.2-2 produce thumbnails that differ by one byte
each — the
COMmarker's encoder string — and nothing else. Byte-exactnesshere does not depend on having jellyfin's ffmpeg build.