An Android-first, performance-focused image optimization and editing pipeline built with Flutter and Rust. Inspired by Squoosh, engineered for mobile.
Shrinky is not just an image editor—it is a robust image processing engine designed to deliver instant previews, deferred high-quality exports, and aggressive memory management. By combining a beautiful cross-platform Flutter UI with a blazingly fast, multi-threaded Rust backend, it brings desktop-class image processing directly to local device hardware without relying on the cloud.
Modern mobile devices handle multi-megabyte images constantly, yet performing basic crop, resize, and compression operations locally often results in sluggish UIs, Out-Of-Memory (OOM) crashes, or poor output quality.
Shrinky solves this through a Proxy-Image Architecture:
- Local Only: No cloud servers. All processing stays on-device for total privacy and zero latency.
- Rust Engine: The heavy lifting (decoding, encoding, scaling, compressing) is written in Rust, leveraging memory safety and aggressive multi-threading.
- Flutter UI: The interface is built in Flutter for smooth, 60fps interaction and cross-platform flexibility.
- Proxy Workflow: When an image loads, a lightweight proxy is generated. The UI manipulates this proxy instantly. Only when the user hits "Save" does the Rust engine apply the operations to the full-resolution original image using high-quality (Lanczos3) resampling.
Current production efforts focus heavily on delivering the highest quality JPEG optimization experience before expanding codec support.
- Geometric Edits: Crop, Resize, Rotate, Flip, and Straighten.
- Advanced Compression: MozJPEG (Production), OxiPNG, WebP, and AVIF encoding (Experimental).
- Target Size Optimization: A binary-search optimization loop to dynamically compress an image to hit a specific file size (e.g., "Compress to exactly 500 KB").
- Iterative Workflow: Infinite Undo/Redo architecture without memory bloat.
- Export & Share: Export directly to device storage or trigger native sharing.
- Professional Diagnostics: Deep, configurable telemetry, JSONL performance logging, and structured device benchmarking suites.
| Format | Status | Notes |
|---|---|---|
| JPEG (MozJPEG) | Production Ready | Primary optimized workflow |
| PNG (OxiPNG) | Experimental | Internal builds only |
| WebP | Experimental | Internal builds only |
| AVIF | Experimental | Internal builds only |
- JPEG is the primary production optimization pipeline.
- Experimental codecs remain available for internal testing and benchmarking.
- Additional optimization work (compression speed, memory efficiency, quality tuning) is ongoing before production release.
Production builds expose only features that satisfy stringent criteria:
- Performance targets
- Memory targets
- Device validation
- Benchmark coverage
- Stability requirements
Experimental codecs remain available in development builds until they meet these production standards.
User
↓
Flutter UI
↓
Application Layer (Riverpod)
↓
FFI Bridge (flutter_rust_bridge)
↓
Rust Processing Engine
↓
Proxy Pipeline (Triangle Resample) -> Preview
Export Pipeline (Lanczos3 Resample) -> Export Cache
↓
Storage / Native Share
- Flutter UI: Handles user input, gesture recognition (panning, zooming), and displays the proxy preview.
- Application Layer: Managed via Riverpod. Orchestrates edit sessions, undo/redo history stacks, and background export queues.
- FFI Bridge: Generated by
flutter_rust_bridge. Maps Dart classes to Rust structs with zero-copy memory transfers where possible. - Rust Processing Engine: Owns the
EditorEnginestate, holding theoriginal_imageandproxy_image. - Export Cache: Prevents duplicate processing by caching final outputs until the edit stack changes.
The Rust backend exposes a powerful Native Processing API. These APIs run off the UI thread and utilize the image and custom codec crates to bypass slow Dart implementations.
The core stateful engine. Kept alive in Rust memory, referenced via an opaque pointer in Dart.
load_from_path(path: String): Instantiates the engine, parses EXIF orientation, decodes the original, and generates the lightweight UI proxy.apply_operations(operations: Vec<EditOperation>, is_export: bool): Executes the edit chain. Ifis_exportis false, it uses fast Triangle scaling on the proxy. If true, it uses Lanczos3 scaling on the full-resolution original.encode_image(codec: ImageCodec, quality: f32): Encodes the current state buffer into the final format.encode_image_to_target_size(codec: ImageCodec, target_kb: i32): Executes a multi-pass compression loop to hone in on the requested target file size.
- Selection: User selects an image.
- Proxy Generation: The Rust engine decodes the JPEG, applies EXIF rotation, and creates a downscaled proxy bounded to 1440px.
- Editing Session: As the user crops or resizes,
apply_operations(..., is_export: false)is called. The proxy updates instantly. - Preview Rendering: The proxy is quickly encoded to JPEG at 70% quality and piped back over FFI to the Flutter
Image.memorywidget. - Export Request: Upon saving or sharing, a background isolate triggers
apply_operations(..., is_export: true)on the original image, followed by high-quality encoding.
The application utilizes an immutable history stack.
Instead of storing full image buffers for every Undo step, the application stores an array of EditOperation enums.
When the user clicks "Undo", the state pointer moves back, and the Rust engine simply replays the operations from the original proxy baseline. This allows infinite undo/redo without causing an OOM crash.
The project features a mature, event-driven telemetry architecture designed to measure real-world mobile performance.
- Event Model: Telemetry is dispatched to a global Stream.
- JSONL Logs: Persisted to disk to allow deep analysis of cache hit rates, memory consumption, and encoding times.
- Rust Tracing: The Rust engine utilizes the
tracingcrate, seamlessly forwarding native spans (likejpeg_decode_ms) back to Dart. - Session IDs & Schemas: Every event contains a schema version and a unique Session ID to correlate operations across long benchmarks.
The project provides a unified Makefile as the single operational entry point. Do not guess commands. Use the Makefile.
| Command | Purpose |
|---|---|
make setup |
Installs Flutter/Rust dependencies and generates FFI bindings. |
make run |
Daily development with hot-reload. Clean UI, no overhead. |
make internal |
Engineering mode. Debug UI overlay enabled with basic metrics. |
make profiling |
Deep investigation mode. Heavy JSONL logging and Rust tracing enabled. |
make test |
Runs all unit tests. |
make integration-test |
Runs standard integration tests. |
make benchmark |
Runs the automated device benchmark suite (Core + Stress + Soak). |
make release |
Builds both the production APK and App Bundle. |
make logs |
Pulls performance telemetry logs from the connected Android device. |
make clean |
Cleans Flutter build directories and temporary outputs. |
make rebuild |
Performs a clean-all, setup, and dependency regeneration. |
Note: You can pass a specific device using the DEVICE flag:
make run DEVICE=emulator-5554.
If you must run manually, the Telemetry architecture is driven by the --dart-define=TELEMETRY_MODE flag.
- Internal Mode:
flutter run --debug --dart-define=TELEMETRY_MODE=internal - Profiling Mode:
flutter run --profile --dart-define=TELEMETRY_MODE=profiling - FRB Generation:
flutter_rust_bridge_codegen generate - Production Build:
flutter build apk --release(No custom product flavors exist)
Because this project is heavily performance-focused, it relies on an ADB-powered automated on-device benchmarking framework to prevent regressions, measure real-world performance on physical Android devices, and validate cache, history, and memory behavior.
- Unit Tests: Run via
make test. Validates UI logic, Riverpod providers, and routing. - Integration Tests: Run via
make integration-test. Standard Flutterintegration_testscripts to validate complete user flows on a device. Alternatively, run directly on a specific device:flutter test integration_test/device_benchmark_test.dart -d <device_id>
The Benchmark Framework acts as a headless driver. It loads a massive test dataset and forces the Rust engine through thousands of operations while tracking memory, RSS peaks, and execution times.
The recommended way to run benchmarks is through the automated pipeline, which handles building the APK, pushing the dataset, running the tests via ADB, pulling the results, and generating a report:
dart run scripts/adb_benchmark_runner.dartFlags:
--skip-build— Skip dataset generation, APK build, and push (use when re-running for faster iteration)--stress— Include edit chain stress + undo/redo torture tests--soak— Include 5-minute long session soak test to detect memory leaks
Examples:
# Re-run without rebuilding
dart run scripts/adb_benchmark_runner.dart --skip-build
# Full stress test run
dart run scripts/adb_benchmark_runner.dart --stress --soakNote: You can also trigger these via the Makefile with make benchmark, make stress, and make soak.
- Core Benchmarks: Image Load, Preview Generation, Undo/Redo latency, Export Generation, Target Size optimization, Resize downscaling, and Cache Validation.
- Stress Tests: Rapid edit chains (to track latency scaling and memory growth) and Undo/Redo torture cycles.
- Soak Tests: A 5-minute continuous loop of edits, undos, redos, and exports to detect performance degradation over time.
The framework automatically compares new results against the stored baseline (Core_Pipeline_latest.json). It fails if:
- P95 Latency increases by > 15%
- Peak Memory increases by > 20%
To reset the baseline, simply delete Core_Pipeline_latest.json from the output directory.
Reports are generated in logs/adb_benchmarks/ in JSON, CSV, and Markdown formats. A combined Device_Benchmark_Report.md is also generated which includes device specs and thermal data (polled every 15 seconds via ADB to identify thermal throttling).
To synthesize test images without running benchmarks, use:
dart run scripts/generate_dataset.dartThis generates images (1MP to 64MP JPEGs, and PNGs) in integration_test/assets/dataset/.
Note on Codecs: Experimental codecs are benchmarked internally and may be visible during
make run,make internal, andmake profiling. Benchmark results for these codecs should not be interpreted as production guarantees.
If the build cache becomes corrupted, utilize the modular cleanup commands:
make clean-flutter: Runsflutter clean.make clean-rust: Cleans Cargo targets.make clean-build: Deletes.dart_tool,build/,.flutter-plugins, and generated plugin registries.make clean-all: Performs a total repository wipe.make logs-clear: Wipes all accumulated telemetry JSONL logs from the connected physical device via ADB.
Ensure you have the flutter_rust_bridge_codegen binary installed globally via cargo install flutter_rust_bridge_codegen.
Telemetry is disabled in standard make run. You must execute make profiling to generate JSONL logs, and then execute make logs to pull them to your laptop.
If you encounter weird build cache issues on Android, run make rebuild.
| Task | Command |
|---|---|
| Setup Project | make setup |
| Run Clean | make run |
| Run w/ Diagnostics | make internal |
| Run w/ Deep Tracing | make profiling |
| Build Production | make release |
| Test & Benchmark | make benchmark |
| Pull Device Logs | make logs |
| Reset Project | make rebuild |
Copyright © 2026 Ashok Kumawat
Licensed under the Apache License, Version 2.0. See the LICENSE file for details.