Files
meshcore-open/lib/services/image_codec_service.dart
T
2026-08-11 00:15:45 -07:00

1574 lines
57 KiB
Dart

import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../models/image_codec_support.dart';
import '../utils/app_logger.dart';
import '../widgets/image_send_codec_binding.dart';
import 'app_settings_service.dart';
import 'image_codec_backend.dart' show kImageCodecBitstreamPathAvailable;
import 'image_codec_file_store.dart';
import 'image_codec_session.dart';
import 'image_codec_settings_store.dart';
/// Neural image codec (AEIC-SE) for sending photographs over the mesh.
///
/// Structurally a sibling of [TranslationService] — same model registry, same
/// `_runExclusive` serial lock, same load/unload lifecycle — with four
/// deliberate differences. Read `translation_service.dart` alongside this file:
///
/// 1. The heavy work runs in a worker isolate this class owns
/// ([ImageCodecSession]) rather than inside a package that owns its own
/// thread. A 1385 GFLOP decode on the root isolate would stall the BLE
/// notify stream and drop mesh traffic.
/// 2. [handleMemoryPressure] exists. The decode graph is ~886M parameters and
/// 2.16 GiB peak resident; it must be evicted on background and on memory
/// warnings or Android's low-memory killer takes the whole app. Do NOT copy
/// the translation stack's hold-until-disconnect behaviour.
/// 3. The download is **resumable and checksum-verified**, and fetches a *list*
/// of assets, because the model is an ONNX graph plus an 869 MiB
/// external-weights sibling. See the `---- download` section.
/// 4. Preferences live in [ImageCodecSettingsStore] until the `imageCodec*`
/// fields land on `AppSettings` (see that file's doc comment).
///
/// ## Two sessions, two lifetimes
///
/// The installed model is a *bundle* ([ImageCodecBundle]): a ~2.16 GiB-peak
/// synthesis decoder, two fp32 entropy graphs (64 MB send-side, 58 MB
/// decode-side, ~0.35 GiB peak and only ever one of them resident) and 813 KB
/// of CDF tables. Encoding needs only the send-side entropy graph; decoding
/// needs the decode-side graph and then the synthesis decoder, one after the
/// other — both resident at once measures 2.44 GiB. The worker therefore
/// creates ORT sessions lazily and can drop either half on its own
/// ([releaseDecoderSession] / [releaseEntropySession]), so a send never pays
/// for the decoder and [handleMemoryPressure] can shed the expensive half while
/// leaving the send path warm.
///
/// ## What works today
///
/// [decodeLatent] — the real ONNX synthesis pass, latent to pixels — plus
/// whatever the backend's entropy path supports. While
/// [kImageCodecBitstreamPathAvailable] is false, [encode] and [decodeBitstream]
/// throw [ImageCodecEntropyPathMissing] and [availability] reports
/// `unavailable` with [unavailableReason] carrying the sentence to show the
/// user. A build where that gate is true but the *installed files* are
/// decoder-only is a different, recoverable state: `disabled` +
/// [needsModelUpgrade] + [ImageCodecBundleIncomplete].
///
/// It implements [ImageSendCodec] directly, so `ImageSendPreviewSheet` and
/// `pickAndPreviewImage` bind to it with no adapter — replace
/// `FakeImageSendCodec()` at the two call sites in `chat_screen.dart` /
/// `channel_chat_screen.dart` with `context.read<ImageCodecService>()`.
class ImageCodecService extends ChangeNotifier implements ImageSendCodec {
final AppSettingsService _appSettingsService;
final ImageCodecFileStore _fileStore;
final ImageCodecSettingsStore _settingsStore;
/// Creates a new [http.Client] per download attempt.
///
/// Injectable so the download/resume/checksum logic can be tested against a
/// `MockClient` — without this the only way to exercise an 872 MB two-file
/// resumable transfer would be to actually perform one.
final http.Client Function() _newClient;
ImageCodecService(
this._appSettingsService, {
ImageCodecFileStore? fileStore,
ImageCodecSettingsStore? settingsStore,
http.Client Function()? clientFactory,
}) : _fileStore = fileStore ?? ImageCodecFileStore(),
_settingsStore = settingsStore ?? PrefsImageCodecSettingsStore(),
_newClient = clientFactory ?? http.Client.new;
// ---- state (naming/order mirrors TranslationService:50-62) ----------------
bool _disposed = false;
bool _isBusy = false;
bool _isDownloading = false;
bool _cancelDownloadRequested = false;
String? _lastError;
Future<void> _queue = Future<void>.value();
ImageCodecSession? _session;
ImageCodecBundle? _loadedBundle;
ImageCodecBundle? _failedBundle;
int _downloadedBytes = 0;
int? _downloadTotalBytes;
String? _downloadFileName;
// codec-specific
double? _codecProgress;
String? _codecStage;
// ---- getters (mirror translation_service.dart:64-76) ---------------------
bool get isBusy => _isBusy;
bool get isDownloading => _isDownloading;
String? get lastError => _lastError;
int get downloadedBytes => _downloadedBytes;
int? get downloadTotalBytes => _downloadTotalBytes;
String? get downloadFileName => _downloadFileName;
double? get downloadProgress {
final total = _downloadTotalBytes;
if (!_isDownloading || total == null || total <= 0) {
return null;
}
return (_downloadedBytes / total).clamp(0.0, 1.0);
}
/// 0..1 while an encode or decode is running, null when idle.
double? get codecProgress => _codecProgress;
/// Short human label for the current codec phase, null when idle.
String? get codecStage => _codecStage;
bool get isModelLoaded => _loadedBundle != null;
ImageCodecPreferences get preferences => _settingsStore.preferences;
/// Composer default rate point.
AeicRatePoint get defaultRatePoint => preferences.aeicRatePoint;
// ---- availability --------------------------------------------------------
/// The signal the compose UI binds to.
///
/// Mapping decisions worth knowing:
/// * `unavailable` means "this build/device can never do this" — web, a build
/// whose inference backend failed to load, or (today, always) a build
/// without the rANS entropy path. Sending a picture needs bytes, and this
/// build can only turn a *latent* into a picture. See
/// [kImageCodecBitstreamPathAvailable] and [ImageCodecEntropyPathMissing];
/// [unavailableReason] carries the explanation for the UI.
/// * `disabled` covers "switched off in settings", "no model downloaded
/// yet" and "the installed model predates the entropy bundle", because the
/// sheet's remedy is the same in all three: send the user to settings.
/// [needsModelDownload] and [needsModelUpgrade] distinguish them, and
/// [statusReason] renders the difference as a sentence.
@override
ImageCodecAvailability get availability {
if (unavailableReason != null) {
return ImageCodecAvailability.unavailable;
}
if (!_appSettingsService.settings.imageMessagesEnabled ||
!preferences.enabled) {
return ImageCodecAvailability.disabled;
}
if (_isDownloading) {
return ImageCodecAvailability.downloading;
}
final model = selectedModel;
if (model == null || model.localPath.isEmpty) {
return ImageCodecAvailability.disabled;
}
// A decoder-only install cannot encode, and a bundle-version-1 install can
// encode but not decode; neither is "ready", so the compose UI must not say
// so. `disabled` (not `unavailable`) because the remedy is a download —
// see [needsModelUpgrade] and [statusReason]. Kept in step with
// [needsModelUpgrade] on purpose: `ready` plus an upgrade prompt would be a
// contradiction the sheet has no way to render.
if (installedBundle?.supportsDecode != true) {
return ImageCodecAvailability.disabled;
}
return ImageCodecAvailability.ready;
}
/// Why [availability] is `unavailable`, or null when it is not.
///
/// EXACTLY THREE CAUSES, all of them permanent properties of the build or the
/// platform, none of them fixable by the user:
/// * web, which has no native inference runtime;
/// * a build compiled without the entropy path
/// ([kImageCodecBitstreamPathAvailable]);
/// * a backend that failed to load.
///
/// An incomplete or legacy install is deliberately NOT one of them: its
/// remedy is a download, so it is `disabled` + [needsModelUpgrade]. Reporting
/// it here would tell every user of the decoder-only build that their phone
/// can never send a picture, which is false.
@override
String? get unavailableReason {
if (kIsWeb) {
return 'The image codec needs a native inference runtime, which the web '
'build does not have.';
}
if (!kImageCodecBitstreamPathAvailable) {
return 'This build ships the image decoder only. Encoding and decoding a '
'bitstream also needs the entropy-side graph and the rANS coder, '
'which are not included yet.';
}
if (_backendMissing) {
return _lastError ?? 'The inference backend failed to load.';
}
return null;
}
/// One user-facing sentence for whatever non-ready state the codec is in, or
/// null when it is ready.
///
/// [unavailableReason] can only explain the three permanent causes, so a
/// sheet that renders it alone falls back to a generic "not available" string
/// for the two states the user can actually DO something about — the feature
/// being switched off and the model not being installed. This getter covers
/// every case and is what a "why can't I send?" banner should show; it is a
/// superset, so `statusReason ?? unavailableReason` is never needed.
String? get statusReason {
final permanent = unavailableReason;
if (permanent != null) {
return permanent;
}
if (!_appSettingsService.settings.imageMessagesEnabled) {
return 'Image messages are switched off. Turn them on in Settings to '
'send and receive pictures.';
}
if (!preferences.enabled) {
return 'The image codec is switched off in Settings.';
}
if (_isDownloading) {
final name = _downloadFileName;
return name == null
? 'The image codec model is downloading.'
: 'The image codec model is downloading ($name).';
}
if (needsModelDownload) {
return 'The image codec model is not downloaded yet. It is about '
'${(kImageCodecBundleTotalBytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} '
'GB and can be fetched from Settings.';
}
if (needsModelUpgrade) {
return 'The installed image codec model is missing part of the current '
'bundle, so it cannot send and receive pictures. Re-download it in '
'Settings.';
}
if (_lastError != null && !canRunInference) {
return _lastError;
}
return null;
}
/// True when the feature is on but no weights are on disk.
bool get needsModelDownload {
final model = selectedModel;
return preferences.enabled && (model == null || model.localPath.isEmpty);
}
/// True when a model IS installed but it is missing part of the current
/// bundle: a decoder-only (pre-bundle) install with no entropy graph and no
/// CDF tables, or a bundle-version-1 install with the send-side entropy graph
/// but no decode-side one.
///
/// The remedy is a re-download, which is why this is not folded into
/// [unavailableReason]. Keyed off both the recorded [kImageCodecBundleVersion]
/// and the actual paths, so a record hand-edited to claim version 1 without
/// the files still reports true.
bool get needsModelUpgrade {
final model = selectedModel;
if (model == null || model.localPath.isEmpty) {
return false;
}
if (model.bundleVersion < kImageCodecBundleVersion) {
return true;
}
return installedBundle?.supportsDecode != true;
}
/// Paths of the installed model, or null when nothing is installed.
///
/// The decoder graph is [ImageCodecModelRecord.localPath]; the two entropy
/// graphs and the CDF tables are resolved BY ROLE, from
/// [ImageCodecModelRecord.assetRoles] first and the registry spec second, so
/// nothing here re-derives a filename by convention. A pre-bundle record
/// simply yields an incomplete bundle.
ImageCodecBundle? get installedBundle {
final model = selectedModel;
if (model == null || model.localPath.isEmpty) {
return null;
}
final spec = _specForId(model.id);
final directory = _directoryOf(model.localPath);
String? pathFor(
ImageCodecAssetRole role,
bool Function(String) looksRight,
) {
// 1. What the installer recorded this file AS. Exact, and the only source
// that can tell the two entropy graphs apart.
final recorded = model.fileNameForRole(role);
if (recorded != null) {
return '$directory/$recorded';
}
// 2. The registry spec, for records written before assetRoles existed.
final declared = spec?.maybeAssetFor(role)?.fileName;
if (declared != null && model.assetFileNames.contains(declared)) {
return '$directory/$declared';
}
// 3. Last resort for a custom-URL install or an old record: the recorded
// names, never a guess at a name that was never downloaded.
for (final name in model.assetFileNames) {
if (looksRight(name)) {
return '$directory/$name';
}
}
return null;
}
final decoderName = model.localPath.split('/').last;
// The bundle now holds THREE `.onnx` files, so "any .onnx that is not the
// decoder" no longer identifies the send-side graph on its own. The
// heuristic is only ever reached for a record with no matching spec entry,
// and it must not be allowed to hand the decode-side export to the encoder:
// that graph has no `image` input and would fail at the first run.
final decodeGraphName =
model.fileNameForRole(ImageCodecAssetRole.entropyDecodeGraph) ??
spec?.maybeAssetFor(ImageCodecAssetRole.entropyDecodeGraph)?.fileName;
return ImageCodecBundle(
decoderGraphPath: model.localPath,
entropyGraphPath: pathFor(
ImageCodecAssetRole.entropyGraph,
(name) =>
name.endsWith('.onnx') &&
name != decoderName &&
name != decodeGraphName,
),
// Declared-name only, no heuristic. A record written before bundle
// version 2 has no decode-side graph on disk at all, and inventing a
// filename for it would trade a clean "re-download" prompt for an opaque
// ORT session-creation failure.
entropyDecodeGraphPath: pathFor(
ImageCodecAssetRole.entropyDecodeGraph,
(_) => false,
),
tablesPath: pathFor(
ImageCodecAssetRole.cdfTables,
(name) => name.endsWith('.bin'),
),
ratePoint: spec?.ratePoint ?? preferences.aeicRatePoint,
);
}
static String _directoryOf(String path) {
final index = path.lastIndexOf('/');
return index <= 0 ? path : path.substring(0, index);
}
ImageCodecModelSpec? _specForId(String id) {
for (final spec in imageCodecPresetModels) {
if (spec.id == id) return spec;
}
return null;
}
/// Set once a session load has failed with [ImageCodecUnimplemented], i.e.
/// this build has no inference runtime. Sticky: retrying cannot help.
bool _backendMissing = false;
// ---- registry (mirror translation_service.dart:129-174) ------------------
List<ImageCodecModelRecord> get availableModels =>
preferences.downloadedModels;
ImageCodecModelRecord? get selectedModel {
final selectedId = preferences.selectedModelId;
if (selectedId == null) {
return availableModels.isNotEmpty ? availableModels.first : null;
}
for (final model in availableModels) {
if (model.id == selectedId) {
return model;
}
}
return availableModels.isNotEmpty ? availableModels.first : null;
}
/// Loads persisted preferences and reconciles them with what is on disk.
///
/// Call once at startup, next to
/// `await translationService.refreshDownloadedModels();` in `main.dart`.
Future<void> refreshDownloadedModels() async {
if (_isDownloading) return;
await _settingsStore.load();
if (kIsWeb) {
_notify();
return;
}
final scanned = await _fileStore.scanDownloadedModels();
if (scanned.isEmpty) {
_notify();
return;
}
// The file store reports one record per FILE, which is no longer one record
// per model: a bundle is four files, and taking them at face value would
// list `aeic_cdf_ft32.bin` as an installed codec. Reconcile against the
// registry here, so this survives whatever the store does.
final onDisk = {for (final model in scanned) model.name: model};
final existingByPath = {
for (final model in preferences.downloadedModels) model.localPath: model,
};
final merged = <ImageCodecModelRecord>[];
// Every filename the registry knows about, so a half-installed bundle's
// leftovers are never mistaken for standalone models.
final claimed = <String>{
for (final spec in imageCodecPresetModels)
for (final asset in spec.assets) asset.fileName,
};
for (final spec in imageCodecPresetModels) {
final graphFile = onDisk[spec.fileName];
if (graphFile == null) continue;
final present = [
for (final asset in spec.assets)
if (onDisk.containsKey(asset.fileName)) asset.fileName,
];
final existing = existingByPath[graphFile.localPath];
final complete = spec.assets.every(
(asset) => onDisk.containsKey(asset.fileName),
);
merged.add(
ImageCodecModelRecord(
id: spec.id,
name: spec.fileName,
sourceUrl: existing?.sourceUrl ?? spec.graph.sourceUrl,
localPath: graphFile.localPath,
downloadedAt: existing?.downloadedAt ?? graphFile.downloadedAt,
fileSizeBytes: [
for (final name in present) onDisk[name]!.fileSizeBytes,
].fold<int>(0, (sum, size) => sum + size),
assetFileNames: present,
assetRoles: {
for (final asset in spec.assets)
if (onDisk.containsKey(asset.fileName))
asset.fileName: asset.role,
},
bundleVersion: complete ? kImageCodecBundleVersion : 0,
),
);
}
// Anything not part of a registry bundle is a custom-URL install; keep it
// as its own single-file record so a hand-fetched graph is not deleted.
for (final model in scanned) {
if (claimed.contains(model.name)) continue;
final existing = existingByPath[model.localPath];
merged.add(
existing?.copyWith(fileSizeBytes: model.fileSizeBytes) ?? model,
);
}
var updated = preferences.copyWith(downloadedModels: merged);
_failedBundle = null;
if (updated.selectedModelId == null && merged.isNotEmpty) {
updated = updated.copyWith(selectedModelId: merged.first.id);
}
await _settingsStore.save(updated);
_notify();
}
Future<void> setEnabled(bool value) async {
await _settingsStore.save(preferences.copyWith(enabled: value));
if (!value) {
await releaseModel();
}
_notify();
}
Future<void> setSelectedModelId(String? id) async {
await _settingsStore.save(preferences.copyWith(selectedModelId: id));
// The cached session belongs to the previous model.
await releaseModel();
_notify();
}
Future<void> setDefaultRatePoint(AeicRatePoint ratePoint) async {
await _settingsStore.save(
preferences.copyWith(ratePoint: ratePoint.wireValue),
);
_notify();
}
// ---- gating --------------------------------------------------------------
/// Whether a model file is present and a session could be loaded.
///
/// This is the ONNX-only capability: it gates [decodeLatentToRgb], which
/// works. [canEncode]/[canDecode] additionally require the entropy path.
bool get canRunInference {
final model = selectedModel;
return !kIsWeb &&
!_backendMissing &&
model != null &&
model.localPath.isNotEmpty;
}
/// Whether a picture can be turned into bytes on this device right now.
///
/// Three independent facts, all required: a model is installed and the
/// backend loads ([canRunInference]), the build contains the entropy path
/// ([kImageCodecBitstreamPathAvailable]), and the *installed* bundle carries
/// the entropy graph and the CDF tables.
bool get canEncode =>
canRunInference &&
kImageCodecBitstreamPathAvailable &&
installedBundle?.isComplete == true;
/// Everything [canEncode] needs, PLUS the decode-side entropy graph.
///
/// Not an alias for [canEncode] any more. Decoding re-runs the entropy model
/// to reproduce the encoder's symbol probabilities, but it has to do it one
/// stage at a time, which the send-side export cannot do — so a bundle-
/// version-1 install can send a picture and not open one. Treating the two as
/// the same fact would let the receive path start a decode that fails
/// halfway.
bool get canDecode =>
canRunInference &&
kImageCodecBitstreamPathAvailable &&
installedBundle?.supportsDecode == true;
/// Whether a reassembled chunk set is complete enough to attempt a decode.
///
/// A truncated AEIC bitstream does not decode to a degraded picture — the
/// rANS decoder desynchronises and produces garbage without raising — so the
/// answer is strictly "all data chunks present". Single-loss recovery is the
/// parity chunk's job in `image_chunk_transport.dart`, upstream of here.
bool canDecodeChunkSet(int received, int total) {
if (!canDecode || total <= 0) return false;
return received >= total;
}
// ---- download -------------------------------------------------------------
//
// Derived from translation_service.dart:176-366, then changed in three ways
// that matter at 872 MB across two files:
//
// 1. RESUMABLE. Each of the 8 range chunks is its own file whose *length is
// its progress marker*, so a dropped connection resumes with
// `Range: bytes=<start+have>-<end>` instead of restarting from zero. The
// chunk filenames embed the total size, so a re-download of a file whose
// length changed upstream can never resume onto stale offsets.
// 2. VERIFIED. A completed file is SHA-256'd against the expected digest
// before it is recorded. Without this, corruption arrived as an opaque
// session-load failure that got poisoned into `_failedModelPath` — a dead
// feature with no diagnostic and no way back except clearing app data.
// 3. MULTI-FILE. A model is a list of assets (graph + external weights), all
// of which must land in one directory under exact names. See
// `ImageCodecModelSpec`.
static const int _parallelChunks = 8;
static const int _parallelMinBytes = 10 * 1024 * 1024; // 10 MB
/// Downloads every asset of a declared preset model.
///
/// Refuses a spec whose URLs are placeholders — see
/// [ImageCodecModelSpec.urlsArePlaceholders] and the banner on
/// [imageCodecPresetModels]. That refusal is the guard against shipping the
/// invented HuggingFace paths.
Future<ImageCodecModelRecord> downloadPresetModel(
ImageCodecModelSpec spec,
) async {
if (spec.urlsArePlaceholders) {
throw StateError(
'Model "${spec.id}" has placeholder URLs that have never been fetched. '
'Publish the weights and update imageCodecPresetModels first.',
);
}
if (!spec.isComplete) {
throw StateError(
'Model "${spec.id}" is missing one of the four bundle roles; it would '
'install a codec that cannot encode or decode.',
);
}
return _downloadAssets(
id: spec.id,
assets: spec.assets,
declaredTotalBytes: spec.totalSizeBytes,
// Role-resolved, not positional: the graph handed to ORT is the decoder,
// which is the smallest of the four files and would never be assets.first
// if anyone sorted the list by size.
primary: spec.assetFor(ImageCodecAssetRole.decoderGraph),
bundleVersion: kImageCodecBundleVersion,
);
}
/// Downloads a single-file model from an arbitrary URL.
///
/// Kept for the settings screen's "custom URL" affordance. A model with
/// external weights cannot be expressed this way; use [downloadPresetModel].
Future<ImageCodecModelRecord> downloadModel({
required String sourceUrl,
String? fileName,
String? id,
}) async {
final uri = Uri.tryParse(sourceUrl);
if (uri == null || !uri.hasScheme) {
throw ArgumentError('Invalid model URL.');
}
final resolvedFileName =
fileName ??
_sanitizeFileName(
uri.pathSegments.isNotEmpty
? uri.pathSegments.last
: 'image-codec-model.onnx',
);
return _downloadAssets(
id: id ?? resolvedFileName,
assets: [
ImageCodecModelAsset(
role: ImageCodecAssetRole.decoderGraph,
fileName: resolvedFileName,
sourceUrl: sourceUrl,
sizeBytes: 0,
),
],
declaredTotalBytes: 0,
// A single-file install is by definition decoder-only.
bundleVersion: 0,
);
}
Future<ImageCodecModelRecord> _downloadAssets({
required String id,
required List<ImageCodecModelAsset> assets,
required int declaredTotalBytes,
required int bundleVersion,
ImageCodecModelAsset? primary,
}) async {
for (final asset in assets) {
final uri = Uri.tryParse(asset.sourceUrl);
if (uri == null || !uri.hasScheme) {
throw ArgumentError('Invalid model URL: ${asset.sourceUrl}');
}
}
return _runExclusive(() async {
_setBusy(true);
_setDownloading(true);
_lastError = null;
try {
_downloadedBytes = 0;
_downloadTotalBytes = declaredTotalBytes > 0
? declaredTotalBytes
: null;
_cancelDownloadRequested = false;
// One progress bar across the whole set: `_downloadedBytes` accumulates
// over every asset and `_downloadTotalBytes` is the sum of all four, so
// a 900 MB bundle shows one monotonic bar rather than four that each
// snap back to zero. `_downloadFileName` names the file in flight.
final graphAsset = primary ?? assets.first;
final downloadedFiles = <String, DownloadedCodecFile>{};
for (final asset in assets) {
downloadedFiles[asset.fileName] = await _downloadAsset(asset);
}
final graph = downloadedFiles[graphAsset.fileName]!;
final record = ImageCodecModelRecord(
id: id,
name: graphAsset.fileName,
sourceUrl: graphAsset.sourceUrl,
localPath: graph.localPath,
downloadedAt: DateTime.now(),
// The whole bundle, not just the graph: this is what the settings
// screen shows next to the installed model, and 3 MB would be a lie
// about 900 MB of storage.
fileSizeBytes: downloadedFiles.values.fold<int>(
0,
(sum, file) => sum + file.fileSizeBytes,
),
assetFileNames: [for (final asset in assets) asset.fileName],
// The only place the role of each downloaded file is known for
// certain. Recorded now so nothing downstream has to infer it from a
// filename — see [ImageCodecModelRecord.assetRoles].
assetRoles: {for (final asset in assets) asset.fileName: asset.role},
bundleVersion: bundleVersion,
);
final updated = [
for (final existing in preferences.downloadedModels)
if (existing.id != record.id) existing,
record,
];
await _settingsStore.save(
preferences.copyWith(
downloadedModels: updated,
selectedModelId: record.id,
modelSourceUrl: graphAsset.sourceUrl,
),
);
_failedBundle = null;
return record;
} finally {
_setDownloading(false);
}
});
}
Future<DownloadedCodecFile> _downloadAsset(ImageCodecModelAsset asset) async {
final uri = Uri.parse(asset.sourceUrl);
_downloadFileName = asset.fileName;
_notify();
final finalPath = await _fileStore.modelFilePath(asset.fileName);
// Already here and intact? Credit its bytes to the progress bar and move on.
// This is what makes retrying after a failure on asset 2 of 2 cheap.
final existing = await _fileStore.fileSize(finalPath);
if (existing > 0 &&
(asset.sizeBytes == 0 || existing == asset.sizeBytes) &&
await _checksumMatches(asset, finalPath)) {
_downloadedBytes += existing;
_notify();
return DownloadedCodecFile(localPath: finalPath, fileSizeBytes: existing);
}
if (existing > 0) {
await _fileStore.deleteFile(finalPath);
}
final head = await _probe(uri);
final totalSize = head.contentLength ?? asset.sizeBytes;
final DownloadedCodecFile downloaded;
if (head.supportsRange && totalSize > _parallelMinBytes) {
downloaded = await _downloadRanged(
uri: uri,
fileName: asset.fileName,
totalSize: totalSize,
);
} else {
downloaded = await _downloadSingle(uri: uri, fileName: asset.fileName);
}
await _verifyChecksum(asset, downloaded.localPath);
await _fileStore.deletePartialDownloads(asset.fileName);
return downloaded;
}
Future<_HeadResult> _probe(Uri uri) async {
final client = _newClient();
try {
final response = await client.send(http.Request('HEAD', uri));
await response.stream.drain<void>();
return _HeadResult(
contentLength: response.contentLength,
supportsRange:
response.headers['accept-ranges']?.contains('bytes') == true,
);
} finally {
client.close();
}
}
/// Non-resumable fallback: used when the server will not honour Range, or the
/// file is small enough that resume is pointless.
Future<DownloadedCodecFile> _downloadSingle({
required Uri uri,
required String fileName,
}) async {
final client = _newClient();
try {
final response = await client.send(http.Request('GET', uri));
if (response.statusCode < 200 || response.statusCode >= 300) {
throw StateError('Model download failed: HTTP ${response.statusCode}');
}
_downloadTotalBytes ??= response.contentLength;
_notify();
final trackedStream = _trackDownloadProgress(response.stream);
return await _fileStore.writeModelBytes(
fileName: fileName,
chunks: trackedStream,
);
} finally {
client.close();
}
}
/// 8-way parallel, per-chunk resumable.
Future<DownloadedCodecFile> _downloadRanged({
required Uri uri,
required String fileName,
required int totalSize,
}) async {
final chunkSize = (totalSize / _parallelChunks).ceil();
final chunkPaths = <String>[];
final clients = <http.Client>[];
try {
final futures = <Future<void>>[];
for (var i = 0; i < _parallelChunks; i++) {
final start = i * chunkSize;
if (start >= totalSize) break;
final end = (start + chunkSize - 1).clamp(0, totalSize - 1);
final expected = end - start + 1;
// The chunk key carries totalSize so a file that changed length upstream
// cannot resume onto offsets computed for the old length. Silent
// cross-version splicing is the one failure mode a naive resume has that
// no-resume does not, and it produces a corrupt model with a valid size.
final chunkPath = await _fileStore.chunkFilePath(
'$fileName.$totalSize',
i,
);
chunkPaths.add(chunkPath);
var have = await _fileStore.fileSize(chunkPath);
if (have > expected) {
// Only reachable if a previous run wrote past its range. Distrust it.
await _fileStore.deleteFile(chunkPath);
have = 0;
}
_downloadedBytes += have;
if (have == expected) {
continue; // Complete from a previous attempt.
}
final client = _newClient();
clients.add(client);
futures.add(
_downloadRange(
client: client,
uri: uri,
chunkPath: chunkPath,
start: start + have,
end: end,
),
);
}
_notify();
await Future.wait(futures);
if (_cancelDownloadRequested) {
throw const ImageCodecDownloadCancelled();
}
return await _fileStore.combineChunks(
fileName: fileName,
chunkPaths: chunkPaths,
);
} finally {
for (final client in clients) {
client.close();
}
// Chunk files are deliberately NOT deleted on failure — they are the
// resume state. `combineChunks` reaps them once the merge succeeds, and
// `deletePartialDownloads` sweeps them after verification.
}
}
Future<void> _downloadRange({
required http.Client client,
required Uri uri,
required String chunkPath,
required int start,
required int end,
}) async {
final request = http.Request('GET', uri);
request.headers['Range'] = 'bytes=$start-$end';
final response = await client.send(request);
if (response.statusCode != 206) {
await response.stream.drain<void>();
throw StateError(
'Range download failed: HTTP ${response.statusCode}'
'${response.statusCode == 200 ? ' (server ignored Range header)' : ''}',
);
}
await _fileStore.appendBytes(
path: chunkPath,
chunks: _trackDownloadProgress(response.stream),
);
}
/// True when [path] matches [asset]'s digest, or when there is no digest.
Future<bool> _checksumMatches(ImageCodecModelAsset asset, String path) async {
if (!asset.hasChecksum) {
return true;
}
try {
final actual = await _fileStore.sha256OfFile(path);
return actual == asset.sha256.toLowerCase();
} catch (_) {
return false;
}
}
/// Verifies [path] and deletes it if the digest is wrong.
///
/// A no-op when the asset has no digest — which is the case for every preset
/// today, because nothing has been published to hash. That is a real hole:
/// see the note on [ImageCodecModelAsset.sha256].
Future<void> _verifyChecksum(ImageCodecModelAsset asset, String path) async {
if (!asset.hasChecksum) {
appLogger.warn(
'No SHA-256 for ${asset.fileName}; integrity unverified',
tag: 'ImageCodec',
);
return;
}
_downloadFileName = 'Verifying ${asset.fileName}';
_notify();
final actual = await _fileStore.sha256OfFile(path);
if (actual != asset.sha256.toLowerCase()) {
await _fileStore.deleteFile(path);
throw ImageCodecIntegrityFailure(
fileName: asset.fileName,
expected: asset.sha256.toLowerCase(),
actual: actual,
);
}
}
void cancelDownload() {
if (!_isDownloading) {
return;
}
_cancelDownloadRequested = true;
_lastError = 'Download stopped.';
_notify();
}
Future<void> removeModel(ImageCodecModelRecord model) async {
// Free the isolate first: on Android the file cannot be deleted while the
// native session still has it mapped.
await releaseModel();
await _runExclusive(() async {
_setBusy(true);
_lastError = null;
await _fileStore.deleteModel(model);
// deleteModel only knows about `localPath` (the 3 MB graph) unless the
// store has been taught about bundles, so sweep the recorded siblings
// here too. Without this, "Remove model" leaves 940 MB on the device.
final directory = _directoryOf(model.localPath);
for (final name in model.assetFileNames) {
final path = '$directory/$name';
if (path == model.localPath) continue;
await _fileStore.deleteFile(path);
await _fileStore.deletePartialDownloads(name);
}
final updated = preferences.downloadedModels
.where((entry) => entry.id != model.id)
.toList();
var next = preferences.copyWith(downloadedModels: updated);
if (next.selectedModelId == model.id) {
next = next.copyWith(
selectedModelId: updated.isNotEmpty ? updated.first.id : null,
);
}
await _settingsStore.save(next);
});
}
// ---- codec API -----------------------------------------------------------
/// [ImageSendCodec] entry point used by `ImageSendPreviewSheet`.
///
/// Takes the raw picked file (JPEG/PNG/HEIC/…), centre-crops and scales it to
/// [kImageCodecSquareSize] square, then encodes. Throws on failure so the
/// sheet can surface it — unlike the nullable service-level methods below,
/// whose contract is "null means not available".
@override
Future<Uint8List> encode(
Uint8List imageBytes,
ImageCodecRatePoint rate,
) async {
if (!kImageCodecBitstreamPathAvailable) {
// Fail here rather than after a 512x512 raster and a session load, and
// fail with the specific exception so the sheet can say why.
throw const ImageCodecEntropyPathMissing();
}
if (installedBundle?.isComplete != true) {
// A different failure with a different remedy: the build can do this, the
// installed files cannot. Do not collapse the two.
throw const ImageCodecBundleIncomplete();
}
final result = await encodeImage(
rgbBytes: await _toSquareRgb(imageBytes),
ratePoint: aeicRatePointForUi(rate),
);
final bitstream = result?.bitstream;
if (bitstream == null) {
throw StateError(_lastError ?? 'Image codec is not available.');
}
return bitstream;
}
/// Encodes an already-decoded RGB image.
///
/// [rgbBytes] must be exactly `512 * 512 * 3` packed 8-bit RGB. Anything else
/// is rejected: the decoder's SD-Turbo UNet needs a 64x64 latent, so 512 is a
/// hard floor, not a default.
Future<ImageCodecResult?> encodeImage({
required Uint8List rgbBytes,
required AeicRatePoint ratePoint,
}) async {
if (!canEncode) return null;
const expected =
kImageCodecSquareSize * kImageCodecSquareSize * _bytesPerPixel;
if (rgbBytes.length != expected) {
throw ArgumentError.value(
rgbBytes.length,
'rgbBytes',
'expected $expected bytes of ${kImageCodecSquareSize}x'
'$kImageCodecSquareSize RGB',
);
}
return _runCodecJob(
stage: 'Encoding…',
ratePoint: ratePoint,
action: (session) => session.encode(
rgbBytes,
ratePoint,
kImageCodecSquareSize,
onProgress: _reportCodecProgress,
),
wrap: (bytes, elapsed) => ImageCodecResult(
bitstream: bytes,
ratePoint: ratePoint,
resolution: kImageCodecSquareSize,
durationMs: elapsed,
status: ImageCodecStatus.completed,
),
);
}
/// Encodes a source file, resizing to 512x512 first.
///
/// Returns null when [canEncode] is false.
Future<ImageCodecResult?> encodeImageFile({
required String path,
AeicRatePoint? ratePoint,
}) async {
if (!canEncode) return null;
final bytes = await _fileStore.readFileBytes(path);
return encodeImage(
rgbBytes: await _toSquareRgb(bytes),
ratePoint: ratePoint ?? defaultRatePoint,
);
}
/// Decodes a reassembled bitstream back to PNG bytes.
///
/// [resolution] comes from the chunk-0 metadata byte; a stream announcing a
/// resolution this build cannot decode is rejected rather than guessed at.
Future<ImageCodecResult?> decodeBitstream({
required Uint8List bitstream,
required AeicRatePoint ratePoint,
required int resolution,
}) async {
if (!kImageCodecBitstreamPathAvailable) {
// A received image cannot be silently dropped: the receive path has to be
// able to tell the user why nothing rendered.
throw const ImageCodecEntropyPathMissing();
}
// `supportsDecode`, not `isComplete`: a bundle-version-1 install has the
// send-side entropy graph and can encode, but the decode-side graph is what
// turns bytes back into a latent. Same remedy either way — re-download.
if (installedBundle?.supportsDecode != true) {
throw const ImageCodecBundleIncomplete();
}
if (!canDecode) return null;
if (bitstream.isEmpty) return null;
if (resolution != kImageCodecSquareSize) {
_lastError = 'Unsupported image resolution: $resolution.';
_notify();
return ImageCodecResult(
ratePoint: ratePoint,
resolution: resolution,
durationMs: 0,
status: ImageCodecStatus.failed,
);
}
// The tables are per-checkpoint. Decoding an ft16 stream against ft32
// tables does not fail — it desynchronises rANS and yields a sharp,
// plausible, wrong picture — so a rate mismatch is refused up front. It is
// a failed *result*, not a throw: it is a property of the received message,
// not a programming error.
final installedRate = installedBundle?.ratePoint;
if (installedRate != null && installedRate != ratePoint) {
_lastError =
'This picture was sent at ${ratePoint.name}, but the installed model '
'is ${installedRate.name}.';
_notify();
return ImageCodecResult(
ratePoint: ratePoint,
resolution: resolution,
durationMs: 0,
status: ImageCodecStatus.failed,
);
}
final result = await _runCodecJob(
stage: 'Decoding…',
ratePoint: ratePoint,
action: (session) => session.decode(
bitstream,
ratePoint,
resolution,
onProgress: _reportCodecProgress,
),
// The worker returns packed RGB, exactly like decodeLatent. Labelling it
// `pngBytes` (as this used to) hands the receive path bytes that are not
// a PNG under a name that says they are; `Image.memory` then fails on a
// decode that actually succeeded.
wrap: (bytes, elapsed) => ImageCodecResult(
rgbBytes: bytes,
ratePoint: ratePoint,
resolution: resolution,
durationMs: elapsed,
status: ImageCodecStatus.completed,
),
);
final rgb = result?.rgbBytes;
if (result == null || rgb == null) {
return result;
}
// Both forms: RGB for anything that wants pixels, PNG for `Image.memory`.
return ImageCodecResult(
rgbBytes: rgb,
pngBytes: await rgbToPng(rgb, result.resolution),
ratePoint: result.ratePoint,
resolution: result.resolution,
durationMs: result.durationMs,
status: result.status,
);
}
/// Runs the synthesis half of the decoder: latent -> packed 8-bit RGB.
///
/// **This is the only inference path that works in this build.** It is what a
/// device smoke test should call: dump a `y_hat` from
/// `aic/exp/aeic_runner.py`, ship the 65,536 float32s to the phone, and
/// compare the result against the desktop ONNX output. Everything from a
/// bitstream to `y_hat` needs the entropy path — see
/// [ImageCodecEntropyPathMissing].
///
/// Returns null when no model is available; the returned result carries
/// [ImageCodecResult.rgbBytes].
Future<ImageCodecResult?> decodeLatent(Float32List yHat) async {
if (!canRunInference) return null;
if (yHat.length != kImageCodecLatentElements) {
throw ArgumentError.value(
yHat.length,
'yHat',
'expected $kImageCodecLatentElements float32 latents '
'(shape $kImageCodecLatentShape)',
);
}
return _runCodecJob(
stage: 'Rendering…',
ratePoint: kShippingAeicRatePoint,
action: (session) =>
session.decodeLatent(yHat, onProgress: _reportCodecProgress),
wrap: (bytes, elapsed) => ImageCodecResult(
rgbBytes: bytes,
ratePoint: kShippingAeicRatePoint,
resolution: kImageCodecSquareSize,
durationMs: elapsed,
status: ImageCodecStatus.completed,
),
);
}
/// [decodeLatent] followed by PNG encoding, for anything that renders an
/// `Image.memory`.
Future<ImageCodecResult?> decodeLatentToPng(Float32List yHat) async {
final result = await decodeLatent(yHat);
final rgb = result?.rgbBytes;
if (result == null || rgb == null) {
return result;
}
return ImageCodecResult(
rgbBytes: rgb,
pngBytes: await rgbToPng(rgb, result.resolution),
ratePoint: result.ratePoint,
resolution: result.resolution,
durationMs: result.durationMs,
status: result.status,
);
}
/// Packed 8-bit RGB -> PNG, via `dart:ui` so this adds no dependency.
///
/// Runs on the calling isolate. A single 512x512 raster is four orders of
/// magnitude cheaper than the 1385 GFLOP decode that produced it.
static Future<Uint8List> rgbToPng(Uint8List rgb, int side) async {
final pixels = side * side;
if (rgb.length != pixels * _bytesPerPixel) {
throw ArgumentError.value(
rgb.length,
'rgb',
'expected ${pixels * _bytesPerPixel} bytes for ${side}x$side RGB',
);
}
final rgba = Uint8List(pixels * 4);
for (var i = 0, s = 0, d = 0; i < pixels; i++, s += 3, d += 4) {
rgba[d] = rgb[s];
rgba[d + 1] = rgb[s + 1];
rgba[d + 2] = rgb[s + 2];
rgba[d + 3] = 0xFF;
}
final buffer = await ui.ImmutableBuffer.fromUint8List(rgba);
final descriptor = ui.ImageDescriptor.raw(
buffer,
width: side,
height: side,
pixelFormat: ui.PixelFormat.rgba8888,
);
ui.Codec? codec;
ui.Image? image;
try {
codec = await descriptor.instantiateCodec();
image = (await codec.getNextFrame()).image;
final png = await image.toByteData(format: ui.ImageByteFormat.png);
if (png == null) {
throw StateError('Could not PNG-encode the decoded image.');
}
return png.buffer.asUint8List();
} finally {
image?.dispose();
codec?.dispose();
descriptor.dispose();
}
}
/// Cooperative cancel for a long encode/decode, mirroring [cancelDownload].
///
/// The worker polls a flag between backend stages; a backend stuck inside one
/// blocking native call will not notice until that call returns. For a hard
/// stop use [handleMemoryPressure] / [releaseModel], which kill the isolate.
void cancelCodecJob() {
final session = _session;
if (session == null || _codecStage == null) return;
_lastError = 'Image processing stopped.';
session.cancel();
_notify();
}
Future<ImageCodecResult?> _runCodecJob({
required String stage,
required AeicRatePoint ratePoint,
required Future<Uint8List> Function(ImageCodecSession session) action,
required ImageCodecResult Function(Uint8List bytes, int elapsedMs) wrap,
}) async {
final bundle = installedBundle;
if (bundle == null) return null;
final started = DateTime.now();
try {
return await _runExclusive(() async {
_setCodecStage('Loading model…', 0);
final session = await _ensureSession(bundle);
if (session == null) {
return null;
}
_setCodecStage(stage, 0);
final bytes = await action(session);
return wrap(bytes, DateTime.now().difference(started).inMilliseconds);
});
} catch (error) {
_lastError = error.toString();
appLogger.warn('Image codec job failed: $error', tag: 'ImageCodec');
return ImageCodecResult(
ratePoint: ratePoint,
resolution: kImageCodecSquareSize,
durationMs: DateTime.now().difference(started).inMilliseconds,
status: ImageCodecStatus.failed,
);
} finally {
_setCodecStage(null, null);
}
}
// ---- pixel preparation ---------------------------------------------------
static const int _bytesPerPixel = 3;
/// Centre-crops [imageBytes] to a square and scales it to
/// [kImageCodecSquareSize], returning packed 8-bit RGB.
///
/// Uses `dart:ui` rather than a decode package so this adds no dependency.
/// It runs on the root isolate, which is acceptable: it is a single 512x512
/// raster, three orders of magnitude cheaper than the 1385 GFLOP inference
/// that follows in the worker.
Future<Uint8List> _toSquareRgb(Uint8List imageBytes) async {
final codec = await ui.instantiateImageCodec(imageBytes);
final frame = await codec.getNextFrame();
final source = frame.image;
ui.Image? scaled;
try {
// The WHOLE frame, stretched to the square — not a centre crop.
// Aspect ratio is deliberately not preserved: the codec's graph is
// statically 512x512, so something has to give, and losing 25% of a 4:3
// photo (44% of 16:9, or the subject's head in a portrait) is worse than
// distorting it. Nothing outside the frame is discarded this way.
// NOTE: the aspect ratio is NOT transmitted, so the receiver cannot undo
// the stretch — see the note in image_chunk_transport.dart on the
// metadata byte.
final src = ui.Rect.fromLTWH(
0,
0,
source.width.toDouble(),
source.height.toDouble(),
);
final dstSize = kImageCodecSquareSize.toDouble();
final recorder = ui.PictureRecorder();
ui.Canvas(recorder).drawImageRect(
source,
src,
ui.Rect.fromLTWH(0, 0, dstSize, dstSize),
ui.Paint()..filterQuality = ui.FilterQuality.medium,
);
final picture = recorder.endRecording();
try {
scaled = await picture.toImage(
kImageCodecSquareSize,
kImageCodecSquareSize,
);
} finally {
picture.dispose();
}
final rgba = await scaled.toByteData(format: ui.ImageByteFormat.rawRgba);
if (rgba == null) {
throw StateError('Could not rasterise the image.');
}
return _rgbaToRgb(rgba.buffer.asUint8List());
} finally {
scaled?.dispose();
source.dispose();
codec.dispose();
}
}
static Uint8List _rgbaToRgb(Uint8List rgba) {
final pixels = rgba.length ~/ 4;
final rgb = Uint8List(pixels * _bytesPerPixel);
for (var i = 0, o = 0; i < rgba.length; i += 4, o += _bytesPerPixel) {
rgb[o] = rgba[i];
rgb[o + 1] = rgba[i + 1];
rgb[o + 2] = rgba[i + 2];
}
return rgb;
}
// ---- model memory (mirrors translation_service.dart:586-629) -------------
Future<ImageCodecSession?> _ensureSession(ImageCodecBundle bundle) async {
if (_session != null && _loadedBundle == bundle) {
return _session;
}
if (bundle == _failedBundle) {
return null;
}
final previous = _session;
if (previous != null) {
_session = null;
_loadedBundle = null;
await previous.dispose();
}
try {
final session = await ImageCodecSession.spawn(bundle);
_session = session;
_loadedBundle = bundle;
_failedBundle = null;
return session;
} on ImageCodecUnimplemented catch (error) {
// No inference runtime in this build; retrying can never help, so poison
// the whole feature rather than just this path.
_backendMissing = true;
_failedBundle = bundle;
_lastError = error.toString();
appLogger.warn('Image codec unavailable: $error', tag: 'ImageCodec');
_notify();
return null;
} catch (_) {
_failedBundle = bundle;
rethrow;
}
}
Future<void> releaseModel() async {
await _runExclusive(() async {
final session = _session;
if (session == null) {
_loadedBundle = null;
return;
}
_session = null;
_loadedBundle = null;
await session.dispose();
});
}
/// Drops the ~2.16 GiB image decoder session, keeping the isolate and the
/// ~0.35 GiB entropy session alive.
///
/// This is the cheap eviction: measured, 2.44 GiB resident falls to 0.35 GiB,
/// and it leaves the send path warm because encoding never touches the image
/// decoder. Safe to call when nothing is loaded.
Future<void> releaseDecoderSession() async {
final session = _session;
if (session == null) return;
await session.release(decoder: true);
appLogger.info('Image codec decoder session released', tag: 'ImageCodec');
}
/// Drops the entropy session (whichever direction is resident), keeping the
/// image decoder.
///
/// Rarely what you want on its own — `decode()` does it internally between
/// the entropy half and the synthesis half — and it is the WRONG half to shed
/// under memory pressure: it frees 0.35 GiB and costs the send path its warm
/// session. Exposed only so a host that knows no send is coming can shed it.
Future<void> releaseEntropySession() async {
final session = _session;
if (session == null) return;
await session.release(entropy: true);
}
/// Sheds codec memory under pressure.
///
/// Not present in [TranslationService], and required here: the decode graph
/// peaks around 2.16 GiB resident, far above what Android will let a
/// backgrounded app keep. Wire it to an app-level [WidgetsBindingObserver] —
/// `didChangeAppLifecycleState` (paused/hidden) and `didHaveMemoryPressure`.
/// Idempotent and safe when nothing is loaded.
///
/// Order matters: the IMAGE DECODER goes first, always. Measured, it is
/// 2.16 GiB of a 2.44 GiB peak — 89% of the resident set — and it is the half
/// the *send* path does not need. On a soft eviction ([keepEntropySession])
/// the 0.35 GiB entropy graph is deliberately kept, so a user who backgrounds
/// the app and comes back to send a photo pays nothing while the big half is
/// already gone. Never invert this: dropping the entropy graph first would
/// free an eighth of the memory and cost the only path that was warm.
///
/// The default is still the hard stop — kill the isolate — because that also
/// returns ORT's arena and any native allocation the plugin is holding, which
/// closing sessions alone does not.
Future<void> handleMemoryPressure({bool keepEntropySession = false}) async {
final session = _session;
if (session == null && _loadedBundle == null) {
return;
}
// Deliberately does NOT go through _runExclusive: memory pressure arrives
// while a decode may be mid-flight and holding the lock, and that decode is
// exactly what has to die. Killing the isolate fails its pending futures,
// which _runCodecJob reports as a failed result.
if (keepEntropySession && session != null) {
await session.release(decoder: true);
appLogger.info(
'Image codec decoder evicted (entropy session kept)',
tag: 'ImageCodec',
);
_notify();
return;
}
_session = null;
_loadedBundle = null;
_codecProgress = null;
_codecStage = null;
if (session != null) {
// Ask for the big half back before the shutdown handshake, so the peak
// does not have to wait on isolate teardown.
await session.release(decoder: true);
await session.dispose();
}
appLogger.info('Image codec model evicted', tag: 'ImageCodec');
_notify();
}
// ---- plumbing (copied from translation_service.dart:631-698) ------------
Future<T> _runExclusive<T>(Future<T> Function() action) {
final completer = Completer<T>();
_setBusy(true);
_queue = _queue.then((_) async {
if (_disposed) {
completer.completeError(StateError('ImageCodecService disposed.'));
return;
}
try {
completer.complete(await action());
} catch (error, stackTrace) {
completer.completeError(error, stackTrace);
} finally {
_setBusy(false);
}
});
return completer.future;
}
Stream<List<int>> _trackDownloadProgress(Stream<List<int>> source) async* {
await for (final chunk in source) {
if (_cancelDownloadRequested) {
throw const ImageCodecDownloadCancelled();
}
_downloadedBytes += chunk.length;
_notify();
yield chunk;
}
}
void _reportCodecProgress(double value) {
_codecProgress = value.clamp(0.0, 1.0);
_notify();
}
void _setCodecStage(String? stage, double? progress) {
_codecStage = stage;
_codecProgress = progress;
_notify();
}
void _notify() {
if (_disposed) {
return;
}
notifyListeners();
}
void _setBusy(bool value) {
if (_isBusy == value) {
return;
}
_isBusy = value;
_notify();
}
void _setDownloading(bool value) {
_isDownloading = value;
if (!value) {
_cancelDownloadRequested = false;
_downloadedBytes = 0;
_downloadTotalBytes = null;
_downloadFileName = null;
}
_notify();
}
/// Sweeps resume state for a model the user is no longer downloading.
///
/// Spec-shaped rather than filename-shaped: a bundle leaves chunk files for
/// up to four assets, and sweeping only the graph's would strand ~900 MB of
/// hidden resume state that nothing ever collects.
Future<void> discardPartialDownload(ImageCodecModelSpec spec) async {
for (final asset in spec.assets) {
await _fileStore.deletePartialDownloads(asset.fileName);
}
}
/// Sweeps resume state for one file, for the custom-URL install path.
Future<void> discardPartialDownloadFile(String fileName) =>
_fileStore.deletePartialDownloads(fileName);
String _sanitizeFileName(String fileName) {
final cleaned = fileName.replaceAll(RegExp(r'[^A-Za-z0-9._-]'), '_');
return cleaned.isEmpty ? 'image-codec-model.onnx' : cleaned;
}
@override
void dispose() {
_disposed = true;
final session = _session;
_session = null;
_loadedBundle = null;
if (session != null) {
unawaited(session.dispose());
}
super.dispose();
}
}
/// What a HEAD probe told us about a model asset.
class _HeadResult {
final int? contentLength;
final bool supportsRange;
const _HeadResult({required this.contentLength, required this.supportsRange});
}