From 1cc2741b1677e51644627f724494ba494bdf4cee Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:11:31 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=B4=EB=A6=AC=EC=8A=A4=20=EC=A0=84:=20?= =?UTF-8?q?=EA=B0=80=EC=A7=9C=20AI=20=EA=B2=B0=EA=B3=BC=20=EB=A7=89?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20S10=20=ED=95=B4=EC=83=81=EB=8F=84=20?= =?UTF-8?q?=EC=86=8D=EB=8F=84=20=C2=B7=20=EC=98=88=EC=83=81=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20(156-2)=20=C2=B7=20=EB=9D=BC=EC=9D=B4=EC=84=A0?= =?UTF-8?q?=EC=8A=A4=20(64)=20=C2=B7=20zip=20=EC=8B=A4=ED=96=89=20?= =?UTF-8?q?=ED=9D=94=EC=A0=81=20=EA=B2=80=EC=82=AC=20(166)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- assets/l10n/en.json | 5 ++ assets/l10n/ja.json | 5 ++ assets/l10n/zh-Hans.json | 5 ++ .../licenses/THIRD_PARTY_NOTICES_ANDROID.txt | 65 +++++++++++++++++++ lib/app/ai_local.dart | 40 ++++++++++++ lib/app/ai_upscale.dart | 43 +++++++++++- lib/app/settings.dart | 9 +++ lib/core/sd_cli.dart | 12 +++- lib/ui/ai_upscale_ui.dart | 49 +++++++++++++- lib/ui/license_texts.dart | 10 ++- test/ai_image_test.dart | 21 +++++- test/ai_upscale_test.dart | 56 ++++++++++++++++ test/licenses_test.dart | 10 +++ tool/make_layout.ps1 | 7 +- tool/make_zip.ps1 | 39 +++++++++++ 15 files changed, 364 insertions(+), 12 deletions(-) create mode 100644 assets/licenses/THIRD_PARTY_NOTICES_ANDROID.txt create mode 100644 tool/make_zip.ps1 diff --git a/assets/l10n/en.json b/assets/l10n/en.json index a35bffc..40b05a7 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -69,9 +69,12 @@ "AI 번역": "AI translation", "AI 설정 · 모델 받기 · 서비스 추가": "AI settings · download models · add services", "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}): {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)", + "AI 엔진이 해상도를 올리지 못했습니다 (처리 장치를 바꿔 보세요)": "The AI engine could not upscale the image (try another processing device)", "AI 인식": "AI recognition", "AI 자막": "AI subtitles", "AI 자막 {0}개": "AI subtitles for {0}", @@ -1235,6 +1238,8 @@ "이 기기 (SD1.5 + LCM)": "This device (SD1.5 + LCM)", "이 기기 ({0})": "This device ({0})", "이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "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.", "이 기기용 파일 없음": "No file for this device", "이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "Couldn't save the password because this device's secure storage is unavailable", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index c44550d..2d1f128 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -69,9 +69,12 @@ "AI 번역": "AI 翻訳", "AI 설정 · 모델 받기 · 서비스 추가": "AI 設定・モデルのダウンロード・サービス追加", "AI 엔진 ({0}) 도 함께 받습니다.": "AI エンジン ({0}) も一緒にダウンロードします。", + "AI 엔진이 그림을 그리지 못했습니다 (처리 장치를 바꿔 보세요)": "AI エンジンが画像を描けませんでした (処理装置を変えてみてください)", "AI 엔진이 멈췄습니다 (코드 {0}). 메모리가 모자라거나 파일이 깨졌을 수 있습니다": "AI エンジンが停止しました (コード {0})。メモリ不足かファイルの破損の可能性があります", "AI 엔진이 실패했습니다 ({0}): {1}": "AI エンジンが失敗しました ({0}): {1}", "AI 엔진이 없습니다. 환경 설정 > AI 그림에서 받아 주세요.": "AI エンジンがありません。環境設定 > AI 画像でダウンロードしてください。", + "AI 엔진이 해상도를 올리거나 그리지 못했습니다 (처리 장치를 바꿔 보세요)": "AI エンジンが解像度アップまたは描画をできませんでした (処理装置を変えてみてください)", + "AI 엔진이 해상도를 올리지 못했습니다 (처리 장치를 바꿔 보세요)": "AI エンジンが解像度を上げられませんでした (処理装置を変えてみてください)", "AI 인식": "AI 認識", "AI 자막": "AI 字幕", "AI 자막 {0}개": "AI 字幕 {0}個", @@ -1235,6 +1238,8 @@ "이 기기 (SD1.5 + LCM)": "この端末 (SD1.5 + LCM)", "이 기기 ({0})": "この端末 ({0})", "이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "この端末にはパスワード自体ではなく、元に戻せないハッシュだけが残ります。", + "이 기기에서 한 장 약 {0} (지난번 걸린 시간 기준)": "この端末で 1 枚約 {0} (前回の所要時間から)", + "이 기기에서 한 장 약 {0} · 모두 약 {1} (지난번 걸린 시간 기준)": "この端末で 1 枚約 {0}・全部で約 {1} (前回の所要時間から)", "이 기기에서는 AI 번역을 쓸 수 없습니다.": "この端末では AI 翻訳を使えません。", "이 기기용 파일 없음": "このデバイス用のファイルなし", "이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "この端末の安全な保存領域を使えないため、パスワードを保存できませんでした", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 9071c9a..08808e3 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -69,9 +69,12 @@ "AI 번역": "AI 翻译", "AI 설정 · 모델 받기 · 서비스 추가": "AI 设置 · 下载模型 · 添加服务", "AI 엔진 ({0}) 도 함께 받습니다.": "也会一并下载 AI 引擎 ({0})。", + "AI 엔진이 그림을 그리지 못했습니다 (처리 장치를 바꿔 보세요)": "AI 引擎未能生成图片 (请尝试更换处理设备)", "AI 엔진이 멈췄습니다 (코드 {0}). 메모리가 모자라거나 파일이 깨졌을 수 있습니다": "AI 引擎已停止 (代码 {0})。可能是内存不足或文件损坏", "AI 엔진이 실패했습니다 ({0}): {1}": "AI 引擎失败 ({0}): {1}", "AI 엔진이 없습니다. 환경 설정 > AI 그림에서 받아 주세요.": "没有 AI 引擎。请在 设置 > AI 图片 中下载。", + "AI 엔진이 해상도를 올리거나 그리지 못했습니다 (처리 장치를 바꿔 보세요)": "AI 引擎未能放大或生成 (请尝试更换处理设备)", + "AI 엔진이 해상도를 올리지 못했습니다 (처리 장치를 바꿔 보세요)": "AI 引擎未能提高分辨率 (请尝试更换处理设备)", "AI 인식": "AI 识别", "AI 자막": "AI 字幕", "AI 자막 {0}개": "AI 字幕 {0} 个", @@ -1235,6 +1238,8 @@ "이 기기 (SD1.5 + LCM)": "本设备 (SD1.5 + LCM)", "이 기기 ({0})": "本设备 ({0})", "이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "本设备只保存无法还原的哈希值,而不是密码本身。", + "이 기기에서 한 장 약 {0} (지난번 걸린 시간 기준)": "本设备每张约 {0} (按上次耗时)", + "이 기기에서 한 장 약 {0} · 모두 약 {1} (지난번 걸린 시간 기준)": "本设备每张约 {0} · 共约 {1} (按上次耗时)", "이 기기에서는 AI 번역을 쓸 수 없습니다.": "此设备无法使用 AI 翻译。", "이 기기용 파일 없음": "没有此设备的文件", "이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "本设备的安全存储不可用,无法保存密码", diff --git a/assets/licenses/THIRD_PARTY_NOTICES_ANDROID.txt b/assets/licenses/THIRD_PARTY_NOTICES_ANDROID.txt new file mode 100644 index 0000000..e2a0a53 --- /dev/null +++ b/assets/licenses/THIRD_PARTY_NOTICES_ANDROID.txt @@ -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 그림의 서버 · 서비스: 앱에는 들어 있지 않으며, 사용자가 직접 추가한 서버나 서비스의 약관을 따릅니다. + 그때는 그림 · 프롬프트가 기기 밖으로 나갑니다. diff --git a/lib/app/ai_local.dart b/lib/app/ai_local.dart index d51c5ec..7fc2578 100644 --- a/lib/app/ai_local.dart +++ b/lib/app/ai_local.dart @@ -6,6 +6,7 @@ import 'package:archive/archive_io.dart'; import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:image/image.dart' as img; import 'package:path/path.dart' as p; import '../core/ai_catalog.dart'; @@ -434,21 +435,56 @@ class LocalSdEngine implements ImageAiEngine { .toList() ..sort((a, b) => a.path.compareTo(b.path)); 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)]; } } +/// 그림 [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> Function(String exe, List args, + {void Function(SdProgress)? onProgress, void Function(Process)? onStart}); + /// sd-cli 를 돌리고 진행을 알린다. 실패하면 마지막 출력 몇 줄로 [ImageAiException] Future> runSdCli(String exe, List args, {void Function(SdProgress)? onProgress, void Function(Process)? onStart}) async { final proc = await Process.start(exe, args, workingDirectory: p.dirname(exe)); onStart?.call(proc); final tail = []; + final fatal = []; var saved = false; void feed(String chunk) { if (chunk.contains('images saved')) saved = true; for (final piece in chunk.split(RegExp(r'[\r\n]+'))) { if (piece.trim().isEmpty) continue; + if (isSdFatalLine(piece)) fatal.add(piece.trim()); final prog = parseSdProgress(piece); if (prog != null) { onProgress?.call(prog); @@ -463,6 +499,10 @@ Future> runSdCli(String exe, List args, final b = proc.stderr.transform(const Utf8Decoder(allowMalformed: true)).listen(feed).asFuture(); final code = await proc.exitCode; await Future.wait([a, b]); + // 156-2: 업스케일러 · 장치를 만들지 못해도 sd-cli 는 원본을 저장하고 0 으로 끝난다 - 기록으로 실패를 알아챈다 + if (fatal.isNotEmpty) { + throw ImageAiException(tr('AI 엔진이 해상도를 올리거나 그리지 못했습니다 (처리 장치를 바꿔 보세요)'), detail: fatal.join('\n')); + } // 그림을 다 저장한 뒤 끝내면서 0 이 아닌 코드로 끝나는 일이 있다 (이 판의 sd-cli) - 저장했으면 성공으로 본다 if (code != 0 && !saved) { final err = tail.where((l) => l.contains('[E]') || l.toLowerCase().contains('error')).toList(); diff --git a/lib/app/ai_upscale.dart b/lib/app/ai_upscale.dart index c3706a3..2f80acf 100644 --- a/lib/app/ai_upscale.dart +++ b/lib/app/ai_upscale.dart @@ -66,13 +66,47 @@ Uint8List _finish((Uint8List, int, int, int, String, int) a) { 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 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 의 그림은 부르는 쪽이 임시 파일로). /// 결과를 [save] 가 true 면 저장하고 그 경로를, 아니면 임시 파일 경로를 돌려준다 (비교해 보고 저장할 때). 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 String modelPath; + /// sd-cli 실행기 (시험에서 가짜 엔진) + final SdRunner runner; + /// 카탈로그 id (자동이 CPU 에서 만화용을 골랐는지 알리려고) final String modelId; Process? _proc; @@ -89,7 +123,7 @@ class AiUpscaler { final tmp = await Directory.systemTemp.createTemp('jj_up_'); final out = p.join(tmp.path, 'up.png'); 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)), onStart: (pr) => _proc = pr); } catch (_) { @@ -98,6 +132,11 @@ class AiUpscaler { } if (_cancelled) throw ImageAiException(tr('취소했습니다'), cancelled: true); 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; } diff --git a/lib/app/settings.dart b/lib/app/settings.dart index a93d296..622d6a5 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -482,6 +482,9 @@ class AppSettings { /// 모델 'auto' (GPU 면 사진용, CPU 뿐이면 가볍고 빠른 만화용) · 'photo' · 'anime' String aiUpModel = 'auto'; + /// 156-2: 해상도 올리기에 걸린 시간 (처리 장치|모델 → 원본 100만 화소당 초) - "한 장 약 n초" 를 미리 알리려고 + Map aiUpSecPerMp = {}; + /// 배율 2 · 3 · 4 int aiUpScale = 2; @@ -668,6 +671,7 @@ class AppSettings { 'aiSaveDir': aiSaveDir, 'aiAgreed': aiAgreed, 'aiUpModel': aiUpModel, + 'aiUpSecPerMp': aiUpSecPerMp, 'aiUpScale': aiUpScale, 'aiUpFormat': aiUpFormat, 'aiUpJpgQuality': aiUpJpgQuality, @@ -855,6 +859,11 @@ class AppSettings { ..aiSaveDir = j['aiSaveDir'] as String? ?? '' ..aiAgreed = [for (final x in (j['aiAgreed'] as List?) ?? const []) '$x'] ..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 ..aiUpFormat = const ['png', 'jpg'].contains(j['aiUpFormat']) ? j['aiUpFormat'] as String : 'same' ..aiUpJpgQuality = ((j['aiUpJpgQuality'] as num?)?.toInt() ?? 92).clamp(50, 100) diff --git a/lib/core/sd_cli.dart b/lib/core/sd_cli.dart index 0de244b..e3ea070 100644 --- a/lib/core/sd_cli.dart +++ b/lib/core/sd_cli.dart @@ -109,12 +109,22 @@ List sdCliArgs(ImageGenRequest r, SdModelPaths m, {required String out, } /// 해상도 올리기 (121): sd-cli -M upscale -List sdUpscaleArgs({required String model, required String input, required String out, int repeats = 1, String? backend}) => [ +List sdUpscaleArgs( + {required String model, + required String input, + required String out, + int repeats = 1, + String? backend, + int threads = 0, + int tileSize = 0}) => + [ '-M', 'upscale', '--upscale-model', model, '-i', input, if (repeats > 1) ...['--upscale-repeats', '$repeats'], if (backend != null && backend.isNotEmpty) ...['--backend', backend], + if (threads > 0) ...['-t', '$threads'], + if (tileSize > 0) ...['--upscale-tile-size', '$tileSize'], '-o', out, ]; diff --git a/lib/ui/ai_upscale_ui.dart b/lib/ui/ai_upscale_ui.dart index 60400d2..f4a2c58 100644 --- a/lib/ui/ai_upscale_ui.dart +++ b/lib/ui/ai_upscale_ui.dart @@ -148,6 +148,27 @@ Future _download(BuildContext context, AiStore store, List files) return ok; } +/// 156-2: 걸린 시간을 남긴다 (다음에 "한 장 약 n초" 로) +Future _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 _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) Future upscaleOne(BuildContext context, AppController c, {required String input, required String nameFrom, required String outDir}) async { final up = await _prepare(context, c); @@ -157,6 +178,8 @@ Future upscaleOne(BuildContext context, AppController c, {required Stri var toPhoto = false; // 관리자: CPU 뿐이라 자동이 만화용을 고르면 사진은 질감이 뭉개질 수 있다 - 알리고 바로 바꿀 수 있게 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); unawaited(showDialog( context: context, @@ -173,6 +196,7 @@ Future upscaleOne(BuildContext context, AppController c, {required Stri LinearProgressIndicator(value: v), const SizedBox(height: 4), 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) ...[ const SizedBox(height: 10), Text(tr("사진에는 '사진' 모델이 더 낫지만, 이 기기 (그래픽카드 없음) 에서는 한 장에 약 20분 걸립니다."), @@ -202,8 +226,10 @@ Future upscaleOne(BuildContext context, AppController c, {required Stri )); String? four; Object? error; + final watch = Stopwatch()..start(); try { four = await up.upscale4x(input, onProgress: (v) => progress.value = v); + await _recordSpeed(c, up, input, watch.elapsed); } catch (e) { error = e; } @@ -242,16 +268,31 @@ Future upscaleOne(BuildContext context, AppController c, {required Stri /// 121: 폴더 · ZIP 의 모든 장을 올려 저장 (백그라운드 - 작업 알림 · 작업 현황에 남은 장 · 예상 시간) Future upscaleAll(BuildContext context, AppController c, ReaderSource src) async { 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( context, title: trf('{0}장 모두 해상도 올리기', [n]), - body: trf('{0}배로 올려 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.', [c.settings.aiUpScale]), + body: [ + trf('{0}배로 올려 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.', [c.settings.aiUpScale]), + ?estimate, + ].join('\n\n'), ok: tr('시작'), danger: false, ); if (!ok || !context.mounted) return; - final up = await _prepare(context, c); - if (up == null || !context.mounted) return; final messenger = ScaffoldMessenger.maybeOf(context); final failed = []; String? lastDir; @@ -259,7 +300,9 @@ Future upscaleAll(BuildContext context, AppController c, ReaderSource src) final file = await readerPageFile(c, src, i); if (file == null) return; try { + final watch = Stopwatch()..start(); 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); } catch (e) { failed.add('${src.pageName(i)}: $e'); diff --git a/lib/ui/license_texts.dart b/lib/ui/license_texts.dart index cd1bfb9..ab68475 100644 --- a/lib/ui/license_texts.dart +++ b/lib/ui/license_texts.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/foundation.dart' show LicenseEntryWithLineBreaks, LicenseRegistry; import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show rootBundle; @@ -6,10 +8,12 @@ import '../core/ai_catalog.dart'; import '../l10n/tr.dart'; /// 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) -const _licenseAssets = <(List, String)>[ +List<(List, String)> get _licenseAssets => [ (['JJ_MKVMaker - THIRD_PARTY_NOTICES'], licenseNoticesAsset), ( [ @@ -23,7 +27,7 @@ const _licenseAssets = <(List, String)>[ 'assets/licenses/GPL-3.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'), (['rsync'], 'assets/licenses/RSYNC_COPYING.txt'), (['PDFium'], 'assets/licenses/PDFIUM_LICENSES.txt'), diff --git a/test/ai_image_test.dart b/test/ai_image_test.dart index b02441d..df33ffd 100644 --- a/test/ai_image_test.dart +++ b/test/ai_image_test.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:archive/archive_io.dart'; import 'package:crypto/crypto.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/settings.dart'; import 'package:jj_mkvmaker/core/ai_catalog.dart'; @@ -394,7 +395,8 @@ void main() { 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); + // 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 번을 만든다 final fake = File(p.join(dir.path, 'fake-sd.cmd')) ..writeAsStringSync('@echo off\r\n' @@ -421,6 +423,23 @@ void main() { 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())); + expect(Directory(out).listSync().whereType().where((f) => f.path.endsWith('.png')), isEmpty); + }); + test('150 · 151: 취소하면 "취소했습니다" (실패 아님) · 그리는 동안 쓰는 파일 표시', () async { if (!Platform.isWindows) return markTestSkipped('Windows 전용'); final dir = Directory.systemTemp.createTempSync('jj_ai_cancel_'); diff --git a/test/ai_upscale_test.dart b/test/ai_upscale_test.dart index 762a84b..a824cde 100644 --- a/test/ai_upscale_test.dart +++ b/test/ai_upscale_test.dart @@ -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/settings.dart'; import 'package:jj_mkvmaker/core/ai_catalog.dart'; +import 'package:jj_mkvmaker/services/image_ai.dart'; import 'package:path/path.dart' as p; void main() { @@ -126,4 +127,59 @@ void main() { expect(b.aiPrompt, 'a cat'); 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 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())); + }); + + 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}); + }); + }); } diff --git a/test/licenses_test.dart b/test/licenses_test.dart index cf3da88..bd399c5 100644 --- a/test/licenses_test.dart +++ b/test/licenses_test.dart @@ -19,6 +19,7 @@ void main() { expect(find('aria2'), contains('Version 2')); expect(find('H2Orestart'), contains('Version 3, 29 June 2007')); 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('yt-dlp (실행 파일에 묶인'), contains('THIRD-PARTY LICENSES')); final notices = find('JJ_MKVMaker'); @@ -37,4 +38,13 @@ void main() { 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')); + }); } diff --git a/tool/make_layout.ps1 b/tool/make_layout.ps1 index eb0a9e8..6fa4dcd 100644 --- a/tool/make_layout.ps1 +++ b/tool/make_layout.ps1 @@ -23,11 +23,14 @@ New-Item -ItemType Directory -Force $Target | Out-Null $lib = Join-Path $Target 'Lib' 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 Get-ChildItem $Target | Where-Object { $_.Name -notin 'Lib' } | Remove-Item -Recurse -Force } 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 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)) diff --git a/tool/make_zip.ps1 b/tool/make_zip.ps1 new file mode 100644 index 0000000..3ba0e8d --- /dev/null +++ b/tool/make_zip.ps1 @@ -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