Add DeepZoom static preview services and samples#379
Open
mattleibow wants to merge 92 commits intomainfrom
Open
Add DeepZoom static preview services and samples#379mattleibow wants to merge 92 commits intomainfrom
mattleibow wants to merge 92 commits intomainfrom
Conversation
Extract the core DeepZoom rendering system from PR #378: - DeepZoom collection/image source parsers (DZC/DZI XML) - Tile cache, scheduler, and HTTP/file fetchers - Controller (orchestrates loading), viewport (centered-fit geometry), renderer (draws tiles) - 436 unit tests (animation tests excluded — no dependency on gestures/animations) - Blazor sample page (Deep Zoom Preview) with testgrid DZI static asset - MAUI sample page (Deep Zoom Preview) with testgrid DZI app-package asset - Documentation: deep-zoom.md, deep-zoom-blazor.md, deep-zoom-maui.md - Solution file updated to include DeepZoom test project - DeepZoom added to MAUI ExtendedDemos and Blazor NavMenu No gestures, animations, or custom controls included. This is a minimal static preview system for gigapixel images. Images render centered-fit (aspect ratio preserved, no cropping/stretching). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
📖 Documentation Preview The documentation for this PR has been deployed and is available at: This preview will be updated automatically when you push new commits to this PR. This comment is automatically updated by the documentation staging workflow. |
… add README - Blazor: remove duplicated testgrid assets from wwwroot/deepzoom/ - Blazor: load DZI directly from GitHub raw URL (no local duplication) - Blazor: switch SKCanvasView → SKGLView for hardware-accelerated rendering - Controller: SetControlSize() now refits viewport when canvas size changes, ensuring centered-fit works correctly in any aspect ratio (portrait/landscape) - DeepZoom: add README.md with architecture overview and Mermaid diagrams showing component relationships, tile pipeline, and rendering flow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- SKDeepZoomTileRequest: correct fields are {TileId, Priority}, not IsFallback/FallbackParent
- SKDeepZoomCollectionSubImage.Source is string? (URI path), not SKDeepZoomImageSource
- Remove incorrect inheritance arrow from SubImage → ImageSource
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Blazor (DeepZoom.razor): - Canvas now fills available viewport height using flexbox + calc(100vh) - Added collapsible debug inspector sidebar showing: image source info (dimensions, tile size, levels, format, aspect ratio), viewport state (control size, origin, scale, zoom), tile cache stats (count/max/pending), and a live tile table with level/col/row/priority/cached status - Added PendingTileCount property to SKDeepZoomController for inspector - Inspector refreshes every 250ms during tile loading - Window resize triggers canvas invalidate via JS interop - Toggle button (fixed bottom-right) shows/hides the inspector MAUI (DeepZoomPage.xaml.cs): - Removed AppPackageFetcher and embedded testgrid assets - Now fetches DZI and tiles from GitHub raw URL (same as Blazor) - Removed TestGrid/ from Resources/Raw/ Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add deepZoomUnregisterResize JS function to remove the resize handler; call it in DisposeAsync so listeners don't accumulate on page navigation - Switch Blazor page from IDisposable → IAsyncDisposable to cleanly await the JS interop call during disposal - Use parameterless SKDeepZoomHttpTileFetcher() in both Blazor and MAUI samples so the fetcher owns and disposes its HttpClient (_ownsClient=true) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Blazor: zoom slider (0.1–10×) in toolbar + mouse drag to pan - Reset view button returns to fit-to-view - Inspector zoom label auto-syncs with viewport state - MAUI: Slider in toolbar for zoom + PanGestureRecognizer for drag pan - Toolbar shows current zoom level label - Both samples call controller.Pan() and controller.SetZoom() APIs - Bare minimum interaction for testing tile loading at various zoom levels Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Remove pan direction inversion in both Blazor and MAUI samples - Fix MAUI pan to compute frame-to-frame delta from TotalX/TotalY Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Remove MaxViewportWidth = fitWidth from FitToView() so users can zoom out beyond the initial fit without snapping back - Track _userHasZoomed in controller; SetControlSize no longer resets the viewport to fit when the user has manually adjusted zoom/pan - ResetView() and Load() clear the flag to restore auto-refit behaviour - All pan/zoom methods set the flag Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
FitToView() no longer sets MaxViewportWidth to the fit width, so users can zoom out past the initial fit level. Update the test to assert double.MaxValue and update the comment accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Zoom range: - Blazor and MAUI slider max increased 10x → 50x (step 0.05) - Native 1:1 pixel zoom for 8192px image on 1500px canvas is ~5.46x, so 50x allows substantial upscaling to inspect tile quality Inspector - new 'Level Selection' section: - Current level and max level (e.g. '13 / 13⚠️ ') - Level dimensions (e.g. '8192 × 8192 px') - Tile actual size (e.g. '256 × 256 px' - the stored file dimensions) - Tile on screen size (pixels the tile occupies at current zoom) - Native 1:1 zoom indicator - Warning message when at max level: 'Max detail — native resolution reached, zooming further upscales tiles' Level selection explanation (why it stays at level 13 from zoom 2.73+): The testgrid is 8192×8192, max level = 13. GetOptimalLevel selects the lowest level where levelWidth > controlWidth/viewportWidth. For a 1500px canvas, level 13 (8192px) is selected at zoom ≥ 8192/1500/~2 ≈ 2.73x. Beyond that there are no higher levels, so it stays at 13 — correct. Testgrid DZI has Overlap=0 (tiles were generated without overlap pixels). The generate-dzi.cs script already defaults to overlap=1 for new images. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Inspector bug fix: - tilePxH was computed as (TileSize * Scale / levelH) * AspectRatio which is wrong for non-square images. Correct formula divides by AspectRatio: tilePxH = TileSize * Scale / levelH / AspectRatio Testgrid regeneration: - Recreated 8192x8192 source image as a colored grid pattern with cell labels (using PIL in a helper script) - Ran scripts/generate-dzi.cs with --overlap 1 to generate all 14 levels (0-13) with 1px tile overlap as the user requested - testgrid.dzi now has Overlap='1' instead of Overlap='0' - Also committed scripts/generate-dzi.cs to this branch for reproducibility Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Previously the⚠️ appeared whenever level 13 was selected, even at zoom 2.73x where it is the correct level and no upscaling is happening. Now 'isUpscaling' is true only when zoom > nativeZoom (the 1:1 pixel mapping threshold, ≈ imageWidth / controlWidth). This means the warning appears only when the renderer would need a higher level than maxLevel but none exists — i.e. tiles are genuinely being upscaled. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Both Blazor and MAUI samples now bake the current git branch into the
assembly at build time via AssemblyMetadataAttribute('GitBranch', ...).
At runtime the URL is constructed from the embedded branch name, so:
- main builds fetch tiles from main
- PR builds fetch tiles from their own branch
Auto-detection: A 'DetectGitBranch' MSBuild target runs 'git rev-parse
--abbrev-ref HEAD' before the build. Falls back to 'main' if git is not
available or the property is explicitly overridden by CI via -p:GitBranch.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Previous approach added AssemblyAttribute items inside a target's
<ItemGroup> before GenerateAssemblyInfo — but GenerateAssemblyInfo
had already captured the item list at evaluation time, so GitBranch
was always 'main'.
New approach: a 'DetectAndWriteGitBranch' target (BeforeTargets=CoreCompile)
writes a small generated .cs file to the intermediate output directory and
adds it to the Compile item group at target execution time. This is picked
up by CoreCompile (the actual C# compiler), so the branch value is correct.
Also fixed:
- MAUI csproj had unclosed <PropertyGroup> tag (Bug 1)
- Detached HEAD fallback ('HEAD' → 'main') for CI shallow checkouts (Bug 3)
Verification on branch 'feature/deep-zoom-static-preview':
obj/.../GitBranchInfo.cs contains 'feature/deep-zoom-static-preview'
The DLL contains the correct branch name at offset 90216
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Reads GitHub Actions (GITHUB_HEAD_REF, GITHUB_REF_NAME) env vars - Reads Azure DevOps (BUILD_SOURCEBRANCH) env var with refs/heads/ stripping - Falls back to local git, then to 'main' - Config embedded as resource, read at runtime via GetManifestResourceStream - Removes AssemblyMetadataAttribute/GitBranchInfo.cs approach Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Root cause: the right edge of tile N was computed as (float)topLeft.X + (float)(screenRight - topLeft.X) while the left edge of tile N+1 was independently computed as (float)screenRight Float arithmetic makes (float)A + (float)(B-A) ≠ (float)B in general, producing sub-pixel gaps (measured 3e-5 to 6e-5px) that flickered as the viewport moved. IIIF images showed this visibly since they have no overlap to mask seams; DZI was less affected due to the 1px tile overlap. Fix: pixel-snap all four corners in GetTileDestRect using Math.Floor for the top-left and Math.Ceiling for the bottom-right. This guarantees that adjacent tiles share the same integer pixel boundary: - ceil(shared_boundary) ≥ floor(shared_boundary) always holds (no gap) - At most 1px overlap (tile N+1 covers tile N's last pixel) - Seamless appearance regardless of viewport position or zoom level Add two regression tests: - TileLayout_GetTileDestRect_AdjacentIiifTiles_NoGap: verifies no gap with non-zero viewport origin (the worst-case for float precision drift) - TileLayout_GetTileDestRect_AdjacentDziTiles_NoGap: verifies DZI still has no gap (overlap ≥ 0) after the change Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Restore resources/collections/testgrid/ to exactly match main. The PR had deleted all tile levels below 14-15 and created a fresh testgrid.dzi; this restores the full set of levels (0-15) and the original testgrid.dzi (no trailing newline) from main. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…Point<T> - Add Rect<T> and Point<T> as readonly record structs (where T : struct). No INumber<T> constraint so both netstandard2.0 and net9/10 are supported. record struct auto-generates Deconstruct, so existing tuple-pattern code in tests (var (x, y, w, h) = ...) is unchanged. - Remove SKImagePyramidRectI and SKImagePyramidRectF (deleted files). - Update ISKImagePyramidSource.GetTileBounds: SKImagePyramidRectI → Rect<int> - Update ISKImagePyramidRenderer.DrawTile/DrawFallbackTile: SKImagePyramidRectF → Rect<float> - Update SKImagePyramidDziSource, SKImagePyramidIiifSource: return Rect<int> - Update SKImagePyramidTileLayout: return Rect<float>, replace .Right/.Bottom with inline X+Width/Y+Height arithmetic - Update SKImagePyramidViewport: ElementToLogicalPoint/LogicalToElementPoint return Point<double>; GetZoomRect returns Rect<double> - Update SKImagePyramidSubImage: GetMosaicBounds returns Rect<double>; ParentToLocal/LocalToParent return Point<double> - Update SKImagePyramidController.GetZoomRect: return Rect<double> - Update SKImagePyramidRenderer.DrawTile/DrawFallbackTile: Rect<float> params, inline right/bottom arithmetic - Update DebugBorderRenderer: Rect<float> list and params, inline arithmetic - Update edge-case tests: replace .Right with .X + .Width assertions All 463 tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace deleted SKImagePyramidRectI and SKImagePyramidRectF with the new generic Rect<int> and Rect<float> in controller, iiif, and deepzoom docs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
## Bugs fixed
### HIGH: GetVisibleTiles returns spurious tiles outside image bounds
After clamping pixel coords to level bounds, add early-out when
pixelRight <= pixelLeft or pixelBottom <= pixelTop. Prevents tile (0,0,0)
from being fetched when viewport is entirely to the left or above the image.
Regression tests: GetVisibleTiles_ViewportEntirelyLeftOfImage_ReturnsEmpty,
GetVisibleTiles_ViewportEntirelyAboveImage_ReturnsEmpty
### HIGH: SKImagePyramidRenderer hard-casts ISKImagePyramidTile → crash with custom decoders
Replace bare (SKImagePyramidImageTile)tile with 'tile is not SKImagePyramidImageTile; return'.
Custom decoders producing a different ISKImagePyramidTile implementation no longer throw
InvalidCastException — the renderer silently skips tiles it cannot handle.
### HIGH: IIIF scale factor ≤ 0 silently corrupts all pyramid geometry
A scale factor of 0 caused (int)(+∞) = int.MinValue in GetLevelWidth/Height,
poisoning tile counts and dest rects. Two defences added:
1. ParseDocument: filter non-positive values from JSON scaleFactors before use.
2. Constructor: throw ArgumentException if any scale factor ≤ 0.
Regression test: IiifSource_ZeroScaleFactor_ThrowsArgumentException
### HIGH: Tile resource leak — TOCTOU race in LoadTileAsync → PutAsync
The check '!ct.IsCancellationRequested' at line 453 and the same check inside
PutAsync created a window where the tile was neither stored nor disposed.
Fix: pass CancellationToken.None to PutAsync — the store decision was already
made; there is no reason to re-check cancellation inside PutAsync.
### HIGH: Infinite retry storm for permanently failing tiles (HTTP 404 etc.)
LoadTileAsync already received null from FetchTileAsync but never recorded it,
so ScheduleTileLoads re-queued the same tile every render frame (up to 60×/s).
Fix: Add _failedTiles ConcurrentDictionary. When FetchTileAsync returns null,
record in _failedTiles and return. ScheduleTileLoads skips tiles in _failedTiles.
_failedTiles is cleared on Load() and ReplaceCache() so retries happen on reload.
### MEDIUM: GetZoomRect returns wrong visible height
GetZoomRect used viewportWidth/AspectRatio which is the image's logical height,
not the actual visible height of the control. For any control shape other than
matching the image aspect ratio, the result was wrong.
Fix: height = viewportWidth * controlHeight / controlWidth (same as ViewportHeight).
Updated 5 tests that were written to match the buggy behavior.
### MEDIUM: Use-after-dispose of fetcher in LoadTileAsync
Background tasks captured _tileSource/_fetcher as fields, creating a window
where Dispose/Load could replace them after a task passed its cancellation check.
Fix: capture source and fetcher as local variables at the start of LoadTileAsync.
All 466 tests pass.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
## Bugs fixed ### HIGH: SKPathEffect leaked every render frame in debug zoom SKPathEffect.CreateDash() returns a native handle that SKPaint.Dispose() does NOT dispose. Extracted to 'using var dashEffect' before the paint so it is properly disposed after DrawRect. ### HIGH: BrowserStorageTileCache leaks overwritten tile AddToMemIndex used ContainsKey then _memIndex[id] = tile without disposing the old entry. Changed to TryGetValue + conditional dispose (with reference equality guard to avoid self-dispose). ### MEDIUM: OnExampleSelected missing concurrent-load guard Added 'if (_urlLoading) return;' at entry so rapid example selection while a load is in progress is ignored. ## Docs fixed (7 issues) - controller.md:164 — Rect<float> has no .Right/.Bottom; use X+Width/Y+Height - controller.md:423 — SKImagePyramidTileScheduler → SKImagePyramidTileLayout - caching.md:91 — controller.Render(e.Surface.Canvas) → controller.Render(renderer) - blazor.md:123 — SKImagePyramidHttpTileFetcher() missing decoder argument - blazor.md:154 — FitToView() → ResetView() (method was renamed) - maui.md:207 — FitToView() → ResetView() ## Tests - Renamed TileSchedulerTest.cs → TileLayoutTest.cs (class inside was TileLayoutTest) - Added GeometricTypesTest.cs: 16 tests covering Rect<int/float/double> and Point<int/float/double> construction, equality, deconstruct, default values, and inline right/bottom arithmetic patterns. 482 tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…Image, Rect<float> with SKRect - Delete SkiaSharp.Extended.Abstractions project (merge all files into SkiaSharp.Extended) - Delete ISKImagePyramidTile, ISKImagePyramidTileDecoder, SKImagePyramidImageTile, SKImagePyramidImageTileDecoder - ISKImagePyramidTileFetcher/Cache/Renderer now use SKImage directly - SKImagePyramidHttpTileFetcher/FileTileFetcher inline SKImage.FromEncodedData, no decoder param - SKImagePyramidTileLayout returns SKRect instead of Rect<float> - SKImagePyramidRenderer simplified: no type check, SKImage+SKRect directly - Update tests, Blazor sample (BrowserStorageTileCache, DelayTileCache, DebugBorderRenderer, ImagePyramid.razor) - Delete GeometricTypesTest.cs (tests were for now-internal Rect<T> types) - All 466 tests pass Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Fix ImagePyramidPage.xaml.cs: 3 SKImagePyramidHttpTileFetcher() calls no longer pass decoder - caching.md: replace ISKImagePyramidTile with SKImage in interface listing and all examples - fetching.md: remove ISKImagePyramidTileDecoder injection pattern, inline SKImage.FromEncodedData - controller.md: fix HttpTileFetcher calls and TileBorderRenderer decorator example (SKRect/SKImage) - index.md: remove Abstractions note, remove decoder from mermaid/table, fix quick-start code - maui.md: fix AppPackageFetcher class, HttpTileFetcher constructor, usage snippets - deepzoom.md / iiif.md / blazor.md: fix HttpTileFetcher constructor calls Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…lesystem + browser caches - Add SKImagePyramidTile sealed class holding SKImage + RawData (byte[]) - No re-encoding needed for L2 caches; raw bytes stored alongside decoded image - Implements IDisposable, disposing the inner SKImage - Update fetcher pipeline to buffer bytes before decoding (handles forward-only streams) - SKImagePyramidHttpTileFetcher: ReadAsByteArrayAsync then FromEncodedData - SKImagePyramidFileTileFetcher: ReadAllBytes then FromEncodedData - Update all interfaces: ISKImagePyramidTileFetcher, ISKImagePyramidTileCache, ISKImagePyramidRenderer now use SKImagePyramidTile instead of bare SKImage - Add SourceId to ISKImagePyramidSource (FNV-1a hash of URI + dimensions) Implemented in DziSource, IiifSource, DziCollectionSource - SKImagePyramidController now requires ISKImagePyramidTileCache in constructor - Add SKImagePyramidFileSystemTileCache: two-tier memory+disk cache Stores tile.RawData to disk, re-decodes on L2 hit, promotes to L1 memory cache - Add SkiaSharp.Extended.UI.Blazor project with SKImagePyramidBrowserStorageTileCache Stores tile.RawData as base64 in sessionStorage (no re-encoding) - Update Blazor sample: BrowserStorageTileCache uses tile.RawData, DelayTileCache and DebugBorderRenderer updated to SKImagePyramidTile - Update MAUI sample: ImagePyramidPage passes SKImagePyramidMemoryTileCache to controller - Update all 466 tests (all passing) - Update docs: fetching.md, caching.md, controller.md, maui.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Fix caching.md: update all code block signatures from SKImage? to SKImagePyramidTile? (interface listing, TieredCache example, BoundedDictionaryCache example) - Fix TOCTOU race in SKImagePyramidFileSystemTileCache._cleanupRunning: change bool to int, use Interlocked.CompareExchange to guard cleanup entry and Interlocked.Exchange(ref _cleanupRunning, 0) in finally block - Cache SourceId in all 3 source types (lazy _sourceId ??= FnvHash(...)) to avoid recomputing the hash on every property access Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…lders; add SourceId flow and cache expiry
Subfolder reorganization (git mv, history preserved):
- ImagePyramid/Caching/: ISKImagePyramidTileCache, SKImagePyramidMemoryTileCache, SKImagePyramidFileSystemTileCache
- ImagePyramid/Fetching/: ISKImagePyramidTileFetcher, SKImagePyramidHttpTileFetcher, SKImagePyramidFileTileFetcher, SKImagePyramidTile
- ImagePyramid/Sources/: ISKImagePyramidSource, SKImagePyramidDziSource, SKImagePyramidIiifSource, SKImagePyramidDziCollectionSource, SKImagePyramidDziCollectionSubImage, SKImagePyramidSubImage, SKImagePyramidDisplayRect
- Namespaces unchanged (SkiaSharp.Extended)
SourceId flow through tiles:
- SKImagePyramidTile: add SourceId property (optional constructor param, default "")
- ISKImagePyramidTileCache: add ActiveSourceId { get; set; } to interface
- SKImagePyramidMemoryTileCache: implement ActiveSourceId (ignored, stored only)
- SKImagePyramidFileSystemTileCache: remove sourceId from constructor; use ActiveSourceId for directory namespacing; WriteToDiskAsync uses tile.SourceId
- SKImagePyramidController.Load(): sets _cache.ActiveSourceId = source.SourceId
- SKImagePyramidController.LoadTileAsync(): stamps tile.SourceId from source after fetch
- BrowserStorageTileCache (sample + library): implement ActiveSourceId (ignored)
- DelayTileCache (sample): delegate ActiveSourceId to inner cache
Cache expiry:
- ISKImagePyramidSource: add CacheExpiry { get; } property
- SKImagePyramidDziSource: CacheExpiry = null (static content, cache forever)
- SKImagePyramidIiifSource: CacheExpiry = 7 days (server content may update)
- SKImagePyramidDziCollectionSource: CacheExpiry = null (static content)
- SKImagePyramidFileSystemTileCache: add Expiry property (default 30 days via DefaultExpiry static); TryGetAsync deletes and returns null for expired tiles
- SKImagePyramidController.Load(): applies source.CacheExpiry to filesystem cache if applicable
Tests: 472 passing (+6 new tests for FileSystemTileCache expiry, ActiveSourceId, and SourceId)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…settable, Clear deletes all
- SKImagePyramidFileSystemTileCache: rename static DefaultExpiry -> DefaultMaxExpiry;
add public DefaultExpiry instance property storing ctor value;
initialize Expiry = DefaultExpiry in ctor (eliminates fragile _expiry fallback)
- TryGetAsync: use Expiry directly (no more effectiveExpiry fallback logic)
- Clear(): delete entire _basePath instead of active source subdirectory only
- SKImagePyramidController.Load(): always set fsCache.Expiry using
'tileSource.CacheExpiry ?? fsCache.DefaultExpiry' so expiry resets
correctly when switching from IIIF (7 days) back to DZI (no expiry)
- SKImagePyramidTile.SourceId: change to { get; internal set; } so controller
can stamp SourceId without creating a redundant wrapper object
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…controller The controller no longer manages disk caching or the fetch pipeline. Instead it holds an internal memory render buffer and delegates all tile acquisition to ISKImagePyramidTileProvider. Key changes: - Add ISKImagePyramidTileProvider interface (GetTileAsync(url, ct)) - Add SKImagePyramidHttpTileProvider: HTTP fetch + optional URL-keyed disk cache using FNV-1a hash; expiry-aware; atomic writes via tmp file - Add SKImagePyramidFileTileProvider: local file read, no disk cache (the file IS the source) - SKImagePyramidController: parameterless constructor, internal _renderBuffer (256-entry memory cache), Load/ReplaceProvider API. No more TryGetAsync/PutAsync in LoadTileAsync. - Remove ActiveSourceId from ISKImagePyramidTileCache and memory cache (source namespacing now handled by URL-keyed provider) - Blazor sample: DelayTileProvider + BrowserStorageTileProvider replace DelayTileCache + BrowserStorageTileCache; same decorator pattern - MAUI sample: parameterless controller, HttpTileProvider - All 472 tests pass Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Delete ISKImagePyramidTileFetcher, SKImagePyramidHttpTileFetcher, SKImagePyramidFileTileFetcher (replaced by provider pattern) - SKImagePyramidHttpTileProvider: ThrowIfCancellationRequested at entry; rethrow OperationCanceledException when ct.IsCancellationRequested so cancelled tiles are not blacklisted in _failedTiles. HTTP timeouts (TaskCanceledException with ct not cancelled) still return null. - Fix TileFetcherTest and TileFetchersTest: cancellation now expects OperationCanceledException instead of null - Fix controller XML doc: ISKImagePyramidTileFetcher → ISKImagePyramidTileProvider - Fix SKImagePyramidFileSystemTileCache XML doc: remove stale ISKImagePyramidTileCache.ActiveSourceId reference - Rewrite fetching.md to describe ISKImagePyramidTileProvider pattern - Update index.md, controller.md, blazor.md, maui.md, iiif.md, deepzoom.md: replace all fetcher references with provider equivalents All 472 tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- ISKImagePyramidTileCache is now sync-only: remove TryGetAsync, PutAsync, and nullable tile parameter from Put. The interface represents only the controller's internal hot render buffer, not a general-purpose cache tier. - SKImagePyramidMemoryTileCache is now internal sealed: users never create it directly; the controller owns it. Expose via InternalsVisibleTo for tests. - Delete SKImagePyramidFileSystemTileCache: orphaned dead code. Disk caching now lives inside HttpTileProvider (or custom provider decorators). - Delete SKImagePyramidBrowserStorageTileCache from the Blazor library: replaced by BrowserStorageTileProvider in the Blazor sample, which correctly implements ISKImagePyramidTileProvider instead. - Add source/SkiaSharp.Extended/Internals.cs with InternalsVisibleTo for the test assembly so tests can still unit-test SKImagePyramidMemoryTileCache. - TileCacheTest: replace null tile placeholders with real SKImagePyramidTile instances via MakeTile() helper; delete FileSystemTileCacheTest class; migrate the two tile-identity tests into TileCacheTest. - Update caching.md to reflect the new two-concern design: render buffer (controller-owned, sync) vs persistent storage (provider-owned, async). All 468 tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- controller.TileSource → controller.Source (3 files) - controller.ReplaceProvider() → controller.SetProvider() (1 file) - SKImagePyramidFileTileProvider → SKTieredTileProvider(new SKFileTileFetcher()) (3 files) - SKImagePyramidHttpTileProvider → SKTieredTileProvider(new SKHttpTileFetcher()) (3 files) - Remove SourceId tests (constructor param removed) (1 file) - Add tests for new nullable rawData constructor Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Split the monolithic HttpTileProvider (which mixed HTTP fetching with disk caching) into separate, composable interfaces: - ISKTileFetcher: pure origin fetch (HTTP, file, composite) - ISKTileCacheStore: persistent storage (disk, null, chained) - SKTieredTileProvider: composes fetcher + cache store - SKImagePyramidTileData: shared data wrapper for the pipeline - TileFailureTracker: exponential backoff replacing permanent blacklist Controller API changes: - SetProvider()/Load() decoupled (provider set once, sources swap freely) - Source/Provider exposed as read-only properties - Memory cache injectable via constructor for testing/observability - RetryFailedTiles() for clearing transient failures Other changes: - SKImagePyramidTile.RawData now nullable (render-only tiles) - SourceId removed from tiles (URL-keyed caching sufficient) - SKImagePyramidMemoryTileCache promoted to public - DZI Parse(xml, Uri) auto-derives TilesBaseUri from convention - Old SKImagePyramidHttpTileProvider/FileTileProvider removed Platform composition is now trivial: - Desktop: SKHttpTileFetcher + SKDiskTileCacheStore - WASM: SKHttpTileFetcher + custom IndexedDbTileCacheStore - MAUI bundled: MauiAssetTileFetcher (no cache needed) - MAUI hybrid: SKCompositeTileFetcher(Asset, Http) + DiskCache All 656 tests pass (468 ImagePyramid + 188 core). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ts; update docs Provider lifecycle fixes: - Blazor ImagePyramid.razor RebuildProvider(): dispose old _delayProvider before replacing (controller does NOT own provider lifecycle). Remove false comments. - Blazor ImagePyramid.razor DisposeAsync(): always dispose _delayProvider explicitly, regardless of whether controller was set. - MAUI ImagePyramidPage: add _provider field; replace inline new-per-load SKTieredTileProvider(new SKHttpTileFetcher()) calls with ReplaceProvider() helper that disposes old before assigning new, preventing HttpClient leaks. BrowserStorageTileProvider fixes: - Line 24: change `if (ct.IsCancellationRequested) return null` to `ct.ThrowIfCancellationRequested()` — returning null causes controller to permanently record a failure via TileFailureTracker. - Line 36: guard `WriteToBrowserAsync` call with `if (tile.RawData != null)` — RawData is nullable and would NullReferenceException at base64 conversion. Tests (33 new, total 501): - TileFailureTrackerTest: backoff timing, exponential delay, max retries, Reset, ResetAll - SKNullTileCacheStoreTest: always-null reads, no-op writes - SKDiskTileCacheStoreTest: round-trip, miss, expiry, remove, clear, bucketing - SKChainedTileCacheStoreTest: first-wins reads, writes-to-all, empty ctor throws - SKCompositeTileFetcherTest: first-wins, fallthrough, all-miss, cancellation - SKTieredTileProviderTest: cache hit, miss+fetch, null fetch, cancellation, persist Docs: rewrite fetching.md for new ISKTileFetcher/ISKTileCacheStore/SKTieredTileProvider architecture; fix all stale SKImagePyramidHttpTileProvider/SKImagePyramidFileTileProvider references across controller.md, caching.md, index.md, blazor.md, maui.md, iiif.md, deepzoom.md; convert old constructor args (diskCachePath, httpClient) to new SKTieredTileProvider(fetcher, cacheStore) pattern. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Extract canvas/controller/rendering logic from sample pages into
reusable ImagePyramidView components, separating rendering concerns
from the debug/inspector UI.
## Blazor (samples/SkiaSharpDemo.Blazor/)
- Add Components/ImagePyramid/ImagePyramidView.razor + .razor.cs
- Owns SKGLView, SKImagePyramidController, default renderer
- Parameters: Source (ISKImagePyramidSource), Provider (ISKImagePyramidTileProvider),
Renderer (ISKImagePyramidRenderer), DebugZoom, MinZoom, MaxZoom, OnInvalidate
- Exposes Controller (read-only), Zoom, ResetView(), SetZoom(), SyncZoom()
- Handles pan/touch gestures and JS resize registration internally
- Uses OnParametersSet for proper Blazor parameter change detection
- Refactor Pages/ImagePyramid.razor to use <ImagePyramidView>
- Page retains: URL loading, source selection, inspector wiring,
delay/cache provider construction, zoom slider
- Page no longer owns: canvas element, controller, renderer, pan handlers
## MAUI (samples/SkiaSharpDemo/)
- Add Demos/ImagePyramid/ImagePyramidView.cs
- ContentView wrapping SKCanvasView + SKImagePyramidController
- Bindable Source and Provider properties trigger controller.Load()
- Initialize()/Cleanup() called from page OnAppearing/OnDisappearing
- Exposes Controller (read-only), Zoom, ResetView(), SetZoom()
- Owns pan gesture handling internally
- Refactor ImagePyramidPage.xaml to use <demos:ImagePyramidView>
- Refactor ImagePyramidPage.xaml.cs: page manages provider lifecycle,
passes Source/Provider to view, reads Zoom from view for slider sync
## Library change
- Add Canvas property to ISKImagePyramidRenderer interface
Both SKImagePyramidRenderer and DebugBorderRenderer already had it;
adding it to the interface allows view components to set the canvas
on any renderer without casting to a concrete type.
All 828 tests pass (501 image pyramid + 188 core + 139 MAUI).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
🔴 HIGH: Blazor unmatched 'style' attribute runtime crash - Add [Parameter(CaptureUnmatchedValues = true)] AdditionalAttributes to ImagePyramidView.razor.cs - Splat @attributes on root <div> in ImagePyramidView.razor - Removes the hardcoded 'ip-canvas-area' class so callers control layout 🟡 MEDIUM: MAUI event handler leak in ImagePyramidView.Cleanup() - Store lambda in _invalidateHandler field instead of inline lambda - -= with a new lambda is a no-op; the stored field reference correctly unsubscribes from InvalidateRequired on Cleanup() 🟡 MEDIUM: MAUI missing IIIF /info.json auto-append - ImagePyramidPage.xaml.cs now appends /info.json to IIIF URLs before fetching, matching the Blazor sample's existing behaviour - baseDir/stem now derived from fetchUrl (the resolved URL) not raw url Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Extracts the core DeepZoom static rendering system. This is the minimal set of services needed to load and render Deep Zoom collections and images without gestures, animations, or custom controls.
What's included
SKDeepZoomCollectionSource,SKDeepZoomImageSource)SKDeepZoomTileCache), scheduler (SKDeepZoomTileScheduler), and HTTP/file fetchersSKDeepZoomController) — orchestrates loading and delegates to tile servicesSKDeepZoomViewport) — centered-fit geometry (aspect ratio preserved, no cropping/stretching)SKDeepZoomRenderer) — draws best-resolution tiles ontoSKCanvasSkiaSharp.Extended.DeepZoom.TestsprojectPages/DeepZoom.razor) — minimal static preview usingSKCanvasViewDemos/DeepZoom/DeepZoomPage) — same concept, uses app-package assetsdeep-zoom.md,deep-zoom-blazor.md,deep-zoom-maui.mdWhat's NOT included (intentionally)
SKGestureTracker,SKGestureDetector, etc.)SKAnimationSpring,SKAnimationTimer, etc.)SKDeepZoomViewcontrolArchitecture
The sample page passes a URI to
SKDeepZoomController, which loads the DZI/DZC, manages tiles, and firesInvalidateRequiredwhen new tiles are ready. On each paint, the page callsSetControlSize()thenRender(). The only input is the canvas size — the image is always centered-fit.Tests
Related: #378