diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 24f9306..093ad22 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,10 @@ + + + + + = Build.VERSION_CODES.Q) { + startForeground(NOTIFICATION_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + startForeground(NOTIFICATION_ID, n) + } + if (wakeLock == null) { + wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager) + .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "jj_mkvmaker:jobs") + .apply { acquire(6 * 60 * 60 * 1000L) } + } + return START_NOT_STICKY + } + + /// Android 15 부터 dataSync 서비스는 하루 6시간까지 → 시간이 다 되면 멈춘다 (작업은 앱이 화면에 있으면 계속) + override fun onTimeout(startId: Int, fgsType: Int) { + stopSelf() + } + + override fun onDestroy() { + running = false + wakeLock?.let { if (it.isHeld) it.release() } + wakeLock = null + super.onDestroy() + } + + companion object { + const val CHANNEL_ID = "jobs" + const val NOTIFICATION_ID = 1 + const val EXTRA_TEXT = "text" + const val EXTRA_PROGRESS = "progress" + + /// 서비스가 떠 있는지 (떠 있으면 알림만 바꾼다 - 백그라운드에서는 서비스를 새로 시작할 수 없음) + @Volatile + var running = false + + fun update(context: Context, text: String, progress: Int) { + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + nm.notify(NOTIFICATION_ID, notification(context, text, progress)) + } + + private fun notification(context: Context, text: String, progress: Int): Notification { + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && nm.getNotificationChannel(CHANNEL_ID) == null) { + nm.createNotificationChannel( + NotificationChannel(CHANNEL_ID, "작업 진행", NotificationManager.IMPORTANCE_LOW).apply { + description = "다운로드 · MKV 만들기 · AI 자막 진행 상황" + setShowBadge(false) + } + ) + } + val open = PendingIntent.getActivity( + context, 0, + Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + val b = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(context, CHANNEL_ID) + } else { + @Suppress("DEPRECATION") Notification.Builder(context) + } + b.setSmallIcon(android.R.drawable.stat_sys_download) + .setContentTitle("JJ_MKVMaker 작업 중") + .setContentText(text) + .setContentIntent(open) + .setOngoing(true) + .setOnlyAlertOnce(true) + if (progress in 0..100) b.setProgress(100, progress, false) + return b.build() + } + } +} diff --git a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt index ec5a36d..fd176aa 100644 --- a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt +++ b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt @@ -27,6 +27,14 @@ class MainActivity : FlutterActivity() { result.success(null) } "storageRoot" -> result.success(Environment.getExternalStorageDirectory().path) + "keepAlive" -> { + keepAlive(call.argument("text") ?: "", call.argument("progress") ?: -1) + result.success(null) + } + "stopKeepAlive" -> { + stopService(Intent(this, KeepAliveService::class.java)) + result.success(null) + } "downloadToolsInit" -> inBackground(result) { downloadToolsInit() } "downloadToolsUpdate" -> inBackground(result) { YoutubeDL.updateYoutubeDL(applicationContext)?.name ?: "" @@ -36,6 +44,31 @@ class MainActivity : FlutterActivity() { } } + /// 작업 진행 알림 (포그라운드 서비스) 시작 · 갱신. 처음 한 번 알림 권한을 묻는다 (Android 13+, 거절해도 작업은 계속) + private var askedNotifications = false + + private fun keepAlive(text: String, progress: Int) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !askedNotifications && + checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + askedNotifications = true + requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 2) + } + if (KeepAliveService.running) { + KeepAliveService.update(this, text, progress) + return + } + val i = Intent(this, KeepAliveService::class.java) + .putExtra(KeepAliveService.EXTRA_TEXT, text) + .putExtra(KeepAliveService.EXTRA_PROGRESS, progress) + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) startForegroundService(i) else startService(i) + } catch (e: Exception) { + // 백그라운드에서는 새로 시작할 수 없다 (Android 12+). 이미 떠 있으면 다음 갱신 때 바뀐다 + android.util.Log.w("jj_mkvmaker", "keepAlive", e) + } + } + /// 오래 걸리는 일 (처음 켤 때 실행 파일 풀기 · yt-dlp 업데이트) 은 메인 스레드 밖에서 private fun inBackground(result: MethodChannel.Result, work: () -> Any?) { Thread { diff --git a/lib/core/download_detect.dart b/lib/core/download_detect.dart index dd31278..dd986a8 100644 --- a/lib/core/download_detect.dart +++ b/lib/core/download_detect.dart @@ -157,10 +157,14 @@ enum YtQuality { } /// yt-dlp 형식 인수 -List ytDlpFormatArgs(YtContainer c, YtQuality q) { +/// +/// [preferH264]: 같은 크기면 H.264 를 먼저 고른다 (Android: 휴대폰 · 태블릿은 AV1 · VP9 을 하드웨어로 못 푸는 일이 많아 +/// 재생이 끊기고 배터리를 많이 씀). YouTube 의 H.264 는 1080p 까지라 그보다 큰 화질을 고르면 크기가 먼저다. +List ytDlpFormatArgs(YtContainer c, YtQuality q, {bool preferH264 = false}) { final res = q.height == null ? '' : 'res:${q.height},'; + final codec = preferH264 ? 'vcodec:h264,' : ''; return switch (c) { - YtContainer.mp4 => ['-S', '${res}ext:mp4:m4a', '--merge-output-format', 'mp4', '--remux-video', 'mp4'], + YtContainer.mp4 => ['-S', '$res${codec}ext:mp4:m4a', '--merge-output-format', 'mp4', '--remux-video', 'mp4'], YtContainer.webm => ['-S', '${res}ext:webm:webm', '--merge-output-format', 'webm'], YtContainer.mp3 => ['-f', 'ba/b', '-x', '--audio-format', 'mp3', '--audio-quality', '0'], YtContainer.m4a => ['-f', 'ba[ext=m4a]/ba/b', '-x', '--audio-format', 'm4a'], diff --git a/lib/main.dart b/lib/main.dart index b291dc1..df3c773 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,6 +15,7 @@ import 'app/settings.dart'; import 'core/app_update.dart'; import 'core/playlist.dart'; import 'platform/android/android_download_tools.dart'; +import 'platform/android/android_keep_alive.dart'; import 'platform/android/android_storage.dart'; import 'platform/windows/app_paths.dart'; import 'platform/windows/cef_runtime.dart'; @@ -355,6 +356,8 @@ Future runAndroid(String dataDir) async { }; downloads.addToEditList = controller.addDownloaded; downloads.log = controller.note; + // 진행 중인 일이 있으면 화면에서 내려가도 계속 (알림에 진행 상황) + AndroidKeepAlive(controller, downloads); WidgetsBinding.instance.addPostFrameCallback((_) async { if (controller.settings.startScreen == 'browser') { @@ -454,7 +457,19 @@ class JjCapCutApp extends StatelessWidget { onExit: onExit, downloads: downloads, bookmarks: bookmarks, - child: AppDropArea(c: controller, child: UiScaler(c: controller, child: child!))), + child: AppDropArea(c: controller, child: _SystemBarsArea(child: UiScaler(c: controller, child: child!)))), home: HomePage(c: controller, downloads: downloads, onExit: onExit, bookmarks: bookmarks), ); } + +/// Android: 앱 화면이 상태 표시줄 · 아래쪽 작업 표시줄 밑까지 그려지므로 (Android 15 부터 기본) +/// 위쪽 버튼 줄이 시계 · 배터리 표시와 겹치지 않게 그만큼 띄운다. 전체 화면 재생 중에는 표시줄이 숨어 여백도 없다. +class _SystemBarsArea extends StatelessWidget { + final Widget child; + const _SystemBarsArea({required this.child}); + + @override + Widget build(BuildContext context) => Platform.isAndroid + ? ColoredBox(color: Theme.of(context).scaffoldBackgroundColor, child: SafeArea(child: child)) + : child; +} diff --git a/lib/platform/android/android_download_tools.dart b/lib/platform/android/android_download_tools.dart index 62d9843..d322984 100644 --- a/lib/platform/android/android_download_tools.dart +++ b/lib/platform/android/android_download_tools.dart @@ -70,7 +70,7 @@ class AndroidDownloadTools { // --ffmpeg-location 은 파일 경로도 받는다 (libffmpeg.so = ffmpeg 실행 파일) ffmpegDir: t.ffmpeg, jsRuntime: 'quickjs:${t.quickjs}', - formatArgs: () => ytDlpFormatArgs(s().ytContainer, s().ytQuality), + formatArgs: () => ytDlpFormatArgs(s().ytContainer, s().ytQuality, preferH264: true), cookieArgs: () => ytDlpCookieArgs( browser: s().ytCookiesBrowser, file: s().ytCookiesFile, diff --git a/lib/platform/android/android_keep_alive.dart b/lib/platform/android/android_keep_alive.dart new file mode 100644 index 0000000..96b134d --- /dev/null +++ b/lib/platform/android/android_keep_alive.dart @@ -0,0 +1,61 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; + +import '../../app/app_controller.dart'; +import '../../app/download_manager.dart'; +import '../../services/downloader.dart'; + +/// Android: 다운로드 · MKV 만들기 · AI 자막이 진행 중이면 포그라운드 서비스 (MainActivity.kt · KeepAliveService.kt) +/// 를 띄워 앱이 화면에서 내려가도 계속하게 하고, 알림에 진행 상황을 보여 준다. 모두 끝나면 내린다. +class AndroidKeepAlive { + static const _ch = MethodChannel('jj_mkvmaker/android'); + + final AppController c; + final DownloadManager downloads; + Timer? _timer; + bool _on = false; + String _last = ''; + + AndroidKeepAlive(this.c, this.downloads) { + c.addListener(_changed); + downloads.addListener(_changed); + } + + /// 진행률 알림은 자주 바뀌므로 1초에 한 번만 보낸다 + void _changed() => _timer ??= Timer(const Duration(seconds: 1), _apply); + + Future _apply() async { + _timer = null; + // 일시정지한 다운로드는 세지 않는다 (받는 중 · 대기 중만) + final running = downloads.tasks + .where((t) => t.state == DownloadState.downloading || t.state == DownloadState.queued) + .length; + final (text, progress) = status(c.currentJob, c.busy, running, downloads.overallProgress); + try { + if (text == null) { + if (_on) await _ch.invokeMethod('stopKeepAlive'); + _on = false; + _last = ''; + return; + } + final key = '$text|$progress'; + if (_on && key == _last) return; + await _ch.invokeMethod('keepAlive', {'text': text, 'progress': progress}); + _on = true; + _last = key; + } catch (_) {} + } + + /// 알림 글과 진행률 (0~100, 모르면 -1). 진행 중인 일이 없으면 글이 null. + static (String?, int) status(String? job, bool busy, int downloads, double? downloadProgress) { + final parts = [ + if (busy) job ?? '작업 중', + if (downloads > 0) '다운로드 $downloads개${downloadProgress == null ? '' : ' · ${(downloadProgress * 100).round()}%'}', + ]; + if (parts.isEmpty) return (null, -1); + // 진행률 막대는 다운로드만 있을 때 (작업 진행률은 동영상마다 따로라 하나로 합치기 어렵다) + final p = !busy && downloadProgress != null ? (downloadProgress * 100).round().clamp(0, 100) : -1; + return (parts.join(' · '), p); + } +} diff --git a/lib/platform/common/nllb_translator.dart b/lib/platform/common/nllb_translator.dart index fbed2ef..551c133 100644 --- a/lib/platform/common/nllb_translator.dart +++ b/lib/platform/common/nllb_translator.dart @@ -11,6 +11,7 @@ import 'package:path/path.dart' as p; import '../../core/nllb_tokenizer.dart'; import '../../services/ai_services.dart'; +import 'onnx_external.dart'; import 'ort_tensors.dart'; /// NLLB-200 (ONNX, int8) 로컬 번역기. Windows·Android 공용. @@ -42,7 +43,7 @@ class NllbTranslator implements Translator { Future load(String modelDir) async { if (_isolate != null) return; for (final f in files) { - if (!File(p.join(modelDir, f)).existsSync()) { + if (!File(p.join(modelDir, f)).existsSync() && !OnnxExternal.isReady(p.join(modelDir, f))) { throw FileSystemException('번역 모델 파일이 없습니다', p.join(modelDir, f)); } } @@ -164,18 +165,16 @@ void _workerMain(List args) { } final id = m['id'] as int; final lines = (m['lines'] as List).cast(); + final src = m['src'] as String, tgt = m['tgt'] as String; try { - final out = []; - for (var i = 0; i < lines.length; i += batch) { - if (cancel.value != 0) { - main.send({'type': 'error', 'id': id, 'cancelled': true}); - return; - } - final chunk = lines.sublist(i, math.min(i + batch, lines.length)); - out.addAll(engine.translateBatch(chunk, m['src'] as String, m['tgt'] as String)); - main.send({'type': 'progress', 'id': id, 'value': out.length / lines.length}); + final out = engine.translateAll(lines, src, tgt, batch, + cancelled: () => cancel.value != 0, + progress: (v) => main.send({'type': 'progress', 'id': id, 'value': v})); + if (out == null) { + main.send({'type': 'error', 'id': id, 'cancelled': true}); + } else { + main.send({'type': 'result', 'id': id, 'lines': out}); } - main.send({'type': 'result', 'id': id, 'lines': out}); } catch (e) { main.send({'type': 'error', 'id': id, 'error': '$e'}); } @@ -186,39 +185,117 @@ class _NllbEngine { static const _layers = 12, _heads = 16, _headDim = 64; final NllbTokenizer tok; - final OrtSession encoder; - final OrtSession decoder; - final OrtRunOptions runOptions = OrtRunOptions(); - late final Map _outIndex = { - for (var i = 0; i < decoder.outputNames.length; i++) decoder.outputNames[i]: i, - }; + final String dir; + final int threads; - _NllbEngine(this.tok, this.encoder, this.decoder); + /// 메모리가 적은 기기 (Android): 인코더와 디코더를 동시에 열지 않는다. + /// 둘을 함께 열면 불러오는 동안 1.5GB 를 넘게 써서 4GB 기기에서는 Android 가 앱을 강제로 끈다. + /// → 모든 줄을 먼저 인코딩해 두고 인코더를 닫은 뒤 디코더를 연다. 인코딩 결과는 대상 언어와 상관없으므로 + /// 같은 줄을 다른 언어로 번역할 때 다시 쓴다. + final bool lowMemory; + OrtSession? _enc, _dec; + final OrtRunOptions runOptions = OrtRunOptions(); + Map? _outIdx; + + /// 마지막으로 인코딩한 줄 (lowMemory) + String? _cacheKey; + List<_Encoded> _cache = const []; + + _NllbEngine(this.tok, this.dir, this.threads, this.lowMemory); factory _NllbEngine.load(String dir, int threads) { OrtEnv.instance.init(); final tok = NllbTokenizer.fromJson( File(p.join(dir, 'tokenizer.json')).readAsStringSync()); - return _NllbEngine( - tok, - openSession(p.join(dir, 'encoder_model_quantized.onnx'), threads: threads), - openSession(p.join(dir, 'decoder_model_merged_quantized.onnx'), threads: threads), - ); + // JJ_NLLB_LOW_MEMORY=1: PC 에서 Android 방식을 시험할 때 + final low = Platform.isAndroid || Platform.environment['JJ_NLLB_LOW_MEMORY'] == '1'; + final e = _NllbEngine(tok, dir, threads, low); + // 넉넉한 기기는 미리 둘 다 연다 (모델 파일이 깨졌으면 여기서 알 수 있게) + if (!e.lowMemory) { + e.encoder; + e.decoder; + } + return e; + } + + OrtSession get encoder => _enc ??= _open('encoder_model_quantized.onnx'); + OrtSession get decoder => _dec ??= _open('decoder_model_merged_quantized.onnx'); + + /// lowMemory: 외부 데이터 모델로 연다 (처음 한 번 바꾸고 원래 파일은 지움) + OrtSession _open(String name) { + final path = p.join(dir, name); + return openSession(lowMemory ? OnnxExternal.ensure(path, deleteOriginal: true) : path, + threads: threads, lowMemory: lowMemory); + } + Map get _outIndex => _outIdx ??= { + for (var i = 0; i < decoder.outputNames.length; i++) decoder.outputNames[i]: i, + }; + + void _closeEncoder() { + _enc?.release(); + _enc = null; + } + + void _closeDecoder() { + _dec?.release(); + _dec = null; + _outIdx = null; } void release() { runOptions.release(); - encoder.release(); - decoder.release(); + _closeEncoder(); + _closeDecoder(); OrtEnv.instance.release(); } + /// 모든 줄 번역. [batch] 줄씩. 취소되면 null. + List? translateAll(List lines, String src, String tgt, int batch, + {required bool Function() cancelled, required void Function(double) progress}) { + final out = []; + if (!lowMemory) { + for (var i = 0; i < lines.length; i += batch) { + if (cancelled()) return null; + out.addAll(translateBatch(lines.sublist(i, math.min(i + batch, lines.length)), src, tgt)); + progress(out.length / lines.length); + } + return out; + } + // 1단계: 인코딩 (전체의 20%) - 같은 줄이면 지난번 결과를 쓴다 + final key = '$src\n${lines.join('\n')}'; + if (_cacheKey != key) { + _cacheKey = null; + _cache = const []; + _closeDecoder(); + final enc = <_Encoded>[]; + for (var i = 0; i < lines.length; i += batch) { + if (cancelled()) return null; + enc.add(_encode(lines.sublist(i, math.min(i + batch, lines.length)), src)); + progress(0.2 * math.min(i + batch, lines.length) / lines.length); + } + _closeEncoder(); + _cache = enc; + _cacheKey = key; + } + // 2단계: 디코딩 + var done = 0; + for (final e in _cache) { + if (cancelled()) return null; + out.addAll(_decode(e, tgt)); + done += e.count; + progress(0.2 + 0.8 * done / lines.length); + } + return out; + } + /// 여러 줄을 한 번에 번역 (탐욕적 디코딩 + KV 캐시) - List translateBatch(List lines, String src, String tgt) { - final result = List.filled(lines.length, ''); + List translateBatch(List lines, String src, String tgt) => _decode(_encode(lines, src), tgt); + + /// 인코더 실행. 결과는 Dart 메모리로 복사해 둔다 (인코더를 닫아도 남도록) + _Encoded _encode(List lines, String src) { // 빈 줄은 건너뜀 final idx = [for (var i = 0; i < lines.length; i++) if (lines[i].trim().isNotEmpty) i]; - if (idx.isEmpty) return result; + if (idx.isEmpty) return _Encoded(lines.length, idx, Float32List(0), const [], const [], 0); final encoded = [for (final i in idx) tok.encode(lines[i], src)]; final b = encoded.length; @@ -235,7 +312,24 @@ class _NllbEngine { final inMask = int64Tensor(mask, [b, encLen]); final encOut = encoder.run(runOptions, {'input_ids': inIds, 'attention_mask': inMask}); inIds.release(); - final hidden = encOut[0]!; + inMask.release(); + final h = encOut[0]!; + final copy = _Encoded(lines.length, idx, Float32List.fromList(floatView(h)), tensorShape(h), mask, encLen); + for (final v in encOut) { + v?.release(); + } + return copy; + } + + /// 디코더 실행 (탐욕적 디코딩 + KV 캐시) + List _decode(_Encoded e, String tgt) { + final result = List.filled(e.count, ''); + final idx = e.idx; + if (idx.isEmpty) return result; + final b = idx.length; + final encLen = e.encLen; + final inMask = int64Tensor(e.mask, [b, encLen]); + final hidden = floatTensor(e.hidden, e.hiddenShape); final tgtId = tok.langId(tgt); final maxNew = math.min(256, encLen * 2 + 10); @@ -359,3 +453,17 @@ class _NllbEngine { return false; } } + +/// 인코더 결과 한 묶음 (Dart 메모리) +class _Encoded { + /// 묶음의 전체 줄 수 (빈 줄 포함) + final int count; + + /// 번역할 (비어 있지 않은) 줄의 위치 + final List idx; + final Float32List hidden; + final List hiddenShape; + final List mask; + final int encLen; + const _Encoded(this.count, this.idx, this.hidden, this.hiddenShape, this.mask, this.encLen); +} diff --git a/lib/platform/common/onnx_external.dart b/lib/platform/common/onnx_external.dart new file mode 100644 index 0000000..ef2e1f2 --- /dev/null +++ b/lib/platform/common/onnx_external.dart @@ -0,0 +1,475 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:path/path.dart' as p; + +/// ONNX 모델을 메모리가 적은 기기 (Android) 에 맞게 바꾼다. 처음 번역할 때 한 번. +/// +/// 1. 큰 가중치를 별도 파일 (외부 데이터) 로 옮긴다. 한 파일짜리 모델을 열면 ONNX Runtime 은 파일 내용과 +/// 가중치를 잠깐 함께 들고 있어 모델 크기의 2~3배를 쓴다. 외부 데이터는 파일을 메모리 맵으로 바로 쓰므로 +/// 앱 메모리를 거의 쓰지 않고, Android 도 필요하면 그 메모리를 비울 수 있다. +/// 2. 정수 lm_head: NLLB 디코더의 마지막 단계는 int8 공유 임베딩 (25.6만 단어 × 1024) 을 매번 전치하고 +/// float 로 풀어 (1GB) 곱한다. 이것을 다른 층과 같은 동적 양자화 정수 곱 +/// (DynamicQuantizeLinear → MatMulInteger → 배율) 으로 바꾸고, 전치한 가중치는 미리 만들어 둔다. +/// +/// 측정 (NLLB 디코더, 그래프 최적화 끔): 실행 중 최대 1.8GB · 한 단계 800ms → 570MB (대부분 파일 맵) · 60ms. +/// 바꾼 모델이 원래와 같은 결과를 내는지: tool/check_onnx_external.py +/// +/// 파일 전체를 메모리에 올리지 않는다: protobuf 머리만 읽으며 큰 raw_data 는 조각조각 .data 파일로 복사하고, +/// 모델 구조 (수 MB) 만 메모리에서 다시 쓴다. +class OnnxExternal { + /// 이보다 큰 가중치만 옮긴다 + static const threshold = 1024; + + /// .data 안에서 가중치 시작 위치 정렬 (메모리 맵에 유리) + static const align = 4096; + + /// 바꾼 모델 경로: encoder_model_quantized.onnx → encoder_model_quantized_jj2.onnx (+ .data). + /// 바꾸는 방식이 달라지면 숫자를 올린다 (예전 것은 지우고 다시 만든다). + static String externalPath(String onnx) => + p.join(p.dirname(onnx), '${p.basenameWithoutExtension(onnx)}_jj2.onnx'); + + /// 예전 방식으로 바꾼 파일 (정수 lm_head 없음 - 디코더가 1GB 넘게 씀) + static List _legacy(String onnx) { + final base = p.join(p.dirname(onnx), '${p.basenameWithoutExtension(onnx)}_ext.onnx'); + return [base, '$base.data']; + } + + /// [onnx] 를 [out] (+ [out].data) 로 바꾼다. 중간에 실패하면 만들던 파일을 지운다. + static void convert(String onnx, String out) { + final tmp = '$out.tmp', tmpData = '$out.data.tmp'; + final src = File(onnx).openSync(); + final data = File(tmpData).openSync(mode: FileMode.write); + try { + final model = _Rewriter(src, data, p.basename('$out.data')).model(); + File(tmp).writeAsBytesSync(model, flush: true); + } catch (_) { + src.closeSync(); + data.closeSync(); + for (final f in [tmp, tmpData]) { + try { + File(f).deleteSync(); + } catch (_) {} + } + rethrow; + } + src.closeSync(); + data.closeSync(); + // .data 를 먼저 옮겨 두고 모델 파일을 마지막에 (모델 파일이 있으면 다 된 것) + File(tmpData).renameSync('$out.data'); + File(tmp).renameSync(out); + } + + static bool isReady(String onnx) { + final ext = externalPath(onnx); + return File(ext).existsSync() && File('$ext.data').existsSync(); + } + + /// 바꾼 모델이 없으면 만든다. 바꾼 모델 경로를 돌려준다. + /// [deleteOriginal]: 다 바꾸면 원래 파일을 지운다 (저장 공간 절약) + static String ensure(String onnx, {bool deleteOriginal = false}) { + final ext = externalPath(onnx); + for (final f in _legacy(onnx)) { + try { + File(f).deleteSync(); + } catch (_) {} + } + if (isReady(onnx)) return ext; + convert(onnx, ext); + if (deleteOriginal) { + try { + File(onnx).deleteSync(); + } catch (_) {} + } + return ext; + } +} + +/// 정수 lm_head 로 바꿀 곳: Transpose(W) → DequantizeLinear(·, scale, zp) → MatMul(h, ·) → out +class _LmHead { + final String weight, transposed, dequantized, scale, zeroPoint, input, output; + const _LmHead(this.weight, this.transposed, this.dequantized, this.scale, this.zeroPoint, this.input, this.output); + + String get key => [weight, transposed, dequantized, scale, zeroPoint, input, output].join('|'); +} + +/// 노드 하나 (분석용) +class _Node { + String op = ''; + final inputs = [], outputs = []; + var perm = []; +} + +class _Rewriter { + final RandomAccessFile src; + final RandomAccessFile data; + final String location; + final _buf = Uint8List(10); + + /// 정수 lm_head 로 바꿀 곳 (없으면 null) + _LmHead? _lm; + + /// 주 그래프의 W (전치본을 만들 원본): raw 위치 · 크기 · 형 + int? _wRaw, _wRows, _wCols, _wType; + + _Rewriter(this.src, this.data, this.location); + + /// ModelProto: graph (7) 만 다시 쓰고 나머지는 그대로 + Uint8List model() { + _analyze(); + final out = BytesBuilder(copy: false); + _fields(0, src.lengthSync(), (no, wt, keyStart, valStart, valLen, end) { + if (no == 7 && wt == 2) { + _bytes(out, 7, _graph(valStart, valLen, main: true)); + } else { + out.add(_read(keyStart, end - keyStart)); + } + }); + return out.takeBytes(); + } + + // ───────── 분석: 정수 lm_head 로 바꿀 곳 찾기 ───────── + + void _analyze() { + final mainInits = {}; + final subgraphs = >[]; + void graph(int start, int len, bool main) { + final nodes = <_Node>[]; + _fields(start, len, (no, wt, keyStart, valStart, valLen, end) { + if (no == 1 && wt == 2) { + nodes.add(_parseNode(valStart, valLen, (gs, gl) => graph(gs, gl, false))); + } else if (main && no == 5 && wt == 2) { + _fields(valStart, valLen, (n, w, ks, vs, vl, e) { + if (n == 8 && w == 2) mainInits.add(utf8.decode(_read(vs, vl))); + }); + } + }); + if (!main) subgraphs.add(nodes); + } + + _fields(0, src.lengthSync(), (no, wt, keyStart, valStart, valLen, end) { + if (no == 7 && wt == 2) graph(valStart, valLen, true); + }); + + _LmHead? found; + for (final nodes in subgraphs) { + for (final tr in nodes) { + if (tr.op != 'Transpose' || tr.inputs.length != 1 || !mainInits.contains(tr.inputs[0])) continue; + if (tr.perm.isNotEmpty && (tr.perm.length != 2 || tr.perm[0] != 1 || tr.perm[1] != 0)) continue; + final dq = nodes.where((n) => n.op == 'DequantizeLinear' && n.inputs.length == 3 && n.inputs[0] == tr.outputs[0]); + if (dq.length != 1) continue; + final mm = nodes.where((n) => n.op == 'MatMul' && n.inputs.length == 2 && n.inputs[1] == dq.first.outputs[0]); + if (mm.length != 1) continue; + final lm = _LmHead(tr.inputs[0], tr.outputs[0], dq.first.outputs[0], dq.first.inputs[1], dq.first.inputs[2], + mm.first.inputs[0], mm.first.outputs[0]); + // 여러 갈래에 같은 모양으로 있어야 한다 (NLLB merged 디코더: If 의 두 갈래) + if (found != null && found.key != lm.key) return; + found = lm; + } + } + _lm = found; + } + + _Node _parseNode(int start, int len, void Function(int, int) subgraph) { + final n = _Node(); + _fields(start, len, (no, wt, keyStart, valStart, valLen, end) { + if (wt != 2) return; + switch (no) { + case 1: + n.inputs.add(utf8.decode(_read(valStart, valLen))); + case 2: + n.outputs.add(utf8.decode(_read(valStart, valLen))); + case 4: + n.op = utf8.decode(_read(valStart, valLen)); + case 5: // AttributeProto + var name = ''; + final ints = []; + _fields(valStart, valLen, (an, aw, aks, avs, avl, ae) { + if (an == 1 && aw == 2) name = utf8.decode(_read(avs, avl)); + if (an == 6 && aw == 2) subgraph(avs, avl); // g + if (an == 11 && aw == 2) subgraph(avs, avl); // graphs + if (an == 8 && aw == 0) ints.add(_readVarint(avs).$1); + if (an == 8 && aw == 2) { + for (var q = avs; q < avs + avl;) { + final (v, k) = _readVarint(q); + ints.add(v); + q += k; + } + } + }); + if (name == 'perm') n.perm = ints; + } + }); + return n; + } + + // ───────── 다시 쓰기 ───────── + + /// GraphProto: initializer (5) 를 외부 데이터로, 노드 (1) 안의 하위 그래프를 다시 쓰고, + /// 정수 lm_head 로 바꾸는 갈래에서는 세 노드를 빼고 새 노드를 붙인다 + Uint8List _graph(int start, int len, {required bool main}) { + final out = BytesBuilder(copy: false); + final lm = _lm; + var replaced = false; + _fields(start, len, (no, wt, keyStart, valStart, valLen, end) { + if (no == 5 && wt == 2) { + _bytes(out, 5, _tensor(valStart, valLen, main: main)); + } else if (no == 1 && wt == 2) { + final n = _parseNode(valStart, valLen, (_, _) {}); + if (lm != null && + ((n.op == 'Transpose' && n.outputs.contains(lm.transposed)) || + (n.op == 'DequantizeLinear' && n.outputs.contains(lm.dequantized)) || + (n.op == 'MatMul' && n.outputs.contains(lm.output) && n.inputs.contains(lm.dequantized)))) { + replaced = true; + return; // 뺀다 + } + _bytes(out, 1, _node(valStart, valLen)); + } else { + out.add(_read(keyStart, end - keyStart)); + } + }); + if (replaced && lm != null) { + // 정수 lm_head: out = Cast(MatMulInteger(DQL(h), Wᵀ)) × (h 배율 × W 배율) + for (final nb in [ + _newNode('DynamicQuantizeLinear', [lm.input], ['jj_lm_hq', 'jj_lm_hs', 'jj_lm_hz'], 'jj_lm_dq'), + _newNode('MatMulInteger', ['jj_lm_hq', lm.transposed, 'jj_lm_hz', lm.zeroPoint], ['jj_lm_i32'], 'jj_lm_mmi'), + _newNode('Cast', ['jj_lm_i32'], ['jj_lm_f'], 'jj_lm_cast', castTo: 1), // FLOAT + _newNode('Mul', ['jj_lm_hs', lm.scale], ['jj_lm_s'], 'jj_lm_scale'), + _newNode('Mul', ['jj_lm_f', 'jj_lm_s'], [lm.output], 'jj_lm_out'), + ]) { + _bytes(out, 1, nb); + } + } + // 주 그래프 끝에 전치한 가중치 Wᵀ 를 더한다 (W 는 단어 찾기 (Gather) 에 계속 쓰인다) + if (main && lm != null && _wRaw != null) { + _bytes(out, 5, _transposedInit(lm.transposed)); + } + return out.takeBytes(); + } + + /// NodeProto: 속성 (5) 안의 하위 그래프만 다시 쓴다 + Uint8List _node(int start, int len) { + final out = BytesBuilder(copy: false); + _fields(start, len, (no, wt, keyStart, valStart, valLen, end) { + if (no == 5 && wt == 2) { + _bytes(out, 5, _attribute(valStart, valLen)); + } else { + out.add(_read(keyStart, end - keyStart)); + } + }); + return out.takeBytes(); + } + + /// AttributeProto: g (6) · graphs (11) + Uint8List _attribute(int start, int len) { + final out = BytesBuilder(copy: false); + _fields(start, len, (no, wt, keyStart, valStart, valLen, end) { + if ((no == 6 || no == 11) && wt == 2) { + _bytes(out, no, _graph(valStart, valLen, main: false)); + } else { + out.add(_read(keyStart, end - keyStart)); + } + }); + return out.takeBytes(); + } + + /// TensorProto: 큰 raw_data (9) 를 .data 로 옮기고 external_data (13) · data_location (14) 를 붙인다 + Uint8List _tensor(int start, int len, {required bool main}) { + final out = BytesBuilder(copy: false); + int? rawStart, rawLen, type; + var name = ''; + final dims = []; + var external = false; + _fields(start, len, (no, wt, keyStart, valStart, valLen, end) { + if (no == 14 && wt == 0) external = true; // 이미 외부 데이터 + if (no == 8 && wt == 2) name = utf8.decode(_read(valStart, valLen)); + if (no == 2 && wt == 0) type = _readVarint(valStart).$1; + if (no == 1 && wt == 0) dims.add(_readVarint(valStart).$1); + if (no == 1 && wt == 2) { + for (var q = valStart; q < valStart + valLen;) { + final (v, k) = _readVarint(q); + dims.add(v); + q += k; + } + } + if (no == 9 && wt == 2 && valLen >= OnnxExternal.threshold) { + rawStart = valStart; + rawLen = valLen; + } else { + out.add(_read(keyStart, end - keyStart)); + } + }); + if (rawStart == null || external) { + // 옮길 것이 없으면 원래 그대로 + if (rawStart != null) return _read(start, len); + return out.takeBytes(); + } + // 전치본을 만들 W (int8 · uint8 2차원) + if (main && name == _lm?.weight && dims.length == 2 && (type == 2 || type == 3) && dims[0] * dims[1] == rawLen) { + _wRaw = rawStart; + _wRows = dims[0]; + _wCols = dims[1]; + _wType = type; + } + final offset = _alignData(); + const chunk = 4 << 20; + for (var done = 0; done < rawLen!; done += chunk) { + final n = rawLen! - done < chunk ? rawLen! - done : chunk; + data.writeFromSync(_read(rawStart! + done, n)); + } + _externalFields(out, offset, rawLen!); + return out.takeBytes(); + } + + /// W [rows × cols] 를 전치해 .data 에 쓰고, Wᵀ initializer 를 만든다. + /// 256 열씩 나눠 (64MB) W 를 여러 번 읽는다 - 큰 메모리를 한 번에 잡지 않도록. + Uint8List _transposedInit(String name) { + final rows = _wRows!, cols = _wCols!, raw = _wRaw!; + final offset = _alignData(); + const block = 256, readRows = 4096; + for (var c0 = 0; c0 < cols; c0 += block) { + final bc = cols - c0 < block ? cols - c0 : block; + final outBuf = Uint8List(bc * rows); + for (var r0 = 0; r0 < rows; r0 += readRows) { + final br = rows - r0 < readRows ? rows - r0 : readRows; + final inBuf = _read(raw + r0 * cols, br * cols); + for (var r = 0; r < br; r++) { + final ib = r * cols + c0; + final ob = r0 + r; + for (var c = 0; c < bc; c++) { + outBuf[c * rows + ob] = inBuf[ib + c]; + } + } + } + data.writeFromSync(outBuf); + } + final out = BytesBuilder(copy: false); + _key(out, 1, 0); + _varint(out, cols); + _key(out, 1, 0); + _varint(out, rows); + _key(out, 2, 0); + _varint(out, _wType!); + _str(out, 8, name); + _externalFields(out, offset, rows * cols); + return out.takeBytes(); + } + + /// .data 끝을 정렬 위치까지 0 으로 채우고 그 위치를 돌려준다 + int _alignData() { + var offset = data.lengthSync(); + final pad = (OnnxExternal.align - offset % OnnxExternal.align) % OnnxExternal.align; + data.setPositionSync(offset); + if (pad > 0) { + data.writeFromSync(Uint8List(pad)); + offset += pad; + } + return offset; + } + + void _externalFields(BytesBuilder out, int offset, int length) { + for (final (k, v) in [('location', location), ('offset', '$offset'), ('length', '$length')]) { + final entry = BytesBuilder(copy: false); + _str(entry, 1, k); + _str(entry, 2, v); + _bytes(out, 13, entry.takeBytes()); + } + _key(out, 14, 0); + _varint(out, 1); // EXTERNAL + } + + /// 새 NodeProto (기본 도메인). [castTo]: Cast 의 to 속성 + static Uint8List _newNode(String op, List inputs, List outputs, String name, {int? castTo}) { + final out = BytesBuilder(copy: false); + for (final i in inputs) { + _str(out, 1, i); + } + for (final o in outputs) { + _str(out, 2, o); + } + _str(out, 3, name); + _str(out, 4, op); + if (castTo != null) { + final a = BytesBuilder(copy: false); + _str(a, 1, 'to'); + _key(a, 3, 0); + _varint(a, castTo); + _key(a, 20, 0); + _varint(a, 2); // AttributeType.INT + _bytes(out, 5, a.takeBytes()); + } + return out.takeBytes(); + } + + /// [start] 부터 [len] 바이트 안의 protobuf 필드를 차례로 + void _fields(int start, int len, + void Function(int no, int wt, int keyStart, int valStart, int valLen, int end) f) { + var pos = start; + final end = start + len; + while (pos < end) { + final keyStart = pos; + final (key, kn) = _readVarint(pos); + pos += kn; + final no = key >> 3, wt = key & 7; + int valStart = pos, valLen; + switch (wt) { + case 0: + final (_, n) = _readVarint(pos); + valLen = n; + case 1: + valLen = 8; + case 5: + valLen = 4; + case 2: + final (l, n) = _readVarint(pos); + valStart = pos + n; + valLen = l; + default: + throw FormatException('지원하지 않는 protobuf 형식 (wire type $wt, 위치 $keyStart)'); + } + final fieldEnd = valStart + valLen; + if (fieldEnd > end) throw FormatException('모델 파일이 잘렸습니다 (위치 $keyStart)'); + f(no, wt, keyStart, valStart, valLen, fieldEnd); + pos = fieldEnd; + } + } + + (int, int) _readVarint(int pos) { + src.setPositionSync(pos); + final n = src.readIntoSync(_buf); + var v = 0, shift = 0; + for (var i = 0; i < n; i++) { + final b = _buf[i]; + v |= (b & 0x7f) << shift; + if (b < 0x80) return (v, i + 1); + shift += 7; + } + throw FormatException('잘못된 varint (위치 $pos)'); + } + + Uint8List _read(int pos, int len) { + src.setPositionSync(pos); + final b = src.readSync(len); + if (b.length != len) throw FormatException('모델 파일이 잘렸습니다 (위치 $pos)'); + return b; + } + + static void _key(BytesBuilder out, int no, int wt) => _varint(out, (no << 3) | wt); + + static void _varint(BytesBuilder out, int v) { + while (v >= 0x80) { + out.addByte((v & 0x7f) | 0x80); + v >>= 7; + } + out.addByte(v); + } + + static void _bytes(BytesBuilder out, int no, Uint8List b) { + _key(out, no, 2); + _varint(out, b.length); + out.add(b); + } + + static void _str(BytesBuilder out, int no, String s) => _bytes(out, no, utf8.encode(s)); +} diff --git a/lib/platform/common/ort_tensors.dart b/lib/platform/common/ort_tensors.dart index 67f23e2..a631584 100644 --- a/lib/platform/common/ort_tensors.dart +++ b/lib/platform/common/ort_tensors.dart @@ -45,7 +45,12 @@ OrtValueTensor _create(ffi.Pointer data, int bytes, List shape, i /// /// onnxruntime 1.4.1 의 OrtSession.fromFile 은 Windows 에서 경로를 UTF-8 로 넘겨 실패한다 /// (Windows 의 ORT 는 wchar_t 경로를 받음). 여기서는 플랫폼에 맞는 문자열로 직접 호출한다. -OrtSession openSession(String path, {int threads = 1}) { +/// +/// [lowMemory]: 메모리가 적은 기기 (Android) 용. 외부 데이터 모델 ([OnnxExternal]) 과 함께 쓴다. +/// 그래프 최적화를 끈다: 최적화 (BASIC 이상) 의 상수 접기는 int8 가중치를 float 로 풀어 디코더가 1GB 넘게 쓴다. +/// prepacking · 메모리 아레나도 끈다. 측정 (NLLB 디코더): 기본 최대 1.5GB → 외부 데이터 + 이 설정 112MB. +/// 대신 계산이 몇 배 느려진다. +OrtSession openSession(String path, {int threads = 1, bool lowMemory = false}) { final optPP = calloc>(); _check(_api.CreateSessionOptions.asFunction< bg.OrtStatusPtr Function(ffi.Pointer>)>()(optPP)); @@ -58,7 +63,31 @@ OrtSession openSession(String path, {int threads = 1}) { bg.OrtStatusPtr Function(ffi.Pointer, int)>()(opt, 1)); _check(_api.SetSessionGraphOptimizationLevel.asFunction< bg.OrtStatusPtr Function(ffi.Pointer, int)>()( - opt, bg.GraphOptimizationLevel.ORT_ENABLE_ALL)); + opt, lowMemory ? bg.GraphOptimizationLevel.ORT_DISABLE_ALL : bg.GraphOptimizationLevel.ORT_ENABLE_ALL)); + if (lowMemory) { + final off = )>>>[ + _api.DisableCpuMemArena, + _api.DisableMemPattern, + ]; + for (final f in off) { + _check(f.asFunction)>()(opt)); + } + final add = _api.AddSessionConfigEntry.asFunction< + bg.OrtStatusPtr Function(ffi.Pointer, ffi.Pointer, ffi.Pointer)>(); + for (final (k, v) in const [ + ('session.disable_prepacking', '1'), + ('session.use_device_allocator_for_initializers', '1'), + ]) { + final kp = k.toNativeUtf8(), vp = v.toNativeUtf8(); + try { + _check(add(opt, kp.cast(), vp.cast())); + } finally { + calloc + ..free(kp) + ..free(vp); + } + } + } // 바인딩은 char* 로 선언되어 있지만 Windows 네이티브는 wchar_t* 를 읽는다 final ffi.Pointer pathPtr = Platform.isWindows diff --git a/lib/services/model_store.dart b/lib/services/model_store.dart index 3938280..e031b40 100644 --- a/lib/services/model_store.dart +++ b/lib/services/model_store.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; +import '../platform/common/onnx_external.dart'; + /// 내려받을 AI 모델 class ModelSpec { final String id; @@ -81,11 +83,14 @@ class ModelStore { Future isInstalled(ModelSpec m) async { final dir = await folderOf(m); for (final f in m.files) { - if (!await File(p.join(dir, f.name)).exists()) return false; + if (!_present(p.join(dir, f.name))) return false; } return true; } + /// 파일이 있거나, 외부 데이터 형식으로 바꿔 둔 것이 있으면 (Android 는 바꾼 뒤 원래 파일을 지운다) + static bool _present(String path) => File(path).existsSync() || OnnxExternal.isReady(path); + /// 없는 파일만 내려받는다. [onProgress] 0.0~1.0, [isCancelled] 가 true 면 중단. Future download( ModelSpec m, { @@ -100,7 +105,7 @@ class ModelStore { try { for (final f in m.files) { final target = File(p.join(dir, f.name)); - if (await target.exists()) { + if (_present(target.path)) { done += f.bytes; continue; } diff --git a/lib/ui/ai_dialog.dart b/lib/ui/ai_dialog.dart index 0fb9d67..893522b 100644 --- a/lib/ui/ai_dialog.dart +++ b/lib/ui/ai_dialog.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import '../app/app_controller.dart'; @@ -77,8 +79,8 @@ class _AiDialogState extends State<_AiDialog> { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - const Text('영상의 음성을 인식하고 선택한 언어로 번역합니다. 모든 처리는 이 PC 에서 이루어집니다.', - style: TextStyle(fontSize: 12, color: JjColors.textDim)), + Text('영상의 음성을 인식하고 선택한 언어로 번역합니다. 모든 처리는 이 ${Platform.isAndroid ? '기기' : 'PC'} 에서 이루어집니다.', + style: const TextStyle(fontSize: 12, color: JjColors.textDim)), const SizedBox(height: 16), _label('원어 (영상 속 언어)'), DropdownButton( diff --git a/test/keep_alive_test.dart b/test/keep_alive_test.dart new file mode 100644 index 0000000..c051961 --- /dev/null +++ b/test/keep_alive_test.dart @@ -0,0 +1,15 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/platform/android/android_keep_alive.dart'; + +void main() { + test('Android 진행 알림 글 · 진행률', () { + // 진행 중인 일이 없으면 알림을 내린다 + expect(AndroidKeepAlive.status(null, false, 0, null), (null, -1)); + // 다운로드만: 진행률 막대 + expect(AndroidKeepAlive.status(null, false, 2, 0.456), ('다운로드 2개 · 46%', 46)); + expect(AndroidKeepAlive.status(null, false, 1, null), ('다운로드 1개', -1)); + // 작업 (MKV · AI) 이 있으면 글만 + expect(AndroidKeepAlive.status('MKV 만들기', true, 0, null), ('MKV 만들기', -1)); + expect(AndroidKeepAlive.status('AI 자막', true, 1, 0.5), ('AI 자막 · 다운로드 1개 · 50%', -1)); + }); +} diff --git a/test/onnx_external_test.dart b/test/onnx_external_test.dart new file mode 100644 index 0000000..cdfe12d --- /dev/null +++ b/test/onnx_external_test.dart @@ -0,0 +1,28 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/platform/common/onnx_external.dart'; +import 'package:path/path.dart' as p; + +/// 실제 NLLB 모델을 외부 데이터 형식으로 바꿔 본다 (모델이 없으면 건너뜀). +/// 바꾼 모델이 원래와 같은 결과를 내는지는 tool/check_onnx_external.py 로 확인한다. +void main() { + final dir = p.join(Platform.environment['JJ_MKVMAKER_MODELS'] ?? r'M:\jj_CapCut\models', 'nllb-200-distilled-600M'); + final out = Platform.environment['JJ_ONNX_EXT_OUT']; + test('ONNX 외부 데이터로 바꾸기', () { + final tmp = out ?? Directory.systemTemp.createTempSync('onnx_ext').path; + // 바꾼 모델 (약 1.1GB) 은 확인용으로 폴더를 정했을 때만 남긴다 + if (out == null) addTearDown(() => Directory(tmp).deleteSync(recursive: true)); + for (final f in ['encoder_model_quantized.onnx', 'decoder_model_merged_quantized.onnx']) { + final src = p.join(dir, f); + final dst = OnnxExternal.externalPath(p.join(tmp, f)); + final sw = Stopwatch()..start(); + OnnxExternal.convert(src, dst); + final model = File(dst).lengthSync(), data = File('$dst.data').lengthSync(); + // ignore: avoid_print + print('$f → model ${model >> 10}KB + data ${data >> 20}MB (${sw.elapsedMilliseconds}ms)'); + expect(model, lessThan(20 << 20)); + expect(data, greaterThan(File(src).lengthSync() * 0.9)); + } + }, skip: Directory(dir).existsSync() ? false : '모델 없음', timeout: const Timeout(Duration(minutes: 5))); +} diff --git a/test/queue_test.dart b/test/queue_test.dart index cd8327f..ea4cd37 100644 --- a/test/queue_test.dart +++ b/test/queue_test.dart @@ -91,6 +91,9 @@ void main() { expect(ytDlpFormatArgs(YtContainer.mp4, YtQuality.p1080).join(' '), '-S res:1080,ext:mp4:m4a --merge-output-format mp4 --remux-video mp4'); expect(ytDlpFormatArgs(YtContainer.webm, YtQuality.best).join(' '), '-S ext:webm:webm --merge-output-format webm'); + // Android: 같은 크기면 H.264 (하드웨어 재생) + expect(ytDlpFormatArgs(YtContainer.mp4, YtQuality.p1080, preferH264: true).take(2).join(' '), + '-S res:1080,vcodec:h264,ext:mp4:m4a'); expect(ytDlpFormatArgs(YtContainer.mp3, YtQuality.p720), contains('mp3')); expect(ytDlpCookieArgs(browser: 'firefox'), ['--cookies-from-browser', 'firefox']); expect(ytDlpCookieArgs(browser: 'firefox', file: r'C:\c.txt'), ['--cookies', r'C:\c.txt']); diff --git a/tool/check_onnx_external.py b/tool/check_onnx_external.py new file mode 100644 index 0000000..82747eb --- /dev/null +++ b/tool/check_onnx_external.py @@ -0,0 +1,41 @@ +# 외부 데이터로 바꾼 NLLB 모델 (lib/platform/common/onnx_external.dart) 이 원래 모델과 같은 결과를 내는지 확인. +# +# 사용: flutter test test/onnx_external_test.dart 를 JJ_ONNX_EXT_OUT=<폴더> 로 실행해 바꾼 모델을 만든 뒤 +# python tool/check_onnx_external.py <원래 모델 폴더> <바꾼 모델 폴더> +# 필요: pip install onnxruntime numpy +import sys +import numpy as np +import onnxruntime as ort + +src, dst = sys.argv[1], sys.argv[2] + + +def session(path, low): + o = ort.SessionOptions() + if low: # Android 와 같은 설정 + o.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL + o.enable_cpu_mem_arena = False + o.enable_mem_pattern = False + o.add_session_config_entry('session.disable_prepacking', '1') + return ort.InferenceSession(path, o, providers=['CPUExecutionProvider']) + + +ids = np.array([[256047, 9906, 1159, 5, 2], [256047, 59002, 2, 1, 1]], dtype=np.int64) +mask = np.array([[1, 1, 1, 1, 1], [1, 1, 1, 0, 0]], dtype=np.int64) + +a = session(f'{src}/encoder_model_quantized.onnx', False).run(None, {'input_ids': ids, 'attention_mask': mask})[0] +b = session(f'{dst}/encoder_model_quantized_jj2.onnx', True).run(None, {'input_ids': ids, 'attention_mask': mask})[0] +print('encoder max diff', float(np.abs(a - b).max())) +assert np.allclose(a, b, atol=1e-3) + +feeds = {'encoder_attention_mask': mask, 'input_ids': np.array([[2], [2]], dtype=np.int64), + 'encoder_hidden_states': a, 'use_cache_branch': np.array([False])} +for l in range(12): + for kind in ('decoder', 'encoder'): + for kv in ('key', 'value'): + feeds[f'past_key_values.{l}.{kind}.{kv}'] = np.zeros((2, 16, 0, 64), dtype=np.float32) +x = session(f'{src}/decoder_model_merged_quantized.onnx', False).run(['logits'], feeds)[0] +y = session(f'{dst}/decoder_model_merged_quantized_jj2.onnx', True).run(['logits'], feeds)[0] +print('decoder max diff', float(np.abs(x - y).max()), 'same argmax', bool((x.argmax(-1) == y.argmax(-1)).all())) +assert (x.argmax(-1) == y.argmax(-1)).all() +print('OK')