315 lines
8.1 KiB
Dart
315 lines
8.1 KiB
Dart
import 'dart:async';
|
|
import 'dart:isolate';
|
|
import 'dart:ui' as ui;
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
|
|
|
import '../../utils/logger.dart';
|
|
import 'rlottie_disk_cache.dart';
|
|
import 'rlottie_ffi.dart';
|
|
import 'rlottie_worker.dart';
|
|
|
|
class RlottieClip {
|
|
RlottieClip({required this.key, required this.px});
|
|
|
|
final String key;
|
|
final int px;
|
|
|
|
int frameCount = 0;
|
|
int durationMs = 1000;
|
|
double frameRate = 60;
|
|
|
|
List<ui.Image?> _images = const [];
|
|
ui.Image? _lastImage;
|
|
|
|
final ValueNotifier<int> ready = ValueNotifier(0);
|
|
|
|
bool complete = false;
|
|
int bytes = 0;
|
|
int lastUsed = 0;
|
|
int active = 0;
|
|
|
|
bool get playable => frameCount > 0;
|
|
|
|
ui.Image? frameAt(int index) {
|
|
if (index < 0 || index >= _images.length) return _lastImage;
|
|
return _images[index] ?? _lastImage;
|
|
}
|
|
|
|
void _allocate(int count) {
|
|
_images = List<ui.Image?>.filled(count, null);
|
|
}
|
|
|
|
void _setFrame(int index, ui.Image image) {
|
|
if (index < 0 || index >= _images.length) {
|
|
image.dispose();
|
|
return;
|
|
}
|
|
_images[index] = image;
|
|
_lastImage = image;
|
|
bytes += px * px * 4;
|
|
var r = ready.value;
|
|
while (r < _images.length && _images[r] != null) {
|
|
r++;
|
|
}
|
|
if (r != ready.value) ready.value = r;
|
|
}
|
|
|
|
void dispose() {
|
|
for (final img in _images) {
|
|
img?.dispose();
|
|
}
|
|
_images = const [];
|
|
_lastImage = null;
|
|
bytes = 0;
|
|
ready.dispose();
|
|
}
|
|
}
|
|
|
|
class _Job {
|
|
_Job(this.clip, this.url, this.completer);
|
|
final RlottieClip clip;
|
|
final String url;
|
|
final Completer<RlottieClip?> completer;
|
|
List<Uint8List?> rawFrames = const [];
|
|
}
|
|
|
|
class RlottieEngine {
|
|
RlottieEngine._();
|
|
static final RlottieEngine instance = RlottieEngine._();
|
|
|
|
static String? debugLibraryPath;
|
|
|
|
static const int _maxBytes = 384 * 1024 * 1024;
|
|
static const int _modelCacheBytes = 20 * 1024 * 1024;
|
|
|
|
final Map<String, RlottieClip> _clips = {};
|
|
final Map<String, Future<RlottieClip?>> _loading = {};
|
|
final Map<int, _Job> _jobs = {};
|
|
|
|
int _totalBytes = 0;
|
|
int _clock = 0;
|
|
int _nextJobId = 1;
|
|
|
|
bool? _available;
|
|
Future<SendPort>? _workerFuture;
|
|
|
|
int _tick() => ++_clock;
|
|
String _keyFor(String url, int px) => '$url@$px';
|
|
|
|
bool get available {
|
|
return _available ??= () {
|
|
final bindings = RlottieBindings.open(path: debugLibraryPath);
|
|
if (bindings == null) return false;
|
|
bindings.configureModelCache(_modelCacheBytes);
|
|
return true;
|
|
}();
|
|
}
|
|
|
|
Future<SendPort> _worker() {
|
|
return _workerFuture ??= () async {
|
|
final receive = ReceivePort();
|
|
await Isolate.spawn(rlottieWorkerMain, receive.sendPort);
|
|
final broadcast = receive.asBroadcastStream();
|
|
final port = await broadcast.first as SendPort;
|
|
broadcast.listen(_onWorkerMessage);
|
|
return port;
|
|
}();
|
|
}
|
|
|
|
void _onWorkerMessage(dynamic message) {
|
|
if (message is ClipMeta) {
|
|
_onMeta(message);
|
|
} else if (message is RenderedFrame) {
|
|
_onFrame(message);
|
|
} else if (message is RenderDone) {
|
|
_onDone(message);
|
|
} else if (message is RenderError) {
|
|
_onError(message);
|
|
}
|
|
}
|
|
|
|
void _onMeta(ClipMeta meta) {
|
|
final job = _jobs[meta.jobId];
|
|
if (job == null) return;
|
|
final clip = job.clip
|
|
..frameCount = meta.totalFrame
|
|
..frameRate = meta.frameRate
|
|
..durationMs = meta.durationMs;
|
|
clip._allocate(meta.totalFrame);
|
|
job.rawFrames = List<Uint8List?>.filled(meta.totalFrame, null);
|
|
if (!job.completer.isCompleted) {
|
|
job.completer.complete(clip);
|
|
}
|
|
}
|
|
|
|
void _onFrame(RenderedFrame frame) {
|
|
final job = _jobs[frame.jobId];
|
|
if (job == null) return;
|
|
final bytes = frame.data.materialize().asUint8List();
|
|
if (frame.index < job.rawFrames.length) {
|
|
job.rawFrames[frame.index] = bytes;
|
|
}
|
|
ui.decodeImageFromPixels(
|
|
bytes,
|
|
frame.px,
|
|
frame.px,
|
|
ui.PixelFormat.rgba8888,
|
|
(image) {
|
|
job.clip._setFrame(frame.index, image);
|
|
_totalBytes += frame.px * frame.px * 4;
|
|
_evictIfNeeded();
|
|
},
|
|
);
|
|
}
|
|
|
|
void _onDone(RenderDone done) {
|
|
final job = _jobs.remove(done.jobId);
|
|
if (job == null) return;
|
|
final clip = job.clip..complete = true;
|
|
final raw = job.rawFrames;
|
|
if (raw.length == clip.frameCount && !raw.contains(null)) {
|
|
unawaited(RlottieDiskCache.instance.store(
|
|
url: job.url,
|
|
px: clip.px,
|
|
frameCount: clip.frameCount,
|
|
frameRate: clip.frameRate,
|
|
durationMs: clip.durationMs,
|
|
frames: raw.cast<Uint8List>(),
|
|
));
|
|
}
|
|
job.rawFrames = const [];
|
|
}
|
|
|
|
void _onError(RenderError error) {
|
|
final job = _jobs.remove(error.jobId);
|
|
if (job == null) return;
|
|
logger.w('rlottie render failed (${job.url}): ${error.message}');
|
|
if (!job.completer.isCompleted) job.completer.complete(null);
|
|
if (job.clip.frameCount == 0) {
|
|
_clips.remove(job.clip.key);
|
|
job.clip.dispose();
|
|
}
|
|
}
|
|
|
|
Future<RlottieClip?> acquire(String url, int px, {String? inlineJson}) async {
|
|
if (!available) return null;
|
|
final key = _keyFor(url, px);
|
|
|
|
final cached = _clips[key];
|
|
if (cached != null) {
|
|
cached.lastUsed = _tick();
|
|
cached.active++;
|
|
return cached;
|
|
}
|
|
final pending = _loading[key];
|
|
if (pending != null) {
|
|
final clip = await pending;
|
|
if (clip != null) {
|
|
clip.lastUsed = _tick();
|
|
clip.active++;
|
|
}
|
|
return clip;
|
|
}
|
|
|
|
final future = _load(url, px, key, inlineJson);
|
|
_loading[key] = future;
|
|
final clip = await future;
|
|
_loading.remove(key);
|
|
if (clip != null) {
|
|
clip.lastUsed = _tick();
|
|
clip.active++;
|
|
}
|
|
return clip;
|
|
}
|
|
|
|
Future<RlottieClip?> _load(
|
|
String url, int px, String key, String? inlineJson) async {
|
|
if (inlineJson == null) {
|
|
final disk = await RlottieDiskCache.instance.load(url, px);
|
|
if (disk != null) {
|
|
final clip = await _clipFromDisk(key, px, disk);
|
|
_clips[key] = clip;
|
|
return clip;
|
|
}
|
|
}
|
|
|
|
final json = inlineJson ?? await _fetchJson(url);
|
|
if (json == null) return null;
|
|
|
|
final clip = RlottieClip(key: key, px: px);
|
|
_clips[key] = clip;
|
|
final jobId = _nextJobId++;
|
|
final completer = Completer<RlottieClip?>();
|
|
_jobs[jobId] = _Job(clip, url, completer);
|
|
|
|
final port = await _worker();
|
|
port.send(RenderJob(
|
|
jobId: jobId,
|
|
json: json,
|
|
cacheKey: url,
|
|
px: px,
|
|
libPath: debugLibraryPath,
|
|
));
|
|
return completer.future;
|
|
}
|
|
|
|
Future<RlottieClip> _clipFromDisk(String key, int px, DiskClip disk) async {
|
|
final clip = RlottieClip(key: key, px: px)
|
|
..frameCount = disk.frameCount
|
|
..frameRate = disk.frameRate
|
|
..durationMs = disk.durationMs
|
|
..complete = true;
|
|
clip._allocate(disk.frameCount);
|
|
for (var i = 0; i < disk.frameCount; i++) {
|
|
final image = await _decode(disk.frames[i], px);
|
|
clip._setFrame(i, image);
|
|
_totalBytes += px * px * 4;
|
|
}
|
|
_evictIfNeeded();
|
|
return clip;
|
|
}
|
|
|
|
Future<ui.Image> _decode(Uint8List rgba, int px) {
|
|
final completer = Completer<ui.Image>();
|
|
ui.decodeImageFromPixels(
|
|
rgba, px, px, ui.PixelFormat.rgba8888, completer.complete);
|
|
return completer.future;
|
|
}
|
|
|
|
Future<String?> _fetchJson(String url) async {
|
|
try {
|
|
final file = await DefaultCacheManager().getSingleFile(url);
|
|
return await file.readAsString();
|
|
} catch (e) {
|
|
logger.w('rlottie fetch failed ($url): $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<void> prewarm(String url, int px) async {
|
|
if (!available) return;
|
|
final clip = await acquire(url, px);
|
|
if (clip != null) release(clip);
|
|
}
|
|
|
|
void release(RlottieClip clip) {
|
|
if (clip.active > 0) clip.active--;
|
|
clip.lastUsed = _tick();
|
|
_evictIfNeeded();
|
|
}
|
|
|
|
void _evictIfNeeded() {
|
|
if (_totalBytes <= _maxBytes) return;
|
|
final candidates = _clips.values.where((c) => c.active <= 0).toList()
|
|
..sort((a, b) => a.lastUsed.compareTo(b.lastUsed));
|
|
for (final clip in candidates) {
|
|
if (_totalBytes <= _maxBytes) break;
|
|
_totalBytes -= clip.bytes;
|
|
_clips.remove(clip.key);
|
|
clip.dispose();
|
|
}
|
|
}
|
|
}
|