릴리스 전: 가짜 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
RyuJongJin 2026-10-09 17:11:31 +09:00
parent ebe66a7714
commit 1cc2741b16
15 changed files with 364 additions and 12 deletions

View File

@ -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",

View File

@ -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 翻訳を使えません。",
"이 기기용 파일 없음": "このデバイス用のファイルなし", "이 기기용 파일 없음": "このデバイス用のファイルなし",
"이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "この端末の安全な保存領域を使えないため、パスワードを保存できませんでした", "이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "この端末の安全な保存領域を使えないため、パスワードを保存できませんでした",

View File

@ -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 翻译。",
"이 기기용 파일 없음": "没有此设备的文件", "이 기기용 파일 없음": "没有此设备的文件",
"이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "本设备的安全存储不可用,无法保存密码", "이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "本设备的安全存储不可用,无法保存密码",

View File

@ -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 그림의 서버 · 서비스: 앱에는 들어 있지 않으며, 사용자가 직접 추가한 서버나 서비스의 약관을 따릅니다.
그때는 그림 · 프롬프트가 기기 밖으로 나갑니다.

View File

@ -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();

View File

@ -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;
} }

View File

@ -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)

View File

@ -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,
]; ];

View File

@ -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');

View File

@ -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'),

View File

@ -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_');

View File

@ -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});
});
});
} }

View File

@ -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'));
});
} }

View File

@ -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))

39
tool/make_zip.ps1 Normal file
View File

@ -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