DW-39: Support TIFF files larger than 2 GB via page-by-page streaming#151
DW-39: Support TIFF files larger than 2 GB via page-by-page streaming#151Sawraz-IS wants to merge 2 commits into
Conversation
mee-ironsoftware
left a comment
There was a problem hiding this comment.
Approving — the approach is correct and appropriately minimal (swap the oversized byte[] for a FileStream; the extracted ReadTiffFrames is a genuine cleanup), and the paths that matter for the use case (SaveAs / GetAllFrames / FrameCount) work. Verdict: fix-then-ship — a few seams around the streaming path should be addressed or documented first. No blockers. Details inline.
| // Hold the decoded pages directly. Binary is deliberately NOT set: the | ||
| // source file is larger than a single byte[] can hold, so it is | ||
| // re-encoded on demand if the raw bytes are ever requested. | ||
| _lazyImage = new Lazy<IReadOnlyList<Image>>(() => frames); |
There was a problem hiding this comment.
Major: The streaming path deliberately never sets Binary, but the Binary getter, when _binary == null, re-encodes on demand from GetFirstInternalImage() (frame 0 only) using the no-arg default encoder — which is BMP (GetDefaultImageEncoder → BmpEncoder). Consequences for any AnyBitmap produced here: GetBytes(), GetStream(), Length, ToString(), GetHashCode() return a single-page BMP (not the document, no exception), and Format/GetImageFormat() report Bmp where FromFile on the same file reports Tiff. FromTiffFile is public and documented as usable on normal files, so this is a silent divergence there. SaveAs(".tif") is fine (it hits the TiffEncoder/InternalSaveAsMultiPageTiff branch, not the shortcut). Suggest: document this on FromTiffFile, or re-encode multi-frame as multi-page TIFF here (mirror the SaveAs branch) / throw a clear "raw bytes unavailable for streamed TIFF" rather than returning a misleading page 0. Add a test asserting content/GetImageFormat(), not just FrameCount+dims.
| List<Image> images = new(); | ||
| // open a TIFF stored in the stream | ||
| using (Tiff tiff = Tiff.ClientOpen("in-memory", "r", tiffStream, new TiffStream())) | ||
| short num = tiff.NumberOfDirectories(); |
There was a problem hiding this comment.
Major (edge): short num = tiff.NumberOfDirectories() with for (short i ...) caps the loop at 32,767 pages. A TIFF with more directories overflows negative, the loop never runs, frames.Count == 0, and LoadLargeTiffFromFile throws "contained no decodable image pages" — a misleading error for exactly the huge multi-page files this feature targets (the 3.8 GB validation file had only 1,200 pages, so this was never exercised). LibTiff.NET's SetDirectory(short) is short-typed upstream, but this should be detected and reported, not silently mis-labeled. Suggest iterating with do { ... } while (tiff.ReadDirectory()) (index as int), or explicitly throwing an actionable message when the directory count exceeds short.MaxValue.
| // therefore File.ReadAllBytes) cannot exceed ~2 GB. Files above this | ||
| // threshold are routed to a streaming loader for TIFF, or rejected with a | ||
| // clear message for formats that have no page-based streaming decoder. | ||
| private const long MaxInMemoryFileBytes = 2_000_000_000L; |
There was a problem hiding this comment.
Minor: MaxInMemoryFileBytes = 2_000_000_000, but File.ReadAllBytes actually succeeds up to Array.MaxLength (~2,147,483,591). A non-TIFF file between those two values now throws NotSupportedException even though the old code loaded it — a silent capability reduction (rare, and the safe direction, but unflagged). Suggest setting the constant to the real Array.MaxLength, or commenting that the gap is an intentional safety margin.
| if (IsTiffFile(file)) | ||
| { | ||
| // Stream the TIFF page-by-page; never materialise the whole file. | ||
| LoadLargeTiffFromFile(file); |
There was a problem hiding this comment.
Minor: preserveOriginalFormat is dropped on the streaming route — FromFile(bigTiff, preserveOriginalFormat: false) is ignored, and streamed pages are always decoded RGBA. Likely immaterial for TIFF, but it's a silent inconsistency between the two routes of the same overload. Thread the flag through, or note in the remarks that it doesn't apply to the streaming path.
| var (width, height, horizontalResolution, verticalResolution) = SetWidthHeight(tiff, i, ref imageWidth, ref imageHeight, ref imageXResolution, ref imageYResolution); | ||
| // A single page is still decoded into one RGBA buffer, so its pixel | ||
| // count is bounded by the .NET single-array index limit. Multi-page | ||
| // files of any total size are fine as long as each page fits. |
There was a problem hiding this comment.
Nit: These comments (and the ReadTiffFrames remarks) say "the whole file is never buffered" / "per-page memory stays bounded", but all decoded pages are retained simultaneously in the returned List<Image> and held in _lazyImage. Decode is one page at a time; retention is all-pages, so total footprint still scales with page count × decoded page size. The PR body is honest about this — the code comments should match it. (The "~184 MB peak managed heap" figure for 1,200 retained RGBA pages is also likely understated, since ImageSharp pixel buffers are pooled/unmanaged and not counted in the managed heap.)
…ge loop from PR review
DW-39: Native support for TIFF files larger than 2 GB
Problem
AnyBitmaploaded every file throughFile.ReadAllBytes, which is capped at ~2 GB by .NET's 32-bit array index. TIFF files above that size could not be opened at all — the load failed before any decoding could happen. Customers had to split large TIFFs externally before processing.Change
Add a streaming loader that reads the file with a
FileStreamand decodes one TIFF directory (page) at a time via LibTiff (already referenced), so the whole file is never materialised as a singlebyte[].ReadTiffFrames(Tiff)shared by both the in-memory and streaming paths.AnyBitmap.FromTiffFile(string)streams a TIFF from disk page-by-page.FromFile/ thestringconstructors auto-route TIFF files above the in-memory size limit to the streaming loader, and raise a clear, actionable exception for oversized non-TIFF formats instead of the opaque .NET array-size error.0x2B) in addition to classic TIFF (0x2A), in both little- and big-endian — large multi-gigabyte TIFFs use the BigTIFF layout.The file size is no longer the constraint; only an individual page must fit within one decode buffer.
Validation
FromTiffFile_*tests).Note for reviewers
AnyBitmapmaterialises every frame it holds, so loading all pages of a very large multi-page TIFF at once still scales total RAM with page count × page size. The hard single-buffer (int-indexed array) limit — the actual blocker — is removed; per-page memory stays bounded.