Files
imajviewer/lib/services/crop_save_service.dart
Alhan a2d2c0d151
Build & Release / build-linux (push) Has been cancelled
Build & Release / build-windows (push) Has been cancelled
Build & Release / create-release (push) Has been cancelled
chore: crop save JPEG quality 93 -> 95 (v1.6.2)
2026-10-14 04:04:54 +03:00

154 lines
5.8 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:io';
import 'dart:math' as math;
import 'package:image/image.dart' as img;
import 'package:path/path.dart' as p;
class CropSaveError implements Exception {
final String code;
const CropSaveError(this.code);
@override
String toString() => 'CropSaveError($code)';
}
/// Viewport -> tam cozunurluk kaynak eslemesi (image_canvas'tan gelir).
class CropViewParams {
final int w, h; // kaynak dosya boyutu
final double vw, vh; // viewport boyutu
final double tx, ty, sc; // pan/zoom
final double angleDeg; // donus (derece)
final bool flipH; // yatay aynalama (goruntunun kendisi degil, gorunum)
final double cx, cy; // donus merkezi (viewport ortasi)
final double k; // kaynak -> layout olcegi
final double ox, oy; // layout offset
const CropViewParams({
required this.w,
required this.h,
required this.vw,
required this.vh,
required this.tx,
required this.ty,
required this.sc,
required this.angleDeg,
this.flipH = false,
required this.cx,
required this.cy,
required this.k,
required this.ox,
required this.oy,
});
}
class CropSaveService {
/// Tam cozunurlukte decode + EXIF yonunu gomme (engine goruntusuyle tutarli).
static Future<img.Image> load(String srcPath) async {
final bytes = await File(srcPath).readAsBytes();
final src = img.decodeImage(bytes);
if (src == null) throw const CropSaveError('decode');
return img.bakeOrientation(src);
}
/// Hedef yol: `resim.png` -> `resim_143025.jpg` (hep JPEG; cakismada null).
static String? buildOutputPath(String srcPath) {
final base = p.basenameWithoutExtension(srcPath);
final dir = p.dirname(srcPath);
final now = DateTime.now();
final hh = now.hour.toString().padLeft(2, '0');
final mm = now.minute.toString().padLeft(2, '0');
final ss = now.second.toString().padLeft(2, '0');
final candidate = p.join(dir, '${base}_$hh$mm$ss.jpg');
return File(candidate).existsSync() ? null : candidate;
}
/// Ekranda gorunen bolgeyi birebir (donus dahil) tam cozunurlukte kaydedir.
/// Duzlem: viewport -> (tx,ty,sc) -> (vp ortasi etrafinda donus) -> (k,ox,oy) -> kaynak px.
static Future<String> saveCrop({
required String srcPath,
required img.Image source,
required CropViewParams vp,
List<double>? matrix,
int jpegQuality = 95,
}) async {
final outPath = buildOutputPath(srcPath);
if (outPath == null) throw const CropSaveError('file_exists');
final out = vp.angleDeg == 0
? _cropAxisAligned(source, vp)
: _cropRotated(source, vp);
// Ekranda yatay aynalama acikken cikti da aynalanir (cikti = viewport'un birebir eslemesi).
if (vp.flipH) img.flipHorizontal(out);
// Çıktıda yön fiziksel — EXIF tag'ini temizle (çift dönüşü önler).
out.exif.imageIfd.orientation = null;
if (matrix != null) _applyMatrix(out, matrix);
final jpeg = img.JpegEncoder(quality: jpegQuality).encode(out);
await File(outPath).writeAsBytes(jpeg);
return outPath;
}
/// Donus yok: gorulen dikdortgeni birebir kes (hizli yol).
static img.Image _cropAxisAligned(img.Image src, CropViewParams vp) {
double toX(double vx) => ((vx - vp.tx) / vp.sc - vp.ox) / vp.k;
double toY(double vy) => ((vy - vp.ty) / vp.sc - vp.oy) / vp.k;
final x0 = toX(0), x1 = toX(vp.vw), y0 = toY(0), y1 = toY(vp.vh);
final x = x0.round().clamp(0, src.width - 1);
final y = y0.round().clamp(0, src.height - 1);
final xr = x1.round().clamp(x + 1, src.width);
final yr = y1.round().clamp(y + 1, src.height);
return img.copyCrop(src, x: x, y: y, width: xr - x, height: yr - y);
}
/// Donus var: ekran grid'ini kaynaga birebir ornekler (bilinear).
static img.Image _cropRotated(img.Image src, CropViewParams vp) {
final step = vp.sc * vp.k; // ekran px basina kaynak px
if (step <= 0) throw const CropSaveError('empty_rect');
final ow = (vp.vw / step).round().clamp(1, 1 << 14);
final oh = (vp.vh / step).round().clamp(1, 1 << 14);
final angle = vp.angleDeg * math.pi / 180;
final cos = math.cos(-angle), sin = math.sin(-angle);
(double, double) toSrc(double vx, double vy) {
final x = (vx - vp.tx) / vp.sc - vp.cx;
final y = (vy - vp.ty) / vp.sc - vp.cy;
return ((cos * x - sin * y + vp.cx - vp.ox) / vp.k,
(sin * x + cos * y + vp.cy - vp.oy) / vp.k);
}
final out = img.Image(width: ow, height: oh);
for (var y = 0; y < oh; y++) {
final vy = (y + 0.5) * step;
for (var x = 0; x < ow; x++) {
final s = toSrc((x + 0.5) * step, vy);
final c = _sampleBilinear(src, s.$1, s.$2);
out.setPixelRgb(x, y, c.$1, c.$2, c.$3);
}
}
return out;
}
/// Manuel bilinear ornekleme (package:image 4.x API'si).
static (int, int, int) _sampleBilinear(img.Image src, double fx, double fy) {
final x0 = fx.floor(), y0 = fy.floor();
final tx = (fx - x0).clamp(0.0, 1.0), ty = (fy - y0).clamp(0.0, 1.0);
final c00 = src.getPixelClamped(x0, y0), c10 = src.getPixelClamped(x0 + 1, y0),
c01 = src.getPixelClamped(x0, y0 + 1), c11 = src.getPixelClamped(x0 + 1, y0 + 1);
int lerp(num a, num b, double t) => (a + (b - a) * t).toInt();
return (
lerp(lerp(c00.r, c10.r, tx), lerp(c01.r, c11.r, tx), ty),
lerp(lerp(c00.g, c10.g, tx), lerp(c01.g, c11.g, tx), ty),
lerp(lerp(c00.b, c10.b, tx), lerp(c01.b, c11.b, tx), ty),
);
}
/// Flutter'la ayni 0-255 alan/semantigindaki 4x5 matrisi uygular.
static void _applyMatrix(img.Image image, List<double> m) {
for (final pixel in image) {
final r = pixel.r.toDouble(), g = pixel.g.toDouble(), b = pixel.b.toDouble();
pixel.r = (m[0] * r + m[1] * g + m[2] * b + m[4]).clamp(0, 255).round();
pixel.g = (m[5] * r + m[6] * g + m[7] * b + m[9]).clamp(0, 255).round();
pixel.b = (m[10] * r + m[11] * g + m[12] * b + m[14]).clamp(0, 255).round();
}
}
}