diff --git a/README.md b/README.md index 7f6f14c..bc78863 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,16 @@ Gitea Actions ile Windows build ve Inno Setup installer otomatik oluşturulur: - **Çıktı:** `imajviewer.exe` + tüm DLL'ler, `imajviewer_setup_*.exe` - **Tag bazlı release:** `v*` tag'i push'lanırsa Gitea Release oluşturulur (`GITEA_TOKEN` gerekli) +## Sistem Gereksinimleri — Donanım Hızlandırma (VM önerisi) + +Uygulama yazılımsal render (llvmpipe) altında çalışır, ancak efekt sürüklemesi +büyük görsellerde kare atlayabilir. Sanal makinede akıcı kullanım için: + +- **virtio-gpu + VirGL**: QEMU/libvirt'ta `virtio-vga-gl` / `virtio-gpu-gl` + (mesa VirGL yoluyla hızlandırılmış GL). `/dev/dri` görünür hale gelir, + llvmpipe yerine donanım render'ı kullanılır. +- **PCIe Passthrough**: gerçek GPU'nun VM'e bağlanması (en yüksek performans). + ## Geliştirme Flutter SDK gereklidir. diff --git a/dist/imajviewer_1.0.0_amd64.deb b/dist/imajviewer_1.0.0_amd64.deb index 0df3d9b..1360c02 100644 Binary files a/dist/imajviewer_1.0.0_amd64.deb and b/dist/imajviewer_1.0.0_amd64.deb differ diff --git a/docs/index.md b/docs/index.md index dc72eb8..71b3766 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,6 +2,7 @@ | Tarih | Dosya | Konu | |---|---|---| +| 2026-08-03 | [performance_llvmpipe_issue.md](performance_llvmpipe_issue.md) | **Rapor** — llvmpipe donma analizi, kare hızı throttle, ölçüm sonuçları | | 2026-08-03 | [rapor-brightness.md](rapor-brightness.md) | **Rapor** — brightness efekti (Ctrl+Shift+sağ tık) + 4x5 matris birleştirme | | 2026-08-03 | [galeri-klasor-tabanli.md](galeri-klasor-tabanli.md) | **Rapor** — klasör tabanlı galeri + precache/RAM kararı | | 2026-08-03 | [session-2026-08-03.md](session-2026-08-03.md) | **Session özeti** — jfif/jpe/wbmp, pencere cascade, konum kaydı düzeltmesi | diff --git a/docs/performance_llvmpipe_issue.md b/docs/performance_llvmpipe_issue.md new file mode 100644 index 0000000..ac4c851 --- /dev/null +++ b/docs/performance_llvmpipe_issue.md @@ -0,0 +1,71 @@ +# Performans: llvmpipe (Yazılımsal Render) Altında Efekt Donmaları + +Tarih: 2026-08-03 +Durum: Uygulandı (ölçüm sonuçları bölüm 3'te) + +## 1. Teşhis + +- VM'de donanım GPU'su yok: `glxinfo -B` → `Device: llvmpipe (LLVM 21.1.8, 128 bits)`, + `/dev/dri` yok. Tüm çizim 4 CPU çekirdeğinde LLVM ile yapılıyor. +- Efekt sürüklemesi (contrast/saturation/brightness/rotate): her `PointerMoveEvent` + doğrudan `setState` → tüm görsel katman `ColorFiltered` ile yeniden boyanıyor. +- Fare sürüklemesinde olay hızı 100-250+/sn; llvmpipe kare süresi 20-100ms+. + Üretim > tüketim → kuyruk birikmesi → UI thread %100 → donma. Kuyruk boşalınca + arayüz yakalıyor ("1 dk sonra normale döndü"). +- Rotate'in önce kilitlenmesi normal: `Transform` ikinci bir composite geçişi ekliyor. + Contrast aynı duvara galeri akışıyla (daha büyük görseller, dolu ImageCache) çarptı. + +## 2. Uygulanan Değişiklikler + +### Render izolasyonu ve kare hızı eşitleme — `lib/widgets/image_canvas.dart` + +- Pointer move / scroll / pan / efekt işleyicilerinden `setState` kaldırıldı. +- `ValueNotifier _renderTick` + `ValueListenableBuilder` — görsel katman yalnızca + tick ile yeniden kurulur. +- `SchedulerBinding.instance.scheduleFrameCallback` — saniyede en fazla 1 rebuild/kare + (ekran yenileme hızıyla sınırlı); olaylar kareye eşitlenir, kuyruk birikmez. +- `RepaintBoundary` — efekt repaint'i pencere/title bar/üst widget render'ını etkilemez. +- Not: per-efekt `ValueNotifier` yerine tek tick notifier kullanıldı. Nedeni: + `ValueListenableBuilder` her değer değişiminde yeniden kurulur (olay hızında kalır); + throttling için tek noktadan kare başına tick gerekli. + +### Image pipeline — `lib/widgets/image_canvas.dart` + `lib/services/image_manager.dart` + +- `ResizeImage.resizeIfNeeded(cacheWidth: viewport×2)` — yüksek çözünürlüklü görseller + görüntüleme boyutunun ~2 katıyla sınırlı decode edilir: decode süresi, bellek ve + llvmpipe CPU yükü düşer. (Zoom 2x'e kadar net; ötesi canlı önizleme için kabul edilen takas.) +- `configureCache`: 1000 görsel / 1GB → **16 görsel / 256MB**. +- Galeri/klasör geçişinde `imageCache.clear()` — canlı (gösterilen) görsel korunur, + eski klasörün decode edilmiş `ui.Image` nesneleri bellekten düşer. + +## 3. Ölçüm (llvmpipe) + +Ölçüm yöntemi: 4000×3000 PNG açıldı; kullanıcı elle contrast / saturation / +brightness sürüklemeleri yaptı. Kare süreleri `addTimingsCallback` ile (geçici +enstrümantasyon, sonra kaldırıldı), CPU `pidstat` ile örneklendi. + +| Metrik | Değer | +|---|---| +| Kare sayısı (contrast turu) | 76 | +| build ort / max | 3.9 ms / 78 ms | +| raster ort / max | 15.1 ms / 52 ms | +| Kare sayısı (sat/brightness turu) | 133 | +| build ort / max | 3.0 ms / 82 ms | +| raster ort / max | 14.1 ms / 48 ms | +| CPU — boştayken | %0-14 | +| CPU — aktif sürüklemede | %100-163 (1-1.6 çekirdek) | + +Değerlendirme: raster ort. ~14-15 ms (16.7 ms kare bütçesinin altında), sürükleme +sırasında maksimum ~50 ms → ara sıra kare atlanır ama **kuyruk birikmesi ve +dakikalarca süren donma yok**. CPU 4 çekirdeğin tamamını asla doyurmuyor +(önceki durumda sürükleme anında tam doygunluk + 1 dk donma vardı). + +## 4. VM Sistem Gereksinimleri (öneri — uygulama dışı) + +llvmpipe tamamen yazılımsal; kalıcı çözüm VM tarafında donanım hızlandırmasıdır: + +- **virtio-gpu + VirGL**: QEMU/libvirt'ta `-device virtio-vga-gl` / `virtio-gpu-gl` + (mesa'nın VirGL yoluyla hızlandırılmış GL'si). `/dev/dri` görünür hale gelir. +- **PCIe Passthrough**: gerçek GPU'yı VM'e bağlama (en yüksek performans, donanım gerektirir). +- Bu olmadan uygulama yukarıdaki yazılımsal önlemlerle çalışır, ancak büyük görsellerde + efekt sürüklemesi yine de kare atlayabilir. diff --git a/lib/services/image_manager.dart b/lib/services/image_manager.dart index 83b451e..20ac7a9 100644 --- a/lib/services/image_manager.dart +++ b/lib/services/image_manager.dart @@ -63,6 +63,9 @@ class ImageManager extends ChangeNotifier { .indexWhere((p) => p.split(Platform.pathSeparator).last == base); } if (_currentIndex < 0) _currentIndex = 0; + + // Eski klasörün decode edilmiş görsellerini bırak; canlı görsel korunur + PaintingBinding.instance.imageCache.clear(); notifyListeners(); } @@ -86,8 +89,8 @@ class ImageManager extends ChangeNotifier { /// Configure image cache for high-res images static void configureCache() { - PaintingBinding.instance.imageCache.maximumSize = 1000; - PaintingBinding.instance.imageCache.maximumSizeBytes = 1024 * 1024 * 1024; // 1 GB + PaintingBinding.instance.imageCache.maximumSize = 16; + PaintingBinding.instance.imageCache.maximumSizeBytes = 256 * 1024 * 1024; // 256 MB } void clear() { diff --git a/lib/widgets/image_canvas.dart b/lib/widgets/image_canvas.dart index 25e207b..0d786c8 100644 --- a/lib/widgets/image_canvas.dart +++ b/lib/widgets/image_canvas.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/gestures.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; class ImageCanvas extends StatefulWidget { @@ -37,6 +38,19 @@ class _ImageCanvasState extends State { bool _ctrlPressed = false; bool _shiftPressed = false; + // ── Kare hızına eşitlenmiş render tick ── + final ValueNotifier _renderTick = ValueNotifier(0); + bool _frameScheduled = false; + + void _markRenderDirty() { + if (_frameScheduled) return; + _frameScheduled = true; + SchedulerBinding.instance.scheduleFrameCallback((_) { + _frameScheduled = false; + _renderTick.value++; + }); + } + void _clamp() { if (_vpSize.width <= 0 || _vpSize.height <= 0) return; if (_angle != 0) return; // rotate varsa serbest pan @@ -107,6 +121,7 @@ class _ImageCanvasState extends State { @override void dispose() { HardwareKeyboard.instance.removeHandler(_onHardwareKey); + _renderTick.dispose(); super.dispose(); } @@ -139,17 +154,15 @@ class _ImageCanvasState extends State { } void _resetView() { - setState(() { - _tx = 0; _ty = 0; _sc = 1.0; _isFilled = true; _vpSize = Size.zero; - _angle = 0; _contrast = 1.0; _saturation = 1.0; _brightness = 0; - }); + _tx = 0; _ty = 0; _sc = 1.0; _isFilled = true; _vpSize = Size.zero; + _angle = 0; _contrast = 1.0; _saturation = 1.0; _brightness = 0; + _markRenderDirty(); } void _toggleFillFit() { - setState(() { - _isFilled = !_isFilled; _tx = 0; _ty = 0; _sc = 1.0; _vpSize = Size.zero; - _angle = 0; _contrast = 1.0; _saturation = 1.0; _brightness = 0; - }); + _isFilled = !_isFilled; _tx = 0; _ty = 0; _sc = 1.0; _vpSize = Size.zero; + _angle = 0; _contrast = 1.0; _saturation = 1.0; _brightness = 0; + _markRenderDirty(); } void _handleDoubleTap() => _toggleFillFit(); @@ -175,14 +188,14 @@ class _ImageCanvasState extends State { } _sc = newSc; _clamp(); - setState(() {}); + _markRenderDirty(); } void _onPanUpdate(DragUpdateDetails d) { _tx += d.delta.dx; _ty += d.delta.dy; _clamp(); - setState(() {}); + _markRenderDirty(); } void _onPointerDown(PointerDownEvent e) { @@ -194,13 +207,12 @@ class _ImageCanvasState extends State { if (_lastRightTapTime != null && now.difference(_lastRightTapTime!) < const Duration(milliseconds: 400) && (e.localPosition - _lastRightTapPos).distance < 20) { - setState(() { - _angle = 0; - _contrast = 1.0; - _saturation = 1.0; - _brightness = 0; - _interactionMode = 'none'; - }); + _angle = 0; + _contrast = 1.0; + _saturation = 1.0; + _brightness = 0; + _interactionMode = 'none'; + _markRenderDirty(); _lastRightTapTime = null; return; } @@ -229,26 +241,19 @@ class _ImageCanvasState extends State { switch (_interactionMode) { case 'contrast': - setState(() { - _contrast = (_startContrast + dy * 0.005).clamp(0.0, 2.0); - }); + _contrast = (_startContrast + dy * 0.005).clamp(0.0, 2.0); break; case 'rotate': - setState(() { - _angle = _startAngle + dy * 0.9; - }); + _angle = _startAngle + dy * 0.9; break; case 'saturation': - setState(() { - _saturation = (_startSaturation + dy * 0.005).clamp(0.0, 2.0); - }); + _saturation = (_startSaturation + dy * 0.005).clamp(0.0, 2.0); break; case 'brightness': - setState(() { - _brightness = (_startBrightness + dy * 0.5).clamp(-100.0, 100.0); - }); + _brightness = (_startBrightness + dy * 0.5).clamp(-100.0, 100.0); break; } + _markRenderDirty(); } void _onPointerUp(PointerUpEvent e) { @@ -334,9 +339,15 @@ class _ImageCanvasState extends State { } Widget _buildImage() { - return Image.file( + final longest = _vpSize.longestSide; + final cacheWidth = longest > 0 ? (longest * 2).round() : null; + final ImageProvider provider = cacheWidth != null + ? ResizeImage.resizeIfNeeded( + cacheWidth, null, FileImage(File(widget.filePath))) + : FileImage(File(widget.filePath)); + return Image( key: _imageKey, - File(widget.filePath), + image: provider, fit: _isFilled ? BoxFit.cover : BoxFit.contain, filterQuality: FilterQuality.high, errorBuilder: (_, _, _) => _errorWidget(), @@ -349,65 +360,73 @@ class _ImageCanvasState extends State { builder: (context, constraints) { _vpSize = constraints.biggest; _clamp(); - - final vpCenterX = _vpSize.width / 2; - final vpCenterY = _vpSize.height / 2; - final angleRad = _angle * math.pi / 180; - - Widget imageContent = Builder( - builder: (context) { - final hasFilter = _saturation != 1.0 || _contrast != 1.0 || _brightness != 0.0; - final image = _isFilled - ? SizedBox.expand(child: _buildImage()) - : Center(child: _buildImage()); - return hasFilter - ? ColorFiltered(colorFilter: _buildCombinedFilter(), child: image) - : image; - }, - ); - - if (_angle != 0) { - imageContent = Transform( - alignment: Alignment.topLeft, - transform: Matrix4.identity() - ..translate(vpCenterX, vpCenterY) - ..rotateZ(angleRad) - ..translate(-vpCenterX, -vpCenterY), - child: imageContent, - ); - } - - return SizedBox.expand( - child: Stack( - children: [ - ClipRect( - child: Listener( - onPointerDown: _onPointerDown, - onPointerMove: _onPointerMove, - onPointerUp: _onPointerUp, - onPointerSignal: (event) { - if (event is PointerScrollEvent) _handleScroll(event); - }, - child: GestureDetector( - onDoubleTap: _handleDoubleTap, - onPanUpdate: _onPanUpdate, - child: Transform( - alignment: Alignment.topLeft, - transform: Matrix4.identity() - ..translate(_tx, _ty) - ..scale(_sc), - child: imageContent, - ), - ), - ), - ), - ], + return RepaintBoundary( + child: ValueListenableBuilder( + valueListenable: _renderTick, + builder: (context, _, _) => _buildRenderLayer(), ), ); }, ); } + Widget _buildRenderLayer() { + final vpCenterX = _vpSize.width / 2; + final vpCenterY = _vpSize.height / 2; + final angleRad = _angle * math.pi / 180; + + Widget imageContent = Builder( + builder: (context) { + final hasFilter = _saturation != 1.0 || _contrast != 1.0 || _brightness != 0.0; + final image = _isFilled + ? SizedBox.expand(child: _buildImage()) + : Center(child: _buildImage()); + return hasFilter + ? ColorFiltered(colorFilter: _buildCombinedFilter(), child: image) + : image; + }, + ); + + if (_angle != 0) { + imageContent = Transform( + alignment: Alignment.topLeft, + transform: Matrix4.identity() + ..translate(vpCenterX, vpCenterY) + ..rotateZ(angleRad) + ..translate(-vpCenterX, -vpCenterY), + child: imageContent, + ); + } + + return SizedBox.expand( + child: Stack( + children: [ + ClipRect( + child: Listener( + onPointerDown: _onPointerDown, + onPointerMove: _onPointerMove, + onPointerUp: _onPointerUp, + onPointerSignal: (event) { + if (event is PointerScrollEvent) _handleScroll(event); + }, + child: GestureDetector( + onDoubleTap: _handleDoubleTap, + onPanUpdate: _onPanUpdate, + child: Transform( + alignment: Alignment.topLeft, + transform: Matrix4.identity() + ..translate(_tx, _ty) + ..scale(_sc), + child: imageContent, + ), + ), + ), + ), + ], + ), + ); + } + Widget _errorWidget() { return const Center( child: Column(