From 850b148d9e6def254fe527d9d17adb7ca24a367f Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:22:01 +0900 Subject: [PATCH] =?UTF-8?q?ARMv8.0=20=EA=B8=B0=EA=B8=B0=EC=9A=A9=20AI=20?= =?UTF-8?q?=EC=97=94=EC=A7=84=20=C2=B7=20=EC=8B=A0=ED=98=B8=EB=B3=84=20?= =?UTF-8?q?=EB=AC=B8=EA=B5=AC=20=C2=B7=20=EB=B0=9B=EA=B8=B0=20=EC=A0=84=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC=20=C2=B7=20APK=20=EB=84=A4=EC=9D=B4=ED=8B=B0?= =?UTF-8?q?=EB=B8=8C=20=EB=AA=A9=EB=A1=9D=20(195)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 지시 (정식 판 문제, P11 = Snapdragon 662 에서 해상도 올리기가 "코드 -4" 로 바로 멈춤): sd-cli 를 armv8.2 (dotprod · fp16 · i8mm) 로만 빌드해 dotprod · fp16 이 없는 CPU 에서 SIGILL 로 죽었다. - tool/build_sdcpp_android.sh [v82|v80]: v80 (armv8-a) 판 libsdcli_v80.so 를 더한다 (APK +15MB). 앱은 /proc/cpuinfo 의 모든 코어 Features 에 asimddp · fphp 가 있을 때만 빠른 판, 아니면 (모름 포함) v80 판. - 엔진이 신호로 죽으면: SIGILL → "이 기기의 CPU 가 이 AI 엔진을 지원하지 않습니다", SIGKILL → 메모리, 그 밖은 코드만 (메모리 · 파일 탓이라고 하지 않음) - 모델을 받기 전에 엔진을 --help 로 한 번 실행해 보고, 못 돌면 받지 않고 이유를 알린다. 결과는 작업 기록에 한 줄 ("AI 엔진 확인: 판 · 결과") - APK 에 넣을 third_party 실행 파일을 android/app/third_party_libs.txt 로 정한다 (gradle 은 목록 밖을 빼고, check_apk.ps1 이 목록과 APK 를 비교) - 기기 확인 (a4): P11 "libsdcli_v80.so · 통과", 해상도 올리기 약 5분 30초 · 저장. 노트 10+ (dotprod 있음) 는 빠른 판 그대로 Co-Authored-By: Claude Opus 5.5 --- android/app/build.gradle.kts | 7 ++++ android/app/third_party_libs.txt | 7 ++++ assets/l10n/en.json | 7 ++++ assets/l10n/ja.json | 7 ++++ assets/l10n/zh-Hans.json | 7 ++++ lib/app/ai_local.dart | 69 ++++++++++++++++++++++++++++++-- lib/main.dart | 2 + test/sd_engine_select_test.dart | 38 ++++++++++++++++++ tool/build_sdcpp_android.sh | 23 +++++++---- tool/check_apk.ps1 | 24 ++++++++++- 10 files changed, 178 insertions(+), 13 deletions(-) create mode 100644 android/app/third_party_libs.txt create mode 100644 test/sd_engine_select_test.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index fbe8416..d7a8d15 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -63,6 +63,13 @@ android { sourceSets["main"].jniLibs.srcDir("../../third_party/rsync/android") // 123 · 121: stable-diffusion.cpp 의 sd-cli (libsdcli.so 라는 이름의 실행 파일): tool/build_sdcpp_android.sh sourceSets["main"].jniLibs.srcDir("../../third_party/sdcpp/android") + // 195: third_party 의 실행 파일은 third_party_libs.txt 에 적은 것만 (공유 폴더에 다른 판이 있어도 섞이지 않게) + val thirdPartyLibs = file("third_party_libs.txt").readLines().map { it.trim() }.filter { it.isNotEmpty() && !it.startsWith("#") }.toSet() + listOf("../../third_party/rsync/android", "../../third_party/sdcpp/android").forEach { dir -> + fileTree(dir).matching { include("**/*.so") }.forEach { so -> + if (so.name !in thirdPartyLibs) packaging.jniLibs.excludes += "**/${so.name}" + } + } // ffmpeg · whisper 등 네이티브 라이브러리를 압축하지 않고 그대로 (설치 후 바로 로드) packaging { diff --git a/android/app/third_party_libs.txt b/android/app/third_party_libs.txt new file mode 100644 index 0000000..c3640e7 --- /dev/null +++ b/android/app/third_party_libs.txt @@ -0,0 +1,7 @@ +# APK 에 넣을 third_party 의 네이티브 실행 파일 (arm64-v8a). 이 목록이 기준이다 (관리자 지시, 195): +# third_party 는 git 밖 (공유 폴더) 이라 빌드하는 순간 거기 있는 파일이 APK 에 들어갔다 - 쓰지 않는 파일이 섞이지 않게. +# 여기 없는 파일은 android/app/build.gradle.kts 가 APK 에서 빼고, tool/check_apk.ps1 이 목록과 APK 를 비교한다. +# 파일을 더하거나 빼면 그 파일을 쓰는 코드와 함께 이 목록을 고친다. +librsync.so +libsdcli.so +libsdcli_v80.so diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 2fee53b..462196e 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -77,8 +77,10 @@ "AI 번역": "AI translation", "AI 설정 · 모델 받기 · 서비스 추가": "AI settings · download models · add services", "AI 엔진 ({0}) 도 함께 받습니다.": "The AI engine ({0}) will be downloaded too.", + "AI 엔진 확인: {0} · {1}": "AI engine check: {0} · {1}", "AI 엔진이 그림을 그리지 못했습니다 (처리 장치를 바꿔 보세요)": "The AI engine could not draw the image (try another processing device)", "AI 엔진이 멈췄습니다 (코드 {0}). 메모리가 모자라거나 파일이 깨졌을 수 있습니다": "The AI engine stopped (code {0}). Memory may be low or a file may be damaged", + "AI 엔진이 멈췄습니다 (코드 {0}). 엔진 기록은 [자세히] 에 있습니다": "The AI engine stopped (code {0}). The engine log is under [Details]", "AI 엔진이 실패했습니다 ({0}): {1}": "The AI engine failed ({0}): {1}", "AI 엔진이 없습니다. 환경 설정 > AI 그림에서 받아 주세요.": "No AI engine. Download it in Settings > AI images.", "AI 엔진이 해상도를 올리거나 그리지 못했습니다 (처리 장치를 바꿔 보세요)": "The AI engine could not upscale or draw (try another processing device)", @@ -820,6 +822,7 @@ "멈춤 (이번 실행 동안)": "Pause (this session)", "멈춤 (이번 실행 동안) · 시작 버튼을 누르면 다시 동기화합니다": "Paused (this session) · press the start button to sync again", "멈춤 · {0}": "Stopped · {0}", + "메모리가 모자라 Android 가 AI 엔진을 끝냈습니다. 다른 앱을 닫거나 환경 설정의 해상도 올리기 메모리를 \"적게\" 로 해 보세요": "Android stopped the AI engine because memory ran out. Close other apps or set the upscaling memory in Settings to \"Low\"", "모니터링": "Monitor", "모니터링의 [실행]": "[Run] in Monitoring", "모델 (선택)": "Model (optional)", @@ -1173,6 +1176,7 @@ "실행 · 종료": "Run · quit", "실행 취소": "Undo", "실행한 rsync 가 없습니다. Rsync 화면에서 → · ← · ⇄ 로 실행하면 여기에 자동으로 등록됩니다.": "No rsync runs yet. Runs started with → · ← · ⇄ on the Rsync screen are added here automatically.", + "실행해 보지 못함 ({0})": "Could not run it ({0})", "싱크": "Sync", "쓰레드 확인 (AI · 변환 작업이 멈출 때까지)": "Thread check (until AI · conversion jobs stop)", "쓸 수 없는 이름입니다": "This name cannot be used", @@ -1413,8 +1417,10 @@ "이 기기에서 한 장 약 {0} (지난번 걸린 시간 기준)": "About {0} per image on this device (based on last time)", "이 기기에서 한 장 약 {0} · 모두 약 {1} (지난번 걸린 시간 기준)": "About {0} per image, {1} in total on this device (based on last time)", "이 기기에서는 AI 번역을 쓸 수 없습니다.": "AI translation is not available on this device.", + "이 기기에서는 AI 엔진을 찾지 못했습니다. 앱을 다시 설치해 보세요": "The AI engine was not found on this device. Try reinstalling the app", "이 기기에서는 쓸 수 없는 컴포넌트입니다": "This component is not available on this device", "이 기기용 파일 없음": "No file for this device", + "이 기기의 CPU 가 이 AI 엔진을 지원하지 않습니다": "This device's CPU does not support this AI engine", "이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "Couldn't save the password because this device's secure storage is unavailable", "이 기기의 폴더": "Folder on this device", "이 단축키를 등록할 수 없습니다 (다른 프로그램이 사용 중일 수 있음)": "Cannot register this hotkey (another program may be using it)", @@ -1861,6 +1867,7 @@ "터키어": "Turkish", "토": "Sat", "토렌트 · 마그넷 다운로드": "Torrent · magnet download", + "통과": "Passed", "트랙 #{0} ({1}){2}": "Track #{0} ({1}){2}", "트랙 {0}": "Track {0}", "트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "Reopen with the tray icon or the hotkey below", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 8e69ca7..0016015 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -77,8 +77,10 @@ "AI 번역": "AI 翻訳", "AI 설정 · 모델 받기 · 서비스 추가": "AI 設定・モデルのダウンロード・サービス追加", "AI 엔진 ({0}) 도 함께 받습니다.": "AI エンジン ({0}) も一緒にダウンロードします。", + "AI 엔진 확인: {0} · {1}": "AI エンジンの確認: {0} · {1}", "AI 엔진이 그림을 그리지 못했습니다 (처리 장치를 바꿔 보세요)": "AI エンジンが画像を描けませんでした (処理装置を変えてみてください)", "AI 엔진이 멈췄습니다 (코드 {0}). 메모리가 모자라거나 파일이 깨졌을 수 있습니다": "AI エンジンが停止しました (コード {0})。メモリ不足かファイルの破損の可能性があります", + "AI 엔진이 멈췄습니다 (코드 {0}). 엔진 기록은 [자세히] 에 있습니다": "AI エンジンが停止しました (コード {0})。エンジンの記録は [詳細] にあります", "AI 엔진이 실패했습니다 ({0}): {1}": "AI エンジンが失敗しました ({0}): {1}", "AI 엔진이 없습니다. 환경 설정 > AI 그림에서 받아 주세요.": "AI エンジンがありません。環境設定 > AI 画像でダウンロードしてください。", "AI 엔진이 해상도를 올리거나 그리지 못했습니다 (처리 장치를 바꿔 보세요)": "AI エンジンが解像度アップまたは描画をできませんでした (処理装置を変えてみてください)", @@ -820,6 +822,7 @@ "멈춤 (이번 실행 동안)": "停止 (今回の実行中)", "멈춤 (이번 실행 동안) · 시작 버튼을 누르면 다시 동기화합니다": "停止中 (今回の実行中) · 開始ボタンで再び同期します", "멈춤 · {0}": "停止 · {0}", + "메모리가 모자라 Android 가 AI 엔진을 끝냈습니다. 다른 앱을 닫거나 환경 설정의 해상도 올리기 메모리를 \"적게\" 로 해 보세요": "メモリ不足のため Android が AI エンジンを終了しました。ほかのアプリを閉じるか、設定の解像度アップのメモリを「少なめ」にしてください", "모니터링": "モニタリング", "모니터링의 [실행]": "モニタリングの [実行]", "모델 (선택)": "モデル (任意)", @@ -1173,6 +1176,7 @@ "실행 · 종료": "実行・終了", "실행 취소": "元に戻す", "실행한 rsync 가 없습니다. Rsync 화면에서 → · ← · ⇄ 로 실행하면 여기에 자동으로 등록됩니다.": "実行した rsync はありません。Rsync 画面で → · ← · ⇄ で実行すると自動でここに登録されます。", + "실행해 보지 못함 ({0})": "実行できませんでした ({0})", "싱크": "同期", "쓰레드 확인 (AI · 변환 작업이 멈출 때까지)": "スレッド確認(AI・変換作業が止まるまで)", "쓸 수 없는 이름입니다": "使用できない名前です", @@ -1413,8 +1417,10 @@ "이 기기에서 한 장 약 {0} (지난번 걸린 시간 기준)": "この端末で 1 枚約 {0} (前回の所要時間から)", "이 기기에서 한 장 약 {0} · 모두 약 {1} (지난번 걸린 시간 기준)": "この端末で 1 枚約 {0}・全部で約 {1} (前回の所要時間から)", "이 기기에서는 AI 번역을 쓸 수 없습니다.": "この端末では AI 翻訳を使えません。", + "이 기기에서는 AI 엔진을 찾지 못했습니다. 앱을 다시 설치해 보세요": "この端末で AI エンジンが見つかりませんでした。アプリを再インストールしてください", "이 기기에서는 쓸 수 없는 컴포넌트입니다": "この端末では使えないコンポーネントです", "이 기기용 파일 없음": "このデバイス用のファイルなし", + "이 기기의 CPU 가 이 AI 엔진을 지원하지 않습니다": "この端末の CPU はこの AI エンジンに対応していません", "이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "この端末の安全な保存領域を使えないため、パスワードを保存できませんでした", "이 기기의 폴더": "この端末のフォルダー", "이 단축키를 등록할 수 없습니다 (다른 프로그램이 사용 중일 수 있음)": "このショートカットを登録できません(他のプログラムが使用中の可能性があります)", @@ -1861,6 +1867,7 @@ "터키어": "トルコ語", "토": "土", "토렌트 · 마그넷 다운로드": "トレント・マグネットのダウンロード", + "통과": "合格", "트랙 #{0} ({1}){2}": "トラック #{0}({1}){2}", "트랙 {0}": "トラック {0}", "트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "トレイアイコンを押すか、下のショートカットで再表示します", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 2d1f13b..e338c43 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -77,8 +77,10 @@ "AI 번역": "AI 翻译", "AI 설정 · 모델 받기 · 서비스 추가": "AI 设置 · 下载模型 · 添加服务", "AI 엔진 ({0}) 도 함께 받습니다.": "也会一并下载 AI 引擎 ({0})。", + "AI 엔진 확인: {0} · {1}": "AI 引擎检查:{0} · {1}", "AI 엔진이 그림을 그리지 못했습니다 (처리 장치를 바꿔 보세요)": "AI 引擎未能生成图片 (请尝试更换处理设备)", "AI 엔진이 멈췄습니다 (코드 {0}). 메모리가 모자라거나 파일이 깨졌을 수 있습니다": "AI 引擎已停止 (代码 {0})。可能是内存不足或文件损坏", + "AI 엔진이 멈췄습니다 (코드 {0}). 엔진 기록은 [자세히] 에 있습니다": "AI 引擎已停止(代码 {0})。引擎记录在 [详细] 中", "AI 엔진이 실패했습니다 ({0}): {1}": "AI 引擎失败 ({0}): {1}", "AI 엔진이 없습니다. 환경 설정 > AI 그림에서 받아 주세요.": "没有 AI 引擎。请在 设置 > AI 图片 中下载。", "AI 엔진이 해상도를 올리거나 그리지 못했습니다 (처리 장치를 바꿔 보세요)": "AI 引擎未能放大或生成 (请尝试更换处理设备)", @@ -820,6 +822,7 @@ "멈춤 (이번 실행 동안)": "暂停 (本次运行期间)", "멈춤 (이번 실행 동안) · 시작 버튼을 누르면 다시 동기화합니다": "已暂停 (本次运行期间) · 按开始按钮重新同步", "멈춤 · {0}": "已停止 · {0}", + "메모리가 모자라 Android 가 AI 엔진을 끝냈습니다. 다른 앱을 닫거나 환경 설정의 해상도 올리기 메모리를 \"적게\" 로 해 보세요": "内存不足,Android 结束了 AI 引擎。请关闭其他应用,或在设置中将提高分辨率的内存设为“少”", "모니터링": "监控", "모니터링의 [실행]": "监视中的 [执行]", "모델 (선택)": "模型 (可选)", @@ -1173,6 +1176,7 @@ "실행 · 종료": "运行 · 退出", "실행 취소": "撤销", "실행한 rsync 가 없습니다. Rsync 화면에서 → · ← · ⇄ 로 실행하면 여기에 자동으로 등록됩니다.": "还没有运行过 rsync。在 Rsync 画面用 → · ← · ⇄ 运行后会自动登记到这里。", + "실행해 보지 못함 ({0})": "无法运行({0})", "싱크": "同步", "쓰레드 확인 (AI · 변환 작업이 멈출 때까지)": "线程检查(直到 AI · 转换任务停止)", "쓸 수 없는 이름입니다": "不能使用此名称", @@ -1413,8 +1417,10 @@ "이 기기에서 한 장 약 {0} (지난번 걸린 시간 기준)": "本设备每张约 {0} (按上次耗时)", "이 기기에서 한 장 약 {0} · 모두 약 {1} (지난번 걸린 시간 기준)": "本设备每张约 {0} · 共约 {1} (按上次耗时)", "이 기기에서는 AI 번역을 쓸 수 없습니다.": "此设备无法使用 AI 翻译。", + "이 기기에서는 AI 엔진을 찾지 못했습니다. 앱을 다시 설치해 보세요": "在此设备上找不到 AI 引擎。请尝试重新安装应用", "이 기기에서는 쓸 수 없는 컴포넌트입니다": "此组件在本设备上不可用", "이 기기용 파일 없음": "没有此设备的文件", + "이 기기의 CPU 가 이 AI 엔진을 지원하지 않습니다": "此设备的 CPU 不支持该 AI 引擎", "이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "本设备的安全存储不可用,无法保存密码", "이 기기의 폴더": "本设备上的文件夹", "이 단축키를 등록할 수 없습니다 (다른 프로그램이 사용 중일 수 있음)": "无法注册此快捷键(可能已被其他程序使用)", @@ -1861,6 +1867,7 @@ "터키어": "土耳其语", "토": "六", "토렌트 · 마그넷 다운로드": "种子 · 磁力链接下载", + "통과": "通过", "트랙 #{0} ({1}){2}": "轨道 #{0}({1}){2}", "트랙 {0}": "轨道 {0}", "트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "点击托盘图标或用下方快捷键重新打开", diff --git a/lib/app/ai_local.dart b/lib/app/ai_local.dart index 40e8e00..67d5af0 100644 --- a/lib/app/ai_local.dart +++ b/lib/app/ai_local.dart @@ -75,11 +75,17 @@ class AiStore extends ChangeNotifier { } /// 받기 (SHA-256 확인). 저장 공간이 모자라면 먼저 알린다. zip (실행 파일) 은 푼다. - Future install(String id) => installFile(aiFile(id)); + /// 195: 받기 전 엔진 검사를 거치게 [installAll] 로 (하나만 받을 때도) + Future install(String id) => installAll([aiFile(id)]); /// 여러 개를 차례로 (기다리는 것은 [queued] 로 보인다). 하나가 실패하면 멈추고 알린다 Future installAll(List files) async { final todo = [for (final f in files) if (!isFileInstalled(f) && !progress.containsKey(f.id)) f]; + // 195: 큰 모델을 받기 전에 이 기기에서 엔진이 도는지 (안 되면 받지 않고 이유를 알린다) + if (todo.any((f) => f.kind != 'engine')) { + final problem = await sdEngineProblem(); + if (problem != null) throw ImageAiException(problem); + } queued.addAll(todo.map((f) => f.id)); notifyListeners(); try { @@ -297,7 +303,8 @@ class AiStore extends ChangeNotifier { if (cuda) return null; try { final dir = await const MethodChannel('jj_mkvmaker/android').invokeMethod('nativeLibDir'); - final f = dir == null ? null : File(p.join(dir, 'libsdcli.so')); + // 195: dotprod · fp16 이 없는 CPU (ARMv8.0) 는 그 명령을 쓰지 않는 판으로 (v8.2 판은 SIGILL 로 죽는다) + final f = dir == null ? null : File(p.join(dir, sdCliLibName(await _cpuInfo()))); return f != null && f.existsSync() ? f.path : null; } catch (_) { return null; @@ -309,6 +316,45 @@ class AiStore extends ChangeNotifier { return exe; } + static Future _cpuInfo() async { + try { + return await File('/proc/cpuinfo').readAsString(); + } catch (_) { + return ''; // 모르면 어느 CPU 에서나 도는 판 ([sdCliLibName]) + } + } + + /// 195: 큰 모델을 받기 전에 엔진을 짧게 실행해 본다 (도움말). 이 기기에서 못 돌면 쉬운 말 한 줄, 되면 null. 한 번만 본다 + Future sdEngineProblem() => _engineProblem ??= _checkEngine(); + Future? _engineProblem; + + Future _checkEngine() async { + if (!Platform.isAndroid) return null; + final lib = sdCliLibName(await _cpuInfo()); + final exe = await sdCli(); + String? problem; + String result; + if (exe == null) { + problem = tr('이 기기에서는 AI 엔진을 찾지 못했습니다. 앱을 다시 설치해 보세요'); + result = problem; + } else { + try { + final r = await Process.run(exe, ['--help'], workingDirectory: p.dirname(exe)).timeout(const Duration(seconds: 20)); + // 신호로 죽었을 때만 (도움말은 0 이 아닌 코드로 끝날 수 있다) + problem = r.exitCode < 0 ? sdExitMessage(r.exitCode) : null; + result = problem ?? tr('통과'); + } catch (e) { + result = trf('실행해 보지 못함 ({0})', [e.runtimeType]); // 받기는 막지 않는다 + } + } + // 관리자 지시: 기기별 문제를 볼 때 쓰게 작업 기록에 한 줄 (이 기기에서 고른 판 · 결과) + log?.call(trf('AI 엔진 확인: {0} · {1}', [lib, result])); + return problem; + } + + /// 작업 기록에 한 줄 (앱이 켜질 때 AppController.note 로 이어 둔다) + static void Function(String line)? log; + static String? _find(Directory dir, String name) { if (!dir.existsSync()) return null; for (final e in dir.listSync(recursive: true, followLinks: false)) { @@ -507,12 +553,27 @@ Future> runSdCli(String exe, List args, if (code != 0 && !saved) { final err = tail.where((l) => l.contains('[E]') || l.toLowerCase().contains('error')).toList(); // 150: 쉬운 말 한 줄, 엔진 원문은 [자세히] 에 - throw ImageAiException(trf('AI 엔진이 멈췄습니다 (코드 {0}). 메모리가 모자라거나 파일이 깨졌을 수 있습니다', [code]), - detail: (err.isEmpty ? tail : err).join('\n')); + throw ImageAiException(sdExitMessage(code), detail: (err.isEmpty ? tail : err).join('\n')); } return tail; } +/// 195: Android sd-cli 판 고르기 - 모든 코어의 Features 에 asimddp (dotprod) · fphp (fp16) 가 있으면 빠른 v8.2 판, +/// 아니면 (모름 포함) 어느 arm64 에서나 도는 v8.0 판 (tool/build_sdcpp_android.sh) +String sdCliLibName(String cpuinfo) { + final lines = [for (final l in cpuinfo.split('\n')) if (l.trimLeft().toLowerCase().startsWith('features')) l]; + bool has(String line, String f) => line.split(RegExp(r'[\s:]+')).contains(f); + final v82 = lines.isNotEmpty && lines.every((l) => has(l, 'asimddp') && has(l, 'fphp')); + return v82 ? 'libsdcli.so' : 'libsdcli_v80.so'; +} + +/// 195: 엔진이 끝난 코드 → 쉬운 말 (음수 = 그 신호로 죽음). 메모리 · 파일 탓은 실제 그 원인일 때만 말한다 +String sdExitMessage(int code) => switch (code) { + -4 => tr('이 기기의 CPU 가 이 AI 엔진을 지원하지 않습니다'), // SIGILL: CPU 에 없는 명령 + -9 => tr('메모리가 모자라 Android 가 AI 엔진을 끝냈습니다. 다른 앱을 닫거나 환경 설정의 해상도 올리기 메모리를 "적게" 로 해 보세요'), + _ => trf('AI 엔진이 멈췄습니다 (코드 {0}). 엔진 기록은 [자세히] 에 있습니다', [code]), + }; + /// 처리 장치 후보 (Windows: CUDA 판 · Vulkan 판의 GPU · CPU, Android: CPU) Future> sdDevices(AiStore store) async { final out = []; diff --git a/lib/main.dart b/lib/main.dart index a4f258c..94f1751 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -119,6 +119,7 @@ Future main(List args) async { final freshInstall = !File(p.join(dataDir, 'settings.json')).existsSync(); ComponentStore.dataDirectory = dataDir; AiStore.instance = AiStore(dataDir); // 123 · 121: AI 모델 · 엔진 (앱 데이터 폴더의 ai) + AiStore.log = controller.note; VersionSnapshot.instance = VersionSnapshot(dataDir); await controller.init(); await setupMasterLock(controller); // 124: 저장된 비밀번호를 쓰기 전에 (실시간 동기화보다 먼저) @@ -477,6 +478,7 @@ Future runAndroid(String dataDir) async { if (autoBackup) controller.note(tr('Android 자동 백업에서 되살린 설정으로 시작합니다')); ComponentStore.dataDirectory = dataDir; AiStore.instance = AiStore(dataDir); // 123 · 121: AI 모델 · 엔진 (앱 데이터 폴더의 ai) + AiStore.log = controller.note; String? sharedSnapshots; try { final root = await const MethodChannel('jj_mkvmaker/android').invokeMethod('storageRoot'); diff --git a/test/sd_engine_select_test.dart b/test/sd_engine_select_test.dart new file mode 100644 index 0000000..8b779e5 --- /dev/null +++ b/test/sd_engine_select_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/ai_local.dart'; + +/// 195: Android 의 AI 엔진 (sd-cli) 판 고르기 · 엔진이 죽은 신호별 문구 +void main() { + // 실제 기기의 /proc/cpuinfo Features 줄 (a4 확인) + const p11 = 'processor\t: 0\nFeatures\t: fp asimd evtstrm aes pmull sha1 sha2 crc32 cpuid\n' + 'processor\t: 7\nFeatures\t: fp asimd evtstrm aes pmull sha1 sha2 crc32 cpuid\n'; // Snapdragon 662 (ARMv8.0) + const note10 = 'processor\t: 0\nFeatures\t: fp asimd evtstrm aes pmull sha1 sha2 crc32 atomics fphp asimdhp cpuid asimdrdm lrcpc dcpop asimddp\n' + 'processor\t: 4\nFeatures\t: fp asimd evtstrm aes pmull sha1 sha2 crc32 atomics fphp asimdhp cpuid asimdrdm lrcpc dcpop asimddp\n'; + const s10 = 'Features\t: fp asimd evtstrm aes pmull sha1 sha2 crc32 atomics fphp asimdhp cpuid asimdrdm jscvt fcma lrcpc dcpop sha3 ' + 'sm3 sm4 asimddp sha512 asimdfhm dit uscat ilrcpc flagm ssbs sb paca pacg dcpodp flagm2 frint i8mm bf16 bti\n'; + + test('195: dotprod · fp16 이 없는 CPU (P11) 는 v8.0 판, 있으면 (노트 10+ · S10) 빠른 판', () { + expect(sdCliLibName(p11), 'libsdcli_v80.so'); + expect(sdCliLibName(note10), 'libsdcli.so'); + expect(sdCliLibName(s10), 'libsdcli.so'); + }); + + test('195: 모르면 (읽지 못함 · 코어마다 다름) 어느 CPU 에서나 도는 판', () { + expect(sdCliLibName(''), 'libsdcli_v80.so'); + // 한 코어라도 없으면 (그 코어에서 돌면 죽으므로) + expect(sdCliLibName('Features\t: fp asimd asimddp fphp\nFeatures\t: fp asimd\n'), 'libsdcli_v80.so'); + // 이름이 비슷한 다른 기능 (asimdhp 만) 은 fphp 로 치지 않는다 + expect(sdCliLibName('Features\t: fp asimd asimddp asimdhp\n'), 'libsdcli_v80.so'); + }); + + test('195: 엔진이 죽은 신호별 문구 - CPU 미지원 · 메모리는 그 신호일 때만', () { + expect(sdExitMessage(-4), contains('CPU')); + expect(sdExitMessage(-4), isNot(contains('메모리'))); + expect(sdExitMessage(-9), contains('메모리')); + for (final code in [1, -6, -11]) { + expect(sdExitMessage(code), contains('$code')); + expect(sdExitMessage(code), isNot(contains('메모리'))); + expect(sdExitMessage(code), isNot(contains('깨졌'))); + } + }); +} diff --git a/tool/build_sdcpp_android.sh b/tool/build_sdcpp_android.sh index 4679cbc..f8cfb39 100644 --- a/tool/build_sdcpp_android.sh +++ b/tool/build_sdcpp_android.sh @@ -3,9 +3,18 @@ # third_party/sdcpp/android/arm64-v8a/libsdcli.so 로 둔다 (APK 의 jniLibs 로 들어가 앱의 네이티브 라이브러리 폴더에서 실행된다 - # Android 10+ 는 앱 데이터 폴더의 파일을 실행할 수 없으므로 받아서 쓰지 않는다). # 소스: https://github.com/leejet/stable-diffusion.cpp (MIT) 커밋 고정 - Windows 실행 파일 (ai_catalog.dart 의 sdCppVersion) 과 같은 판 -# CPU (arm64, dotprod · i8mm · fp16) 만. OpenMP 는 끈다. -# 사용법 (Git Bash): sh tool/build_sdcpp_android.sh - Android NDK · SDK 의 cmake (tools/android-sdk) 가 필요 +# CPU 만 (OpenMP 는 끈다). 두 벌 (195): +# v82 (기본) = armv8.2-a + dotprod · i8mm · fp16 → libsdcli.so (빠름, S10 · 노트 10+ 등) +# v80 = armv8-a (dotprod · fp16 없는 보급형 CPU, 예: Snapdragon 662) → libsdcli_v80.so +# 앱은 /proc/cpuinfo 의 asimddp · fphp 를 보고 고른다 (lib/app/ai_local.dart sdCli). v82 를 v8.0 CPU 에서 돌리면 SIGILL 로 죽는다. +# 사용법 (Git Bash): sh tool/build_sdcpp_android.sh [v82|v80] - Android NDK · SDK 의 cmake (tools/android-sdk) 가 필요 set -e +VARIANT=${1:-v82} +case "$VARIANT" in + v82) ARCH=armv8.2-a+dotprod+i8mm+fp16; NAME=libsdcli.so ;; + v80) ARCH=armv8-a; NAME=libsdcli_v80.so ;; + *) echo "모르는 판: $VARIANT (v82 · v80)"; exit 1 ;; +esac APP=$(cd "$(dirname "$0")/.." && pwd) COMMIT=228c707fde018221de74674f1c2f480a9d2b228e SHORT=228c707 @@ -23,12 +32,12 @@ git fetch --depth 1 origin "$COMMIT" 2>/dev/null || git fetch origin git checkout -q "$COMMIT" 2>/dev/null || git checkout -q "$SHORT" git submodule update --init --recursive PATH="$CMAKE_DIR:$PATH" -cmake -S . -B build-android -G Ninja -DCMAKE_MAKE_PROGRAM="$CMAKE_DIR/ninja$EXE" \ +cmake -S . -B build-android-$VARIANT -G Ninja -DCMAKE_MAKE_PROGRAM="$CMAKE_DIR/ninja$EXE" \ -DCMAKE_TOOLCHAIN_FILE="$NDK_ROOT/build/cmake/android.toolchain.cmake" \ -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-28 -DCMAKE_BUILD_TYPE=Release \ - -DGGML_OPENMP=OFF -DGGML_CPU_ARM_ARCH=armv8.2-a+dotprod+i8mm+fp16 > cmake.log -cmake --build build-android --target sd-cli -j 8 > build.log 2>&1 + -DGGML_OPENMP=OFF -DGGML_CPU_ARM_ARCH=$ARCH > cmake-$VARIANT.log +cmake --build build-android-$VARIANT --target sd-cli -j 8 > build-$VARIANT.log 2>&1 OUT="$APP/third_party/sdcpp/android/arm64-v8a" mkdir -p "$OUT" -"$TC/llvm-strip$EXE" -o "$OUT/libsdcli.so" build-android/bin/sd-cli -ls -l "$OUT/libsdcli.so" +"$TC/llvm-strip$EXE" -o "$OUT/$NAME" build-android-$VARIANT/bin/sd-cli +ls -l "$OUT/$NAME" diff --git a/tool/check_apk.ps1 b/tool/check_apk.ps1 index 3589bb3..1ffd5b9 100644 --- a/tool/check_apk.ps1 +++ b/tool/check_apk.ps1 @@ -2,9 +2,12 @@ # - 이 파일이 빠지면 플러그인이 하나도 등록되지 않아 켜자마자 꺼진다 ('JNI is not initialized … too early', 묶음5 a192ef6 시험판). # - 원인: android\app\src\main\java\io\flutter\plugins\GeneratedPluginRegistrant.java (git 밖, 생성 파일) 를 지운 뒤 # flutter build apk --no-pub 로 빌드하면 다시 만들어지지 않는다. 지웠으면 --no-pub 없이 빌드하거나 먼저 flutter pub get. +# - (195) third_party 의 네이티브 실행 파일: android\app\third_party_libs.txt 의 것이 모두 들어 있고, 목록에 없는 third_party 파일은 없는지. # 사용: powershell -ExecutionPolicy Bypass -File tool\check_apk.ps1 -Apk build\app\outputs\flutter-apk\app-release.apk param( - [Parameter(Mandatory = $true)][string]$Apk + [Parameter(Mandatory = $true)][string]$Apk, + # 목록 파일 (기본: 이 스크립트가 있는 앱 폴더의 android\app\third_party_libs.txt) + [string]$Libs = '' ) $ErrorActionPreference = 'Stop' Add-Type -AssemblyName System.IO.Compression.FileSystem @@ -26,8 +29,18 @@ function Test-Bytes([byte[]]$hay, [byte[]]$needle) { return $false } +$app = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +if (-not $Libs) { $Libs = Join-Path $app 'android\app\third_party_libs.txt' } +$listed = @(Get-Content $Libs | ForEach-Object { $_.Trim() } | Where-Object { $_ -and -not $_.StartsWith('#') }) +# third_party 에 있는 파일 (목록 밖이면 APK 에 없어야 함) +$tp = @() +foreach ($d in 'third_party\rsync\android', 'third_party\sdcpp\android') { + $dir = Join-Path $app $d + if (Test-Path $dir) { $tp += @(Get-ChildItem $dir -Recurse -Filter *.so | ForEach-Object { $_.Name }) } +} $zip = [System.IO.Compression.ZipFile]::OpenRead($Apk) try { + $libsIn = @($zip.Entries | Where-Object { $_.FullName -like 'lib/arm64-v8a/*.so' } | ForEach-Object { $_.Name }) $dexes = @($zip.Entries | Where-Object { $_.FullName -match '^classes\d*\.dex$' }) if ($dexes.Count -eq 0) { throw "dex 가 없습니다: $Apk" } $found = $null @@ -40,8 +53,15 @@ try { } finally { $zip.Dispose() } +$bad = @() +foreach ($n in $listed) { if ($libsIn -notcontains $n) { $bad += "목록에 있는데 APK 에 없음: $n" } } +foreach ($n in ($tp | Select-Object -Unique)) { if ($listed -notcontains $n -and $libsIn -contains $n) { $bad += "목록에 없는데 APK 에 들어감: $n" } } +if ($bad) { + $bad | ForEach-Object { Write-Host "실패: $_ ($Libs)" -ForegroundColor Red } + exit 1 +} if (-not $found) { Write-Host "실패: GeneratedPluginRegistrant 가 dex 에 없습니다 (켜자마자 꺼지는 APK). flutter pub get 뒤 --no-pub 없이 다시 빌드하세요." -ForegroundColor Red exit 1 } -Write-Host "통과: GeneratedPluginRegistrant 있음 ($found)" +Write-Host "통과: GeneratedPluginRegistrant 있음 ($found) · third_party 실행 파일 $($listed.Count)개 목록과 같음"