릴리스 전: 가짜 AI 결과 막기 · S10 해상도 속도 · 예상 시간 (156-2) · 라이선스 (64) · zip 실행 흔적 검사 (166)
- 156-2: sd-cli 는 업스케일러 · 장치를 만들지 못해도 원본을 저장하고 0 으로 끝난다 (앞 판 Android 의 --backend cuda0). 엔진 기록의 실패 줄 (new_upscaler_ctx failed 등) 과 결과 크기 (정확히 4배 · 그림은 정한 크기) 로 알아채 실패로 알린다 - Android CPU 해상도 올리기: 모든 코어 · 타일 512 (S10 512x384 만화 모델 88~92초 → 45~50초) - [이 장] 진행 창 · [모든 장] 확인 창에 지난번 걸린 시간으로 "한 장 약 n초" (처리 장치 · 모델별) - 64: LGPL 원문을 libmpv 이름으로, Android 고지 문서는 APK 에 맞는 내용 (THIRD_PARTY_NOTICES_ANDROID.txt) - 166: make_layout 이 Logs · *.log · *.dmp 를 빼고, tool/make_zip.ps1 이 zip 목록을 검사 (실행 흔적이 있으면 실패) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>묶음3 v2026.10.09_001
parent
ebe66a7714
commit
1cc2741b16
|
|
@ -69,9 +69,12 @@
|
||||||
"AI 번역": "AI translation",
|
"AI 번역": "AI translation",
|
||||||
"AI 설정 · 모델 받기 · 서비스 추가": "AI settings · download models · add services",
|
"AI 설정 · 모델 받기 · 서비스 추가": "AI settings · download models · add services",
|
||||||
"AI 엔진 ({0}) 도 함께 받습니다.": "The AI engine ({0}) will be downloaded too.",
|
"AI 엔진 ({0}) 도 함께 받습니다.": "The AI engine ({0}) will be downloaded too.",
|
||||||
|
"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}). Memory may be low or a file may be damaged",
|
||||||
"AI 엔진이 실패했습니다 ({0}): {1}": "The AI engine failed ({0}): {1}",
|
"AI 엔진이 실패했습니다 ({0}): {1}": "The AI engine failed ({0}): {1}",
|
||||||
"AI 엔진이 없습니다. 환경 설정 > AI 그림에서 받아 주세요.": "No AI engine. Download it in Settings > AI images.",
|
"AI 엔진이 없습니다. 환경 설정 > AI 그림에서 받아 주세요.": "No AI engine. Download it in Settings > AI images.",
|
||||||
|
"AI 엔진이 해상도를 올리거나 그리지 못했습니다 (처리 장치를 바꿔 보세요)": "The AI engine could not upscale or draw (try another processing device)",
|
||||||
|
"AI 엔진이 해상도를 올리지 못했습니다 (처리 장치를 바꿔 보세요)": "The AI engine could not upscale the image (try another processing device)",
|
||||||
"AI 인식": "AI recognition",
|
"AI 인식": "AI recognition",
|
||||||
"AI 자막": "AI subtitles",
|
"AI 자막": "AI subtitles",
|
||||||
"AI 자막 {0}개": "AI subtitles for {0}",
|
"AI 자막 {0}개": "AI subtitles for {0}",
|
||||||
|
|
@ -1235,6 +1238,8 @@
|
||||||
"이 기기 (SD1.5 + LCM)": "This device (SD1.5 + LCM)",
|
"이 기기 (SD1.5 + LCM)": "This device (SD1.5 + LCM)",
|
||||||
"이 기기 ({0})": "This device ({0})",
|
"이 기기 ({0})": "This device ({0})",
|
||||||
"이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "Only an irreversible hash is stored on this device, not the password itself.",
|
"이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "Only an irreversible hash is stored on this device, not the password itself.",
|
||||||
|
"이 기기에서 한 장 약 {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 번역을 쓸 수 없습니다.": "AI translation is not available on this device.",
|
||||||
"이 기기용 파일 없음": "No file for this device",
|
"이 기기용 파일 없음": "No file for this device",
|
||||||
"이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "Couldn't save the password because this device's secure storage is unavailable",
|
"이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "Couldn't save the password because this device's secure storage is unavailable",
|
||||||
|
|
|
||||||
|
|
@ -69,9 +69,12 @@
|
||||||
"AI 번역": "AI 翻訳",
|
"AI 번역": "AI 翻訳",
|
||||||
"AI 설정 · 모델 받기 · 서비스 추가": "AI 設定・モデルのダウンロード・サービス追加",
|
"AI 설정 · 모델 받기 · 서비스 추가": "AI 設定・モデルのダウンロード・サービス追加",
|
||||||
"AI 엔진 ({0}) 도 함께 받습니다.": "AI エンジン ({0}) も一緒にダウンロードします。",
|
"AI 엔진 ({0}) 도 함께 받습니다.": "AI エンジン ({0}) も一緒にダウンロードします。",
|
||||||
|
"AI 엔진이 그림을 그리지 못했습니다 (처리 장치를 바꿔 보세요)": "AI エンジンが画像を描けませんでした (処理装置を変えてみてください)",
|
||||||
"AI 엔진이 멈췄습니다 (코드 {0}). 메모리가 모자라거나 파일이 깨졌을 수 있습니다": "AI エンジンが停止しました (コード {0})。メモリ不足かファイルの破損の可能性があります",
|
"AI 엔진이 멈췄습니다 (코드 {0}). 메모리가 모자라거나 파일이 깨졌을 수 있습니다": "AI エンジンが停止しました (コード {0})。メモリ不足かファイルの破損の可能性があります",
|
||||||
"AI 엔진이 실패했습니다 ({0}): {1}": "AI エンジンが失敗しました ({0}): {1}",
|
"AI 엔진이 실패했습니다 ({0}): {1}": "AI エンジンが失敗しました ({0}): {1}",
|
||||||
"AI 엔진이 없습니다. 환경 설정 > AI 그림에서 받아 주세요.": "AI エンジンがありません。環境設定 > AI 画像でダウンロードしてください。",
|
"AI 엔진이 없습니다. 환경 설정 > AI 그림에서 받아 주세요.": "AI エンジンがありません。環境設定 > AI 画像でダウンロードしてください。",
|
||||||
|
"AI 엔진이 해상도를 올리거나 그리지 못했습니다 (처리 장치를 바꿔 보세요)": "AI エンジンが解像度アップまたは描画をできませんでした (処理装置を変えてみてください)",
|
||||||
|
"AI 엔진이 해상도를 올리지 못했습니다 (처리 장치를 바꿔 보세요)": "AI エンジンが解像度を上げられませんでした (処理装置を変えてみてください)",
|
||||||
"AI 인식": "AI 認識",
|
"AI 인식": "AI 認識",
|
||||||
"AI 자막": "AI 字幕",
|
"AI 자막": "AI 字幕",
|
||||||
"AI 자막 {0}개": "AI 字幕 {0}個",
|
"AI 자막 {0}개": "AI 字幕 {0}個",
|
||||||
|
|
@ -1235,6 +1238,8 @@
|
||||||
"이 기기 (SD1.5 + LCM)": "この端末 (SD1.5 + LCM)",
|
"이 기기 (SD1.5 + LCM)": "この端末 (SD1.5 + LCM)",
|
||||||
"이 기기 ({0})": "この端末 ({0})",
|
"이 기기 ({0})": "この端末 ({0})",
|
||||||
"이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "この端末にはパスワード自体ではなく、元に戻せないハッシュだけが残ります。",
|
"이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "この端末にはパスワード自体ではなく、元に戻せないハッシュだけが残ります。",
|
||||||
|
"이 기기에서 한 장 약 {0} (지난번 걸린 시간 기준)": "この端末で 1 枚約 {0} (前回の所要時間から)",
|
||||||
|
"이 기기에서 한 장 약 {0} · 모두 약 {1} (지난번 걸린 시간 기준)": "この端末で 1 枚約 {0}・全部で約 {1} (前回の所要時間から)",
|
||||||
"이 기기에서는 AI 번역을 쓸 수 없습니다.": "この端末では AI 翻訳を使えません。",
|
"이 기기에서는 AI 번역을 쓸 수 없습니다.": "この端末では AI 翻訳を使えません。",
|
||||||
"이 기기용 파일 없음": "このデバイス用のファイルなし",
|
"이 기기용 파일 없음": "このデバイス用のファイルなし",
|
||||||
"이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "この端末の安全な保存領域を使えないため、パスワードを保存できませんでした",
|
"이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "この端末の安全な保存領域を使えないため、パスワードを保存できませんでした",
|
||||||
|
|
|
||||||
|
|
@ -69,9 +69,12 @@
|
||||||
"AI 번역": "AI 翻译",
|
"AI 번역": "AI 翻译",
|
||||||
"AI 설정 · 모델 받기 · 서비스 추가": "AI 设置 · 下载模型 · 添加服务",
|
"AI 설정 · 모델 받기 · 서비스 추가": "AI 设置 · 下载模型 · 添加服务",
|
||||||
"AI 엔진 ({0}) 도 함께 받습니다.": "也会一并下载 AI 引擎 ({0})。",
|
"AI 엔진 ({0}) 도 함께 받습니다.": "也会一并下载 AI 引擎 ({0})。",
|
||||||
|
"AI 엔진이 그림을 그리지 못했습니다 (처리 장치를 바꿔 보세요)": "AI 引擎未能生成图片 (请尝试更换处理设备)",
|
||||||
"AI 엔진이 멈췄습니다 (코드 {0}). 메모리가 모자라거나 파일이 깨졌을 수 있습니다": "AI 引擎已停止 (代码 {0})。可能是内存不足或文件损坏",
|
"AI 엔진이 멈췄습니다 (코드 {0}). 메모리가 모자라거나 파일이 깨졌을 수 있습니다": "AI 引擎已停止 (代码 {0})。可能是内存不足或文件损坏",
|
||||||
"AI 엔진이 실패했습니다 ({0}): {1}": "AI 引擎失败 ({0}): {1}",
|
"AI 엔진이 실패했습니다 ({0}): {1}": "AI 引擎失败 ({0}): {1}",
|
||||||
"AI 엔진이 없습니다. 환경 설정 > AI 그림에서 받아 주세요.": "没有 AI 引擎。请在 设置 > AI 图片 中下载。",
|
"AI 엔진이 없습니다. 환경 설정 > AI 그림에서 받아 주세요.": "没有 AI 引擎。请在 设置 > AI 图片 中下载。",
|
||||||
|
"AI 엔진이 해상도를 올리거나 그리지 못했습니다 (처리 장치를 바꿔 보세요)": "AI 引擎未能放大或生成 (请尝试更换处理设备)",
|
||||||
|
"AI 엔진이 해상도를 올리지 못했습니다 (처리 장치를 바꿔 보세요)": "AI 引擎未能提高分辨率 (请尝试更换处理设备)",
|
||||||
"AI 인식": "AI 识别",
|
"AI 인식": "AI 识别",
|
||||||
"AI 자막": "AI 字幕",
|
"AI 자막": "AI 字幕",
|
||||||
"AI 자막 {0}개": "AI 字幕 {0} 个",
|
"AI 자막 {0}개": "AI 字幕 {0} 个",
|
||||||
|
|
@ -1235,6 +1238,8 @@
|
||||||
"이 기기 (SD1.5 + LCM)": "本设备 (SD1.5 + LCM)",
|
"이 기기 (SD1.5 + LCM)": "本设备 (SD1.5 + LCM)",
|
||||||
"이 기기 ({0})": "本设备 ({0})",
|
"이 기기 ({0})": "本设备 ({0})",
|
||||||
"이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "本设备只保存无法还原的哈希值,而不是密码本身。",
|
"이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "本设备只保存无法还原的哈希值,而不是密码本身。",
|
||||||
|
"이 기기에서 한 장 약 {0} (지난번 걸린 시간 기준)": "本设备每张约 {0} (按上次耗时)",
|
||||||
|
"이 기기에서 한 장 약 {0} · 모두 약 {1} (지난번 걸린 시간 기준)": "本设备每张约 {0} · 共约 {1} (按上次耗时)",
|
||||||
"이 기기에서는 AI 번역을 쓸 수 없습니다.": "此设备无法使用 AI 翻译。",
|
"이 기기에서는 AI 번역을 쓸 수 없습니다.": "此设备无法使用 AI 翻译。",
|
||||||
"이 기기용 파일 없음": "没有此设备的文件",
|
"이 기기용 파일 없음": "没有此设备的文件",
|
||||||
"이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "本设备的安全存储不可用,无法保存密码",
|
"이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "本设备的安全存储不可用,无法保存密码",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
JJ_MKVMaker (Android) - 포함된 오픈 소스 구성 요소 및 라이선스 고지
|
||||||
|
=====================================================================
|
||||||
|
|
||||||
|
이 앱은 아래 오픈 소스 구성 요소를 포함하거나 내려받아 사용합니다. 각 구성 요소는 해당 라이선스를 따릅니다.
|
||||||
|
라이선스 원문 (GPL-2.0 · GPL-3.0 · LGPL-2.1 · PDFium · yt-dlp 등) 은 이 화면 (환경 설정 > 프로그램 정보 >
|
||||||
|
오픈 소스 라이선스) 의 [패키지 라이선스] 에서 구성 요소 이름을 눌러 볼 수 있습니다.
|
||||||
|
|
||||||
|
이 앱은 GPLv3 구성 요소 (FFmpegKit · youtubedl-android · rsync) 를 포함합니다. 앱 자체의 대응 소스는
|
||||||
|
같은 판 번호의 GitHub 태그에 있습니다: https://github.com/RyuJongJin/JJ_MKVMaker/tags
|
||||||
|
(예: v2026.10.09_001 판 → 태그 v2026.10.09_001)
|
||||||
|
|
||||||
|
[앱에 들어 있는 라이브러리]
|
||||||
|
|
||||||
|
- Flutter / Dart BSD 3-Clause https://github.com/flutter/flutter
|
||||||
|
- media_kit, media_kit_video MIT https://github.com/media-kit/media-kit
|
||||||
|
- libmpv (media_kit_libs_android_video, 동영상 재생)
|
||||||
|
LGPL-2.1+ https://github.com/media-kit/libmpv-android-video-build
|
||||||
|
[패키지 라이선스] 의 "libmpv" 에 LGPL-2.1 원문이 있습니다.
|
||||||
|
- PDFium (libpdfium.so, PDF 보기) BSD 3-Clause (Google) https://pdfium.googlesource.com/pdfium/
|
||||||
|
판: chromium/7811, 빌드: https://github.com/bblanchon/pdfium-binaries (MIT)
|
||||||
|
소스: https://pdfium.googlesource.com/pdfium/+/refs/heads/chromium/7811
|
||||||
|
들어 있는 라이브러리: FreeType, ICU, libjpeg-turbo, OpenJPEG, libpng, libtiff, Little CMS, AGG 2.3, abseil,
|
||||||
|
fast_float, simdutf, zlib, LLVM libc ([패키지 라이선스] 의 "PDFium")
|
||||||
|
- pdfrx, pdfrx_engine, pdfium_dart, pdfium_flutter MIT https://github.com/espresso3389/pdfrx
|
||||||
|
- whisper.cpp / ggml MIT https://github.com/ggml-org/whisper.cpp
|
||||||
|
- whisper_ggml (JJ_MKVMaker 수정본) MIT https://pub.dev/packages/whisper_ggml
|
||||||
|
- ONNX Runtime · onnxruntime (Dart) MIT https://github.com/microsoft/onnxruntime
|
||||||
|
- flutter_inappwebview (JJ_MKVMaker 수정본) Apache-2.0 https://pub.dev/packages/flutter_inappwebview
|
||||||
|
- file_picker · android_file_picker MIT https://pub.dev/packages/file_picker
|
||||||
|
- charset (Apache-2.0) · cp949_codec (BSD) · 그 밖의 Dart 패키지: [패키지 라이선스]
|
||||||
|
|
||||||
|
[앱에 들어 있는 별도 실행 파일 · 라이브러리]
|
||||||
|
|
||||||
|
- FFmpegKit (ffmpeg_kit_flutter_new_min_gpl 2.6.2, JJ_MKVMaker 수정본 · com.antonkarpenko:ffmpeg-kit-min-gpl 2.2.2)
|
||||||
|
GPL-3.0 (FFmpeg n8.1.2 GPL 빌드 포함)
|
||||||
|
소스: https://github.com/sk3llo/ffmpeg_kit_flutter , FFmpeg: https://ffmpeg.org/releases/ffmpeg-8.1.2.tar.xz
|
||||||
|
- youtubedl-android 0.18.1 (Python · yt-dlp · FFmpeg · aria2c 포함)
|
||||||
|
GPL-3.0 https://github.com/JunkFood02/youtubedl-android
|
||||||
|
· Python (PSF License) · QuickJS (MIT) · yt-dlp (Unlicense) · FFmpeg (GPL) · aria2 (GPL-2.0-or-later)
|
||||||
|
수정하지 않은 공식 배포본 (Maven Central io.github.junkfood02.youtubedl-android 0.18.1) 이며 소스는 위 주소에 있습니다.
|
||||||
|
- rsync 3.4.1 (librsync.so 라는 이름의 실행 파일) GPL-3.0-or-later https://rsync.samba.org
|
||||||
|
공식 소스 (https://download.samba.org/pub/rsync/src/rsync-3.4.1.tar.gz) 를 고치지 않고 Android NDK 로 빌드
|
||||||
|
(빌드 방법: 소스의 tool/build_rsync_android.sh)
|
||||||
|
- stable-diffusion.cpp · ggml (libsdcli.so 라는 이름의 실행 파일) MIT https://github.com/leejet/stable-diffusion.cpp
|
||||||
|
커밋 228c707 을 고치지 않고 Android NDK 로 빌드 (AI 그림 · 해상도 올리기, 빌드 방법: 소스의 tool/build_sdcpp_android.sh)
|
||||||
|
|
||||||
|
[처음 사용할 때 내려받는 AI 모델]
|
||||||
|
|
||||||
|
- Whisper 모델 (ggml-base.bin 등) MIT (OpenAI Whisper) https://huggingface.co/ggerganov/whisper.cpp
|
||||||
|
- NLLB-200 distilled 600M (ONNX) CC-BY-NC 4.0 (비상업적 이용만 허용)
|
||||||
|
https://huggingface.co/facebook/nllb-200-distilled-600M
|
||||||
|
※ NLLB 번역 모델은 상업적 용도로 쓸 수 없습니다.
|
||||||
|
- Stable Diffusion v1.5 CreativeML OpenRAIL-M https://huggingface.co/stable-diffusion-v1-5/stable-diffusion-v1-5
|
||||||
|
※ 라이선스 부속서 A 의 용도 제한을 따라야 합니다 (받기 전에 앱에서 보여 주고 동의를 받습니다).
|
||||||
|
만든 그림에 대한 책임은 쓰는 사람에게 있습니다.
|
||||||
|
- LCM-LoRA (lcm-lora-sdv1-5) OpenRAIL++ https://huggingface.co/latent-consistency/lcm-lora-sdv1-5
|
||||||
|
- TAESD (빠른 디코더) MIT https://huggingface.co/madebyollin/taesd
|
||||||
|
- Real-ESRGAN x4plus · x4plus_anime_6B BSD-3-Clause https://github.com/xinntao/Real-ESRGAN
|
||||||
|
|
||||||
|
[인터넷 서비스]
|
||||||
|
|
||||||
|
- OpenSubtitles.com REST API: 사용자가 발급받은 API 키로 이용하며 OpenSubtitles 이용 약관을 따릅니다.
|
||||||
|
- YouTube 등에서 받은 영상, 토렌트로 받은 파일의 저작권 책임은 이용자에게 있습니다.
|
||||||
|
- AI 그림의 서버 · 서비스: 앱에는 들어 있지 않으며, 사용자가 직접 추가한 서버나 서비스의 약관을 따릅니다.
|
||||||
|
그때는 그림 · 프롬프트가 기기 밖으로 나갑니다.
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:archive/archive_io.dart';
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
import '../core/ai_catalog.dart';
|
import '../core/ai_catalog.dart';
|
||||||
|
|
@ -434,21 +435,56 @@ class LocalSdEngine implements ImageAiEngine {
|
||||||
.toList()
|
.toList()
|
||||||
..sort((a, b) => a.path.compareTo(b.path));
|
..sort((a, b) => a.path.compareTo(b.path));
|
||||||
if (made.isEmpty) throw ImageAiException(trf('그림을 만들지 못했습니다: {0}', [lines.join('\n')]));
|
if (made.isEmpty) throw ImageAiException(trf('그림을 만들지 못했습니다: {0}', [lines.join('\n')]));
|
||||||
|
// 156-2: 정한 크기가 아니면 (바탕 그림을 그대로 돌려준 것 등) 엔진이 그리지 못한 것
|
||||||
|
for (final f in made) {
|
||||||
|
if (!sizeMatches(f.readAsBytesSync(), r.width, r.height)) {
|
||||||
|
for (final x in made) {
|
||||||
|
try {
|
||||||
|
x.deleteSync();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
throw ImageAiException(tr('AI 엔진이 그림을 그리지 못했습니다 (처리 장치를 바꿔 보세요)'), detail: lines.join('\n'));
|
||||||
|
}
|
||||||
|
}
|
||||||
return [for (final (i, f) in made.indexed) GeneratedImage(f.path, seed + i)];
|
return [for (final (i, f) in made.indexed) GeneratedImage(f.path, seed + i)];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 그림 [bytes] 가 [w]×[h] 인지 (머리만 읽음)
|
||||||
|
bool sizeMatches(Uint8List bytes, int w, int h) {
|
||||||
|
final info = img.findDecoderForData(bytes)?.startDecode(bytes);
|
||||||
|
return info != null && info.width == w && info.height == h;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// sd-cli 기록 중 결과를 믿을 수 없게 만드는 실패 (업스케일러 · 장치 · 모델을 만들지 못함)
|
||||||
|
bool isSdFatalLine(String line) {
|
||||||
|
final l = line.toLowerCase();
|
||||||
|
return const [
|
||||||
|
'new_upscaler_ctx failed',
|
||||||
|
'upscale failed',
|
||||||
|
'backend config failed',
|
||||||
|
'failed to initialize',
|
||||||
|
'new_sd_ctx_t failed',
|
||||||
|
].any(l.contains);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// sd-cli 실행기 (시험에서 가짜 엔진으로 바꿀 수 있게)
|
||||||
|
typedef SdRunner = Future<List<String>> Function(String exe, List<String> args,
|
||||||
|
{void Function(SdProgress)? onProgress, void Function(Process)? onStart});
|
||||||
|
|
||||||
/// sd-cli 를 돌리고 진행을 알린다. 실패하면 마지막 출력 몇 줄로 [ImageAiException]
|
/// sd-cli 를 돌리고 진행을 알린다. 실패하면 마지막 출력 몇 줄로 [ImageAiException]
|
||||||
Future<List<String>> runSdCli(String exe, List<String> args,
|
Future<List<String>> runSdCli(String exe, List<String> args,
|
||||||
{void Function(SdProgress)? onProgress, void Function(Process)? onStart}) async {
|
{void Function(SdProgress)? onProgress, void Function(Process)? onStart}) async {
|
||||||
final proc = await Process.start(exe, args, workingDirectory: p.dirname(exe));
|
final proc = await Process.start(exe, args, workingDirectory: p.dirname(exe));
|
||||||
onStart?.call(proc);
|
onStart?.call(proc);
|
||||||
final tail = <String>[];
|
final tail = <String>[];
|
||||||
|
final fatal = <String>[];
|
||||||
var saved = false;
|
var saved = false;
|
||||||
void feed(String chunk) {
|
void feed(String chunk) {
|
||||||
if (chunk.contains('images saved')) saved = true;
|
if (chunk.contains('images saved')) saved = true;
|
||||||
for (final piece in chunk.split(RegExp(r'[\r\n]+'))) {
|
for (final piece in chunk.split(RegExp(r'[\r\n]+'))) {
|
||||||
if (piece.trim().isEmpty) continue;
|
if (piece.trim().isEmpty) continue;
|
||||||
|
if (isSdFatalLine(piece)) fatal.add(piece.trim());
|
||||||
final prog = parseSdProgress(piece);
|
final prog = parseSdProgress(piece);
|
||||||
if (prog != null) {
|
if (prog != null) {
|
||||||
onProgress?.call(prog);
|
onProgress?.call(prog);
|
||||||
|
|
@ -463,6 +499,10 @@ Future<List<String>> runSdCli(String exe, List<String> args,
|
||||||
final b = proc.stderr.transform(const Utf8Decoder(allowMalformed: true)).listen(feed).asFuture<void>();
|
final b = proc.stderr.transform(const Utf8Decoder(allowMalformed: true)).listen(feed).asFuture<void>();
|
||||||
final code = await proc.exitCode;
|
final code = await proc.exitCode;
|
||||||
await Future.wait([a, b]);
|
await Future.wait([a, b]);
|
||||||
|
// 156-2: 업스케일러 · 장치를 만들지 못해도 sd-cli 는 원본을 저장하고 0 으로 끝난다 - 기록으로 실패를 알아챈다
|
||||||
|
if (fatal.isNotEmpty) {
|
||||||
|
throw ImageAiException(tr('AI 엔진이 해상도를 올리거나 그리지 못했습니다 (처리 장치를 바꿔 보세요)'), detail: fatal.join('\n'));
|
||||||
|
}
|
||||||
// 그림을 다 저장한 뒤 끝내면서 0 이 아닌 코드로 끝나는 일이 있다 (이 판의 sd-cli) - 저장했으면 성공으로 본다
|
// 그림을 다 저장한 뒤 끝내면서 0 이 아닌 코드로 끝나는 일이 있다 (이 판의 sd-cli) - 저장했으면 성공으로 본다
|
||||||
if (code != 0 && !saved) {
|
if (code != 0 && !saved) {
|
||||||
final err = tail.where((l) => l.contains('[E]') || l.toLowerCase().contains('error')).toList();
|
final err = tail.where((l) => l.contains('[E]') || l.toLowerCase().contains('error')).toList();
|
||||||
|
|
|
||||||
|
|
@ -66,13 +66,47 @@ Uint8List _finish((Uint8List, int, int, int, String, int) a) {
|
||||||
return ext == 'jpg' ? img.encodeJpg(im, quality: q) : img.encodePng(im);
|
return ext == 'jpg' ? img.encodeJpg(im, quality: q) : img.encodePng(im);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 156-2: Android CPU 는 모든 코어와 큰 타일 (512) 을 쓴다. S10 실측 512×384 (만화 모델):
|
||||||
|
/// 기본 (물리 코어 · 타일 128) 88~92초, -t 8 65~96초, 타일 512 60~65초, 타일 256 108~111초, -t 8 + 타일 512 45~48초.
|
||||||
|
/// 그 밖 (Windows · GPU) 은 엔진 기본값
|
||||||
|
bool _androidCpu(SdDevice d) => Platform.isAndroid && d.backend == 'cpu';
|
||||||
|
int upscaleThreads(SdDevice d) => _androidCpu(d) ? Platform.numberOfProcessors : 0;
|
||||||
|
int upscaleTileSize(SdDevice d) => _androidCpu(d) ? 512 : 0;
|
||||||
|
|
||||||
|
/// 156-2: 걸린 시간을 기억할 열쇠 (처리 장치 · 모델이 같아야 비교가 된다)
|
||||||
|
String upscaleSpeedKey(AiUpscaler up) => '${up.device.backend}|${up.modelId}';
|
||||||
|
|
||||||
|
/// 그림의 화소 수 (백만 단위, 머리만 읽음 - 모르면 null)
|
||||||
|
double? megapixels(Uint8List bytes) {
|
||||||
|
final info = img.findDecoderForData(bytes)?.startDecode(bytes);
|
||||||
|
if (info == null || info.width == 0) return null;
|
||||||
|
return info.width * info.height / 1e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 지난번 걸린 시간으로 이 그림 한 장의 예상 시간 (모르면 null)
|
||||||
|
Duration? upscaleEstimate(Map<String, double> secPerMp, AiUpscaler up, double mp) {
|
||||||
|
final s = secPerMp[upscaleSpeedKey(up)];
|
||||||
|
return s == null ? null : Duration(seconds: (s * mp).round().clamp(1, 1 << 20));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 156-2: [result] 가 [input] 의 정확히 4배 크기인지 (그림 머리만 읽음)
|
||||||
|
bool isFourTimes(Uint8List input, Uint8List result) {
|
||||||
|
final a = img.findDecoderForData(input)?.startDecode(input);
|
||||||
|
final b = img.findDecoderForData(result)?.startDecode(result);
|
||||||
|
if (a == null || b == null || a.width == 0) return false;
|
||||||
|
return b.width == a.width * 4 && b.height == a.height * 4;
|
||||||
|
}
|
||||||
|
|
||||||
/// 그림 한 장 올리기. [input] 은 이 기기의 파일 (ZIP · WebDAV 의 그림은 부르는 쪽이 임시 파일로).
|
/// 그림 한 장 올리기. [input] 은 이 기기의 파일 (ZIP · WebDAV 의 그림은 부르는 쪽이 임시 파일로).
|
||||||
/// 결과를 [save] 가 true 면 저장하고 그 경로를, 아니면 임시 파일 경로를 돌려준다 (비교해 보고 저장할 때).
|
/// 결과를 [save] 가 true 면 저장하고 그 경로를, 아니면 임시 파일 경로를 돌려준다 (비교해 보고 저장할 때).
|
||||||
class AiUpscaler {
|
class AiUpscaler {
|
||||||
AiUpscaler({required this.device, required this.modelPath, this.modelId = ''});
|
AiUpscaler({required this.device, required this.modelPath, this.modelId = '', this.runner = runSdCli});
|
||||||
final SdDevice device;
|
final SdDevice device;
|
||||||
final String modelPath;
|
final String modelPath;
|
||||||
|
|
||||||
|
/// sd-cli 실행기 (시험에서 가짜 엔진)
|
||||||
|
final SdRunner runner;
|
||||||
|
|
||||||
/// 카탈로그 id (자동이 CPU 에서 만화용을 골랐는지 알리려고)
|
/// 카탈로그 id (자동이 CPU 에서 만화용을 골랐는지 알리려고)
|
||||||
final String modelId;
|
final String modelId;
|
||||||
Process? _proc;
|
Process? _proc;
|
||||||
|
|
@ -89,7 +123,7 @@ class AiUpscaler {
|
||||||
final tmp = await Directory.systemTemp.createTemp('jj_up_');
|
final tmp = await Directory.systemTemp.createTemp('jj_up_');
|
||||||
final out = p.join(tmp.path, 'up.png');
|
final out = p.join(tmp.path, 'up.png');
|
||||||
try {
|
try {
|
||||||
await runSdCli(device.exe, sdUpscaleArgs(model: modelPath, input: input, out: out, backend: device.backend),
|
await runner(device.exe, sdUpscaleArgs(model: modelPath, input: input, out: out, backend: device.backend, threads: upscaleThreads(device), tileSize: upscaleTileSize(device)),
|
||||||
onProgress: (prog) => onProgress?.call(prog.phase == 'done' ? 1 : (prog.steps == 0 ? null : prog.step / prog.steps)),
|
onProgress: (prog) => onProgress?.call(prog.phase == 'done' ? 1 : (prog.steps == 0 ? null : prog.step / prog.steps)),
|
||||||
onStart: (pr) => _proc = pr);
|
onStart: (pr) => _proc = pr);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
|
@ -98,6 +132,11 @@ class AiUpscaler {
|
||||||
}
|
}
|
||||||
if (_cancelled) throw ImageAiException(tr('취소했습니다'), cancelled: true);
|
if (_cancelled) throw ImageAiException(tr('취소했습니다'), cancelled: true);
|
||||||
if (!File(out).existsSync()) throw ImageAiException(tr('해상도를 올리지 못했습니다'));
|
if (!File(out).existsSync()) throw ImageAiException(tr('해상도를 올리지 못했습니다'));
|
||||||
|
// 156-2: 엔진이 업스케일러를 만들지 못하면 (장치가 맞지 않는 등) 원본을 그대로 저장하고 정상으로 끝난다 -
|
||||||
|
// 크기가 정확히 4배가 아니면 AI 결과가 아니므로 보통 확대로 꾸며 보이지 않고 실패로 알린다
|
||||||
|
if (!isFourTimes(await File(input).readAsBytes(), await File(out).readAsBytes())) {
|
||||||
|
throw ImageAiException(tr('AI 엔진이 해상도를 올리지 못했습니다 (처리 장치를 바꿔 보세요)'));
|
||||||
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -482,6 +482,9 @@ class AppSettings {
|
||||||
/// 모델 'auto' (GPU 면 사진용, CPU 뿐이면 가볍고 빠른 만화용) · 'photo' · 'anime'
|
/// 모델 'auto' (GPU 면 사진용, CPU 뿐이면 가볍고 빠른 만화용) · 'photo' · 'anime'
|
||||||
String aiUpModel = 'auto';
|
String aiUpModel = 'auto';
|
||||||
|
|
||||||
|
/// 156-2: 해상도 올리기에 걸린 시간 (처리 장치|모델 → 원본 100만 화소당 초) - "한 장 약 n초" 를 미리 알리려고
|
||||||
|
Map<String, double> aiUpSecPerMp = {};
|
||||||
|
|
||||||
/// 배율 2 · 3 · 4
|
/// 배율 2 · 3 · 4
|
||||||
int aiUpScale = 2;
|
int aiUpScale = 2;
|
||||||
|
|
||||||
|
|
@ -668,6 +671,7 @@ class AppSettings {
|
||||||
'aiSaveDir': aiSaveDir,
|
'aiSaveDir': aiSaveDir,
|
||||||
'aiAgreed': aiAgreed,
|
'aiAgreed': aiAgreed,
|
||||||
'aiUpModel': aiUpModel,
|
'aiUpModel': aiUpModel,
|
||||||
|
'aiUpSecPerMp': aiUpSecPerMp,
|
||||||
'aiUpScale': aiUpScale,
|
'aiUpScale': aiUpScale,
|
||||||
'aiUpFormat': aiUpFormat,
|
'aiUpFormat': aiUpFormat,
|
||||||
'aiUpJpgQuality': aiUpJpgQuality,
|
'aiUpJpgQuality': aiUpJpgQuality,
|
||||||
|
|
@ -855,6 +859,11 @@ class AppSettings {
|
||||||
..aiSaveDir = j['aiSaveDir'] as String? ?? ''
|
..aiSaveDir = j['aiSaveDir'] as String? ?? ''
|
||||||
..aiAgreed = [for (final x in (j['aiAgreed'] as List?) ?? const []) '$x']
|
..aiAgreed = [for (final x in (j['aiAgreed'] as List?) ?? const []) '$x']
|
||||||
..aiUpModel = const ['photo', 'anime'].contains(j['aiUpModel']) ? j['aiUpModel'] as String : 'auto'
|
..aiUpModel = const ['photo', 'anime'].contains(j['aiUpModel']) ? j['aiUpModel'] as String : 'auto'
|
||||||
|
..aiUpSecPerMp = {
|
||||||
|
if (j['aiUpSecPerMp'] case final Map m)
|
||||||
|
for (final e in m.entries)
|
||||||
|
if (e.value is num && (e.value as num) > 0) '${e.key}': (e.value as num).toDouble(),
|
||||||
|
}
|
||||||
..aiUpScale = const [3, 4].contains(j['aiUpScale']) ? j['aiUpScale'] as int : 2
|
..aiUpScale = const [3, 4].contains(j['aiUpScale']) ? j['aiUpScale'] as int : 2
|
||||||
..aiUpFormat = const ['png', 'jpg'].contains(j['aiUpFormat']) ? j['aiUpFormat'] as String : 'same'
|
..aiUpFormat = const ['png', 'jpg'].contains(j['aiUpFormat']) ? j['aiUpFormat'] as String : 'same'
|
||||||
..aiUpJpgQuality = ((j['aiUpJpgQuality'] as num?)?.toInt() ?? 92).clamp(50, 100)
|
..aiUpJpgQuality = ((j['aiUpJpgQuality'] as num?)?.toInt() ?? 92).clamp(50, 100)
|
||||||
|
|
|
||||||
|
|
@ -109,12 +109,22 @@ List<String> sdCliArgs(ImageGenRequest r, SdModelPaths m, {required String out,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 해상도 올리기 (121): sd-cli -M upscale
|
/// 해상도 올리기 (121): sd-cli -M upscale
|
||||||
List<String> sdUpscaleArgs({required String model, required String input, required String out, int repeats = 1, String? backend}) => [
|
List<String> sdUpscaleArgs(
|
||||||
|
{required String model,
|
||||||
|
required String input,
|
||||||
|
required String out,
|
||||||
|
int repeats = 1,
|
||||||
|
String? backend,
|
||||||
|
int threads = 0,
|
||||||
|
int tileSize = 0}) =>
|
||||||
|
[
|
||||||
'-M', 'upscale',
|
'-M', 'upscale',
|
||||||
'--upscale-model', model,
|
'--upscale-model', model,
|
||||||
'-i', input,
|
'-i', input,
|
||||||
if (repeats > 1) ...['--upscale-repeats', '$repeats'],
|
if (repeats > 1) ...['--upscale-repeats', '$repeats'],
|
||||||
if (backend != null && backend.isNotEmpty) ...['--backend', backend],
|
if (backend != null && backend.isNotEmpty) ...['--backend', backend],
|
||||||
|
if (threads > 0) ...['-t', '$threads'],
|
||||||
|
if (tileSize > 0) ...['--upscale-tile-size', '$tileSize'],
|
||||||
'-o', out,
|
'-o', out,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,27 @@ Future<bool> _download(BuildContext context, AiStore store, List<AiFile> files)
|
||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 156-2: 걸린 시간을 남긴다 (다음에 "한 장 약 n초" 로)
|
||||||
|
Future<void> _recordSpeed(AppController c, AiUpscaler up, String input, Duration took) async {
|
||||||
|
try {
|
||||||
|
final mp = megapixels(await File(input).readAsBytes());
|
||||||
|
if (mp == null || mp <= 0) return;
|
||||||
|
final v = took.inMilliseconds / 1000 / mp;
|
||||||
|
await c.updateSettings((x) => x.aiUpSecPerMp = {...x.aiUpSecPerMp, upscaleSpeedKey(up): v});
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "이 기기에서 한 장 약 n초 (지난번 걸린 시간 기준)" (처음이면 null)
|
||||||
|
Future<String?> _estimateText(AppController c, AiUpscaler up, String input) async {
|
||||||
|
try {
|
||||||
|
final mp = megapixels(await File(input).readAsBytes());
|
||||||
|
final d = mp == null ? null : upscaleEstimate(c.settings.aiUpSecPerMp, up, mp);
|
||||||
|
return d == null ? null : trf('이 기기에서 한 장 약 {0} (지난번 걸린 시간 기준)', [AiJobs.durationText(d)]);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 121: 한 장 올리기 → 진행 (취소) → [원본 / 올린 것] 비교 → [저장] (새 파일, 원본을 덮지 않음). 저장한 경로 (안 하면 null)
|
/// 121: 한 장 올리기 → 진행 (취소) → [원본 / 올린 것] 비교 → [저장] (새 파일, 원본을 덮지 않음). 저장한 경로 (안 하면 null)
|
||||||
Future<String?> upscaleOne(BuildContext context, AppController c, {required String input, required String nameFrom, required String outDir}) async {
|
Future<String?> upscaleOne(BuildContext context, AppController c, {required String input, required String nameFrom, required String outDir}) async {
|
||||||
final up = await _prepare(context, c);
|
final up = await _prepare(context, c);
|
||||||
|
|
@ -157,6 +178,8 @@ Future<String?> upscaleOne(BuildContext context, AppController c, {required Stri
|
||||||
var toPhoto = false;
|
var toPhoto = false;
|
||||||
// 관리자: CPU 뿐이라 자동이 만화용을 고르면 사진은 질감이 뭉개질 수 있다 - 알리고 바로 바꿀 수 있게
|
// 관리자: CPU 뿐이라 자동이 만화용을 고르면 사진은 질감이 뭉개질 수 있다 - 알리고 바로 바꿀 수 있게
|
||||||
final cpuAnime = c.settings.aiUpModel == 'auto' && up.device.backend == 'cpu' && up.modelId == 'esrgan-anime6b';
|
final cpuAnime = c.settings.aiUpModel == 'auto' && up.device.backend == 'cpu' && up.modelId == 'esrgan-anime6b';
|
||||||
|
final estimate = await _estimateText(c, up, input);
|
||||||
|
if (!context.mounted) return null;
|
||||||
final nav = Navigator.of(context);
|
final nav = Navigator.of(context);
|
||||||
unawaited(showDialog<void>(
|
unawaited(showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -173,6 +196,7 @@ Future<String?> upscaleOne(BuildContext context, AppController c, {required Stri
|
||||||
LinearProgressIndicator(value: v),
|
LinearProgressIndicator(value: v),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(v == null ? tr('준비하는 중') : '${(v * 100).round()}%', style: const TextStyle(fontSize: 12)),
|
Text(v == null ? tr('준비하는 중') : '${(v * 100).round()}%', style: const TextStyle(fontSize: 12)),
|
||||||
|
if (estimate != null) Text(estimate, style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||||
if (cpuAnime) ...[
|
if (cpuAnime) ...[
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Text(tr("사진에는 '사진' 모델이 더 낫지만, 이 기기 (그래픽카드 없음) 에서는 한 장에 약 20분 걸립니다."),
|
Text(tr("사진에는 '사진' 모델이 더 낫지만, 이 기기 (그래픽카드 없음) 에서는 한 장에 약 20분 걸립니다."),
|
||||||
|
|
@ -202,8 +226,10 @@ Future<String?> upscaleOne(BuildContext context, AppController c, {required Stri
|
||||||
));
|
));
|
||||||
String? four;
|
String? four;
|
||||||
Object? error;
|
Object? error;
|
||||||
|
final watch = Stopwatch()..start();
|
||||||
try {
|
try {
|
||||||
four = await up.upscale4x(input, onProgress: (v) => progress.value = v);
|
four = await up.upscale4x(input, onProgress: (v) => progress.value = v);
|
||||||
|
await _recordSpeed(c, up, input, watch.elapsed);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e;
|
error = e;
|
||||||
}
|
}
|
||||||
|
|
@ -242,16 +268,31 @@ Future<String?> upscaleOne(BuildContext context, AppController c, {required Stri
|
||||||
/// 121: 폴더 · ZIP 의 모든 장을 올려 저장 (백그라운드 - 작업 알림 · 작업 현황에 남은 장 · 예상 시간)
|
/// 121: 폴더 · ZIP 의 모든 장을 올려 저장 (백그라운드 - 작업 알림 · 작업 현황에 남은 장 · 예상 시간)
|
||||||
Future<void> upscaleAll(BuildContext context, AppController c, ReaderSource src) async {
|
Future<void> upscaleAll(BuildContext context, AppController c, ReaderSource src) async {
|
||||||
final n = src.length;
|
final n = src.length;
|
||||||
|
final up = await _prepare(context, c);
|
||||||
|
if (up == null || !context.mounted) return;
|
||||||
|
// 156-2: 첫 장 크기와 지난번 걸린 시간으로 한 장 · 모두의 예상 시간
|
||||||
|
String? estimate;
|
||||||
|
try {
|
||||||
|
final first = n == 0 ? null : await readerPageFile(c, src, 0);
|
||||||
|
final mp = first == null ? null : megapixels(await File(first.$1).readAsBytes());
|
||||||
|
final d = mp == null ? null : upscaleEstimate(c.settings.aiUpSecPerMp, up, mp);
|
||||||
|
if (d != null) {
|
||||||
|
estimate = trf('이 기기에서 한 장 약 {0} · 모두 약 {1} (지난번 걸린 시간 기준)',
|
||||||
|
[AiJobs.durationText(d), AiJobs.durationText(d * n)]);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
if (!context.mounted) return;
|
||||||
final ok = await confirmAction(
|
final ok = await confirmAction(
|
||||||
context,
|
context,
|
||||||
title: trf('{0}장 모두 해상도 올리기', [n]),
|
title: trf('{0}장 모두 해상도 올리기', [n]),
|
||||||
body: trf('{0}배로 올려 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.', [c.settings.aiUpScale]),
|
body: [
|
||||||
|
trf('{0}배로 올려 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.', [c.settings.aiUpScale]),
|
||||||
|
?estimate,
|
||||||
|
].join('\n\n'),
|
||||||
ok: tr('시작'),
|
ok: tr('시작'),
|
||||||
danger: false,
|
danger: false,
|
||||||
);
|
);
|
||||||
if (!ok || !context.mounted) return;
|
if (!ok || !context.mounted) return;
|
||||||
final up = await _prepare(context, c);
|
|
||||||
if (up == null || !context.mounted) return;
|
|
||||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||||
final failed = <String>[];
|
final failed = <String>[];
|
||||||
String? lastDir;
|
String? lastDir;
|
||||||
|
|
@ -259,7 +300,9 @@ Future<void> upscaleAll(BuildContext context, AppController c, ReaderSource src)
|
||||||
final file = await readerPageFile(c, src, i);
|
final file = await readerPageFile(c, src, i);
|
||||||
if (file == null) return;
|
if (file == null) return;
|
||||||
try {
|
try {
|
||||||
|
final watch = Stopwatch()..start();
|
||||||
final saved = await up.upscaleAndSave(file.$1, upscaleOptionsOf(c, outDir: file.$3), nameFrom: file.$2, onProgress: progress);
|
final saved = await up.upscaleAndSave(file.$1, upscaleOptionsOf(c, outDir: file.$3), nameFrom: file.$2, onProgress: progress);
|
||||||
|
await _recordSpeed(c, up, file.$1, watch.elapsed);
|
||||||
lastDir = p.dirname(saved);
|
lastDir = p.dirname(saved);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
failed.add('${src.pageName(i)}: $e');
|
failed.add('${src.pageName(i)}: $e');
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart' show LicenseEntryWithLineBreaks, LicenseRegistry;
|
import 'package:flutter/foundation.dart' show LicenseEntryWithLineBreaks, LicenseRegistry;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart' show rootBundle;
|
import 'package:flutter/services.dart' show rootBundle;
|
||||||
|
|
@ -6,10 +8,12 @@ import '../core/ai_catalog.dart';
|
||||||
import '../l10n/tr.dart';
|
import '../l10n/tr.dart';
|
||||||
|
|
||||||
/// 62 · 63 · 64: 라이선스 원문 (assets/licenses - Windows 는 실행 파일 옆에도 같은 파일)
|
/// 62 · 63 · 64: 라이선스 원문 (assets/licenses - Windows 는 실행 파일 옆에도 같은 파일)
|
||||||
const licenseNoticesAsset = 'assets/licenses/THIRD_PARTY_NOTICES.txt';
|
/// 64: 고지 문서는 플랫폼마다 (Android 에는 Windows 파일 이름 · 경로를 보이지 않게)
|
||||||
|
String get licenseNoticesAsset =>
|
||||||
|
Platform.isAndroid ? 'assets/licenses/THIRD_PARTY_NOTICES_ANDROID.txt' : 'assets/licenses/THIRD_PARTY_NOTICES.txt';
|
||||||
|
|
||||||
/// 앱 안 라이선스 화면 (showLicensePage) 에 넣을 것: (구성 요소들, 원문 asset)
|
/// 앱 안 라이선스 화면 (showLicensePage) 에 넣을 것: (구성 요소들, 원문 asset)
|
||||||
const _licenseAssets = <(List<String>, String)>[
|
List<(List<String>, String)> get _licenseAssets => [
|
||||||
(['JJ_MKVMaker - THIRD_PARTY_NOTICES'], licenseNoticesAsset),
|
(['JJ_MKVMaker - THIRD_PARTY_NOTICES'], licenseNoticesAsset),
|
||||||
(
|
(
|
||||||
[
|
[
|
||||||
|
|
@ -23,7 +27,7 @@ const _licenseAssets = <(List<String>, String)>[
|
||||||
'assets/licenses/GPL-3.0.txt'
|
'assets/licenses/GPL-3.0.txt'
|
||||||
),
|
),
|
||||||
(['aria2'], 'assets/licenses/GPL-2.0.txt'),
|
(['aria2'], 'assets/licenses/GPL-2.0.txt'),
|
||||||
(['libmpv', 'LibreOffice 의 LGPL 구성 요소'], 'assets/licenses/LGPL-2.1.txt'),
|
(['libmpv (media_kit, LGPL-2.1+)'], 'assets/licenses/LGPL-2.1.txt'),
|
||||||
(['yt-dlp (실행 파일에 묶인 구성 요소)'], 'assets/licenses/YT-DLP_THIRD_PARTY_LICENSES.txt'),
|
(['yt-dlp (실행 파일에 묶인 구성 요소)'], 'assets/licenses/YT-DLP_THIRD_PARTY_LICENSES.txt'),
|
||||||
(['rsync'], 'assets/licenses/RSYNC_COPYING.txt'),
|
(['rsync'], 'assets/licenses/RSYNC_COPYING.txt'),
|
||||||
(['PDFium'], 'assets/licenses/PDFIUM_LICENSES.txt'),
|
(['PDFium'], 'assets/licenses/PDFIUM_LICENSES.txt'),
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import 'dart:io';
|
||||||
import 'package:archive/archive_io.dart';
|
import 'package:archive/archive_io.dart';
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
import 'package:jj_mkvmaker/app/ai_local.dart';
|
import 'package:jj_mkvmaker/app/ai_local.dart';
|
||||||
import 'package:jj_mkvmaker/app/settings.dart';
|
import 'package:jj_mkvmaker/app/settings.dart';
|
||||||
import 'package:jj_mkvmaker/core/ai_catalog.dart';
|
import 'package:jj_mkvmaker/core/ai_catalog.dart';
|
||||||
|
|
@ -394,7 +395,8 @@ void main() {
|
||||||
if (!Platform.isWindows) return markTestSkipped('Windows 전용 (가짜 sd-cli 가 .cmd)');
|
if (!Platform.isWindows) return markTestSkipped('Windows 전용 (가짜 sd-cli 가 .cmd)');
|
||||||
final dir = Directory.systemTemp.createTempSync('jj_ai_local_');
|
final dir = Directory.systemTemp.createTempSync('jj_ai_local_');
|
||||||
addTearDown(() => dir.deleteSync(recursive: true));
|
addTearDown(() => dir.deleteSync(recursive: true));
|
||||||
final png = File(p.join(dir.path, 'src.png'))..writeAsBytesSync(_png);
|
// 156-2: 정한 크기 (512×512) 여야 엔진이 그린 것으로 본다
|
||||||
|
final png = File(p.join(dir.path, 'src.png'))..writeAsBytesSync(img.encodePng(img.Image(width: 512, height: 512)));
|
||||||
// 받은 인수의 -o (예: ...\ai_X_%d.png) 에 0 · 1 번을 만든다
|
// 받은 인수의 -o (예: ...\ai_X_%d.png) 에 0 · 1 번을 만든다
|
||||||
final fake = File(p.join(dir.path, 'fake-sd.cmd'))
|
final fake = File(p.join(dir.path, 'fake-sd.cmd'))
|
||||||
..writeAsStringSync('@echo off\r\n'
|
..writeAsStringSync('@echo off\r\n'
|
||||||
|
|
@ -421,6 +423,23 @@ void main() {
|
||||||
expect(steps, isEmpty);
|
expect(steps, isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('156-2: 정한 크기가 아닌 그림 (바탕 그림을 그대로 돌려줌 등) 은 실패 · 남기지 않음', () async {
|
||||||
|
if (!Platform.isWindows) return markTestSkipped('Windows 전용 (가짜 sd-cli 가 .cmd)');
|
||||||
|
final dir = Directory.systemTemp.createTempSync('jj_ai_local_');
|
||||||
|
addTearDown(() => dir.deleteSync(recursive: true));
|
||||||
|
final png = File(p.join(dir.path, 'src.png'))..writeAsBytesSync(_png); // 1×1
|
||||||
|
final fake = File(p.join(dir.path, 'fake-sd.cmd'))
|
||||||
|
..writeAsStringSync('@echo off\r\n'
|
||||||
|
':loop\r\nif "%~1"=="" goto done\r\nif "%~1"=="-o" (set OUT=%~2)\r\nshift\r\ngoto loop\r\n:done\r\n'
|
||||||
|
'setlocal enabledelayedexpansion\r\nset A=!OUT:%%d=0!\r\n'
|
||||||
|
'copy /y "${png.path}" "%A%" >nul\r\n');
|
||||||
|
final engine = LocalSdEngine(device: SdDevice(fake.path, 'cpu', 'CPU'), models: const SdModelPaths(model: 'm'));
|
||||||
|
final out = p.join(dir.path, 'out');
|
||||||
|
await expectLater(AiJobs.instance.run(engine, const ImageGenRequest(prompt: 'cat'), outDir: out),
|
||||||
|
throwsA(isA<ImageAiException>()));
|
||||||
|
expect(Directory(out).listSync().whereType<File>().where((f) => f.path.endsWith('.png')), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
test('150 · 151: 취소하면 "취소했습니다" (실패 아님) · 그리는 동안 쓰는 파일 표시', () async {
|
test('150 · 151: 취소하면 "취소했습니다" (실패 아님) · 그리는 동안 쓰는 파일 표시', () async {
|
||||||
if (!Platform.isWindows) return markTestSkipped('Windows 전용');
|
if (!Platform.isWindows) return markTestSkipped('Windows 전용');
|
||||||
final dir = Directory.systemTemp.createTempSync('jj_ai_cancel_');
|
final dir = Directory.systemTemp.createTempSync('jj_ai_cancel_');
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:jj_mkvmaker/app/ai_local.dart';
|
||||||
import 'package:jj_mkvmaker/app/ai_upscale.dart';
|
import 'package:jj_mkvmaker/app/ai_upscale.dart';
|
||||||
import 'package:jj_mkvmaker/app/settings.dart';
|
import 'package:jj_mkvmaker/app/settings.dart';
|
||||||
import 'package:jj_mkvmaker/core/ai_catalog.dart';
|
import 'package:jj_mkvmaker/core/ai_catalog.dart';
|
||||||
|
import 'package:jj_mkvmaker/services/image_ai.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
|
@ -126,4 +127,59 @@ void main() {
|
||||||
expect(b.aiPrompt, 'a cat');
|
expect(b.aiPrompt, 'a cat');
|
||||||
expect(b.aiRecent, ['C:/a/ai_1.png', 'C:/a/ai_2.png']);
|
expect(b.aiRecent, ['C:/a/ai_1.png', 'C:/a/ai_2.png']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('156-2: 가짜 AI 결과 막기', () {
|
||||||
|
// 그림 하나 (원본) 와 그 4배
|
||||||
|
late String input;
|
||||||
|
setUp(() {
|
||||||
|
input = p.join(tmp.path, 'in.png');
|
||||||
|
File(input).writeAsBytesSync(img.encodePng(img.Image(width: 8, height: 6)));
|
||||||
|
});
|
||||||
|
|
||||||
|
AiUpscaler fake(void Function(String input, String out) write, {List<String> log = const []}) => AiUpscaler(
|
||||||
|
device: const SdDevice('sd-cli', 'cpu', 'CPU'),
|
||||||
|
modelPath: 'm.pth',
|
||||||
|
modelId: 'esrgan-anime6b',
|
||||||
|
runner: (exe, args, {onProgress, onStart}) async {
|
||||||
|
write(args[args.indexOf('-i') + 1], args[args.indexOf('-o') + 1]);
|
||||||
|
for (final l in log) {
|
||||||
|
if (isSdFatalLine(l)) throw ImageAiException('fatal', detail: l);
|
||||||
|
}
|
||||||
|
return log;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('엔진이 원본을 그대로 돌려주면 (업스케일러를 만들지 못함) 실패', () async {
|
||||||
|
final up = fake((i, o) => File(i).copySync(o));
|
||||||
|
await expectLater(up.upscale4x(input), throwsA(isA<ImageAiException>()));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('정확히 4배면 통과', () async {
|
||||||
|
final up = fake((i, o) => File(o).writeAsBytesSync(img.encodePng(img.Image(width: 32, height: 24))));
|
||||||
|
final out = await up.upscale4x(input);
|
||||||
|
expect(File(out).existsSync(), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('엔진 기록의 실패 줄을 알아챈다 (0 으로 끝나도)', () {
|
||||||
|
expect(isSdFatalLine('[E] new_upscaler_ctx failed --- main.cpp:966'), true);
|
||||||
|
expect(isSdFatalLine('[ERROR] upscaler backend config failed: unknown backend cuda0'), true);
|
||||||
|
expect(isSdFatalLine('[I] 1/1 images saved --- main.cpp:573'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('그림 만들기: 정한 크기인지', () {
|
||||||
|
final png = img.encodePng(img.Image(width: 512, height: 384));
|
||||||
|
expect(sizeMatches(png, 512, 384), true);
|
||||||
|
expect(sizeMatches(png, 512, 512), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('지난번 걸린 시간으로 한 장 예상 · 설정에 남음', () {
|
||||||
|
final up = fake((i, o) {});
|
||||||
|
expect(upscaleSpeedKey(up), 'cpu|esrgan-anime6b');
|
||||||
|
expect(upscaleEstimate({}, up, 0.2), isNull);
|
||||||
|
// S10 실측: 512×384 (0.197MP) 에 약 87초 → 100만 화소당 약 442초
|
||||||
|
expect(upscaleEstimate({'cpu|esrgan-anime6b': 442}, up, 0.196608)!.inSeconds, 87);
|
||||||
|
final b = AppSettings.fromJson((AppSettings()..aiUpSecPerMp = {'cpu|esrgan-anime6b': 442.5}).toJson());
|
||||||
|
expect(b.aiUpSecPerMp, {'cpu|esrgan-anime6b': 442.5});
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ void main() {
|
||||||
expect(find('aria2'), contains('Version 2'));
|
expect(find('aria2'), contains('Version 2'));
|
||||||
expect(find('H2Orestart'), contains('Version 3, 29 June 2007'));
|
expect(find('H2Orestart'), contains('Version 3, 29 June 2007'));
|
||||||
expect(find('libmpv'), contains('LESSER GENERAL PUBLIC LICENSE'));
|
expect(find('libmpv'), contains('LESSER GENERAL PUBLIC LICENSE'));
|
||||||
|
expect(entries.any((e) => e.packages.any((p) => p.contains('LibreOffice'))), false, reason: 'LGPL 은 libmpv 이름으로');
|
||||||
expect(find('PDFium'), contains('The PDFium Authors'));
|
expect(find('PDFium'), contains('The PDFium Authors'));
|
||||||
expect(find('yt-dlp (실행 파일에 묶인'), contains('THIRD-PARTY LICENSES'));
|
expect(find('yt-dlp (실행 파일에 묶인'), contains('THIRD-PARTY LICENSES'));
|
||||||
final notices = find('JJ_MKVMaker');
|
final notices = find('JJ_MKVMaker');
|
||||||
|
|
@ -37,4 +38,13 @@ void main() {
|
||||||
expect(File('assets/licenses/$f').existsSync(), true, reason: f);
|
expect(File('assets/licenses/$f').existsSync(), true, reason: f);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('64: Android 고지 문서에는 Windows 파일 · 경로가 없고 [패키지 라이선스] 로 안내', () {
|
||||||
|
final a = File('assets/licenses/THIRD_PARTY_NOTICES_ANDROID.txt').readAsStringSync();
|
||||||
|
for (final w in ['libmpv-2.dll', 'flutter_assets', '.exe', r'tools\', r'ffmpeg\']) {
|
||||||
|
expect(a, isNot(contains(w)), reason: w);
|
||||||
|
}
|
||||||
|
expect(a, contains('[패키지 라이선스]'));
|
||||||
|
expect(a, contains('GPLv3'));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,14 @@ New-Item -ItemType Directory -Force $Target | Out-Null
|
||||||
$lib = Join-Path $Target 'Lib'
|
$lib = Join-Path $Target 'Lib'
|
||||||
|
|
||||||
if ($Clean) {
|
if ($Clean) {
|
||||||
robocopy $Release $lib /E /PURGE /XD jj_yt-dlp jj_aria2 models /NFL /NDL /NJH /NJS /NP | Out-Null
|
robocopy $Release $lib /E /PURGE /XD jj_yt-dlp jj_aria2 models Logs /XF *.log *.dmp /NFL /NDL /NJH /NJS /NP | Out-Null
|
||||||
|
# 166: run traces of a dev run in the build folder (Logs, *.log, crash dumps) never ship - robocopy /PURGE keeps excluded items
|
||||||
|
Remove-Item (Join-Path $lib 'Logs') -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
Get-ChildItem $lib -Recurse -File -Include *.log, *.dmp | Remove-Item -Force
|
||||||
# nothing else at the top
|
# nothing else at the top
|
||||||
Get-ChildItem $Target | Where-Object { $_.Name -notin 'Lib' } | Remove-Item -Recurse -Force
|
Get-ChildItem $Target | Where-Object { $_.Name -notin 'Lib' } | Remove-Item -Recurse -Force
|
||||||
} else {
|
} else {
|
||||||
robocopy $Release $lib /E /XD jj_yt-dlp jj_aria2 models /NFL /NDL /NJH /NJS /NP | Out-Null
|
robocopy $Release $lib /E /XD jj_yt-dlp jj_aria2 models Logs /XF *.log *.dmp /NFL /NDL /NJH /NJS /NP | Out-Null
|
||||||
# old single-folder layout: program files at the top that now live in Lib
|
# old single-folder layout: program files at the top that now live in Lib
|
||||||
Get-ChildItem $Target -File | Where-Object {
|
Get-ChildItem $Target -File | Where-Object {
|
||||||
($_.Extension -eq '.dll' -or $_.Name -in 'THIRD_PARTY_NOTICES.txt', 'native_assets.json') -and (Test-Path (Join-Path $lib $_.Name))
|
($_.Extension -eq '.dll' -or $_.Name -in 'THIRD_PARTY_NOTICES.txt', 'native_assets.json') -and (Test-Path (Join-Path $lib $_.Name))
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
# 릴리스 zip 만들기 (166): stage 폴더 (make_layout -Clean) 를 zip 으로 묶고, 목록을 검사한다.
|
||||||
|
# - zip 안 경로는 '/' 로 (Windows tar). 맨 위는 stage 폴더 이름 (JJ_MKVMaker/).
|
||||||
|
# - 실행 흔적 (Logs\ · *.log · crash dump · 설정 · 세션 · 비밀 저장소 · 디버그 빌드 파일) 이 들어 있으면 실패한다.
|
||||||
|
# 사용: powershell -File tool\make_zip.ps1 -Stage '<루트>\release\stage\JJ_MKVMaker' -Zip '<루트>\release\JJ_MKVMaker_v<버전>_win64.zip'
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$Stage,
|
||||||
|
[Parameter(Mandatory = $true)][string]$Zip
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$Stage = (Resolve-Path $Stage).Path
|
||||||
|
$parent = Split-Path $Stage -Parent
|
||||||
|
$name = Split-Path $Stage -Leaf
|
||||||
|
if (Test-Path $Zip) { Remove-Item $Zip -Force }
|
||||||
|
& "$env:SystemRoot\System32\tar.exe" -a -c -f $Zip -C $parent $name
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "tar 실패 ($LASTEXITCODE)" }
|
||||||
|
|
||||||
|
# 목록 검사
|
||||||
|
$list = & "$env:SystemRoot\System32\tar.exe" -t -f $Zip
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "zip 목록을 읽지 못함 ($LASTEXITCODE)" }
|
||||||
|
$bad = $list | Where-Object {
|
||||||
|
$_ -match '(^|/)Logs/' -or
|
||||||
|
$_ -match '\.(log|dmp|mdmp)$' -or
|
||||||
|
$_ -match '(^|/)(settings|session)\.json(\.bak|\.tmp)?$' -or
|
||||||
|
$_ -match '(^|/)settings\.broken-' -or
|
||||||
|
$_ -match '(^|/)secrets\.dat$' -or
|
||||||
|
$_ -match '(^|/)kernel_blob\.bin$' -or
|
||||||
|
$_ -match '\\'
|
||||||
|
}
|
||||||
|
if ($bad) {
|
||||||
|
Remove-Item $Zip -Force
|
||||||
|
throw ("zip 에 넣으면 안 되는 파일이 있어 지웠습니다:`n" + ($bad -join "`n"))
|
||||||
|
}
|
||||||
|
if (-not ($list -contains "$name/jj_mkvmaker.exe")) {
|
||||||
|
Remove-Item $Zip -Force
|
||||||
|
throw "zip 맨 위에 $name/jj_mkvmaker.exe 가 없습니다"
|
||||||
|
}
|
||||||
|
$item = Get-Item $Zip
|
||||||
|
$hash = (Get-FileHash $Zip -Algorithm SHA256).Hash.ToLower()
|
||||||
|
"{0} {1} bytes {2} files sha256 {3}" -f $item.Name, $item.Length, $list.Count, $hash
|
||||||
Loading…
Reference in New Issue