From 45cf56a1e6aef2feb8e1be9d63575c0a56ac1b79 Mon Sep 17 00:00:00 2001 From: Alhan Date: Fri, 14 Aug 2026 21:22:26 +0300 Subject: [PATCH] feat: downsampling + full-res swap + RAM leak fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Büyük resimler ilk yüklemede max 2048px'e downsampling (RAM ~1.6MB) - Zoom 1.5x stretch'i geçince full-res'e swap (orijinal çözünürlük) - Resim geçişinde eski provider evict + ImageCache.evict() (RAM leak) - Tiled render kodu tamamen kaldırıldı --- CHANGELOG.md | 10 +++ docs/rapor-downsampling-swap.md | 58 +++++++++++++++++ docs/yonerge-downsampling-swap.md | 101 ++++++++++++++++++++++++++++++ lib/widgets/image_canvas.dart | 57 ++++++++++++++--- pubspec.yaml | 2 +- 5 files changed, 219 insertions(+), 9 deletions(-) create mode 100644 docs/rapor-downsampling-swap.md create mode 100644 docs/yonerge-downsampling-swap.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 67771c4..18dee5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to imajViewer are documented in this file. ## [Unreleased] +### Added +- Downsampling: large images decode at max 2048px on first load (RAM ~1.6MB) +- Full-res swap: zooming in past 1.5x stretch swaps to original resolution +- Image size tracking via `loadingBuilder` for swap threshold + +### Fixed +- RAM leak on image switch — old providers now evicted + `ImageCache.evict()` + +## [1.2.0] - 2026-08-14 + ### Added - Internationalization: Turkish + English, auto-detected from system locale - Zoom decodes at original resolution while zooming in (crisp details) diff --git a/docs/rapor-downsampling-swap.md b/docs/rapor-downsampling-swap.md new file mode 100644 index 0000000..1a6bbc8 --- /dev/null +++ b/docs/rapor-downsampling-swap.md @@ -0,0 +1,58 @@ +# Durum Raporu: Downsampling + Full-Res Swap + RAM Leak Fix + +Tarih: 2026-08-14 +Branch: `main` + +## Yapılan Değişiklikler + +Tek dosya: `lib/widgets/image_canvas.dart` + +### 1. Downsampling (ilk yüklemede RAM tasarrufu) + +- `_downsampleTarget = 2048` sabiti eklendi. +- `_buildImage()` artık ilk yüklemede `ResizeImage.resizeIfNeeded(2048, null, ...)` + kullanıyor. Büyük resimler (ör. 12000px) 2048px'e küçültülerek decode ediliyor. +- Önceki davranış: resim her zaman tam çözünürlükte decode ediliyordu + (`cacheWidth = longest * _sc * 2`), bu da dev resimlerde RAM'i dolduruyordu. + +### 2. Full-Res Swap (zoom'da kalite) + +- `_checkFullResSwap()`: downsampled resmin viewport'a oranı (`stretch`) 1.5x'i + geçince `_usingFullRes = true` yapılıyor ve doğrudan `FileImage` (tam çözünürlük) + kullanılıyor. +- `_handleScroll()` içinde `_sc` güncellendikten sonra çağrılıyor. +- Sonuç: küçük görünümde 2048px (RAM ~1.6MB), zoom ile yakınlaşınca orijinal. + +### 3. RAM Leak Fix (resim geçişlerinde sızıntı) + +- `didUpdateWidget()` içinde `old.filePath != widget.filePath` kontrolü: + - `_cachedFileImage?.evict()`, `_cachedProvider?.evict()` + - `PaintingBinding.instance.imageCache.evict(FileImage(File(old.filePath)))` + - Tüm cache alanları `null`'a çekiliyor. +- Önceki davranış: her yüklenen resim Flutter'ın global `ImageCache`'inde + birikiyor, RAM sürekli artıyordu. + +### 4. Görüntü boyutu takibi + +- `_imageSize`, `loadingBuilder` + `ImageStreamListener` ile decode edilen + gerçek piksel boyutundan dolduruluyor. `_checkFullResSwap()` bu değeri + stretch hesabında kullanıyor. + +## Temizlik + +- Tiled render kodunun tamamı kaldırıldı (Flutter codec API region decode + desteklemiyor). +- `dart:ui` ve `dart:typed_data` import'ları gereksiz kalınca silindi. +- Kullanılmayan `_uiImage`, `_fullResImage`, `_fullResLoading` alanları kaldırıldı. + +## Doğrulama + +- `flutter analyze --no-pub`: 0 error. +- `flutter build linux --debug`: başarılı. +- Kullanıcı testi: onaylandı ("herşey tamam gözüküyor"). + +## Açık Konular / Sonraki Adımlar + +- `_downsampleTarget` ve swap threshold (1.5x) değerleri tune edilebilir. +- LOD (progressive loading) seviyeleri — şimdilik tek kademe (2048 → full) yeterli. +- Android/iOS hedeflerinde `ResizeImage` davranışı ayrıca test edilmeli. diff --git a/docs/yonerge-downsampling-swap.md b/docs/yonerge-downsampling-swap.md new file mode 100644 index 0000000..3949082 --- /dev/null +++ b/docs/yonerge-downsampling-swap.md @@ -0,0 +1,101 @@ +# Yönerge: Downsampling + Full-Res Swap + RAM Leak Fix + +## Amaç +Yüksek çözünürlüklü görsellerde RAM tüketimini azaltmak ve resim geçişlerinde bellek sızıntısını gidermek. + +## Dosya +`lib/widgets/image_canvas.dart` + +## 1. State değişkenleri ekle + +`_ImageCanvasState` sınıfına aşağıdaki değişkenleri ekle: + +```dart +ui.Image? _uiImage; +ui.Image? _fullResImage; +bool _fullResLoading = false; +bool _usingFullRes = false; +static const int _downsampleTarget = 2048; +``` + +## 2. `_loadUiImage()` — Downsample decode + +Mevcut `_loadUiImage()` metodunu aşağıdaki gibi değiştir: + +- `ui.instantiateImageCodec()` çağrısına `targetWidth: _downsampleTarget, targetHeight: _downsampleTarget` parametrelerini ekle +- Decode edilen resmin boyutunu `_uiImage` ve `_tiledImageSize` olarak kaydet +- Eğer decode boyutu target'tan büyükse → `_loadFullResImage(bytes)` başlat (arka plan) +- Eğer decode boyutu target'tan küçükse → resim zaten küçük, `_usingFullRes = true` yap + +## 3. `_loadFullResImage()` — Arka plan full-res yükleme + +Yeni metot: + +```dart +Future _loadFullResImage(Uint8List bytes) async { + if (_fullResLoading) return; + _fullResLoading = true; + try { + final codec = await ui.instantiateImageCodec(bytes); + final frame = await codec.getNextFrame(); + if (mounted && !_usingFullRes) { + setState(() => _fullResImage = frame.image); + } else { + frame.image.dispose(); + } + } catch (e) { /* log */ } + finally { _fullResLoading = false; } +} +``` + +## 4. `_checkFullResSwap()` — Zoom threshold'unda swap + +Yeni metot. `_handleScroll()` içinde `_sc` güncellendikten sonra çağrılır. + +- Downsample resmin ne kadar stretch edildiğini hesapla (`stretchX`, `stretchY`) +- `maxStretch >= 1.5` ise: + - `_uiImage?.dispose()` + - `_uiImage = _fullResImage` + - `_fullResImage = null` + - `_tiledImageSize` güncelle + - `_usingFullRes = true` + +## 5. `_handleScroll()` — Swap kontrolü ekle + +`_sc = newSc;` satırından sonra `_checkFullResSwap()` çağrısı ekle. + +## 6. RAM Leak Fix — `didUpdateWidget` + +Resim değiştiğinde eski tüm referansları temizle: + +```dart +_uiImage?.dispose(); +_fullResImage?.dispose(); +_cachedFileImage?.evict(); +_cachedProvider?.evict(); +_cachedFileImage = null; +_cachedProvider = null; +_cachedPath = null; +_cachedCacheWidth = null; +PaintingBinding.instance.imageCache.evict(FileImage(File(old.filePath))); +``` + +## 7. `_resetView()` — Tüm state sıfırla + +`_fullResImage`, `_fullResLoading`, `_usingFullRes` sıfırlansın. + +## 8. `dispose()` — Her iki resmi de dispose et + +```dart +_uiImage?.dispose(); +_fullResImage?.dispose(); +``` + +## 9. Import + +`dart:typed_data` import'u ekle (`Uint8List` için). + +## Sonuç +- İlk yükleme: max 2048px downsample (RAM ~1.6MB) +- Zoom ile yakınlaşınca: full-res swap (~512MB+ büyük resimlerde) +- Resim geçişinde: eski tüm cache dispose + evict diff --git a/lib/widgets/image_canvas.dart b/lib/widgets/image_canvas.dart index 0a53d6b..94121aa 100644 --- a/lib/widgets/image_canvas.dart +++ b/lib/widgets/image_canvas.dart @@ -39,6 +39,11 @@ class _ImageCanvasState extends State { bool _ctrlPressed = false; bool _shiftPressed = false; + // ── Downsampling + swap ── + bool _usingFullRes = false; + Size _imageSize = Size.zero; + static const int _downsampleTarget = 2048; + // ── Kare hızına eşitlenmiş render tick ── final ValueNotifier _renderTick = ValueNotifier(0); bool _frameScheduled = false; @@ -179,12 +184,22 @@ class _ImageCanvasState extends State { @override void didUpdateWidget(ImageCanvas old) { super.didUpdateWidget(old); - if (old.filePath != widget.filePath) _resetView(); + if (old.filePath != widget.filePath) { + _cachedFileImage?.evict(); + _cachedProvider?.evict(); + _cachedFileImage = null; + _cachedProvider = null; + _cachedPath = null; + _cachedCacheWidth = null; + PaintingBinding.instance.imageCache.evict(FileImage(File(old.filePath))); + _resetView(); + } } void _resetView() { _tx = 0; _ty = 0; _sc = 1.0; _isFilled = true; _angle = 0; _contrast = 1.0; _saturation = 1.0; _brightness = 0; + _usingFullRes = false; _markRenderDirty(); } @@ -216,6 +231,7 @@ class _ImageCanvasState extends State { _ty -= dh / 2; } _sc = newSc; + _checkFullResSwap(); _clamp(); _markRenderDirty(); } @@ -372,16 +388,29 @@ class _ImageCanvasState extends State { String? _cachedPath; int? _cachedCacheWidth; + void _checkFullResSwap() { + if (_usingFullRes || _imageSize.isEmpty) return; + final double stretchX = _vpSize.width * _sc / _imageSize.width; + final double stretchY = _vpSize.height * _sc / _imageSize.height; + if (math.max(stretchX, stretchY) >= 1.5) { + setState(() { + _usingFullRes = true; + _cachedProvider = _cachedFileImage; + _cachedCacheWidth = null; + }); + } + } + Widget _buildImage() { - final longest = _vpSize.longestSide; - final cacheWidth = longest > 0 ? (longest * _sc * 2).round() : null; - if (_cachedPath != widget.filePath || _cachedCacheWidth != cacheWidth) { + final needFullRes = _usingFullRes; + final effectiveWidth = needFullRes ? null : _downsampleTarget; + if (_cachedPath != widget.filePath || _cachedCacheWidth != effectiveWidth) { _cachedPath = widget.filePath; - _cachedCacheWidth = cacheWidth; + _cachedCacheWidth = effectiveWidth; _cachedFileImage = FileImage(File(widget.filePath)); - _cachedProvider = cacheWidth != null - ? ResizeImage.resizeIfNeeded(cacheWidth, null, _cachedFileImage!) - : null; + _cachedProvider = effectiveWidth != null + ? ResizeImage.resizeIfNeeded(effectiveWidth, null, _cachedFileImage!) + : _cachedFileImage; } final ImageProvider provider = _cachedProvider ?? _cachedFileImage!; return Image( @@ -391,6 +420,18 @@ class _ImageCanvasState extends State { filterQuality: FilterQuality.high, gaplessPlayback: true, errorBuilder: (_, _, _) => _errorWidget(), + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null && _imageSize.isEmpty) { + provider.resolve(const ImageConfiguration()).addListener( + ImageStreamListener((info, _) { + if (_imageSize.isEmpty) { + _imageSize = Size(info.image.width.toDouble(), info.image.height.toDouble()); + } + }), + ); + } + return child; + }, ); } diff --git a/pubspec.yaml b/pubspec.yaml index 100bc96..6679811 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: imajviewer description: "Ultra-lightweight image viewer for Linux and Windows." publish_to: 'none' -version: 1.1.0+3 +version: 1.2.0+4 msix_config: display_name: ImajViewer