Android: 백그라운드 작업 · 상태 표시줄 · H.264 우선 · AI 번역 메모리 (4GB 기기에서 앱이 꺼지던 문제)
- 백그라운드: 다운로드 · MKV · AI 작업 중이면 포그라운드 서비스 (진행 알림, 화면 꺼짐에도 계속), 끝나면 내림
- 상태 표시줄: 앱 화면을 시스템 표시줄 아래로 (위쪽 버튼이 시계 · 배터리와 겹치던 것)
- YouTube: 같은 크기면 H.264 먼저 (하드웨어 재생)
- AI 번역 (NLLB): 번역 모델을 여는 순간 1.3~2.3GB 를 써서 Android 가 앱을 강제로 끄던 문제
· 처음 한 번 모델을 외부 데이터 형식으로 바꾼다 (onnx_external.dart): 가중치를 메모리 맵으로
· 디코더 마지막 단계 (lm_head) 를 정수 곱으로: int8 공유 임베딩을 매번 float 1GB 로 풀던 것을
DynamicQuantizeLinear → MatMulInteger 로, 전치한 가중치는 미리 만들어 둠
· 그래프 최적화 끔 (상수 접기가 int8 가중치를 float 로 풀어 커짐), 인코더 · 디코더를 따로 연다
· 태블릿 (4GB) 측정: 최대 2.3GB (강제 종료) → 736MB, 번역 결과 같음 (tool/check_onnx_external.py)
- AI 창 안내 "이 PC" → Android 는 "이 기기"
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
parent
d517d1bdaf
commit
752e4da999
|
|
@ -6,6 +6,10 @@
|
|||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
||||
<!-- 앱이 화면에서 내려가도 다운로드 · MKV 만들기 · AI 자막을 계속하고 진행 알림을 보여 주기 위해 -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<application
|
||||
android:label="JJ_MKVMaker"
|
||||
android:name="${applicationName}"
|
||||
|
|
@ -33,6 +37,10 @@
|
|||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service
|
||||
android:name=".KeepAliveService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync"/>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
package com.jj.jj_mkvmaker
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
|
||||
/// 다운로드 · MKV 만들기 · AI 자막이 진행 중일 때 앱이 화면에서 내려가도 Android 가 멈추지 않도록 하는
|
||||
/// 포그라운드 서비스. 알림에 진행 상황을 보여 주고, 화면이 꺼져도 CPU 가 잠들지 않게 한다.
|
||||
/// 실제 작업은 앱 (Dart) 이 같은 프로세스에서 계속한다. 작업이 모두 끝나면 앱이 멈춘다.
|
||||
class KeepAliveService : Service() {
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val text = intent?.getStringExtra(EXTRA_TEXT) ?: ""
|
||||
val progress = intent?.getIntExtra(EXTRA_PROGRESS, -1) ?: -1
|
||||
val n = notification(this, text, progress)
|
||||
running = true
|
||||
if (Build.VERSION.SDK_INT >= 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,14 @@ class MainActivity : FlutterActivity() {
|
|||
result.success(null)
|
||||
}
|
||||
"storageRoot" -> result.success(Environment.getExternalStorageDirectory().path)
|
||||
"keepAlive" -> {
|
||||
keepAlive(call.argument<String>("text") ?: "", call.argument<Int>("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 {
|
||||
|
|
|
|||
|
|
@ -157,10 +157,14 @@ enum YtQuality {
|
|||
}
|
||||
|
||||
/// yt-dlp 형식 인수
|
||||
List<String> ytDlpFormatArgs(YtContainer c, YtQuality q) {
|
||||
///
|
||||
/// [preferH264]: 같은 크기면 H.264 를 먼저 고른다 (Android: 휴대폰 · 태블릿은 AV1 · VP9 을 하드웨어로 못 푸는 일이 많아
|
||||
/// 재생이 끊기고 배터리를 많이 씀). YouTube 의 H.264 는 1080p 까지라 그보다 큰 화질을 고르면 크기가 먼저다.
|
||||
List<String> 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'],
|
||||
|
|
|
|||
|
|
@ -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<void> 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<void> _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<void>('stopKeepAlive');
|
||||
_on = false;
|
||||
_last = '';
|
||||
return;
|
||||
}
|
||||
final key = '$text|$progress';
|
||||
if (_on && key == _last) return;
|
||||
await _ch.invokeMethod<void>('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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> 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<Object?> args) {
|
|||
}
|
||||
final id = m['id'] as int;
|
||||
final lines = (m['lines'] as List).cast<String>();
|
||||
final src = m['src'] as String, tgt = m['tgt'] as String;
|
||||
try {
|
||||
final out = <String>[];
|
||||
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<String, int> _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<String, int>? _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<String, int> 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<String>? translateAll(List<String> lines, String src, String tgt, int batch,
|
||||
{required bool Function() cancelled, required void Function(double) progress}) {
|
||||
final out = <String>[];
|
||||
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<String> translateBatch(List<String> lines, String src, String tgt) {
|
||||
final result = List<String>.filled(lines.length, '');
|
||||
List<String> translateBatch(List<String> lines, String src, String tgt) => _decode(_encode(lines, src), tgt);
|
||||
|
||||
/// 인코더 실행. 결과는 Dart 메모리로 복사해 둔다 (인코더를 닫아도 남도록)
|
||||
_Encoded _encode(List<String> 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<String> _decode(_Encoded e, String tgt) {
|
||||
final result = List<String>.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<int> idx;
|
||||
final Float32List hidden;
|
||||
final List<int> hiddenShape;
|
||||
final List<int> mask;
|
||||
final int encLen;
|
||||
const _Encoded(this.count, this.idx, this.hidden, this.hiddenShape, this.mask, this.encLen);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> _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 = <String>[], outputs = <String>[];
|
||||
var perm = <int>[];
|
||||
}
|
||||
|
||||
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 = <String>{};
|
||||
final subgraphs = <List<_Node>>[];
|
||||
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 = <int>[];
|
||||
_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 = <int>[];
|
||||
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<String> inputs, List<String> 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));
|
||||
}
|
||||
|
|
@ -45,7 +45,12 @@ OrtValueTensor _create(ffi.Pointer<ffi.Void> data, int bytes, List<int> 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<ffi.Pointer<bg.OrtSessionOptions>>();
|
||||
_check(_api.CreateSessionOptions.asFunction<
|
||||
bg.OrtStatusPtr Function(ffi.Pointer<ffi.Pointer<bg.OrtSessionOptions>>)>()(optPP));
|
||||
|
|
@ -58,7 +63,31 @@ OrtSession openSession(String path, {int threads = 1}) {
|
|||
bg.OrtStatusPtr Function(ffi.Pointer<bg.OrtSessionOptions>, int)>()(opt, 1));
|
||||
_check(_api.SetSessionGraphOptimizationLevel.asFunction<
|
||||
bg.OrtStatusPtr Function(ffi.Pointer<bg.OrtSessionOptions>, int)>()(
|
||||
opt, bg.GraphOptimizationLevel.ORT_ENABLE_ALL));
|
||||
opt, lowMemory ? bg.GraphOptimizationLevel.ORT_DISABLE_ALL : bg.GraphOptimizationLevel.ORT_ENABLE_ALL));
|
||||
if (lowMemory) {
|
||||
final off = <ffi.Pointer<ffi.NativeFunction<bg.OrtStatusPtr Function(ffi.Pointer<bg.OrtSessionOptions>)>>>[
|
||||
_api.DisableCpuMemArena,
|
||||
_api.DisableMemPattern,
|
||||
];
|
||||
for (final f in off) {
|
||||
_check(f.asFunction<bg.OrtStatusPtr Function(ffi.Pointer<bg.OrtSessionOptions>)>()(opt));
|
||||
}
|
||||
final add = _api.AddSessionConfigEntry.asFunction<
|
||||
bg.OrtStatusPtr Function(ffi.Pointer<bg.OrtSessionOptions>, ffi.Pointer<ffi.Char>, ffi.Pointer<ffi.Char>)>();
|
||||
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<ffi.Char> pathPtr = Platform.isWindows
|
||||
|
|
|
|||
|
|
@ -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<bool> 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<void> 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Language>(
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
|
@ -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)));
|
||||
}
|
||||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
Loading…
Reference in New Issue