diff --git a/.github/workflows/build-android-fcm.yml b/.github/workflows/build-android-fcm.yml index 015969d..fe613a2 100644 --- a/.github/workflows/build-android-fcm.yml +++ b/.github/workflows/build-android-fcm.yml @@ -25,6 +25,8 @@ jobs: contents: read steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Java uses: actions/setup-java@v4 diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index 50cef79..c15b65f 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -25,6 +25,8 @@ jobs: contents: read steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Java uses: actions/setup-java@v4 diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index 3f1b8ce..b216815 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -28,6 +28,8 @@ jobs: contents: read steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Flutter uses: subosito/flutter-action@v2 diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 650c4b5..da87ce4 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -28,6 +28,8 @@ jobs: contents: read steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Flutter uses: subosito/flutter-action@v2 diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index 62d1888..760e1e3 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -28,6 +28,8 @@ jobs: contents: read steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Flutter uses: subosito/flutter-action@v2 diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index cad04ce..14f93d8 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -28,6 +28,8 @@ jobs: contents: read steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Flutter uses: subosito/flutter-action@v2 diff --git a/.github/workflows/flutter-dev.yml b/.github/workflows/flutter-dev.yml index eff518a..6545875 100644 --- a/.github/workflows/flutter-dev.yml +++ b/.github/workflows/flutter-dev.yml @@ -12,6 +12,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Java uses: actions/setup-java@v4 diff --git a/.github/workflows/flutter-main.yml b/.github/workflows/flutter-main.yml index b6f240a..a863adb 100644 --- a/.github/workflows/flutter-main.yml +++ b/.github/workflows/flutter-main.yml @@ -11,6 +11,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Java uses: actions/setup-java@v4 @@ -69,6 +71,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v3 + with: + submodules: recursive - name: Set up Flutter uses: subosito/flutter-action@v2 @@ -95,6 +99,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v3 + with: + submodules: recursive - name: Set up Flutter uses: subosito/flutter-action@v2 @@ -113,6 +119,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v3 + with: + submodules: recursive - name: Set up Flutter uses: subosito/flutter-action@v2 diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index ee4f5ca..c646b02 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -26,6 +26,8 @@ jobs: flavor: [komet, oneme] steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Java uses: actions/setup-java@v4 @@ -101,6 +103,8 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Flutter uses: subosito/flutter-action@v2 with: @@ -127,6 +131,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Flutter uses: subosito/flutter-action@v2 with: @@ -156,6 +162,8 @@ jobs: runs-on: macos-latest steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Flutter uses: subosito/flutter-action@v2 with: @@ -184,6 +192,8 @@ jobs: runs-on: macos-latest steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Flutter uses: subosito/flutter-action@v2 with: diff --git a/.github/workflows/release-main.yml b/.github/workflows/release-main.yml index 18e45cc..1f9f247 100644 --- a/.github/workflows/release-main.yml +++ b/.github/workflows/release-main.yml @@ -21,6 +21,8 @@ jobs: flavor: [komet, oneme] steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Java uses: actions/setup-java@v4 @@ -96,6 +98,8 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Flutter uses: subosito/flutter-action@v2 with: @@ -122,6 +126,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Flutter uses: subosito/flutter-action@v2 with: @@ -151,6 +157,8 @@ jobs: runs-on: macos-latest steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Flutter uses: subosito/flutter-action@v2 with: @@ -177,6 +185,8 @@ jobs: runs-on: macos-latest steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Flutter uses: subosito/flutter-action@v2 with: @@ -238,6 +248,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Download all artifacts uses: actions/download-artifact@v4 diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..cbeb1b9 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "third_party/rlottie"] + path = third_party/rlottie + url = https://github.com/Samsung/rlottie.git diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b424e3e..169f484 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -37,6 +37,18 @@ android { targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName + + externalNativeBuild { + cmake { + arguments += listOf("-DBUILD_SHARED_LIBS=ON") + } + } + } + + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + } } flavorDimensions += "distribution" diff --git a/android/app/src/main/cpp/CMakeLists.txt b/android/app/src/main/cpp/CMakeLists.txt new file mode 100644 index 0000000..fd0b9b7 --- /dev/null +++ b/android/app/src/main/cpp/CMakeLists.txt @@ -0,0 +1,7 @@ +cmake_minimum_required(VERSION 3.10) + +project(komet_rlottie LANGUAGES C CXX ASM) + +add_subdirectory( + "${CMAKE_CURRENT_SOURCE_DIR}/../../../../../third_party/rlottie_build" + "${CMAKE_BINARY_DIR}/rlottie") diff --git a/ios/Podfile b/ios/Podfile index 57209b3..fc81fe8 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -62,6 +62,7 @@ flutter_ios_podfile_setup target 'Runner' do use_frameworks! + pod 'rlottie', :path => '../third_party' flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) target 'RunnerTests' do inherit! :search_paths diff --git a/lib/core/media/rlottie/rlottie.dart b/lib/core/media/rlottie/rlottie.dart new file mode 100644 index 0000000..e7104bd --- /dev/null +++ b/lib/core/media/rlottie/rlottie.dart @@ -0,0 +1,2 @@ +export 'rlottie_engine_stub.dart' + if (dart.library.io) 'rlottie_engine.dart'; diff --git a/lib/core/media/rlottie/rlottie_disk_cache.dart b/lib/core/media/rlottie/rlottie_disk_cache.dart new file mode 100644 index 0000000..0756ab2 --- /dev/null +++ b/lib/core/media/rlottie/rlottie_disk_cache.dart @@ -0,0 +1,184 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../../utils/logger.dart'; + +class DiskClip { + DiskClip({ + required this.px, + required this.frameCount, + required this.frameRate, + required this.durationMs, + required this.frames, + }); + + final int px; + final int frameCount; + final double frameRate; + final int durationMs; + final List frames; +} + +class RlottieDiskCache { + RlottieDiskCache._(); + static final RlottieDiskCache instance = RlottieDiskCache._(); + + static const _magic = 0x4b524c46; + static const _version = 1; + static const int _maxBytes = 256 * 1024 * 1024; + + Directory? _dir; + Future? _dirFuture; + + Future _directory() { + return _dirFuture ??= () async { + final base = await getApplicationSupportDirectory(); + final dir = Directory('${base.path}/rlottie_frames'); + if (!await dir.exists()) await dir.create(recursive: true); + _dir = dir; + return dir; + }(); + } + + String _key(String url, int px) { + final digest = sha1.convert(url.codeUnits).toString().substring(0, 20); + return '${digest}_$px.krlf'; + } + + Future _file(String url, int px) async { + final dir = await _directory(); + return File('${dir.path}/${_key(url, px)}'); + } + + Future load(String url, int px) async { + try { + final file = await _file(url, px); + if (!await file.exists()) return null; + final bytes = await file.readAsBytes(); + final clip = await _decode(bytes); + if (clip != null) { + unawaited(file.setLastModified(DateTime.now()).catchError((_) {})); + } + return clip; + } catch (e) { + logger.w('RlottieDiskCache.load failed: $e'); + return null; + } + } + + Future store({ + required String url, + required int px, + required int frameCount, + required double frameRate, + required int durationMs, + required List frames, + }) async { + if (frames.isEmpty || frames.length != frameCount) return; + try { + final bytes = await _encode(px, frameCount, frameRate, durationMs, frames); + final file = await _file(url, px); + await file.writeAsBytes(bytes, flush: false); + unawaited(_evict()); + } catch (e) { + logger.w('RlottieDiskCache.store failed: $e'); + } + } + + static Future _encode( + int px, + int frameCount, + double frameRate, + int durationMs, + List frames, + ) { + return Isolate.run(() { + final frameBytes = px * px * 4; + final payload = Uint8List(frameBytes * frameCount); + for (var i = 0; i < frameCount; i++) { + payload.setRange(i * frameBytes, (i + 1) * frameBytes, frames[i]); + } + final compressed = gzip.encode(payload); + final header = ByteData(28); + header.setUint32(0, _magic); + header.setUint8(4, _version); + header.setUint32(8, px); + header.setUint32(12, frameCount); + header.setFloat64(16, frameRate); + header.setUint32(24, durationMs); + final out = BytesBuilder(); + out.add(header.buffer.asUint8List()); + out.add(compressed); + return out.toBytes(); + }); + } + + static Future _decode(Uint8List bytes) { + return Isolate.run(() { + if (bytes.length < 28) return null; + final header = ByteData.sublistView(bytes, 0, 28); + if (header.getUint32(0) != _magic) return null; + if (header.getUint8(4) != _version) return null; + final px = header.getUint32(8); + final frameCount = header.getUint32(12); + final frameRate = header.getFloat64(16); + final durationMs = header.getUint32(24); + final frameBytes = px * px * 4; + final payload = Uint8List.fromList(gzip.decode(bytes.sublist(28))); + if (payload.length != frameBytes * frameCount) return null; + final frames = []; + for (var i = 0; i < frameCount; i++) { + frames.add(Uint8List.sublistView( + payload, i * frameBytes, (i + 1) * frameBytes)); + } + return DiskClip( + px: px, + frameCount: frameCount, + frameRate: frameRate, + durationMs: durationMs, + frames: frames, + ); + }); + } + + Future _evict() async { + try { + final dir = _dir ?? await _directory(); + final files = await dir + .list() + .where((e) => e is File && e.path.endsWith('.krlf')) + .cast() + .toList(); + var total = 0; + final stats = <(File, FileStat)>[]; + for (final f in files) { + final st = await f.stat(); + total += st.size; + stats.add((f, st)); + } + if (total <= _maxBytes) return; + stats.sort((a, b) => a.$2.modified.compareTo(b.$2.modified)); + for (final (file, st) in stats) { + if (total <= _maxBytes) break; + total -= st.size; + await file.delete().catchError((_) => file); + } + } catch (e) { + logger.w('RlottieDiskCache.evict failed: $e'); + } + } + + Future clear() async { + try { + final dir = await _directory(); + if (await dir.exists()) await dir.delete(recursive: true); + _dir = null; + _dirFuture = null; + } catch (_) {} + } +} diff --git a/lib/core/media/rlottie/rlottie_engine.dart b/lib/core/media/rlottie/rlottie_engine.dart new file mode 100644 index 0000000..63089a8 --- /dev/null +++ b/lib/core/media/rlottie/rlottie_engine.dart @@ -0,0 +1,314 @@ +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 _images = const []; + ui.Image? _lastImage; + + final ValueNotifier 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.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 completer; + List 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 _clips = {}; + final Map> _loading = {}; + final Map _jobs = {}; + + int _totalBytes = 0; + int _clock = 0; + int _nextJobId = 1; + + bool? _available; + Future? _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 _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.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(), + )); + } + 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 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 _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(); + _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 _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 _decode(Uint8List rgba, int px) { + final completer = Completer(); + ui.decodeImageFromPixels( + rgba, px, px, ui.PixelFormat.rgba8888, completer.complete); + return completer.future; + } + + Future _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 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(); + } + } +} diff --git a/lib/core/media/rlottie/rlottie_engine_stub.dart b/lib/core/media/rlottie/rlottie_engine_stub.dart new file mode 100644 index 0000000..94a66a4 --- /dev/null +++ b/lib/core/media/rlottie/rlottie_engine_stub.dart @@ -0,0 +1,31 @@ +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; + +class RlottieClip { + RlottieClip({this.px = 0}); + + final int px; + int frameCount = 0; + int durationMs = 1000; + double frameRate = 60; + final ValueNotifier ready = ValueNotifier(0); + + ui.Image? frameAt(int index) => null; +} + +class RlottieEngine { + RlottieEngine._(); + static final RlottieEngine instance = RlottieEngine._(); + + static String? debugLibraryPath; + + bool get available => false; + + Future acquire(String url, int px, {String? inlineJson}) async => + null; + + Future prewarm(String url, int px) async {} + + void release(RlottieClip clip) {} +} diff --git a/lib/core/media/rlottie/rlottie_ffi.dart b/lib/core/media/rlottie/rlottie_ffi.dart new file mode 100644 index 0000000..ae2fd7e --- /dev/null +++ b/lib/core/media/rlottie/rlottie_ffi.dart @@ -0,0 +1,112 @@ +import 'dart:ffi'; +import 'dart:io'; + +import 'package:ffi/ffi.dart'; + +typedef _InitNative = Void Function(); +typedef _VoidFn = void Function(); + +typedef _FromDataNative = Pointer Function( + Pointer, Pointer, Pointer); + +typedef _SizeGetterNative = Size Function(Pointer); +typedef _SizeGetter = int Function(Pointer); + +typedef _DoubleGetterNative = Double Function(Pointer); +typedef _DoubleGetter = double Function(Pointer); + +typedef _RenderNative = Void Function( + Pointer, Size, Pointer, Size, Size, Size); +typedef _Render = void Function( + Pointer, int, Pointer, int, int, int); + +typedef _DestroyNative = Void Function(Pointer); +typedef _Destroy = void Function(Pointer); + +typedef _CacheSizeNative = Void Function(Size); +typedef _CacheSize = void Function(int); + +class RlottieBindings { + RlottieBindings._(this._lib) { + _init = _lib.lookupFunction<_InitNative, _VoidFn>('lottie_init'); + _shutdown = _lib.lookupFunction<_InitNative, _VoidFn>('lottie_shutdown'); + _fromData = _lib.lookupFunction<_FromDataNative, _FromDataNative>( + 'lottie_animation_from_data'); + _totalFrame = _lib.lookupFunction<_SizeGetterNative, _SizeGetter>( + 'lottie_animation_get_totalframe'); + _frameRate = _lib.lookupFunction<_DoubleGetterNative, _DoubleGetter>( + 'lottie_animation_get_framerate'); + _duration = _lib.lookupFunction<_DoubleGetterNative, _DoubleGetter>( + 'lottie_animation_get_duration'); + _render = + _lib.lookupFunction<_RenderNative, _Render>('lottie_animation_render'); + _destroy = _lib + .lookupFunction<_DestroyNative, _Destroy>('lottie_animation_destroy'); + _cacheSize = _lib.lookupFunction<_CacheSizeNative, _CacheSize>( + 'lottie_configure_model_cache_size'); + _init(); + } + + final DynamicLibrary _lib; + late final _VoidFn _init; + late final _VoidFn _shutdown; + late final _FromDataNative _fromData; + late final _SizeGetter _totalFrame; + late final _DoubleGetter _frameRate; + late final _DoubleGetter _duration; + late final _Render _render; + late final _Destroy _destroy; + late final _CacheSize _cacheSize; + + static RlottieBindings? open({String? path}) { + try { + final lib = _openLibrary(path); + return lib == null ? null : RlottieBindings._(lib); + } catch (_) { + return null; + } + } + + static DynamicLibrary? _openLibrary(String? path) { + if (path != null) return DynamicLibrary.open(path); + if (Platform.isMacOS || Platform.isIOS) return DynamicLibrary.process(); + try { + return DynamicLibrary.open(rlottieLibraryName); + } catch (_) { + return null; + } + } + + Pointer? loadFromData(String data, String key) { + final dataC = data.toNativeUtf8(); + final keyC = key.toNativeUtf8(); + final resC = ''.toNativeUtf8(); + try { + final anim = _fromData(dataC, keyC, resC); + return anim == nullptr ? null : anim; + } finally { + calloc.free(dataC); + calloc.free(keyC); + calloc.free(resC); + } + } + + int totalFrame(Pointer anim) => _totalFrame(anim); + double frameRate(Pointer anim) => _frameRate(anim); + double duration(Pointer anim) => _duration(anim); + + void render(Pointer anim, int frameNo, Pointer buffer, int px) { + _render(anim, frameNo, buffer, px, px, px * 4); + } + + void destroy(Pointer anim) => _destroy(anim); + + void configureModelCache(int bytes) => _cacheSize(bytes); + + void shutdown() => _shutdown(); +} + +String get rlottieLibraryName { + if (Platform.isWindows) return 'rlottie.dll'; + return 'librlottie.so'; +} diff --git a/lib/core/media/rlottie/rlottie_worker.dart b/lib/core/media/rlottie/rlottie_worker.dart new file mode 100644 index 0000000..21961d1 --- /dev/null +++ b/lib/core/media/rlottie/rlottie_worker.dart @@ -0,0 +1,153 @@ +import 'dart:ffi'; +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:ffi/ffi.dart'; + +import 'rlottie_ffi.dart'; + +class RenderJob { + const RenderJob({ + required this.jobId, + required this.json, + required this.cacheKey, + required this.px, + this.libPath, + }); + + final int jobId; + final String json; + final String cacheKey; + final int px; + final String? libPath; +} + +class ClipMeta { + const ClipMeta({ + required this.jobId, + required this.totalFrame, + required this.frameRate, + required this.durationMs, + }); + + final int jobId; + final int totalFrame; + final double frameRate; + final int durationMs; +} + +class RenderedFrame { + const RenderedFrame({ + required this.jobId, + required this.index, + required this.data, + required this.px, + }); + + final int jobId; + final int index; + final TransferableTypedData data; + final int px; +} + +class RenderDone { + const RenderDone(this.jobId); + final int jobId; +} + +class RenderError { + const RenderError(this.jobId, this.message); + final int jobId; + final String message; +} + +class CancelJob { + const CancelJob(this.jobId); + final int jobId; +} + +void rlottieWorkerMain(SendPort toMain) { + final port = ReceivePort(); + toMain.send(port.sendPort); + + final cancelled = {}; + RlottieBindings? bindings; + String? boundLibPath; + + port.listen((message) { + if (message is CancelJob) { + cancelled.add(message.jobId); + return; + } + if (message is! RenderJob) return; + + final job = message; + cancelled.remove(job.jobId); + + if (bindings == null || boundLibPath != job.libPath) { + bindings = RlottieBindings.open(path: job.libPath); + boundLibPath = job.libPath; + } + final rl = bindings; + if (rl == null) { + toMain.send(RenderError(job.jobId, 'rlottie unavailable')); + return; + } + + final anim = rl.loadFromData(job.json, job.cacheKey); + if (anim == null) { + toMain.send(RenderError(job.jobId, 'parse failed')); + return; + } + + try { + final total = rl.totalFrame(anim); + final fps = rl.frameRate(anim); + final durationMs = fps <= 0 ? 1000 : (total / fps * 1000).round(); + toMain.send(ClipMeta( + jobId: job.jobId, + totalFrame: total, + frameRate: fps, + durationMs: durationMs, + )); + + final px = job.px; + final buffer = calloc(px * px); + try { + for (var i = 0; i < total; i++) { + if (cancelled.contains(job.jobId)) break; + rl.render(anim, i, buffer, px); + final rgba = _bgraToRgba(buffer, px * px); + toMain.send(RenderedFrame( + jobId: job.jobId, + index: i, + data: TransferableTypedData.fromList([rgba]), + px: px, + )); + } + } finally { + calloc.free(buffer); + } + toMain.send(RenderDone(job.jobId)); + } catch (e) { + toMain.send(RenderError(job.jobId, e.toString())); + } finally { + rl.destroy(anim); + cancelled.remove(job.jobId); + } + }); +} + +Uint8List _bgraToRgba(Pointer buffer, int pixels) { + final src = buffer.asTypedList(pixels); + final out = Uint8List(pixels * 4); + for (var i = 0; i < pixels; i++) { + final v = src[i]; + final o = i * 4; + out[o] = (v >> 16) & 0xff; + out[o + 1] = (v >> 8) & 0xff; + out[o + 2] = v & 0xff; + out[o + 3] = (v >> 24) & 0xff; + } + return out; +} diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 1c9d59d..e65168b 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -30,6 +30,7 @@ import '../../../backend/modules/animoji.dart'; import '../../../models/animoji.dart'; import '../../../backend/modules/complaints.dart'; import '../../../core/calls/call_controller.dart'; +import '../../../core/media/rlottie/rlottie.dart'; import '../calls/call_screen.dart'; import '../../../core/protocol/opcode_map.dart'; import '../../../core/protocol/packet.dart'; @@ -460,13 +461,29 @@ class _ChatScreenState extends State bool get _selectionMode => _selectedIds.value.isNotEmpty; + void _prewarmQuickReactions() { + if (!mounted || !RlottieEngine.instance.available) return; + final dpr = + (MediaQuery.maybeOf(context)?.devicePixelRatio ?? 2.0).clamp(1.0, 2.0); + final px = ((44.0 * dpr).clamp(96.0, 512.0) / 32).ceil() * 32; + for (final a in animojiModule.quickAnimojis) { + final url = a.lottieUrl; + if (url != null && url.isNotEmpty) { + unawaited(RlottieEngine.instance.prewarm(url, px)); + } + } + } + @override void initState() { super.initState(); _chatController.chatId = widget.chatId; _chatController.isMounted = () => mounted; unawaited(PushService.clearChatNotification(widget.chatId)); - unawaited(animojiModule.ensureLoaded().catchError((_) {})); + unawaited(animojiModule + .ensureLoaded() + .then((_) => _prewarmQuickReactions()) + .catchError((_) {})); WidgetsBinding.instance.addObserver(this); chats.chatsChanged.addListener(_onChatsBump); _messageController.addListener(_onTextChanged); diff --git a/lib/frontend/widgets/lottie_image.dart b/lib/frontend/widgets/lottie_image.dart index 29bc173..b356466 100644 --- a/lib/frontend/widgets/lottie_image.dart +++ b/lib/frontend/widgets/lottie_image.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:lottie/lottie.dart'; +import '../../core/media/rlottie/rlottie.dart'; + class LottieLoadGovernor { LottieLoadGovernor._() { _budgetMs = _resolveBudgetMs(); @@ -43,158 +45,6 @@ class LottieLoadGovernor { } } -class _LottieFrames { - final LottieDrawable drawable; - final int frameCount; - final Duration duration; - final int pxSize; - final List _images; - ui.Image? _lastImage; - int bytes = 0; - int lastUsed = 0; - int active = 0; - - _LottieFrames({ - required this.drawable, - required this.frameCount, - required this.duration, - required this.pxSize, - }) : _images = List.filled(frameCount, null); - - ui.Image frameAt(int index) { - final existing = _images[index]; - if (existing != null) { - _lastImage = existing; - return existing; - } - - final last = _lastImage; - if (last != null && LottieLoadGovernor.instance.throttled.value) { - return last; - } - - final progress = frameCount <= 1 ? 0.0 : index / (frameCount - 1); - final recorder = ui.PictureRecorder(); - final canvas = Canvas(recorder); - drawable.setProgress(progress); - drawable.draw( - canvas, - Rect.fromLTWH(0, 0, pxSize.toDouble(), pxSize.toDouble()), - fit: BoxFit.contain, - ); - final picture = recorder.endRecording(); - final image = picture.toImageSync(pxSize, pxSize); - picture.dispose(); - - _images[index] = image; - _lastImage = image; - final added = pxSize * pxSize * 4; - bytes += added; - _LottieFrameCache.instance._onBytesAdded(added); - return image; - } - - void dispose() { - for (final image in _images) { - image?.dispose(); - } - _images.fillRange(0, _images.length, null); - _lastImage = null; - bytes = 0; - } -} - -class _LottieFrameCache { - _LottieFrameCache._(); - static final _LottieFrameCache instance = _LottieFrameCache._(); - - static const int _maxBytes = 384 * 1024 * 1024; - static const double _fps = 30; - - final Map _entries = {}; - final Map> _loading = {}; - int _totalBytes = 0; - int _clock = 0; - - int _tick() => ++_clock; - - Future<_LottieFrames?> acquire(String url, int pxSize) async { - final key = '$url@$pxSize'; - final cached = _entries[key]; - if (cached != null) { - cached.lastUsed = _tick(); - cached.active++; - return cached; - } - final pending = _loading[key]; - if (pending != null) { - final entry = await pending; - if (entry != null) { - entry.lastUsed = _tick(); - entry.active++; - } - return entry; - } - final future = _load(url, pxSize, key); - _loading[key] = future; - final entry = await future; - _loading.remove(key); - if (entry != null) { - entry.lastUsed = _tick(); - entry.active++; - } - return entry; - } - - void release(_LottieFrames frames) { - if (frames.active > 0) frames.active--; - frames.lastUsed = _tick(); - _evictIfNeeded(); - } - - Future<_LottieFrames?> _load(String url, int pxSize, String key) async { - try { - final composition = await NetworkLottie( - url, - backgroundLoading: true, - ).load(); - final durationMs = composition.duration.inMilliseconds; - var frameCount = (durationMs / 1000 * _fps).round(); - frameCount = frameCount.clamp(1, 120); - final entry = _LottieFrames( - drawable: LottieDrawable(composition), - frameCount: frameCount, - duration: durationMs <= 0 - ? const Duration(seconds: 1) - : composition.duration, - pxSize: pxSize, - ); - _entries[key] = entry; - return entry; - } catch (_) { - return null; - } - } - - void _onBytesAdded(int bytes) { - _totalBytes += bytes; - _evictIfNeeded(); - } - - void _evictIfNeeded() { - if (_totalBytes <= _maxBytes) return; - final candidates = - _entries.entries.where((e) => e.value.active <= 0).toList() - ..sort((a, b) => a.value.lastUsed.compareTo(b.value.lastUsed)); - for (final candidate in candidates) { - if (_totalBytes <= _maxBytes) break; - _totalBytes -= candidate.value.bytes; - candidate.value.dispose(); - _entries.remove(candidate.key); - } - } -} - class LottieScrollScope extends InheritedWidget { final ValueListenable isScrolling; @@ -233,9 +83,12 @@ class LottiePlayer extends StatefulWidget { class _LottiePlayerState extends State with SingleTickerProviderStateMixin { + static const int _leadFrames = 6; + final ValueNotifier _frameIndex = ValueNotifier(0); late final Ticker _ticker; - _LottieFrames? _frames; + late final bool _native; + RlottieClip? _clip; ValueListenable? _scrollState; int? _px; bool _started = false; @@ -248,6 +101,7 @@ class _LottiePlayerState extends State @override void initState() { super.initState(); + _native = RlottieEngine.instance.available; _ticker = createTicker(_onTick); LottieLoadGovernor.instance.throttled.addListener(_onGateChanged); } @@ -268,9 +122,7 @@ class _LottiePlayerState extends State super.didUpdateWidget(oldWidget); if (oldWidget.lottieUrl != widget.lottieUrl) { _ticker.stop(); - final previous = _frames; - if (previous != null) _LottieFrameCache.instance.release(previous); - _frames = null; + _releaseClip(); _started = false; _showedFrames = false; } @@ -281,21 +133,29 @@ class _LottiePlayerState extends State LottieLoadGovernor.instance.throttled.removeListener(_onGateChanged); _scrollState?.removeListener(_onGateChanged); _ticker.dispose(); - final frames = _frames; - if (frames != null) _LottieFrameCache.instance.release(frames); + _releaseClip(); _frameIndex.dispose(); super.dispose(); } + void _releaseClip() { + final clip = _clip; + if (clip != null) { + clip.ready.removeListener(_onReady); + RlottieEngine.instance.release(clip); + _clip = null; + } + } + void _onTick(Duration elapsed) { - final frames = _frames; - if (frames == null || frames.frameCount <= 1) return; - final periodMs = frames.duration.inMilliseconds; + final clip = _clip; + if (clip == null || clip.frameCount <= 1) return; + final periodMs = clip.durationMs; if (periodMs <= 0) return; final t = (elapsed.inMilliseconds % periodMs) / periodMs; - final index = (t * (frames.frameCount - 1)).round().clamp( + final index = (t * (clip.frameCount - 1)).round().clamp( 0, - frames.frameCount - 1, + clip.frameCount - 1, ); if (index != _frameIndex.value) _frameIndex.value = index; } @@ -306,16 +166,29 @@ class _LottiePlayerState extends State if (_ticker.isActive) _ticker.stop(); return; } - final frames = _frames; - if (frames != null) { - if (!_ticker.isActive && frames.frameCount > 1) _ticker.start(); + final clip = _clip; + if (clip != null) { + _maybeStartTicker(clip); } else if (_canLoad && !_started) { _startLoad(); } } + void _onReady() { + if (!mounted) return; + final clip = _clip; + if (clip != null) _maybeStartTicker(clip); + if (mounted) setState(() {}); + } + + void _maybeStartTicker(RlottieClip clip) { + if (_isScrolling || clip.frameCount <= 1) return; + final lead = clip.frameCount < _leadFrames ? clip.frameCount : _leadFrames; + if (clip.ready.value >= lead && !_ticker.isActive) _ticker.start(); + } + void _ensure(double box) { - if (_frames != null) return; + if (_clip != null) return; final dpr = MediaQuery.devicePixelRatioOf(context); final raw = (box * dpr.clamp(1.0, 2.0)).clamp(96.0, 512.0); _px = (raw / 32).ceil() * 32; @@ -327,19 +200,21 @@ class _LottiePlayerState extends State final px = _px; if (_started || px == null) return; _started = true; - _LottieFrameCache.instance.acquire(widget.lottieUrl, px).then((frames) { - if (frames == null) return; + RlottieEngine.instance.acquire(widget.lottieUrl, px).then((clip) { + if (clip == null) return; if (!mounted) { - _LottieFrameCache.instance.release(frames); + RlottieEngine.instance.release(clip); return; } - setState(() => _frames = frames); - if (!_isScrolling && frames.frameCount > 1) _ticker.start(); + clip.ready.addListener(_onReady); + setState(() => _clip = clip); + _maybeStartTicker(clip); }); } @override Widget build(BuildContext context) { + if (!_native) return _nativeFallback(); return LayoutBuilder( builder: (context, constraints) { final box = @@ -348,15 +223,17 @@ class _LottiePlayerState extends State ? constraints.biggest.shortestSide : 96.0); _ensure(box); - final frames = _frames; - if (frames == null || (_isScrolling && !_showedFrames)) { - return _fallback(box); + final clip = _clip; + if (clip == null || + clip.ready.value == 0 || + (_isScrolling && !_showedFrames)) { + return _staticFallback(box); } _showedFrames = true; return ValueListenableBuilder( valueListenable: _frameIndex, builder: (_, index, _) => RawImage( - image: frames.frameAt(index), + image: clip.frameAt(index), width: box, height: box, fit: BoxFit.contain, @@ -366,7 +243,18 @@ class _LottiePlayerState extends State ); } - Widget _fallback(double box) { + Widget _nativeFallback() { + return Lottie.network( + widget.lottieUrl, + width: widget.size, + height: widget.size, + fit: BoxFit.contain, + frameRate: FrameRate.max, + errorBuilder: (context, _, _) => _staticFallback(widget.size ?? 96.0), + ); + } + + Widget _staticFallback(double box) { final url = widget.fallbackUrl ?? ''; final blank = SizedBox(width: box, height: box); if (url.isEmpty) return blank; diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 3e8a807..9173b07 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -84,6 +84,9 @@ if(TARGET flutter_webrtc_plugin) target_compile_options(flutter_webrtc_plugin PRIVATE -include cstdint) endif() +set(RLOTTIE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../third_party/rlottie_build") +add_subdirectory("${RLOTTIE_DIR}" "${CMAKE_BINARY_DIR}/rlottie") + # === Installation === # By default, "installing" just makes a relocatable bundle in the build @@ -116,6 +119,10 @@ foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) COMPONENT Runtime) endforeach(bundled_library) +install(FILES "$" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + # Copy the native assets provided by the build.dart from all packages. set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") install(DIRECTORY "${NATIVE_ASSETS_DIR}" diff --git a/macos/Podfile b/macos/Podfile index ff5ddb3..742cccf 100644 --- a/macos/Podfile +++ b/macos/Podfile @@ -29,6 +29,7 @@ flutter_macos_podfile_setup target 'Runner' do use_frameworks! + pod 'rlottie', :path => '../third_party' flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) target 'RunnerTests' do inherit! :search_paths diff --git a/pubspec.lock b/pubspec.lock index 919aff8..733c3db 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -274,7 +274,7 @@ packages: source: hosted version: "1.3.3" ffi: - dependency: transitive + dependency: "direct main" description: name: ffi sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" @@ -359,7 +359,7 @@ packages: source: sdk version: "0.0.0" flutter_cache_manager: - dependency: transitive + dependency: "direct main" description: name: flutter_cache_manager sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" @@ -1062,7 +1062,7 @@ packages: source: hosted version: "2.2.1" path_provider_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: path_provider_platform_interface sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" @@ -1102,7 +1102,7 @@ packages: source: hosted version: "3.1.6" plugin_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: plugin_platform_interface sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" diff --git a/pubspec.yaml b/pubspec.yaml index 4f29a8c..747af50 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,7 @@ dependencies: libcompress: ^1.0.0 msgpack_dart: ^1.0.1 crypto: ^3.0.7 + ffi: ^2.1.0 logger: ^2.6.2 device_info_plus: 12.3.0 flutter_timezone: ^5.0.1 @@ -59,6 +60,7 @@ dependencies: package_info_plus: ^9.0.1 mobile_scanner: ^7.2.0 cached_network_image: ^3.4.1 + flutter_cache_manager: ^3.4.1 lottie: ^3.3.1 path_provider: ^2.1.4 share_plus: ^10.1.4 @@ -93,6 +95,8 @@ dev_dependencies: # rules and activating additional ones. flutter_lints: ^6.0.0 flutter_launcher_icons: ^0.14.4 + path_provider_platform_interface: ^2.1.0 + plugin_platform_interface: ^2.1.0 flutter_launcher_icons: image_path: "assets/komet_icon.png" diff --git a/test/rlottie_disk_cache_test.dart b/test/rlottie_disk_cache_test.dart new file mode 100644 index 0000000..8f7c4ee --- /dev/null +++ b/test/rlottie_disk_cache_test.dart @@ -0,0 +1,60 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/core/media/rlottie/rlottie_disk_cache.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +class _FakePathProvider extends PathProviderPlatform + with MockPlatformInterfaceMixin { + _FakePathProvider(this.dir); + final String dir; + @override + Future getApplicationSupportPath() async => dir; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('disk cache round-trips rendered frames', () async { + final tmp = Directory.systemTemp.createTempSync('rlottie_cache_test'); + PathProviderPlatform.instance = _FakePathProvider(tmp.path); + + const px = 32; + const frameCount = 5; + final frames = List.generate(frameCount, (f) { + final buf = Uint8List(px * px * 4); + for (var i = 0; i < buf.length; i++) { + buf[i] = (f * 37 + i) & 0xff; + } + return buf; + }); + + const url = 'https://example.com/anim.json'; + await RlottieDiskCache.instance.store( + url: url, + px: px, + frameCount: frameCount, + frameRate: 30, + durationMs: 166, + frames: frames, + ); + + final loaded = await RlottieDiskCache.instance.load(url, px); + expect(loaded, isNotNull); + expect(loaded!.px, px); + expect(loaded.frameCount, frameCount); + expect(loaded.frameRate, 30); + expect(loaded.durationMs, 166); + for (var f = 0; f < frameCount; f++) { + expect(loaded.frames[f], orderedEquals(frames[f]), + reason: 'frame $f bytes preserved'); + } + + expect(await RlottieDiskCache.instance.load('https://other/x.json', px), + isNull); + + tmp.deleteSync(recursive: true); + }); +} diff --git a/test/rlottie_engine_test.dart b/test/rlottie_engine_test.dart new file mode 100644 index 0000000..097971f --- /dev/null +++ b/test/rlottie_engine_test.dart @@ -0,0 +1,66 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/core/media/rlottie/rlottie_engine.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('rlottie engine renders a real clip into ui.Image frames', () async { + final libPath = Platform.environment['RLOTTIE_LIB']; + if (libPath == null || !File(libPath).existsSync()) { + markTestSkipped('set RLOTTIE_LIB to librlottie.so to run'); + return; + } + RlottieEngine.debugLibraryPath = libPath; + + expect(RlottieEngine.instance.available, isTrue, + reason: 'library should open'); + + final json = File('assets/lottie/ic_settings.json').readAsStringSync(); + const px = 256; + final clip = await RlottieEngine.instance + .acquire('poc://ic_settings', px, inlineJson: json); + + expect(clip, isNotNull); + expect(clip!.frameCount, greaterThan(1)); + expect(clip.durationMs, greaterThan(0)); + + final target = clip.frameCount; + final deadline = DateTime.now().add(const Duration(seconds: 20)); + while (clip.ready.value < target && DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 20)); + } + expect(clip.ready.value, target, reason: 'all frames decoded'); + + final mid = clip.frameAt(target ~/ 2); + expect(mid, isNotNull); + expect(mid!.width, px); + expect(mid.height, px); + + final data = await mid.toByteData(format: ui.ImageByteFormat.rawRgba); + final bytes = data!.buffer.asUint8List(); + var opaque = 0; + var transparent = 0; + for (var i = 3; i < bytes.length; i += 4) { + if (bytes[i] == 0) { + transparent++; + } else if (bytes[i] > 250) { + opaque++; + } + } + expect(opaque, greaterThan(0), reason: 'gear pixels present'); + expect(transparent, greaterThan(0), reason: 'transparent background present'); + + final png = await mid.toByteData(format: ui.ImageByteFormat.png); + final out = Platform.environment['RLOTTIE_PNG_OUT']; + if (out != null) { + File(out).writeAsBytesSync(png!.buffer.asUint8List()); + debugPrint('wrote decoded frame PNG: $out'); + } + + RlottieEngine.instance.release(clip); + }); +} diff --git a/third_party/rlottie b/third_party/rlottie new file mode 160000 index 0000000..f487eff --- /dev/null +++ b/third_party/rlottie @@ -0,0 +1 @@ +Subproject commit f487eff2f8086b84ae1c7faa0418abec909e874b diff --git a/third_party/rlottie.podspec b/third_party/rlottie.podspec new file mode 100644 index 0000000..4d80705 --- /dev/null +++ b/third_party/rlottie.podspec @@ -0,0 +1,50 @@ +Pod::Spec.new do |s| + s.name = 'rlottie' + s.version = '0.2.0' + s.summary = 'Samsung rlottie native Lottie renderer for Komet.' + s.description = 'Compiles the Samsung/rlottie submodule sources into the app so the native animation engine can be reached via dart:ffi (DynamicLibrary.process()).' + s.homepage = 'https://github.com/Samsung/rlottie' + s.license = { :type => 'MIT', :file => 'rlottie/COPYING' } + s.author = { 'Samsung Electronics' => 'opensource@samsung.com' } + s.source = { :path => '.' } + + s.ios.deployment_target = '13.0' + s.osx.deployment_target = '10.15' + s.requires_arc = false + + s.source_files = [ + 'rlottie/inc/*.h', + 'rlottie_build/apple/config.h', + 'rlottie/src/lottie/*.{cpp,h}', + 'rlottie/src/lottie/zip/*.{cpp,h}', + 'rlottie/src/lottie/rapidjson/**/*.h', + 'rlottie/src/vector/*.{cpp,h}', + 'rlottie/src/vector/freetype/*.{cpp,h}', + 'rlottie/src/vector/pixman/pixman-arm-neon-asm.h', + 'rlottie/src/vector/stb/*.{cpp,h}', + 'rlottie/src/binding/c/*.cpp', + ] + s.exclude_files = ['rlottie/src/vector/pixman/*.S'] + s.public_header_files = 'rlottie/inc/*.h' + + s.pod_target_xcconfig = { + 'CLANG_CXX_LANGUAGE_STANDARD' => 'c++14', + 'CLANG_CXX_LIBRARY' => 'libc++', + 'GCC_ENABLE_CPP_EXCEPTIONS' => 'NO', + 'GCC_ENABLE_CPP_RTTI' => 'NO', + 'CLANG_WARN_DOCUMENTATION_COMMENTS' => 'NO', + 'GCC_WARN_INHIBIT_ALL_WARNINGS' => 'YES', + 'DEFINES_MODULE' => 'YES', + 'HEADER_SEARCH_PATHS' => [ + '"${PODS_TARGET_SRCROOT}/rlottie/inc"', + '"${PODS_TARGET_SRCROOT}/rlottie_build/apple"', + '"${PODS_TARGET_SRCROOT}/rlottie/src/lottie"', + '"${PODS_TARGET_SRCROOT}/rlottie/src/lottie/zip"', + '"${PODS_TARGET_SRCROOT}/rlottie/src/lottie/rapidjson"', + '"${PODS_TARGET_SRCROOT}/rlottie/src/vector"', + '"${PODS_TARGET_SRCROOT}/rlottie/src/vector/freetype"', + '"${PODS_TARGET_SRCROOT}/rlottie/src/vector/pixman"', + '"${PODS_TARGET_SRCROOT}/rlottie/src/vector/stb"', + ].join(' '), + } +end diff --git a/third_party/rlottie_build/CMakeLists.txt b/third_party/rlottie_build/CMakeLists.txt new file mode 100644 index 0000000..6ca3c55 --- /dev/null +++ b/third_party/rlottie_build/CMakeLists.txt @@ -0,0 +1,76 @@ +cmake_minimum_required( VERSION 3.10 ) + +project( rlottie VERSION 0.2 LANGUAGES C CXX ASM ) + +set(RLOTTIE "${CMAKE_CURRENT_LIST_DIR}/../rlottie") + +if (NOT EXISTS "${RLOTTIE}/src/CMakeLists.txt") + message(FATAL_ERROR + "rlottie submodule is missing. Run: git submodule update --init --recursive") +endif() + +if (NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE Release) +endif() + +if (NOT DEFINED BUILD_SHARED_LIBS) + set(BUILD_SHARED_LIBS ON) +endif() + +add_library( rlottie ) +set_target_properties( rlottie PROPERTIES DEFINE_SYMBOL RLOTTIE_BUILD ) + +set(LOTTIE_MODULE OFF) +set(LOTTIE_THREAD ON) +set(LOTTIE_CACHE ON) +set(LOTTIE_MODULE_PATH "") + +configure_file(${RLOTTIE}/cmake/config.h.in config.h) + +target_include_directories(rlottie + PUBLIC $ + PRIVATE "${CMAKE_CURRENT_BINARY_DIR}" + ) + +if(MSVC) + target_compile_options(rlottie + PRIVATE + /std:c++14 + /EHs-c- + /GR- + /W3 + ) +else() + target_compile_options(rlottie + PRIVATE + -std=c++14 + -fno-exceptions + -fno-unwind-tables + -fno-asynchronous-unwind-tables + -fno-rtti + -Wall + -fvisibility=hidden + -Wnon-virtual-dtor + -Woverloaded-virtual + -Wno-unused-parameter + ) +endif() + +if (WIN32 AND NOT BUILD_SHARED_LIBS) + target_compile_definitions(rlottie PUBLIC -DRLOTTIE_BUILD=0) +endif() + +set( CMAKE_THREAD_PREFER_PTHREAD TRUE ) +find_package( Threads ) + +if(WIN32) + set( OSSPEC_LIBS Shlwapi.lib ) +endif() + +target_link_libraries(rlottie + PUBLIC + "${CMAKE_THREAD_LIBS_INIT}" + ${OSSPEC_LIBS} + ) + +add_subdirectory(${RLOTTIE}/src "${CMAKE_CURRENT_BINARY_DIR}/rlottie_src") diff --git a/third_party/rlottie_build/KOMET_NOTES.md b/third_party/rlottie_build/KOMET_NOTES.md new file mode 100644 index 0000000..a943060 --- /dev/null +++ b/third_party/rlottie_build/KOMET_NOTES.md @@ -0,0 +1,56 @@ +# rlottie integration (Komet) + +`third_party/rlottie` is a **git submodule** pinned to Samsung/rlottie +`f487eff2f8086b84ae1c7faa0418abec909e874b`. This directory (`rlottie_build/`) +holds Komet's build glue that lives *outside* the submodule (we can't commit into +upstream's tree). + +After cloning or pulling, initialize the submodule: + +``` +git submodule update --init --recursive +``` + +CI does this via `submodules: recursive` on every `actions/checkout` step. + +## What powers what + +The native animated-reaction / animoji / sticker renderer. rlottie renders each +frame to a premultiplied BGRA buffer off the UI thread; the Dart side +(`lib/core/media/rlottie/`) uploads frames to `ui.Image`, caches them in RAM and +on disk, and plays them from cache — so the first playback is as smooth as later +loops. Web has no native path and falls back to the pure-Dart `lottie` player. + +## Files here + +- `CMakeLists.txt` — build wrapper for the CMake platforms (Linux/Windows/Android). + Builds a single self-contained `rlottie` library from the submodule sources with + `LOTTIE_MODULE OFF` (stb compiled in), `LOTTIE_THREAD ON`, `LOTTIE_CACHE ON`. + Bypasses upstream's top-level CMakeLists (which references example/test) and + drives `../rlottie/src` directly. +- `apple/config.h` — static replacement for the CMake-generated `config.h`, used + by the CocoaPods build (which does not run CMake). +- `../rlottie.podspec` — compiles the submodule sources into the app for iOS/macOS + (pod root is `third_party/`, so it can reference both the submodule and this glue). + +## Build wiring + +| Platform | How | Loaded via | Verified | +|----------|-----|-----------|----------| +| Linux | `linux/CMakeLists.txt` → `add_subdirectory(rlottie_build)`, bundled to `lib/` | `DynamicLibrary.open('librlottie.so')` | ✅ full build + bundled .so | +| Android | `android/app/build.gradle.kts` `externalNativeBuild` → `android/app/src/main/cpp/CMakeLists.txt` | `DynamicLibrary.open('librlottie.so')` | ✅ NDK r28c arm64 cross-compile | +| Windows | `windows/CMakeLists.txt` → `add_subdirectory(rlottie_build)`, `rlottie.dll` next to exe | `DynamicLibrary.open('rlottie.dll')` | ⚠️ needs MSVC to verify | +| macOS | `macos/Podfile` `pod 'rlottie', :path => '../third_party'` | `DynamicLibrary.process()` | ⚠️ needs Xcode to verify | +| iOS | `ios/Podfile` `pod 'rlottie', :path => '../third_party'` | `DynamicLibrary.process()` | ⚠️ needs Xcode to verify | + +## Gotchas for the unverified platforms + +- **iOS/macOS symbols:** with `use_frameworks!` the `lottie_animation_*` symbols + live in `rlottie.framework`. If `DynamicLibrary.process()` can't find them, + switch the loader in `lib/core/media/rlottie/rlottie_ffi.dart` to + `DynamicLibrary.open('rlottie.framework/rlottie')`. +- **Windows:** rlottie builds with `/EHs-c- /GR-` and links `Shlwapi.lib` (set in + `CMakeLists.txt`). +- **32-bit ARM:** the pixman NEON `.S` asm is not wired; armv7 uses the C path. +- **Bumping rlottie:** `cd third_party/rlottie && git checkout `, rebuild, + then re-check `apple/config.h` and the podspec source globs still match upstream. diff --git a/third_party/rlottie_build/apple/config.h b/third_party/rlottie_build/apple/config.h new file mode 100644 index 0000000..7a783d5 --- /dev/null +++ b/third_party/rlottie_build/apple/config.h @@ -0,0 +1,7 @@ +#ifndef KOMET_RLOTTIE_CONFIG_H +#define KOMET_RLOTTIE_CONFIG_H + +#define LOTTIE_THREAD_SUPPORT +#define LOTTIE_CACHE_SUPPORT + +#endif diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index 163cf91..89ca94f 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -62,6 +62,9 @@ add_subdirectory("runner") # them to the application. include(flutter/generated_plugins.cmake) +set(RLOTTIE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../third_party/rlottie_build") +add_subdirectory("${RLOTTIE_DIR}" "${CMAKE_BINARY_DIR}/rlottie") + # === Installation === # Support files are copied into place next to the executable, so that it can @@ -99,6 +102,10 @@ install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/opus/opus.dll" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" COMPONENT Runtime) +install(FILES "$" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + # Copy the native assets provided by the build.dart from all packages. set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") install(DIRECTORY "${NATIVE_ASSETS_DIR}"