From 986688eafa5a40c2a7d580b139078c3104c01512 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:31:13 +0900 Subject: [PATCH] =?UTF-8?q?AI=20=ED=95=B4=EC=83=81=EB=8F=84=20=EC=98=AC?= =?UTF-8?q?=EB=A6=AC=EA=B8=B0=20(121)=20=C2=B7=20AI=20=EB=A7=88=EB=AC=B4?= =?UTF-8?q?=EB=A6=AC=20(150~153)=20=C2=B7=20=EB=B0=9B=EA=B8=B0=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=ED=99=95=EC=9D=B8=20=C2=B7=20=ED=99=95=EC=9D=B8?= =?UTF-8?q?=EB=90=A8=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 121: Real-ESRGAN (BSD-3) x4plus · x4plus anime 6B, 공식 주소 · SHA-256 고정. sd-cli -M upscale 로 4배 → 2 · 3배로 줄임 · 보기 화면 [AI 해상도 올리기 (이 장 · 모든 장)], 진행 · 취소, [원본 / 올린 것] 비교 → 새 파일로 저장 (덮어쓰지 않음, ZIP 은 옆 폴더) · 모든 장은 백그라운드 (작업 알림 · 작업 현황, 남은 장 · 예상 시간) · 설정: 모델 (자동 = GPU 면 사진용 · CPU 면 만화용) · 배율 · 형식 · 저장 위치 · 저장한 뒤, 자동 장치는 Vulkan → CUDA → CPU - 150: 취소는 "취소했습니다" 만 (회색), 실패는 쉬운 한 줄 + 엔진 원문은 [자세히] - 151: 그리는 · 올리는 동안 쓰는 모델은 지우기를 끔 - 152: 모델 읽는 동안 남은 시간 없음 · 돌아와도 자판이 올라오지 않음 - 153: 지워진 결과 그림은 "파일이 없습니다" · [목록에서 빼기] - 받기: 206 의 범위 시작이 다르거나 맞지 않는 416 이면 조각을 버리고 처음부터, 받은 파일에 "확인됨" (SHA-256) Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 43 +++++++ assets/l10n/ja.json | 43 +++++++ assets/l10n/zh-Hans.json | 43 +++++++ lib/app/ai_local.dart | 111 +++++++++++++++-- lib/app/ai_upscale.dart | 143 +++++++++++++++++++++ lib/app/settings.dart | 29 +++++ lib/core/ai_catalog.dart | 30 ++++- lib/core/reader_sources.dart | 7 +- lib/core/sd_cli.dart | 4 +- lib/services/image_ai.dart | 8 +- lib/ui/ai_image_page.dart | 68 +++++++++- lib/ui/ai_settings.dart | 96 ++++++++++++++- lib/ui/ai_upscale_ui.dart | 233 +++++++++++++++++++++++++++++++++++ lib/ui/reader_page.dart | 33 ++++- test/ai_image_test.dart | 64 +++++++++- test/ai_upscale_test.dart | 100 +++++++++++++++ 16 files changed, 1023 insertions(+), 32 deletions(-) create mode 100644 lib/app/ai_upscale.dart create mode 100644 lib/ui/ai_upscale_ui.dart create mode 100644 test/ai_upscale_test.dart diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 0567ed6..b62a165 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -67,6 +67,7 @@ "AI 모델 받는 중: {0}": "Downloading AI model: {0}", "AI 번역": "AI translation", "AI 설정 · 모델 받기 · 서비스 추가": "AI settings · download models · add services", + "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 인식": "AI recognition", @@ -85,6 +86,9 @@ "AI 자막을 만들고 이어서 MKV 로 만듭니다": "makes AI subtitles and then makes the MKV", "AI 자막을 만듭니다": "makes AI subtitles", "AI 작업 중이라 이번에는 건너뜁니다": "Skipped this time because an AI job is running", + "AI 해상도 올리기": "AI upscaling", + "AI 해상도 올리기 (모든 장)": "AI upscaling (all pages)", + "AI 해상도 올리기 (이 장)": "AI upscaling (this page)", "API 키": "API key", "API 키 (선택)": "API key (optional)", "API 키 (필수, 무료)": "API key (required, free)", @@ -121,6 +125,7 @@ "JJ_MKVMaker 로 자막 만들기": "Make subtitles with JJ_MKVMaker", "JJ_MKVMaker 로 재생": "Play with JJ_MKVMaker", "JJ_MKVMaker_즐겨찾기.html": "JJ_MKVMaker_bookmarks.html", + "JPG 품질 {0}": "JPG quality {0}", "JavaScript 사용": "Enable JavaScript", "LCM-LoRA (SD1.5, 4단계로 빠르게)": "LCM-LoRA (SD1.5, fast in 4 steps)", "MKV · AI 작업": "MKV · AI task", @@ -164,6 +169,8 @@ "PDF 를 만들지 못했습니다: {0}": "Could not make the PDF: {0}", "PDF 를 열 수 없습니다: {0}": "Cannot open the PDF: {0}", "PDF 에 넣을 그림 선택": "Choose images to add to the PDF", + "Real-ESRGAN x4plus (사진)": "Real-ESRGAN x4plus (photos)", + "Real-ESRGAN x4plus anime 6B (만화 · 그림, 가볍고 빠름)": "Real-ESRGAN x4plus anime 6B (comics · art, light and fast)", "Rsync (지금 여기)": "Rsync (you are here)", "Rsync 화면 확인 창의 [실행]": "[Run] in the Rsync confirmation", "Rsync 확인 창 ({0}): {1}": "Rsync confirmation ({0}): {1}", @@ -187,6 +194,7 @@ "WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV: the app syncs directly instead of rsync (compares size and modified time; follows -u, --delete and --remove-source-files).", "WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --remove-source-files 만 따름).": "WebDAV: the app syncs by itself instead of rsync (compares size and modified time; of the options only -u and --remove-source-files apply).", "WebM (동영상)": "WebM (video)", + "WebP 등은 PNG 로 저장합니다": "WebP and similar are saved as PNG", "Whale 에서 가져오기": "Import from Whale", "Whale 에서 가져옴": "Imported from Whale", "Whisper base (빠름)": "Whisper base (fast)", @@ -260,6 +268,7 @@ "{0} - {1} (약 {2}MB)": "{0} - {1} (about {2}MB)", "{0} / {1}장": "{0} / {1} images", "{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2} (change at the top of the main screen)", + "{0} · {1}배 · {2}": "{0} · {1}× · {2}", "{0} → {1} 번역 {2}%": "{0} → {1} translation {2}%", "{0} 라이선스": "{0} license", "{0} 로 번역 중 {1}%": "Translating to {0} {1}%", @@ -314,9 +323,14 @@ "{0}개를 옮겼습니다 → {1} ({2})": "Moved {0} → {1} ({2})", "{0}개를 찾아 여기서 멈췄습니다. 이름을 더 자세히 넣거나 아래 폴더로 들어가 찾으세요.": "Stopped after finding {0}. Enter a more specific name or search inside a subfolder.", "{0}개를 풀었습니다: {1}": "Extracted {0}: {1}", + "{0}배": "{0}×", + "{0}배로 올려 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.": "Upscales {0}× and saves new files. Originals and ZIPs stay unchanged. It keeps going after you close the screen, and the job notification shows pages left and the estimated time.", "{0}분": "{0} min", "{0}분 {1}초": "{0} min {1} s", "{0}시간 {1}분": "{0} h {1} min", + "{0}장 모두 해상도 올리기": "Upscale all {0} pages", + "{0}장은 실패했습니다": "{0} failed", + "{0}장을 올려 저장했습니다{1}": "Upscaled and saved {0}{1}", "{0}줄": "{0} lines", "{0}초": "{0} s", "{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).": "Checks the source folder every {0} s and syncs the target (while the app is running).", @@ -526,6 +540,7 @@ "다른 프로그램이 쓰고 있습니다": "Another program is using it", "다른 화면으로 가면 페이지 소리 멈춤": "Pause page sound when leaving the browser", "다시 만들기 대기로": "Back to waiting for remake", + "다시 받기": "Download again", "다시 비교": "Compare again", "다시 설치": "Reinstall", "다시 세기": "Recount", @@ -673,6 +688,7 @@ "만들기": "Make", "만들다 만 파일 {0}개": "{0} incomplete files", "만들다 만 파일 {0}개를 지울까요?": "Delete {0} incomplete files?", + "만화 · 그림": "Comics · art", "만화 보기": "Read as comic", "말레이어": "Malay", "맞추기 (검은 여백)": "Fit (black bars)", @@ -697,6 +713,7 @@ "모델 내려받는 중 {0}%": "Downloading model {0}%", "모델 내려받는 중: {0} ({1})": "Downloading model: {0} ({1})", "모델 읽는 중": "Loading model", + "모델 읽는 중 …": "Loading model …", "모델 파일이 잘렸습니다 (위치 {0})": "The model file is truncated (position {0})", "모델을 내려받을 수 없습니다 ({0}): {1}": "Cannot download the model ({0}): {1}", "모두 나중에": "All later", @@ -714,6 +731,7 @@ "목": "Thu", "목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "Hides ad areas in lists · next to · on top of videos", "목록 보기": "View list", + "목록에서 빼기": "Remove from list", "목록에서 제거": "Remove from list", "목록에서 지우기 (파일은 그대로)": "Remove from list (files stay)", "목록을 모두 비울까요?": "Clear the whole list?", @@ -775,6 +793,7 @@ "밤 0~6시": "Night 0-6", "방법: {0}": "Method: {0}", "방식": "Type", + "배율": "Scale", "백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)": "Background: hides only the window and keeps downloading · converting. Reopen with {0} or the tray icon.\nTo quit completely: right-click the tray icon > Quit (always quits whatever the setting)", "백그라운드로": "To background", "백그라운드로 계속 (기본)": "Keep running in the background (default)", @@ -860,6 +879,7 @@ "사용: {0}": "Using: {0}", "사용자가 취소했습니다.": "Cancelled by the user.", "사용할 수 있는 자막 사이트가 없습니다.": "No subtitle site is available.", + "사진": "Photos", "삭제": "Delete", "삭제 (목록에서 제거)": "Delete (remove from list)", "삭제 (중지하고 받던 파일 삭제)": "Delete (stop and delete the partial file)", @@ -1101,6 +1121,9 @@ "오른쪽 90°": "Right 90°", "오픈 소스 라이선스": "Open source licenses", "올려 둔 버전이 없습니다.": "No versions have been published.", + "올린 것": "Upscaled", + "올린 것 (AI)": "Upscaled (AI)", + "올린 것으로 계속 보기": "Keep viewing the upscaled one", "올린 이 {0}": "Uploaded by {0}", "옮겼습니다.": "moved.", "옮기기": "Move", @@ -1128,6 +1151,8 @@ "원문 보기 (번역 끄기)": "Show original (turn off translation)", "원본": "Source", "원본 비율": "Original aspect", + "원본 옆에 \"이름_x2\" (ZIP 은 ZIP 옆 \"ZIP이름_AI\" 폴더)": "Next to the original as \"name_x2\" (for ZIPs, in a \"ZIPname_AI\" folder next to the ZIP)", + "원본 옆으로": "Next to the original", "원본 유지 (재인코딩 없음)": "Keep original (no re-encoding)", "원본 크기": "Original size", "원본 파일 지우기 (--remove-source-files)": "Delete source files (--remove-source-files)", @@ -1138,9 +1163,11 @@ "원본 폴더가 비어 있어 멈췄습니다 (대상 파일은 지우지 않음). SD 카드 · 네트워크 · 권한을 확인하세요: {0}": "Stopped because the source folder is empty (target files were not deleted). Check the SD card, network and permissions: {0}", "원본 폴더가 없습니다": "Source folder not found", "원본 폴더를 읽을 수 없어 멈췄습니다 (대상 파일은 지우지 않음): {0}{1}": "Stopped because the source folder could not be read (target files were not deleted): {0}{1}", + "원본과 같은 형식": "Same format as the original", "원본과 대상이 서로 안에 있습니다": "Source and target are inside each other", "원본에 없는 것을 대상에서 지우기": "Delete from target what is not in the source", "원본에는 아무것도 없는데 대상에는 {0}개가 있습니다. SD 카드가 빠졌거나 네트워크 · 권한 문제일 수 있습니다. 일부러 원본을 비웠다면 지울 목록을 보고 [그래도 맞추기] 를 누르세요.": "The source is empty but the target has {0} items. The SD card may have been removed, or there may be a network or permission problem. If you emptied the source on purpose, review the list and press [Sync anyway].", + "원본으로": "Back to the original", "원본을 읽을 수 없어 멈춤": "Stopped: can't read the source", "원본을 읽을 수 없어 멈췄습니다": "Stopped because the source can't be read", "원본의 {0}배": "{0}× original", @@ -1268,6 +1295,7 @@ "이미 받는 중이거나 받을 수 없는 주소입니다.": "Already downloading or a URL that cannot be downloaded.", "이미 이 폴더에 있습니다: {0}": "Already in this folder: {0}", "이미 있는 폴더입니다: {0}": "The folder already exists: {0}", + "이미 작업 중입니다": "Already working", "이미지 · PDF · ZIP 보기": "Image · PDF · ZIP viewer", "이미지 자막은 번역할 수 없습니다": "Image subtitles cannot be translated", "이미지 자막은 편집할 수 없습니다": "Image subtitles cannot be edited", @@ -1304,6 +1332,7 @@ "자동 감지": "Auto-detect", "자동 백업 그대로": "Keep Auto Backup", "자동 설치를 할 수 없는 위치입니다. 페이지에서 직접 받아 주세요.": "Automatic installation is not possible here. Please download it from the page yourself.", + "자동: 그래픽카드 (GPU) 가 있으면 사진용, CPU 뿐이면 가볍고 빠른 만화용. 원본은 덮어쓰지 않고 새 파일로 저장합니다": "Auto: the photo model with a graphics card (GPU), the light and fast comic model with CPU only. Originals are never overwritten; results are saved as new files", "자동: 잰 결과 {0} 가 가장 빠릅니다": "Auto: measured {0} as the fastest", "자동: 처음 그릴 때 짧게 재서 가장 빠른 장치를 고릅니다": "Auto: a short test on the first drawing picks the fastest processor", "자막": "Subtitles", @@ -1339,6 +1368,8 @@ "자막을 불러올 수 없습니다: {0}": "Cannot load subtitles: {0}", "자막이 비어 있습니다.": "The subtitles are empty.", "자막이 없습니다.": "No subtitles.", + "자세히": "Details", + "자세히 닫기": "Hide details", "자체 서명 인증서를 쓰는 집 NAS 등 (믿을 수 있는 서버에서만)": "For a home NAS with a self-signed certificate, etc. (trusted servers only)", "작업": "Jobs", "작업 기록 보기": "Show job log", @@ -1377,6 +1408,7 @@ "저장 안 함": "Don't save", "저장 위치": "Save locations", "저장 폴더": "Save folder", + "저장 형식": "Save format", "저장: {0}": "Save: {0}", "저장된 비밀번호 · API 키를 지웠습니다": "Deleted saved passwords and API keys", "저장된 비밀번호 · 설정 · 비밀번호가 필요한 기능을 쓸 때 묻습니다. 잊으면 최상 비밀번호로 초기화하거나, 저장된 비밀번호를 지우고 초기화할 수 있습니다.": "Asked when using saved passwords, settings or features that need a password. If you forget it, reset it with the top password, or delete the saved passwords and reset it.", @@ -1393,6 +1425,7 @@ "저장하는 중": "Saving", "저장하지 못했습니다: {0}": "Could not save: {0}", "저장하지 않은 변경": "Unsaved changes", + "저장한 뒤": "After saving", "저장했습니다": "Saved", "저장했습니다: {0}": "Saved: {0}", "저장했습니다: {0} (MKV 에 반영됨)": "Saved: {0} (applied to MKV)", @@ -1450,6 +1483,7 @@ "주소를 https 로 바꾸고 다시 확인": "Switch the address to https and check again", "준비 중": "Preparing", "준비 중…": "Preparing…", + "준비하는 중": "Preparing", "줄 삭제": "Delete line", "중국어": "Chinese", "중국어 간체": "Chinese (Simplified)", @@ -1629,7 +1663,10 @@ "파일 확인 중…": "Checking files…", "파일만 골랐을 때": "When only files are selected", "파일을 분석할 수 없습니다: {0}": "Cannot analyze the file: {0}", + "파일이 깨졌습니다": "The file is damaged", + "파일이 깨졌습니다 · [다시 받기] 를 눌러 주세요": "The file is damaged · tap [Download again]", "파일이 보이지 않습니다: \"모든 파일에 대한 접근\" 권한이 없습니다": "Files are not shown: \"All files access\" is not allowed", + "파일이 없습니다 (지워졌거나 옮겨짐)": "File not found (deleted or moved)", "팝업 끄기": "Close pop-up", "팝업 보기": "Pop-up view", "패키지 라이선스": "Package licenses", @@ -1646,6 +1683,7 @@ "포함된 구성 요소와 라이선스 (THIRD_PARTY_NOTICES.txt)": "Included components and licenses (THIRD_PARTY_NOTICES.txt)", "폴더": "Folder", "폴더 + 파일 목록": "Folders + file list", + "폴더 고르기": "Choose folder", "폴더 만들기": "New folder", "폴더 복사 · 이동 방법": "Folder copy · move method", "폴더 삭제: {0} (안의 {1}개 포함)": "Folder deleted: {0} (including {1} inside)", @@ -1708,6 +1746,10 @@ "한글": "Hangul", "항목 {0}/{1}": "Items {0}/{1}", "항목마다 따로 (기본)": "Each item separately (default)", + "해상도 올리기 모델": "Upscaling model", + "해상도 올리기 모델을 받아 주세요 (환경 설정 > AI 그림)": "Download an upscaling model (Settings > AI images)", + "해상도를 올리지 못했습니다": "Couldn't upscale", + "해상도를 올린 그림 저장 폴더": "Folder for upscaled images", "해제": "Unset", "허용": "Allow", "허용했으면 다시 확인": "Check again after allowing", @@ -1745,6 +1787,7 @@ "확인 · 압축 푸는 중…": "Verifying · extracting…", "확인 중…": "Checking…", "확인 창에서 알린 뒤 허용합니다. 지우기 (--delete) 가 있으면 늘 막습니다. 끄면 늘 막습니다": "Allowed after a notice in the confirmation. Always blocked with delete (--delete). When off, always blocked", + "확인됨": "Verified", "확장 설치": "Installing extension", "확장자": "Ext", "확장자별 재생 프로그램": "Player per file extension", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index d82d136..723cd6e 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -67,6 +67,7 @@ "AI 모델 받는 중: {0}": "AI モデルをダウンロード中: {0}", "AI 번역": "AI 翻訳", "AI 설정 · 모델 받기 · 서비스 추가": "AI 設定・モデルのダウンロード・サービス追加", + "AI 엔진이 멈췄습니다 (코드 {0}). 메모리가 모자라거나 파일이 깨졌을 수 있습니다": "AI エンジンが停止しました (コード {0})。メモリ不足かファイルの破損の可能性があります", "AI 엔진이 실패했습니다 ({0}): {1}": "AI エンジンが失敗しました ({0}): {1}", "AI 엔진이 없습니다. 환경 설정 > AI 그림에서 받아 주세요.": "AI エンジンがありません。環境設定 > AI 画像でダウンロードしてください。", "AI 인식": "AI 認識", @@ -85,6 +86,9 @@ "AI 자막을 만들고 이어서 MKV 로 만듭니다": "AI 字幕を作成し、続けて MKV を作成します", "AI 자막을 만듭니다": "AI 字幕を作成します", "AI 작업 중이라 이번에는 건너뜁니다": "AI 作業中のため今回はスキップします", + "AI 해상도 올리기": "AI 高解像度化", + "AI 해상도 올리기 (모든 장)": "AI 高解像度化 (全ページ)", + "AI 해상도 올리기 (이 장)": "AI 高解像度化 (このページ)", "API 키": "API キー", "API 키 (선택)": "API キー (任意)", "API 키 (필수, 무료)": "API キー(必須・無料)", @@ -121,6 +125,7 @@ "JJ_MKVMaker 로 자막 만들기": "JJ_MKVMaker で字幕を作成", "JJ_MKVMaker 로 재생": "JJ_MKVMaker で再生", "JJ_MKVMaker_즐겨찾기.html": "JJ_MKVMaker_ブックマーク.html", + "JPG 품질 {0}": "JPG 品質 {0}", "JavaScript 사용": "JavaScript を使う", "LCM-LoRA (SD1.5, 4단계로 빠르게)": "LCM-LoRA (SD1.5、4 ステップで高速)", "MKV · AI 작업": "MKV・AI 作業", @@ -164,6 +169,8 @@ "PDF 를 만들지 못했습니다: {0}": "PDF を作成できませんでした: {0}", "PDF 를 열 수 없습니다: {0}": "PDF を開けません: {0}", "PDF 에 넣을 그림 선택": "PDF に追加する画像を選択", + "Real-ESRGAN x4plus (사진)": "Real-ESRGAN x4plus (写真)", + "Real-ESRGAN x4plus anime 6B (만화 · 그림, 가볍고 빠름)": "Real-ESRGAN x4plus anime 6B (マンガ・イラスト、軽くて速い)", "Rsync (지금 여기)": "Rsync (現在地)", "Rsync 화면 확인 창의 [실행]": "Rsync 画面の確認ウィンドウの [実行]", "Rsync 확인 창 ({0}): {1}": "Rsync 確認ウィンドウ ({0}): {1}", @@ -187,6 +194,7 @@ "WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV: rsync の代わりにアプリが直接同期します (サイズ・更新日時で比較、オプションのうち -u・--delete・--remove-source-files に従う)。", "WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --remove-source-files 만 따름).": "WebDAV: rsync の代わりにアプリが直接同期します (サイズ・更新日時で比較、オプションは -u・--remove-source-files のみ反映)。", "WebM (동영상)": "WebM(動画)", + "WebP 등은 PNG 로 저장합니다": "WebP などは PNG で保存します", "Whale 에서 가져오기": "Whale から取り込む", "Whale 에서 가져옴": "Whale から取り込み", "Whisper base (빠름)": "Whisper base(高速)", @@ -260,6 +268,7 @@ "{0} - {1} (약 {2}MB)": "{0} - {1}(約 {2}MB)", "{0} / {1}장": "{0} / {1} 枚", "{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2}(メイン画面の上部で変更)", + "{0} · {1}배 · {2}": "{0}・{1} 倍・{2}", "{0} → {1} 번역 {2}%": "{0} → {1} 翻訳 {2}%", "{0} 라이선스": "{0} ライセンス", "{0} 로 번역 중 {1}%": "{0} に翻訳中 {1}%", @@ -314,9 +323,14 @@ "{0}개를 옮겼습니다 → {1} ({2})": "{0}個を移動しました → {1}({2})", "{0}개를 찾아 여기서 멈췄습니다. 이름을 더 자세히 넣거나 아래 폴더로 들어가 찾으세요.": "{0} 件見つけたところで止めました。名前をより詳しく入力するか、下のフォルダーに入って探してください。", "{0}개를 풀었습니다: {1}": "{0} 個を展開しました: {1}", + "{0}배": "{0} 倍", + "{0}배로 올려 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.": "{0} 倍に高解像度化して新しいファイルで保存します。元のファイル・ZIP はそのままです。画面を閉じても続き、作業通知に残りの枚数と予想時間が表示されます。", "{0}분": "{0} 分", "{0}분 {1}초": "{0}分 {1}秒", "{0}시간 {1}분": "{0}時間 {1}分", + "{0}장 모두 해상도 올리기": "{0} ページすべてを高解像度化", + "{0}장은 실패했습니다": "{0} ページは失敗しました", + "{0}장을 올려 저장했습니다{1}": "{0} ページを高解像度化して保存しました{1}", "{0}줄": "{0}行", "{0}초": "{0}秒", "{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).": "{0} 秒ごとに元フォルダーを確認して対象フォルダーに合わせます (アプリの起動中)。", @@ -526,6 +540,7 @@ "다른 프로그램이 쓰고 있습니다": "他のプログラムが使用中です", "다른 화면으로 가면 페이지 소리 멈춤": "他の画面に移るとページの音を止める", "다시 만들기 대기로": "作り直し待ちに戻す", + "다시 받기": "再ダウンロード", "다시 비교": "もう一度比較", "다시 설치": "再インストール", "다시 세기": "再カウント", @@ -673,6 +688,7 @@ "만들기": "作成", "만들다 만 파일 {0}개": "未完成のファイル {0} 個", "만들다 만 파일 {0}개를 지울까요?": "未完成のファイル {0} 個を削除しますか?", + "만화 · 그림": "マンガ・イラスト", "만화 보기": "マンガ表示", "말레이어": "マレー語", "맞추기 (검은 여백)": "フィット(黒い余白)", @@ -697,6 +713,7 @@ "모델 내려받는 중 {0}%": "モデルをダウンロード中 {0}%", "모델 내려받는 중: {0} ({1})": "モデルをダウンロード中: {0}({1})", "모델 읽는 중": "モデルを読み込み中", + "모델 읽는 중 …": "モデルを読み込み中 …", "모델 파일이 잘렸습니다 (위치 {0})": "モデルファイルが途中で切れています(位置 {0})", "모델을 내려받을 수 없습니다 ({0}): {1}": "モデルをダウンロードできません({0}): {1}", "모두 나중에": "すべて後で", @@ -714,6 +731,7 @@ "목": "木", "목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "リスト・動画の横・動画の上に出る広告エリアを隠します", "목록 보기": "リストを見る", + "목록에서 빼기": "リストから外す", "목록에서 제거": "リストから削除", "목록에서 지우기 (파일은 그대로)": "リストから削除 (ファイルはそのまま)", "목록을 모두 비울까요?": "一覧をすべて空にしますか?", @@ -775,6 +793,7 @@ "밤 0~6시": "夜 0~6 時", "방법: {0}": "方法: {0}", "방식": "方式", + "배율": "倍率", "백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)": "バックグラウンド: ウィンドウだけ隠してダウンロード・変換は続けます。{0} またはトレイアイコンで再表示します。\n完全に終了するには トレイアイコンを右クリック > 終了(どの設定でも常に終了)", "백그라운드로": "バックグラウンドへ", "백그라운드로 계속 (기본)": "バックグラウンドで続ける(既定)", @@ -860,6 +879,7 @@ "사용: {0}": "使用: {0}", "사용자가 취소했습니다.": "ユーザーがキャンセルしました。", "사용할 수 있는 자막 사이트가 없습니다.": "使用できる字幕サイトがありません。", + "사진": "写真", "삭제": "削除", "삭제 (목록에서 제거)": "削除(リストから外す)", "삭제 (중지하고 받던 파일 삭제)": "削除(中止してダウンロード中のファイルを削除)", @@ -1101,6 +1121,9 @@ "오른쪽 90°": "右 90°", "오픈 소스 라이선스": "オープンソースライセンス", "올려 둔 버전이 없습니다.": "公開したバージョンがありません。", + "올린 것": "高解像度化後", + "올린 것 (AI)": "高解像度化後 (AI)", + "올린 것으로 계속 보기": "高解像度化した画像で表示を続ける", "올린 이 {0}": "投稿者 {0}", "옮겼습니다.": "移動しました。", "옮기기": "移動", @@ -1128,6 +1151,8 @@ "원문 보기 (번역 끄기)": "原文を表示 (翻訳をオフ)", "원본": "元", "원본 비율": "元の比率", + "원본 옆에 \"이름_x2\" (ZIP 은 ZIP 옆 \"ZIP이름_AI\" 폴더)": "元のファイルの横に「名前_x2」(ZIP は ZIP の横の「ZIP名_AI」フォルダー)", + "원본 옆으로": "元のファイルの横へ", "원본 유지 (재인코딩 없음)": "元のまま(再エンコードなし)", "원본 크기": "元のサイズ", "원본 파일 지우기 (--remove-source-files)": "元ファイルを削除 (--remove-source-files)", @@ -1138,9 +1163,11 @@ "원본 폴더가 비어 있어 멈췄습니다 (대상 파일은 지우지 않음). SD 카드 · 네트워크 · 권한을 확인하세요: {0}": "元フォルダーが空のため止めました (対象のファイルは削除していません)。SD カード・ネットワーク・権限を確認してください: {0}", "원본 폴더가 없습니다": "元フォルダーがありません", "원본 폴더를 읽을 수 없어 멈췄습니다 (대상 파일은 지우지 않음): {0}{1}": "元フォルダーを読めないため止めました (対象のファイルは削除していません): {0}{1}", + "원본과 같은 형식": "元と同じ形式", "원본과 대상이 서로 안에 있습니다": "元と対象が互いの中にあります", "원본에 없는 것을 대상에서 지우기": "元にないものを対象から削除", "원본에는 아무것도 없는데 대상에는 {0}개가 있습니다. SD 카드가 빠졌거나 네트워크 · 권한 문제일 수 있습니다. 일부러 원본을 비웠다면 지울 목록을 보고 [그래도 맞추기] 를 누르세요.": "元には何もありませんが、対象には {0} 個あります。SD カードが抜けたか、ネットワーク・権限の問題かもしれません。意図的に元を空にした場合は、削除する一覧を見て [それでも同期] を押してください。", + "원본으로": "元の画像で", "원본을 읽을 수 없어 멈춤": "元を読めないため停止", "원본을 읽을 수 없어 멈췄습니다": "元を読めないため停止しました", "원본의 {0}배": "元の {0}倍", @@ -1268,6 +1295,7 @@ "이미 받는 중이거나 받을 수 없는 주소입니다.": "既にダウンロード中か、ダウンロードできない URL です。", "이미 이 폴더에 있습니다: {0}": "すでにこのフォルダーにあります: {0}", "이미 있는 폴더입니다: {0}": "既にあるフォルダーです: {0}", + "이미 작업 중입니다": "すでに作業中です", "이미지 · PDF · ZIP 보기": "画像・PDF・ZIP ビューアー", "이미지 자막은 번역할 수 없습니다": "画像字幕は翻訳できません", "이미지 자막은 편집할 수 없습니다": "画像字幕は編集できません", @@ -1304,6 +1332,7 @@ "자동 감지": "自動検出", "자동 백업 그대로": "自動バックアップのまま", "자동 설치를 할 수 없는 위치입니다. 페이지에서 직접 받아 주세요.": "自動インストールできない場所です。ページから直接ダウンロードしてください。", + "자동: 그래픽카드 (GPU) 가 있으면 사진용, CPU 뿐이면 가볍고 빠른 만화용. 원본은 덮어쓰지 않고 새 파일로 저장합니다": "自動: グラフィックカード (GPU) があれば写真用、CPU だけなら軽く速いマンガ用。元のファイルは上書きせず新しいファイルで保存します", "자동: 잰 결과 {0} 가 가장 빠릅니다": "自動: 計測の結果 {0} が最速です", "자동: 처음 그릴 때 짧게 재서 가장 빠른 장치를 고릅니다": "自動: 初回の描画時に短く計測して最速の装置を選びます", "자막": "字幕", @@ -1339,6 +1368,8 @@ "자막을 불러올 수 없습니다: {0}": "字幕を読み込めません: {0}", "자막이 비어 있습니다.": "字幕が空です。", "자막이 없습니다.": "字幕がありません。", + "자세히": "詳細", + "자세히 닫기": "詳細を閉じる", "자체 서명 인증서를 쓰는 집 NAS 등 (믿을 수 있는 서버에서만)": "自己署名証明書を使う自宅 NAS など (信頼できるサーバーのみ)", "작업": "作業", "작업 기록 보기": "作業記録を表示", @@ -1377,6 +1408,7 @@ "저장 안 함": "保存しない", "저장 위치": "保存先", "저장 폴더": "保存フォルダー", + "저장 형식": "保存形式", "저장: {0}": "保存: {0}", "저장된 비밀번호 · API 키를 지웠습니다": "保存したパスワード・API キーを削除しました", "저장된 비밀번호 · 설정 · 비밀번호가 필요한 기능을 쓸 때 묻습니다. 잊으면 최상 비밀번호로 초기화하거나, 저장된 비밀번호를 지우고 초기화할 수 있습니다.": "保存したパスワード・設定・パスワードが必要な機能を使うときに尋ねます。忘れたら最上位パスワードで初期化するか、保存したパスワードを削除して初期化できます。", @@ -1393,6 +1425,7 @@ "저장하는 중": "保存中", "저장하지 못했습니다: {0}": "保存できませんでした: {0}", "저장하지 않은 변경": "未保存の変更", + "저장한 뒤": "保存後", "저장했습니다": "保存しました", "저장했습니다: {0}": "保存しました: {0}", "저장했습니다: {0} (MKV 에 반영됨)": "保存しました: {0}(MKV に反映済み)", @@ -1450,6 +1483,7 @@ "주소를 https 로 바꾸고 다시 확인": "アドレスを https に変えてもう一度確認", "준비 중": "準備中", "준비 중…": "準備中…", + "준비하는 중": "準備中", "줄 삭제": "行を削除", "중국어": "中国語", "중국어 간체": "中国語(簡体字)", @@ -1629,7 +1663,10 @@ "파일 확인 중…": "ファイルを確認中…", "파일만 골랐을 때": "ファイルだけを選んだとき", "파일을 분석할 수 없습니다: {0}": "ファイルを分析できません: {0}", + "파일이 깨졌습니다": "ファイルが壊れています", + "파일이 깨졌습니다 · [다시 받기] 를 눌러 주세요": "ファイルが壊れています・[再ダウンロード] を押してください", "파일이 보이지 않습니다: \"모든 파일에 대한 접근\" 권한이 없습니다": "ファイルが表示されません: 「すべてのファイルへのアクセス」権限がありません", + "파일이 없습니다 (지워졌거나 옮겨짐)": "ファイルがありません (削除または移動)", "팝업 끄기": "ポップアップを閉じる", "팝업 보기": "ポップアップ表示", "패키지 라이선스": "パッケージのライセンス", @@ -1646,6 +1683,7 @@ "포함된 구성 요소와 라이선스 (THIRD_PARTY_NOTICES.txt)": "含まれるコンポーネントとライセンス(THIRD_PARTY_NOTICES.txt)", "폴더": "フォルダー", "폴더 + 파일 목록": "フォルダー + ファイル一覧", + "폴더 고르기": "フォルダーを選択", "폴더 만들기": "フォルダーを作成", "폴더 복사 · 이동 방법": "フォルダーのコピー · 移動方法", "폴더 삭제: {0} (안의 {1}개 포함)": "フォルダーを削除: {0}(中の {1}個を含む)", @@ -1708,6 +1746,10 @@ "한글": "ハングル", "항목 {0}/{1}": "項目 {0}/{1}", "항목마다 따로 (기본)": "項目ごとに (既定)", + "해상도 올리기 모델": "高解像度化モデル", + "해상도 올리기 모델을 받아 주세요 (환경 설정 > AI 그림)": "高解像度化モデルをダウンロードしてください (環境設定 > AI 画像)", + "해상도를 올리지 못했습니다": "高解像度化できませんでした", + "해상도를 올린 그림 저장 폴더": "高解像度化した画像の保存フォルダー", "해제": "解除", "허용": "許可", "허용했으면 다시 확인": "許可したらもう一度確認", @@ -1745,6 +1787,7 @@ "확인 · 압축 푸는 중…": "確認・展開中…", "확인 중…": "確認中…", "확인 창에서 알린 뒤 허용합니다. 지우기 (--delete) 가 있으면 늘 막습니다. 끄면 늘 막습니다": "確認ウィンドウで知らせたうえで許可します。削除 (--delete) があるときは常に禁止します。オフにすると常に禁止します", + "확인됨": "確認済み", "확장 설치": "拡張機能のインストール", "확장자": "拡張子", "확장자별 재생 프로그램": "拡張子ごとの再生プログラム", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 0b753d4..a5beeb2 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -67,6 +67,7 @@ "AI 모델 받는 중: {0}": "正在下载 AI 模型: {0}", "AI 번역": "AI 翻译", "AI 설정 · 모델 받기 · 서비스 추가": "AI 设置 · 下载模型 · 添加服务", + "AI 엔진이 멈췄습니다 (코드 {0}). 메모리가 모자라거나 파일이 깨졌을 수 있습니다": "AI 引擎已停止 (代码 {0})。可能是内存不足或文件损坏", "AI 엔진이 실패했습니다 ({0}): {1}": "AI 引擎失败 ({0}): {1}", "AI 엔진이 없습니다. 환경 설정 > AI 그림에서 받아 주세요.": "没有 AI 引擎。请在 设置 > AI 图片 中下载。", "AI 인식": "AI 识别", @@ -85,6 +86,9 @@ "AI 자막을 만들고 이어서 MKV 로 만듭니다": "生成 AI 字幕后接着制作 MKV", "AI 자막을 만듭니다": "生成 AI 字幕", "AI 작업 중이라 이번에는 건너뜁니다": "正在进行 AI 任务,本次跳过", + "AI 해상도 올리기": "AI 提高分辨率", + "AI 해상도 올리기 (모든 장)": "AI 提高分辨率 (全部页面)", + "AI 해상도 올리기 (이 장)": "AI 提高分辨率 (本页)", "API 키": "API 密钥", "API 키 (선택)": "API 密钥 (可选)", "API 키 (필수, 무료)": "API 密钥(必填,免费)", @@ -121,6 +125,7 @@ "JJ_MKVMaker 로 자막 만들기": "用 JJ_MKVMaker 生成字幕", "JJ_MKVMaker 로 재생": "用 JJ_MKVMaker 播放", "JJ_MKVMaker_즐겨찾기.html": "JJ_MKVMaker_书签.html", + "JPG 품질 {0}": "JPG 质量 {0}", "JavaScript 사용": "启用 JavaScript", "LCM-LoRA (SD1.5, 4단계로 빠르게)": "LCM-LoRA (SD1.5,4 步快速生成)", "MKV · AI 작업": "MKV · AI 任务", @@ -164,6 +169,8 @@ "PDF 를 만들지 못했습니다: {0}": "无法制作 PDF: {0}", "PDF 를 열 수 없습니다: {0}": "无法打开 PDF: {0}", "PDF 에 넣을 그림 선택": "选择要加入 PDF 的图片", + "Real-ESRGAN x4plus (사진)": "Real-ESRGAN x4plus (照片)", + "Real-ESRGAN x4plus anime 6B (만화 · 그림, 가볍고 빠름)": "Real-ESRGAN x4plus anime 6B (漫画 · 插画,轻量快速)", "Rsync (지금 여기)": "Rsync (当前位置)", "Rsync 화면 확인 창의 [실행]": "Rsync 界面确认窗口的 [执行]", "Rsync 확인 창 ({0}): {1}": "Rsync 确认窗口 ({0}): {1}", @@ -187,6 +194,7 @@ "WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV: 由应用直接同步而非 rsync (比较大小 · 修改时间,遵循 -u · --delete · --remove-source-files)。", "WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --remove-source-files 만 따름).": "WebDAV: 由应用代替 rsync 直接同步 (比较大小和修改时间,选项中仅 -u・--remove-source-files 生效)。", "WebM (동영상)": "WebM(视频)", + "WebP 등은 PNG 로 저장합니다": "WebP 等将保存为 PNG", "Whale 에서 가져오기": "从 Whale 导入", "Whale 에서 가져옴": "从 Whale 导入", "Whisper base (빠름)": "Whisper base(快速)", @@ -260,6 +268,7 @@ "{0} - {1} (약 {2}MB)": "{0} - {1}(约 {2}MB)", "{0} / {1}장": "{0} / {1} 张", "{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2}(在主界面顶部更改)", + "{0} · {1}배 · {2}": "{0} · {1} 倍 · {2}", "{0} → {1} 번역 {2}%": "{0} → {1} 翻译 {2}%", "{0} 라이선스": "{0} 许可证", "{0} 로 번역 중 {1}%": "正在翻译为 {0} {1}%", @@ -314,9 +323,14 @@ "{0}개를 옮겼습니다 → {1} ({2})": "已移动 {0} 个 → {1}({2})", "{0}개를 찾아 여기서 멈췄습니다. 이름을 더 자세히 넣거나 아래 폴더로 들어가 찾으세요.": "找到 {0} 个后已停止。请输入更具体的名称或进入下级文件夹查找。", "{0}개를 풀었습니다: {1}": "已解压 {0} 个: {1}", + "{0}배": "{0} 倍", + "{0}배로 올려 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.": "提高到 {0} 倍并另存为新文件。原文件和 ZIP 保持不变。关闭画面后仍会继续,任务通知中会显示剩余页数和预计时间。", "{0}분": "{0} 分钟", "{0}분 {1}초": "{0} 分 {1} 秒", "{0}시간 {1}분": "{0} 小时 {1} 分", + "{0}장 모두 해상도 올리기": "提高全部 {0} 页的分辨率", + "{0}장은 실패했습니다": "{0} 页失败", + "{0}장을 올려 저장했습니다{1}": "已提高并保存 {0} 页{1}", "{0}줄": "{0} 行", "{0}초": "{0} 秒", "{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).": "每 {0} 秒检查源文件夹并同步到目标文件夹 (应用运行期间)。", @@ -526,6 +540,7 @@ "다른 프로그램이 쓰고 있습니다": "其他程序正在使用", "다른 화면으로 가면 페이지 소리 멈춤": "离开浏览器时暂停网页声音", "다시 만들기 대기로": "改回等待重新制作", + "다시 받기": "重新下载", "다시 비교": "重新比较", "다시 설치": "重新安装", "다시 세기": "重新统计", @@ -673,6 +688,7 @@ "만들기": "生成", "만들다 만 파일 {0}개": "{0} 个未完成的文件", "만들다 만 파일 {0}개를 지울까요?": "删除 {0} 个未完成的文件吗?", + "만화 · 그림": "漫画 · 插画", "만화 보기": "漫画阅读", "말레이어": "马来语", "맞추기 (검은 여백)": "适应(黑边)", @@ -697,6 +713,7 @@ "모델 내려받는 중 {0}%": "正在下载模型 {0}%", "모델 내려받는 중: {0} ({1})": "正在下载模型: {0}({1})", "모델 읽는 중": "正在加载模型", + "모델 읽는 중 …": "正在加载模型 …", "모델 파일이 잘렸습니다 (위치 {0})": "模型文件被截断(位置 {0})", "모델을 내려받을 수 없습니다 ({0}): {1}": "无法下载模型({0}): {1}", "모두 나중에": "全部稍后", @@ -714,6 +731,7 @@ "목": "四", "목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "隐藏列表 · 视频旁 · 视频上方出现的广告区域", "목록 보기": "查看列表", + "목록에서 빼기": "从列表中移除", "목록에서 제거": "从列表中移除", "목록에서 지우기 (파일은 그대로)": "从列表中删除 (文件保留)", "목록을 모두 비울까요?": "清空整个列表吗?", @@ -775,6 +793,7 @@ "밤 0~6시": "夜间 0~6 点", "방법: {0}": "方式: {0}", "방식": "方式", + "배율": "倍率", "백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)": "后台: 仅隐藏窗口,下载 · 转换继续进行。用 {0} 或托盘图标重新打开。\n要完全退出: 右键托盘图标 > 退出(无论何种设置都会退出)", "백그라운드로": "转到后台", "백그라운드로 계속 (기본)": "在后台继续(默认)", @@ -860,6 +879,7 @@ "사용: {0}": "使用: {0}", "사용자가 취소했습니다.": "用户已取消。", "사용할 수 있는 자막 사이트가 없습니다.": "没有可用的字幕网站。", + "사진": "照片", "삭제": "删除", "삭제 (목록에서 제거)": "删除(从列表中移除)", "삭제 (중지하고 받던 파일 삭제)": "删除(停止并删除正在下载的文件)", @@ -1101,6 +1121,9 @@ "오른쪽 90°": "向右 90°", "오픈 소스 라이선스": "开源许可", "올려 둔 버전이 없습니다.": "没有已发布的版本。", + "올린 것": "提高后", + "올린 것 (AI)": "提高后 (AI)", + "올린 것으로 계속 보기": "继续查看提高后的图片", "올린 이 {0}": "上传者 {0}", "옮겼습니다.": "已移动。", "옮기기": "移动", @@ -1128,6 +1151,8 @@ "원문 보기 (번역 끄기)": "显示原文 (关闭翻译)", "원본": "源", "원본 비율": "原始比例", + "원본 옆에 \"이름_x2\" (ZIP 은 ZIP 옆 \"ZIP이름_AI\" 폴더)": "在原文件旁保存为“名称_x2” (ZIP 则保存在 ZIP 旁的“ZIP名称_AI”文件夹)", + "원본 옆으로": "保存在原文件旁", "원본 유지 (재인코딩 없음)": "保持原样(不重新编码)", "원본 크기": "原始尺寸", "원본 파일 지우기 (--remove-source-files)": "删除源文件 (--remove-source-files)", @@ -1138,9 +1163,11 @@ "원본 폴더가 비어 있어 멈췄습니다 (대상 파일은 지우지 않음). SD 카드 · 네트워크 · 권한을 확인하세요: {0}": "源文件夹为空,已停止 (未删除目标文件)。请检查 SD 卡、网络和权限: {0}", "원본 폴더가 없습니다": "源文件夹不存在", "원본 폴더를 읽을 수 없어 멈췄습니다 (대상 파일은 지우지 않음): {0}{1}": "无法读取源文件夹,已停止 (未删除目标文件): {0}{1}", + "원본과 같은 형식": "与原文件相同的格式", "원본과 대상이 서로 안에 있습니다": "源与目标互相包含", "원본에 없는 것을 대상에서 지우기": "从目标中删除源中没有的内容", "원본에는 아무것도 없는데 대상에는 {0}개가 있습니다. SD 카드가 빠졌거나 네트워크 · 권한 문제일 수 있습니다. 일부러 원본을 비웠다면 지울 목록을 보고 [그래도 맞추기] 를 누르세요.": "源中没有任何内容,但目标中有 {0} 个项目。可能是 SD 卡被拔出,或网络 · 权限有问题。如果你有意清空了源,请查看要删除的列表并按 [仍然同步]。", + "원본으로": "回到原图", "원본을 읽을 수 없어 멈춤": "无法读取源,已停止", "원본을 읽을 수 없어 멈췄습니다": "无法读取源,已停止", "원본의 {0}배": "原始的 {0} 倍", @@ -1268,6 +1295,7 @@ "이미 받는 중이거나 받을 수 없는 주소입니다.": "已在下载中,或该地址无法下载。", "이미 이 폴더에 있습니다: {0}": "已在此文件夹中: {0}", "이미 있는 폴더입니다: {0}": "文件夹已存在: {0}", + "이미 작업 중입니다": "已在处理中", "이미지 · PDF · ZIP 보기": "图片 · PDF · ZIP 查看", "이미지 자막은 번역할 수 없습니다": "图像字幕无法翻译", "이미지 자막은 편집할 수 없습니다": "图像字幕无法编辑", @@ -1304,6 +1332,7 @@ "자동 감지": "自动检测", "자동 백업 그대로": "保持自动备份", "자동 설치를 할 수 없는 위치입니다. 페이지에서 직접 받아 주세요.": "此位置无法自动安装。请从页面手动下载。", + "자동: 그래픽카드 (GPU) 가 있으면 사진용, CPU 뿐이면 가볍고 빠른 만화용. 원본은 덮어쓰지 않고 새 파일로 저장합니다": "自动: 有显卡 (GPU) 时用照片模型,仅有 CPU 时用轻量快速的漫画模型。不会覆盖原文件,另存为新文件", "자동: 잰 결과 {0} 가 가장 빠릅니다": "自动: 测得 {0} 最快", "자동: 처음 그릴 때 짧게 재서 가장 빠른 장치를 고릅니다": "自动: 首次绘制时短暂测试并选择最快的处理器", "자막": "字幕", @@ -1339,6 +1368,8 @@ "자막을 불러올 수 없습니다: {0}": "无法加载字幕: {0}", "자막이 비어 있습니다.": "字幕为空。", "자막이 없습니다.": "没有字幕。", + "자세히": "详情", + "자세히 닫기": "收起详情", "자체 서명 인증서를 쓰는 집 NAS 등 (믿을 수 있는 서버에서만)": "用于使用自签名证书的家用 NAS 等 (仅限可信服务器)", "작업": "任务", "작업 기록 보기": "显示任务记录", @@ -1377,6 +1408,7 @@ "저장 안 함": "不保存", "저장 위치": "保存位置", "저장 폴더": "保存文件夹", + "저장 형식": "保存格式", "저장: {0}": "保存: {0}", "저장된 비밀번호 · API 키를 지웠습니다": "已删除保存的密码 · API 密钥", "저장된 비밀번호 · 설정 · 비밀번호가 필요한 기능을 쓸 때 묻습니다. 잊으면 최상 비밀번호로 초기화하거나, 저장된 비밀번호를 지우고 초기화할 수 있습니다.": "使用已保存的密码 · 设置 · 需要密码的功能时询问。忘记时可用最高密码重置,或删除已保存的密码后重置。", @@ -1393,6 +1425,7 @@ "저장하는 중": "正在保存", "저장하지 못했습니다: {0}": "无法保存: {0}", "저장하지 않은 변경": "未保存的更改", + "저장한 뒤": "保存后", "저장했습니다": "已保存", "저장했습니다: {0}": "已保存: {0}", "저장했습니다: {0} (MKV 에 반영됨)": "已保存: {0}(已应用到 MKV)", @@ -1450,6 +1483,7 @@ "주소를 https 로 바꾸고 다시 확인": "将地址改为 https 并重新检查", "준비 중": "准备中", "준비 중…": "准备中…", + "준비하는 중": "正在准备", "줄 삭제": "删除行", "중국어": "中文", "중국어 간체": "简体中文", @@ -1629,7 +1663,10 @@ "파일 확인 중…": "正在检查文件…", "파일만 골랐을 때": "只选择文件时", "파일을 분석할 수 없습니다: {0}": "无法分析文件: {0}", + "파일이 깨졌습니다": "文件已损坏", + "파일이 깨졌습니다 · [다시 받기] 를 눌러 주세요": "文件已损坏 · 请点按 [重新下载]", "파일이 보이지 않습니다: \"모든 파일에 대한 접근\" 권한이 없습니다": "文件不可见: 没有“所有文件访问”权限", + "파일이 없습니다 (지워졌거나 옮겨짐)": "找不到文件 (已删除或移动)", "팝업 끄기": "关闭弹出窗口", "팝업 보기": "弹出窗口观看", "패키지 라이선스": "软件包许可", @@ -1646,6 +1683,7 @@ "포함된 구성 요소와 라이선스 (THIRD_PARTY_NOTICES.txt)": "包含的组件与许可(THIRD_PARTY_NOTICES.txt)", "폴더": "文件夹", "폴더 + 파일 목록": "文件夹 + 文件列表", + "폴더 고르기": "选择文件夹", "폴더 만들기": "新建文件夹", "폴더 복사 · 이동 방법": "文件夹复制 · 移动方式", "폴더 삭제: {0} (안의 {1}개 포함)": "已删除文件夹: {0}(含其中 {1} 个)", @@ -1708,6 +1746,10 @@ "한글": "韩文", "항목 {0}/{1}": "项目 {0}/{1}", "항목마다 따로 (기본)": "逐个项目 (默认)", + "해상도 올리기 모델": "提高分辨率模型", + "해상도 올리기 모델을 받아 주세요 (환경 설정 > AI 그림)": "请下载提高分辨率模型 (设置 > AI 图片)", + "해상도를 올리지 못했습니다": "无法提高分辨率", + "해상도를 올린 그림 저장 폴더": "提高分辨率后图片的保存文件夹", "해제": "解除", "허용": "允许", "허용했으면 다시 확인": "允许后重新检查", @@ -1745,6 +1787,7 @@ "확인 · 압축 푸는 중…": "正在校验 · 解压…", "확인 중…": "正在检查…", "확인 창에서 알린 뒤 허용합니다. 지우기 (--delete) 가 있으면 늘 막습니다. 끄면 늘 막습니다": "在确认窗口中提示后允许。有删除 (--delete) 时始终阻止。关闭后始终阻止", + "확인됨": "已校验", "확장 설치": "安装扩展", "확장자": "扩展名", "확장자별 재생 프로그램": "按扩展名设置播放程序", diff --git a/lib/app/ai_local.dart b/lib/app/ai_local.dart index c11c679..3ff8495 100644 --- a/lib/app/ai_local.dart +++ b/lib/app/ai_local.dart @@ -37,6 +37,15 @@ class AiStore extends ChangeNotifier { bool isInstalled(String id) => isFileInstalled(aiFile(id)); + /// 받을 때 SHA-256 이 공식 값과 같았는지 (표시 파일에 남김) - 화면에 "확인됨" + bool isVerified(AiFile f) { + try { + return (jsonDecode(_mark(f).readAsStringSync()) as Map)['sha256'] == f.sha256; + } catch (_) { + return false; + } + } + bool isFileInstalled(AiFile f) => _mark(f).existsSync() && FileSystemEntity.typeSync(pathOf(f)) != FileSystemEntityType.notFound; /// 받는 중인 것 (id → 0~1, 모르면 null) @@ -219,8 +228,18 @@ class AiStore extends ChangeNotifier { final whole = res.statusCode == 416 && have == f.size; // 이미 다 받아 둠 - 확인만 if (whole) { await res.drain(); + } else if (res.statusCode == 416) { + // 조각이 서버 파일과 맞지 않음 (크기가 다름) - 조각을 버리고 처음부터 (다시 시도만 반복하다 막히지 않게) + await res.drain(); + if (out.existsSync()) out.deleteSync(); + throw HttpException('range not satisfiable - restart'); } else if (res.statusCode == 200) { have = 0; // 이어 주지 않음 - 처음부터 + } else if (res.statusCode == 206 && contentRangeStart(res.headers.value(HttpHeaders.contentRangeHeader)) != have) { + // CDN 이 다른 범위를 줌 - 섞이지 않게 조각을 버리고 처음부터 + await res.drain(); + if (out.existsSync()) out.deleteSync(); + throw HttpException('unexpected range - restart'); } else if (res.statusCode != 206) { await res.drain(); throw HttpException('status ${res.statusCode}'); // 다시 시도 대상 (주소는 사용자에게 보이지 않음) @@ -263,6 +282,7 @@ class AiStore extends ChangeNotifier { if (done < total) throw HttpException('short read $done/$total'); // 끊김 - 이어 받기 if (hash.events.single.toString() != f.sha256) { await out.delete(); + failures[f.id] = tr('파일이 깨졌습니다 · [다시 받기] 를 눌러 주세요'); throw ImageAiException(trf('받은 파일이 손상되었습니다 (SHA-256 다름): {0}', [f.name])); } } @@ -332,6 +352,12 @@ class AiStore extends ChangeNotifier { } } +/// Content-Range ("bytes 120000-299999/300000") 의 시작 (모르면 null) +int? contentRangeStart(String? header) { + final m = RegExp(r'bytes\s+(\d+)-').firstMatch(header ?? ''); + return m == null ? null : int.parse(m.group(1)!); +} + /// 처리 장치 하나 (실행 파일 + sd-cli --backend 값) class SdDevice { final String exe; @@ -381,16 +407,24 @@ class LocalSdEngine implements ImageAiEngine { ); final out = p.join(outDir, '${baseName}_%d.png'); final args = sdCliArgs(req, models, out: out, backend: device.backend, threads: threads); - final lines = await runSdCli(device.exe, args, onProgress: (prog) { - final step = switch (prog.phase) { - 'load' => tr('모델 읽는 중'), - 'sample' => trf('그리는 중 {0}/{1}', [prog.step, prog.steps]), - 'decode' => tr('그림으로 바꾸는 중'), - _ => tr('저장하는 중'), - }; - onProgress?.call(prog.overall, step); - }, onStart: (pr) => _proc = pr); - if (_cancelled) throw ImageAiException(tr('취소했습니다')); + final List lines; + try { + lines = await runSdCli(device.exe, args, onProgress: (prog) { + final step = switch (prog.phase) { + // 152: 읽는 동안은 남은 시간을 모른다 + 'load' => tr('모델 읽는 중 …'), + 'sample' => trf('그리는 중 {0}/{1}', [prog.step, prog.steps]), + 'decode' => tr('그림으로 바꾸는 중'), + _ => tr('저장하는 중'), + }; + onProgress?.call(prog.overall, step); + }, onStart: (pr) => _proc = pr); + } catch (_) { + // 150: 취소로 엔진을 끝낸 것은 실패가 아니다 + if (_cancelled) throw ImageAiException(tr('취소했습니다'), cancelled: true); + rethrow; + } + if (_cancelled) throw ImageAiException(tr('취소했습니다'), cancelled: true); final made = Directory(outDir) .listSync() .whereType() @@ -430,7 +464,9 @@ Future> runSdCli(String exe, List args, // 그림을 다 저장한 뒤 끝내면서 0 이 아닌 코드로 끝나는 일이 있다 (이 판의 sd-cli) - 저장했으면 성공으로 본다 if (code != 0 && !saved) { final err = tail.where((l) => l.contains('[E]') || l.toLowerCase().contains('error')).toList(); - throw ImageAiException(trf('AI 엔진이 실패했습니다 ({0}): {1}', [code, (err.isEmpty ? tail : err).join('\n')])); + // 150: 쉬운 말 한 줄, 엔진 원문은 [자세히] 에 + throw ImageAiException(trf('AI 엔진이 멈췄습니다 (코드 {0}). 메모리가 모자라거나 파일이 깨졌을 수 있습니다', [code]), + detail: (err.isEmpty ? tail : err).join('\n')); } return tail; } @@ -472,6 +508,7 @@ class AiJobs extends ChangeNotifier { int remaining = 0; Duration? eta; ImageAiEngine? _engine; + void Function()? _cancelTask; bool get busy => title != null; @@ -492,7 +529,11 @@ class AiJobs extends ChangeNotifier { notifyListeners(); } + /// 151: 지금 쓰는 받은 파일 (그동안은 [지우기] 를 끈다) + Set inUse = const {}; + void finish() { + inUse = const {}; title = null; progress = null; remaining = 0; @@ -501,7 +542,49 @@ class AiJobs extends ChangeNotifier { notifyListeners(); } - void cancel() => _engine?.cancel(); + void cancel() { + _engine?.cancel(); + _cancelTask?.call(); + } + + /// 121: 여러 장 작업 (폴더 · ZIP 전체 해상도 올리기 등) 을 한 번에 하나로. [body] 는 i 번째 장을 하고 진행 (0~1) 을 알린다. + /// 남은 장 · 예상 시간을 알림 · 작업 현황에 보이고, [onCancel] 로 지금 장을 멈춘다. 끝낸 장 수를 돌려준다. + Future runTask(String title, int count, Future Function(int i, void Function(double? p) progress) body, + {required void Function() onCancel, Set uses = const {}}) async { + if (busy) throw ImageAiException(tr('이미 작업 중입니다')); + inUse = uses; + var stop = false; + this.title = title; + remaining = count; + progress = 0; + eta = null; + _cancelTask = () { + stop = true; + onCancel(); + }; + notifyListeners(); + final started = DateTime.now(); + var done = 0; + try { + for (var i = 0; i < count && !stop; i++) { + await body(i, (pp) { + final overall = (i + (pp ?? 0)) / count; + final spent = DateTime.now().difference(started); + update( + progress: overall, + remaining: count - i, + eta: overall > 0.02 ? spent * ((1 - overall) / overall) : null, + title: '$title ${i + 1}/$count', + ); + }); + done++; + } + } finally { + _cancelTask = null; + finish(); + } + return done; + } /// 그리기 한 번 (한 번에 하나). 진행 · 남은 장 · 예상 시간을 알리고, 그림마다 옆에 json (프롬프트 · 시드 · 모델) 을 남긴다. Future> run(ImageAiEngine engine, ImageGenRequest r, {required String outDir}) async { @@ -509,10 +592,12 @@ class AiJobs extends ChangeNotifier { final started = DateTime.now(); final stamp = started.toIso8601String().replaceAll(RegExp(r'[-:T]'), '').split('.').first; start(trf('AI 그림 ({0})', [engine.label]), engine, r.count); + if (engine is LocalSdEngine) inUse = {...sd15LcmFiles, 'taesd', 'engine-vulkan', 'engine-cuda', 'engine-cudart'}; try { final made = await engine.generate(r, outDir: outDir, baseName: 'ai_$stamp', onProgress: (prog, step) { Duration? left; - if (prog != null && prog > 0.05) { + // 152: 모델을 읽는 동안 (0.1 까지) 은 남은 시간을 모른다 + if (prog != null && prog > 0.12) { final spent = DateTime.now().difference(started); left = spent * ((1 - prog) / prog); } diff --git a/lib/app/ai_upscale.dart b/lib/app/ai_upscale.dart new file mode 100644 index 0000000..dc8e309 --- /dev/null +++ b/lib/app/ai_upscale.dart @@ -0,0 +1,143 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:image/image.dart' as img; +import 'package:path/path.dart' as p; + +import '../core/ai_catalog.dart'; +import '../core/sd_cli.dart'; +import '../l10n/tr.dart'; +import '../services/image_ai.dart'; +import 'ai_local.dart'; + +/// 121: AI 해상도 올리기 (Real-ESRGAN, stable-diffusion.cpp 의 sd-cli -M upscale). +/// 모델은 늘 4배로 그리고, 2 · 3배는 그 결과를 줄여 만든다. 원본은 절대 덮어쓰지 않는다. +class UpscaleOptions { + /// 'auto' · 'photo' · 'anime' + final String model; + final int scale; + + /// 'same' (원본과 같은 형식, WebP 등 못 쓰는 형식은 PNG) · 'png' · 'jpg' + final String format; + final int jpgQuality; + + /// 저장 폴더 (비어 있으면 원본 옆) + final String outDir; + const UpscaleOptions({this.model = 'auto', this.scale = 2, this.format = 'same', this.jpgQuality = 92, this.outDir = ''}); +} + +/// 자동 모델: GPU 가 있으면 사진용 (x4plus), CPU 뿐이면 가볍고 빠른 만화용 (CPU 로 x4plus 는 512px 한 장에 20분 - 이 PC 실측) +String pickUpscaleModel(String setting, {required bool gpu}) => + setting == 'auto' ? (gpu ? 'esrgan-x4plus' : 'esrgan-anime6b') : upscaleModelFor(setting, phone: false); + +/// 저장 형식 확장자 +String upscaleExt(String source, String format) { + final e = p.extension(source).toLowerCase().replaceFirst('.', ''); + return switch (format) { + 'png' => 'png', + 'jpg' => 'jpg', + _ => const ['jpg', 'jpeg'].contains(e) ? 'jpg' : 'png', + }; +} + +/// 저장할 이름: 원본 옆 (또는 [outDir]) 에 "이름_x2.png". 이미 있으면 "이름_x2 (2).png" … (덮어쓰지 않음) +String upscaledPath(String source, {required int scale, required String ext, String outDir = ''}) { + final dir = outDir.isEmpty ? p.dirname(source) : outDir; + final base = '${p.basenameWithoutExtension(source)}_x$scale'; + var path = p.join(dir, '$base.$ext'); + for (var n = 2; File(path).existsSync(); n++) { + path = p.join(dir, '$base ($n).$ext'); + } + return path; +} + +/// 4배로 올린 그림 [fourX] 를 [scale] 배 크기로 줄이고 형식에 맞게 담는다 (따로 돌려 화면이 멈추지 않게) +Future finishUpscale(Uint8List fourX, {required int origW, required int origH, required int scale, required String ext, int quality = 92}) => + compute(_finish, (fourX, origW, origH, scale, ext, quality)); + +Uint8List _finish((Uint8List, int, int, int, String, int) a) { + final (bytes, w, h, scale, ext, q) = a; + var im = img.decodeImage(bytes); + if (im == null) throw const ImageAiException('decode failed'); + if (scale != 4 && w > 0 && h > 0) { + im = img.copyResize(im, width: w * scale, height: h * scale, interpolation: img.Interpolation.cubic); + } + return ext == 'jpg' ? img.encodeJpg(im, quality: q) : img.encodePng(im); +} + +/// 그림 한 장 올리기. [input] 은 이 기기의 파일 (ZIP · WebDAV 의 그림은 부르는 쪽이 임시 파일로). +/// 결과를 [save] 가 true 면 저장하고 그 경로를, 아니면 임시 파일 경로를 돌려준다 (비교해 보고 저장할 때). +class AiUpscaler { + AiUpscaler({required this.device, required this.modelPath}); + final SdDevice device; + final String modelPath; + Process? _proc; + bool _cancelled = false; + + void cancel() { + _cancelled = true; + _proc?.kill(); + } + + /// 4배 결과를 임시 파일로 만든다 (진행: 0~1) + Future upscale4x(String input, {void Function(double? p)? onProgress}) async { + _cancelled = false; + 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), + onProgress: (prog) => onProgress?.call(prog.phase == 'done' ? 1 : (prog.steps == 0 ? null : prog.step / prog.steps)), + onStart: (pr) => _proc = pr); + } catch (_) { + if (_cancelled) throw ImageAiException(tr('취소했습니다'), cancelled: true); + rethrow; + } + if (_cancelled) throw ImageAiException(tr('취소했습니다'), cancelled: true); + if (!File(out).existsSync()) throw ImageAiException(tr('해상도를 올리지 못했습니다')); + return out; + } + + /// 올려서 저장까지: 저장한 경로 + Future upscaleAndSave(String input, UpscaleOptions o, {String? nameFrom, void Function(double? p)? onProgress}) async { + final four = await upscale4x(input, onProgress: onProgress); + try { + return await saveUpscaled(four, source: nameFrom ?? input, original: input, o: o); + } finally { + try { + await Directory(p.dirname(four)).delete(recursive: true); + } catch (_) {} + } + } +} + +/// 4배 결과 [fourX] 를 설정대로 줄이고 담아 새 파일로 저장한다 (이름은 [source] 기준, 크기는 [original] 기준) +Future saveUpscaled(String fourX, {required String source, required String original, required UpscaleOptions o}) async { + final orig = img.decodeImage(await File(original).readAsBytes()); + final ext = upscaleExt(source, o.format); + final bytes = await finishUpscale(await File(fourX).readAsBytes(), + origW: orig?.width ?? 0, origH: orig?.height ?? 0, scale: o.scale, ext: ext, quality: o.jpgQuality); + final path = upscaledPath(source, scale: o.scale, ext: ext, outDir: o.outDir); + await Directory(p.dirname(path)).create(recursive: true); + await File(path).writeAsBytes(bytes, flush: true); + return path; +} + +/// 해상도 올리기에 쓸 장치 · 모델 (없으면 null - 받기 화면으로) +Future prepareUpscaler(AiStore store, {required String modelSetting, String device = 'auto', String autoDevice = ''}) async { + final devices = await sdDevices(store); + if (devices.isEmpty) return null; + SdDevice dev; + if (device != 'auto') { + dev = devices.where((d) => d.key == device).firstOrNull ?? devices.first; + } else { + // 해상도 올리기는 그림 만들기와 달리 Vulkan 이 CUDA 보다 빨랐다 (이 PC, x4plus 512→2048: Vulkan 116초 · CUDA 197초 · CPU 1224초) + dev = devices.where((d) => d.backend.startsWith('vulkan')).firstOrNull ?? + devices.where((d) => d.backend.startsWith('cuda')).firstOrNull ?? + devices.first; + } + final gpu = dev.backend != 'cpu'; + final id = pickUpscaleModel(modelSetting, gpu: gpu); + if (!store.isInstalled(id)) return null; + return AiUpscaler(device: dev, modelPath: store.pathOf(aiFile(id))); +} diff --git a/lib/app/settings.dart b/lib/app/settings.dart index 81c4aec..dcd54d6 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -474,6 +474,23 @@ class AppSettings { /// 라이선스 조건에 동의한 받는 파일 (149-④: 한 번 동의하면 다시 묻지 않음) List aiAgreed = []; + // 121: 해상도 올리기 + /// 모델 'auto' (GPU 면 사진용, CPU 뿐이면 가볍고 빠른 만화용) · 'photo' · 'anime' + String aiUpModel = 'auto'; + + /// 배율 2 · 3 · 4 + int aiUpScale = 2; + + /// 저장 형식 'same' (원본과 같게, 못 쓰는 형식은 PNG) · 'png' · 'jpg' · JPG 품질 + String aiUpFormat = 'same'; + int aiUpJpgQuality = 92; + + /// 저장 위치 (비어 있으면 원본 옆 "이름_x2") + String aiUpDir = ''; + + /// 저장한 뒤 'upscaled' 올린 것으로 계속 보기 · 'original' 원본으로 + String aiUpAfter = 'upscaled'; + /// 실시간 동기화 확인 간격 (초). Windows 는 바뀌면 바로, 그 밖은 이 간격으로 살핀다. int liveSyncIntervalSec = 30; @@ -644,6 +661,12 @@ class AppSettings { 'aiNegative': aiNegative, 'aiSaveDir': aiSaveDir, 'aiAgreed': aiAgreed, + 'aiUpModel': aiUpModel, + 'aiUpScale': aiUpScale, + 'aiUpFormat': aiUpFormat, + 'aiUpJpgQuality': aiUpJpgQuality, + 'aiUpDir': aiUpDir, + 'aiUpAfter': aiUpAfter, 'aiServices': [ for (final x in aiServices) {...x.toJson(), if (plainSecrets) 'apiKey': x.apiKey}, ], @@ -823,6 +846,12 @@ class AppSettings { ..aiNegative = j['aiNegative'] as String? ?? '' ..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' + ..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) + ..aiUpDir = j['aiUpDir'] as String? ?? '' + ..aiUpAfter = j['aiUpAfter'] == 'original' ? 'original' : 'upscaled' ..aiServices = [ for (final x in (j['aiServices'] as List?) ?? const []) if (x is Map) AiService.fromJson(x), diff --git a/lib/core/ai_catalog.dart b/lib/core/ai_catalog.dart index b87b2c9..9ebe389 100644 --- a/lib/core/ai_catalog.dart +++ b/lib/core/ai_catalog.dart @@ -7,7 +7,7 @@ class AiFile { final String sha256; final int size; - /// 'model' 그림 모델 · 'lora' · 'taesd' 빠른 디코더 · 'engine' 실행 파일 (zip) · 'upscale' 해상도 올리기 모델 + /// 'model' 그림 모델 · 'lora' · 'taesd' 빠른 디코더 · 'engine' 실행 파일 (zip) · 'upscale' 해상도 올리기 모델 (Real-ESRGAN) final String kind; /// 라이선스 이름 · 주소 · 받기 전에 보여 줄 조건 (있으면) @@ -81,6 +81,27 @@ const aiCatalog = [ license: 'MIT', licenseUrl: 'https://huggingface.co/madebyollin/taesd', ), + // 121: 해상도 올리기 (Real-ESRGAN, BSD-3 - 상업 사용 가능). stable-diffusion.cpp 가 읽는 RRDBNet 형식만 + AiFile( + id: 'esrgan-anime6b', + name: 'Real-ESRGAN x4plus anime 6B (만화 · 그림, 가볍고 빠름)', + url: 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.2.4/RealESRGAN_x4plus_anime_6B.pth', + sha256: 'f872d837d3c90ed2e05227bed711af5671a6fd1c9f7d7e91c911a61f155e99da', + size: 17938799, + kind: 'upscale', + license: 'BSD-3-Clause', + licenseUrl: 'https://github.com/xinntao/Real-ESRGAN/blob/master/LICENSE', + ), + AiFile( + id: 'esrgan-x4plus', + name: 'Real-ESRGAN x4plus (사진)', + url: 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth', + sha256: '4fa0d38905f75ac06eb49a7951b426670021be3018265fd191d2125df9d682f1', + size: 67040989, + kind: 'upscale', + license: 'BSD-3-Clause', + licenseUrl: 'https://github.com/xinntao/Real-ESRGAN/blob/master/LICENSE', + ), AiFile( id: 'engine-vulkan', name: 'stable-diffusion.cpp (Windows · CPU · Vulkan)', @@ -129,3 +150,10 @@ List<(String, String)> aiLicenseTexts() => [ for (final f in aiCatalog) if (f.kind != 'engine') (f.name, '${f.license} - ${f.licenseUrl}${f.terms == null ? '' : '\n\n${f.terms}'}'), ]; + +/// 121: 해상도 올리기 모델 (설정 'auto' · 'photo' · 'anime'). 자동은 폰이면 가볍고 빠른 만화용, PC 면 사진용 (x4plus) +String upscaleModelFor(String setting, {required bool phone}) => switch (setting) { + 'photo' => 'esrgan-x4plus', + 'anime' => 'esrgan-anime6b', + _ => phone ? 'esrgan-anime6b' : 'esrgan-x4plus', + }; diff --git a/lib/core/reader_sources.dart b/lib/core/reader_sources.dart index e396117..eaa53ab 100644 --- a/lib/core/reader_sources.dart +++ b/lib/core/reader_sources.dart @@ -70,7 +70,10 @@ class ZipImagesSource extends ReaderSource { final List entries; @override final String title; - ZipImagesSource._(this.archive, this._input, this.entries, this.title); + + /// ZIP 파일 (121: 해상도를 올린 그림은 ZIP 을 바꾸지 않고 이 옆 폴더에 저장) + final String path; + ZipImagesSource._(this.archive, this._input, this.entries, this.title, this.path); /// [imageExts] 그림으로 볼 확장자 static Future open(String path, {required String tempDir, required List imageExts}) async { @@ -81,7 +84,7 @@ class ZipImagesSource extends ReaderSource { for (final f in archive.files) if (f.isFile && imageExts.contains(extOf(f.name)) && !f.name.startsWith('__MACOSX/')) f, ]..sort((a, b) => naturalCompare(a.name, b.name)); - return ZipImagesSource._(archive, input, images, vBasename(path)); + return ZipImagesSource._(archive, input, images, vBasename(path), path); } @override diff --git a/lib/core/sd_cli.dart b/lib/core/sd_cli.dart index a21bc84..0de244b 100644 --- a/lib/core/sd_cli.dart +++ b/lib/core/sd_cli.dart @@ -138,7 +138,7 @@ class SdProgress { }; } -final _bar = RegExp(r'\|[=#> ]*\|\s*(\d+)/(\d+)\s*-\s*([\d.]+)\s*(s/it|it/s|MB/s)'); +final _bar = RegExp(r'\|[=#> ]*\|\s*(\d+)/(\d+)\s*-\s*([\d.]+)\s*(s/it|it/s|[KMG]?B/s)'); /// sd-cli 출력 (줄바꿈 · \r 로 나뉜 조각) 에서 진행을 읽는다. 진행이 아니면 null SdProgress? parseSdProgress(String text) { @@ -149,7 +149,7 @@ SdProgress? parseSdProgress(String text) { if (m == null) return null; final a = int.parse(m.group(1)!), b = int.parse(m.group(2)!); final unit = m.group(4)!; - if (unit == 'MB/s') return SdProgress('load', a, b); + if (unit.endsWith('B/s')) return SdProgress('load', a, b); // 모델 읽기 (KB/s · MB/s · GB/s) final v = double.tryParse(m.group(3)!); final spi = v == null || v == 0 ? null : (unit == 's/it' ? v : 1 / v); return SdProgress('sample', a, b, secondsPerStep: spi); diff --git a/lib/services/image_ai.dart b/lib/services/image_ai.dart index 2f7dd28..6e9d268 100644 --- a/lib/services/image_ai.dart +++ b/lib/services/image_ai.dart @@ -19,7 +19,13 @@ class GeneratedImage { class ImageAiException implements Exception { final String message; - const ImageAiException(this.message); + + /// 엔진 원문 (화면에는 [자세히] 를 눌러야 보인다 - 150) + final String detail; + + /// 사용자가 취소함 (오류가 아님) + final bool cancelled; + const ImageAiException(this.message, {this.detail = '', this.cancelled = false}); @override String toString() => message; } diff --git a/lib/ui/ai_image_page.dart b/lib/ui/ai_image_page.dart index beba1d3..ce05831 100644 --- a/lib/ui/ai_image_page.dart +++ b/lib/ui/ai_image_page.dart @@ -13,6 +13,7 @@ import '../l10n/tr.dart'; import '../services/image_ai.dart'; import 'ai_settings.dart'; import 'app_actions.dart'; +import 'browser_page.dart' show browserRouteObserver; import 'reader_page.dart'; import 'theme.dart'; @@ -54,7 +55,7 @@ String aiSaveDir(String setting) { return p.join(home, 'Pictures', 'JJ_MKVMaker_AI'); } -class _AiImagePageState extends State { +class _AiImagePageState extends State with RouteAware { AppController get c => widget.c; final _prompt = TextEditingController(); late final _negative = TextEditingController(text: c.settings.aiNegative); @@ -62,12 +63,29 @@ class _AiImagePageState extends State { late String? _init = widget.initImage; final _results = []; String? _error; + + /// 150: 엔진 원문 ([자세히] 를 눌러야 보임) · 취소 (회색 한 줄, 오류 아님) + String _detail = ''; + bool _showDetail = false; + bool _cancelled = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final route = ModalRoute.of(context); + if (route != null) browserRouteObserver.subscribe(this, route); + } + + /// 152: 다른 화면으로 가면 글 칸의 포커스를 풀어 둔다 (돌아올 때 자판이 저절로 올라와 결과를 가리지 않게) + @override + void didPushNext() => FocusManager.instance.primaryFocus?.unfocus(); String _step = ''; AiStore get store => AiStore.instance ??= AiStore(Directory.systemTemp.path); @override void dispose() { + browserRouteObserver.unsubscribe(this); _prompt.dispose(); _negative.dispose(); _seed.dispose(); @@ -106,6 +124,9 @@ class _AiImagePageState extends State { ); setState(() { _error = null; + _detail = ''; + _showDetail = false; + _cancelled = false; _step = ''; }); try { @@ -120,6 +141,14 @@ class _AiImagePageState extends State { final made = await AiJobs.instance.run(engine, req, outDir: aiSaveDir(s.aiSaveDir)); if (!mounted) return; setState(() => _results.insertAll(0, made)); + } on ImageAiException catch (e) { + if (mounted) { + setState(() { + _cancelled = e.cancelled; + _error = e.message; + _detail = e.detail; + }); + } } catch (e) { if (mounted) setState(() => _error = '$e'); } @@ -229,7 +258,15 @@ class _AiImagePageState extends State { if (_error != null) Padding( padding: const EdgeInsets.only(top: 8), - child: Text(_error!, style: const TextStyle(color: Colors.redAccent)), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(_error!, style: TextStyle(color: _cancelled ? JjColors.textDim : Colors.redAccent)), + if (_detail.isNotEmpty) + TextButton( + onPressed: () => setState(() => _showDetail = !_showDetail), + child: Text(_showDetail ? tr('자세히 닫기') : tr('자세히')), + ), + if (_showDetail) SelectableText(_detail, style: const TextStyle(fontSize: 11, color: JjColors.textDim)), + ]), ), const SizedBox(height: 12), if (_results.isNotEmpty) Text(trf('만든 그림 · 저장: {0}', [aiSaveDir(s.aiSaveDir)]), style: const TextStyle(fontSize: 12)), @@ -296,10 +333,30 @@ class _AiImagePageState extends State { ], ]); - Widget _thumb(GeneratedImage g) => Column(mainAxisSize: MainAxisSize.min, children: [ + Widget _thumb(GeneratedImage g) { + // 153: 갤러리 등에서 지웠으면 빈칸 대신 알리고 목록에서 뺄 수 있게 + if (!File(g.path).existsSync()) { + return SizedBox( + width: 160, + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Container( + width: 160, + height: 160, + color: JjColors.panel, + alignment: Alignment.center, + padding: const EdgeInsets.all(8), + child: Text(tr('파일이 없습니다 (지워졌거나 옮겨짐)'), + textAlign: TextAlign.center, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + ), + TextButton(onPressed: () => setState(() => _results.remove(g)), child: Text(tr('목록에서 빼기'))), + ]), + ); + } + final shown = [for (final r in _results) if (File(r.path).existsSync()) r]; + return Column(mainAxisSize: MainAxisSize.min, children: [ InkWell( - onTap: () => openReader(context, c, ImageFilesSource([for (final r in _results) r.path], tempDir: Directory.systemTemp.path, title: tr('AI 그림')), - start: _results.indexOf(g)), + onTap: () => openReader(context, c, ImageFilesSource([for (final r in shown) r.path], tempDir: Directory.systemTemp.path, title: tr('AI 그림')), + start: shown.indexOf(g)), child: Image.file(File(g.path), width: 160, height: 160, fit: BoxFit.cover), ), Row(mainAxisSize: MainAxisSize.min, children: [ @@ -318,6 +375,7 @@ class _AiImagePageState extends State { ), ]), ]); + } Widget _drop(String label, T value, List items, void Function(T) on) => Row(mainAxisSize: MainAxisSize.min, children: [ Text(label, style: const TextStyle(fontSize: 13)), diff --git a/lib/ui/ai_settings.dart b/lib/ui/ai_settings.dart index 4f06d50..67b86d9 100644 --- a/lib/ui/ai_settings.dart +++ b/lib/ui/ai_settings.dart @@ -125,7 +125,7 @@ class _AiSettingsState extends State { @override Widget build(BuildContext context) { return ListenableBuilder( - listenable: Listenable.merge([c, store]), + listenable: Listenable.merge([c, store, AiJobs.instance]), builder: (context, _) { final s = c.settings; final missing = aiMissingFiles(store); @@ -208,6 +208,85 @@ class _AiSettingsState extends State { ]), ), const Divider(), + // 121: 해상도 올리기 (보기 화면 위쪽 막대의 [AI 해상도 올리기]) + SettingTile( + leading: const Icon(Icons.hd_outlined), + title: Text(tr('해상도 올리기 모델')), + subtitle: Text(tr('자동: 그래픽카드 (GPU) 가 있으면 사진용, CPU 뿐이면 가볍고 빠른 만화용. 원본은 덮어쓰지 않고 새 파일로 저장합니다')), + trailing: DropdownButton( + value: s.aiUpModel, + items: [ + DropdownMenuItem(value: 'auto', child: Text(tr('자동'))), + DropdownMenuItem(value: 'photo', child: Text(tr('사진'))), + DropdownMenuItem(value: 'anime', child: Text(tr('만화 · 그림'))), + ], + onChanged: (v) => c.updateSettings((x) => x.aiUpModel = v ?? 'auto'), + ), + ), + SettingTile( + leading: const Icon(Icons.zoom_out_map), + title: Text(tr('배율')), + trailing: DropdownButton( + value: s.aiUpScale, + items: [for (final n in const [2, 3, 4]) DropdownMenuItem(value: n, child: Text(trf('{0}배', [n])))], + onChanged: (v) => c.updateSettings((x) => x.aiUpScale = v ?? 2), + ), + ), + SettingTile( + leading: const Icon(Icons.image_outlined), + title: Text(tr('저장 형식')), + subtitle: s.aiUpFormat == 'jpg' ? Text(trf('JPG 품질 {0}', [s.aiUpJpgQuality])) : Text(tr('WebP 등은 PNG 로 저장합니다')), + trailing: DropdownButton( + value: s.aiUpFormat, + items: [ + DropdownMenuItem(value: 'same', child: Text(tr('원본과 같은 형식'))), + const DropdownMenuItem(value: 'png', child: Text('PNG')), + const DropdownMenuItem(value: 'jpg', child: Text('JPG')), + ], + onChanged: (v) => c.updateSettings((x) => x.aiUpFormat = v ?? 'same'), + ), + ), + if (s.aiUpFormat == 'jpg') + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Slider( + value: s.aiUpJpgQuality.toDouble(), + min: 50, + max: 100, + divisions: 50, + label: '${s.aiUpJpgQuality}', + onChanged: (v) => c.updateSettings((x) => x.aiUpJpgQuality = v.round()), + ), + ), + SettingTile( + leading: const Icon(Icons.drive_file_move_outline), + title: Text(tr('저장 위치')), + subtitle: Text(s.aiUpDir.isEmpty ? tr('원본 옆에 "이름_x2" (ZIP 은 ZIP 옆 "ZIP이름_AI" 폴더)') : s.aiUpDir), + trailing: Wrap(spacing: 6, children: [ + OutlinedButton( + onPressed: () async { + final d = await pickFolder(context, tr('해상도를 올린 그림 저장 폴더'), s.aiUpDir.isEmpty ? null : s.aiUpDir); + if (d != null) await c.updateSettings((x) => x.aiUpDir = d); + }, + child: Text(tr('폴더 고르기')), + ), + if (s.aiUpDir.isNotEmpty) + TextButton(onPressed: () => c.updateSettings((x) => x.aiUpDir = ''), child: Text(tr('원본 옆으로'))), + ]), + ), + SettingTile( + leading: const Icon(Icons.visibility_outlined), + title: Text(tr('저장한 뒤')), + trailing: DropdownButton( + value: s.aiUpAfter, + items: [ + DropdownMenuItem(value: 'upscaled', child: Text(tr('올린 것으로 계속 보기'))), + DropdownMenuItem(value: 'original', child: Text(tr('원본으로'))), + ], + onChanged: (v) => c.updateSettings((x) => x.aiUpAfter = v ?? 'upscaled'), + ), + ), + const Divider(), SettingTile( leading: const Icon(Icons.public), title: Text(tr('서버 · 서비스 (기기 밖)')), @@ -280,6 +359,8 @@ class _AiSettingsState extends State { AiStore.sizeText(f.size), f.license, if (installed) tr('받음'), + // 받을 때 SHA-256 을 공식 값과 맞춰 보았다 + if (installed && store.isVerified(f)) tr('확인됨'), if (waiting) tr('차례 기다림'), ?store.failures[f.id], ].join(' · ')), @@ -287,7 +368,10 @@ class _AiSettingsState extends State { ? TextButton(onPressed: () => store.cancel(f.id), child: Text(tr('취소'))) : installed ? TextButton( - onPressed: () async { + // 151: 그리는 · 올리는 중에 쓰는 파일은 지울 수 없게 + onPressed: AiJobs.instance.busy && AiJobs.instance.inUse.contains(f.id) + ? null + : () async { final ok = await confirmAction(context, title: trf('{0} 을(를) 지울까요?', [tr(f.name)]), body: tr('다시 쓰려면 다시 받아야 합니다.'), ok: tr('지우기')); if (ok) { @@ -296,7 +380,13 @@ class _AiSettingsState extends State { } }, child: Text(tr('지우기'))) - : OutlinedButton(onPressed: () => _install([f]), child: Text(partial ? tr('이어 받기') : tr('받기'))), + : OutlinedButton( + onPressed: () => _install([f]), + child: Text(store.failures[f.id]?.contains(tr('파일이 깨졌습니다')) == true + ? tr('다시 받기') + : partial + ? tr('이어 받기') + : tr('받기'))), ), ); } diff --git a/lib/ui/ai_upscale_ui.dart b/lib/ui/ai_upscale_ui.dart new file mode 100644 index 0000000..9e010ba --- /dev/null +++ b/lib/ui/ai_upscale_ui.dart @@ -0,0 +1,233 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; + +import '../app/ai_local.dart'; +import '../app/ai_upscale.dart'; +import '../app/app_controller.dart'; +import '../core/reader_sources.dart'; +import '../core/vfs.dart'; +import '../l10n/tr.dart'; +import 'ai_image_page.dart' show aiSaveDir; +import 'ai_settings.dart'; +import 'confirm.dart'; +import 'theme.dart'; + +UpscaleOptions upscaleOptionsOf(AppController c, {String? outDir}) { + final s = c.settings; + return UpscaleOptions( + model: s.aiUpModel, + scale: s.aiUpScale, + format: s.aiUpFormat, + jpgQuality: s.aiUpJpgQuality, + outDir: outDir ?? s.aiUpDir, + ); +} + +/// 보기 화면의 i 번째 장을 이 기기의 파일로 (WebDAV 는 받아 두고, ZIP 은 꺼내 임시 파일로) 과 +/// 저장할 이름 기준 (ZIP 은 ZIP 옆 "ZIP이름_AI" 폴더, WebDAV 는 AI 그림 저장 폴더 - 원본 · ZIP 은 바꾸지 않음) +Future<(String input, String nameFrom, String outDir)?> readerPageFile(AppController c, ReaderSource src, int i) async { + final s = c.settings; + if (src is ImageFilesSource) { + final path = src.paths[i]; + if (!isDav(path)) return (path, path, s.aiUpDir); + final local = await vLocalCopy(path, src.tempDir); + return (local, p.join(aiSaveDir(s.aiSaveDir), vBasename(path)), s.aiUpDir.isEmpty ? aiSaveDir(s.aiSaveDir) : s.aiUpDir); + } + if (src is ZipImagesSource) { + final e = src.entries[i]; + final tmp = await Directory.systemTemp.createTemp('jj_zip_up_'); + final name = p.basename(e.name); + final f = File(p.join(tmp.path, name))..writeAsBytesSync(e.readBytes() ?? const []); + final beside = isDav(src.path) + ? aiSaveDir(s.aiSaveDir) + : p.join(p.dirname(src.path), '${p.basenameWithoutExtension(src.path)}_AI'); + final out = s.aiUpDir.isEmpty ? beside : s.aiUpDir; + return (f.path, p.join(out, name), out); + } + return null; // PDF 등은 아직 +} + +/// 준비: 모델 · 엔진이 없으면 받기 화면으로 (null) +Future _prepare(BuildContext context, AppController c) async { + final store = AiStore.instance ??= AiStore(Directory.systemTemp.path); + final up = await prepareUpscaler(store, modelSetting: c.settings.aiUpModel, device: c.settings.aiDevice, autoDevice: c.settings.aiAutoDevice); + if (up == null && context.mounted) { + ScaffoldMessenger.maybeOf(context) + ?.showSnackBar(SnackBar(content: Text(tr('해상도 올리기 모델을 받아 주세요 (환경 설정 > AI 그림)')))); + await openAiModels(context, c); + } + return up; +} + +/// 121: 한 장 올리기 → 진행 (취소) → [원본 / 올린 것] 비교 → [저장] (새 파일, 원본을 덮지 않음). 저장한 경로 (안 하면 null) +Future upscaleOne(BuildContext context, AppController c, {required String input, required String nameFrom, required String outDir}) async { + final up = await _prepare(context, c); + if (up == null || !context.mounted) return null; + final progress = ValueNotifier(null); + var cancelled = false; + final nav = Navigator.of(context); + unawaited(showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => AlertDialog( + title: Text(tr('AI 해상도 올리기')), + content: SizedBox( + width: 420, + child: ValueListenableBuilder( + valueListenable: progress, + builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(trf('{0} · {1}배 · {2}', [p.basename(nameFrom), c.settings.aiUpScale, up.device.label])), + const SizedBox(height: 10), + LinearProgressIndicator(value: v), + const SizedBox(height: 4), + Text(v == null ? tr('준비하는 중') : '${(v * 100).round()}%', style: const TextStyle(fontSize: 12)), + ]), + ), + ), + actions: [ + TextButton( + onPressed: () { + cancelled = true; + up.cancel(); + }, + child: Text(tr('취소')), + ), + ], + ), + )); + String? four; + Object? error; + try { + four = await up.upscale4x(input, onProgress: (v) => progress.value = v); + } catch (e) { + error = e; + } + nav.pop(); // 진행 창 + if (!context.mounted) return null; + if (four == null) { + if (!cancelled) ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text('$error'))); + return null; + } + try { + final save = await Navigator.of(context).push(MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => _CompareView(original: input, upscaled: four!, title: p.basename(nameFrom)), + )); + if (save != true || !context.mounted) return null; + final saved = await saveUpscaled(four, source: nameFrom, original: input, o: upscaleOptionsOf(c, outDir: outDir)); + if (context.mounted) { + ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(trf('저장했습니다: {0}', [saved])))); + } + return saved; + } catch (e) { + if (context.mounted) ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(trf('저장하지 못했습니다: {0}', [e])))); + return null; + } finally { + try { + await Directory(p.dirname(four)).delete(recursive: true); + } catch (_) {} + } +} + +/// 121: 폴더 · ZIP 의 모든 장을 올려 저장 (백그라운드 - 작업 알림 · 작업 현황에 남은 장 · 예상 시간) +Future upscaleAll(BuildContext context, AppController c, ReaderSource src) async { + final n = src.length; + final ok = await confirmAction( + context, + title: trf('{0}장 모두 해상도 올리기', [n]), + body: trf('{0}배로 올려 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.', [c.settings.aiUpScale]), + 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; + final done = await AiJobs.instance.runTask(tr('AI 해상도 올리기'), n, (i, progress) async { + final file = await readerPageFile(c, src, i); + if (file == null) return; + try { + final saved = await up.upscaleAndSave(file.$1, upscaleOptionsOf(c, outDir: file.$3), nameFrom: file.$2, onProgress: progress); + lastDir = p.dirname(saved); + } catch (e) { + failed.add('${src.pageName(i)}: $e'); + } + }, onCancel: up.cancel, uses: {'esrgan-x4plus', 'esrgan-anime6b', 'engine-vulkan', 'engine-cuda', 'engine-cudart'}); + messenger?.showSnackBar(SnackBar( + duration: const Duration(seconds: 8), + content: Text([ + trf('{0}장을 올려 저장했습니다{1}', [done - failed.length, lastDir == null ? '' : ': $lastDir']), + if (failed.isNotEmpty) trf('{0}장은 실패했습니다', [failed.length]), + ].join(' · ')), + )); +} + +/// 원본 / 올린 것을 바꿔 보며 비교 (확대해서 볼 수 있음) +class _CompareView extends StatefulWidget { + const _CompareView({required this.original, required this.upscaled, required this.title}); + final String original; + final String upscaled; + final String title; + + @override + State<_CompareView> createState() => _CompareViewState(); +} + +class _CompareViewState extends State<_CompareView> { + bool _up = true; + final _zoom = TransformationController(); + + @override + void dispose() { + _zoom.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + leading: IconButton(tooltip: tr('닫기'), icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context, false)), + title: Text(widget.title, maxLines: 1, overflow: TextOverflow.ellipsis), + actions: [ + SegmentedButton( + segments: [ + ButtonSegment(value: false, label: Text(tr('원본'))), + ButtonSegment(value: true, label: Text(tr('올린 것'))), + ], + selected: {_up}, + onSelectionChanged: (v) => setState(() => _up = v.first), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.save_outlined, size: 18), + label: Text(tr('저장')), + ), + const SizedBox(width: 8), + ], + ), + body: InteractiveViewer( + transformationController: _zoom, + maxScale: 8, + child: Center( + // 같은 크기로 보여 확대해 비교한다 (원본은 부드럽게 늘리지 않고 픽셀 그대로) + child: Image.file(File(_up ? widget.upscaled : widget.original), + key: ValueKey(_up), fit: BoxFit.contain, filterQuality: _up ? FilterQuality.medium : FilterQuality.none, + width: double.infinity, height: double.infinity), + ), + ), + bottomNavigationBar: Container( + color: JjColors.panel, + padding: const EdgeInsets.all(8), + child: Text(_up ? tr('올린 것 (AI)') : tr('원본'), textAlign: TextAlign.center), + ), + ); + } +} diff --git a/lib/ui/reader_page.dart b/lib/ui/reader_page.dart index af4a52a..c3bb639 100644 --- a/lib/ui/reader_page.dart +++ b/lib/ui/reader_page.dart @@ -10,6 +10,7 @@ import '../core/reader_sources.dart'; import '../core/vfs.dart'; import '../l10n/tr.dart'; import 'ai_image_page.dart'; +import 'ai_upscale_ui.dart'; import 'theme.dart'; /// 그림 · 만화 · PDF 보기를 연다. [start] 처음 볼 장. 닫으면 [source] 를 정리한다. @@ -531,10 +532,6 @@ class _ReaderPageState extends State { one(_Act(const Icon(Icons.rotate_left), tr('반대로 90° 회전'), () => _rotate(3), on: _turns != 0)), one(_Act(const RotatedBox(quarterTurns: 1, child: Icon(Icons.flip)), tr('상하 반전'), () => setState(() => _flipV = !_flipV), on: _flipV)), - // 123: 이 그림을 바탕으로 AI 그림 (그림 → 그림, 원본은 그대로) - if (_aiSource case final path?) - one(_Act(const Icon(Icons.auto_awesome_outlined), tr('AI 그림으로 (이 그림을 바탕으로)'), - () => AiImagePage.open(Navigator.of(context), c: c, initImage: path))), one(_Act(const Icon(Icons.fit_screen_outlined), tr('한 쪽 맞추기'), () => _updateSettings((x) => x.readerFit = 'page'), on: s.readerFit == 'page')), one(_Act(const Icon(Icons.width_full_outlined), tr('좌우 맞추기 (세로로 밀어 봄)'), @@ -552,9 +549,37 @@ class _ReaderPageState extends State { await _updateSettings((x) => x.readerSystemBars = !x.readerSystemBars); _applySystemBars(); }, on: s.readerSystemBars)), + // AI 는 막대가 좁으면 먼저 ⋮ 로 (늘 쓰는 버튼이 막대에 남게) + // 121: AI 해상도 올리기 (이 장 · 모든 장) - 새 파일로 저장, 원본 · ZIP 은 그대로 + if (_aiOn && src is! PdfSource) ...[ + one(_Act(const Icon(Icons.hd_outlined), tr('AI 해상도 올리기 (이 장)'), _upscalePage)), + one(_Act(const Icon(Icons.burst_mode_outlined), tr('AI 해상도 올리기 (모든 장)'), () => upscaleAll(context, c, src))), + ], + // 123: 이 그림을 바탕으로 AI 그림 (그림 → 그림, 원본은 그대로) + if (_aiSource case final path?) + one(_Act(const Icon(Icons.auto_awesome_outlined), tr('AI 그림으로 (이 그림을 바탕으로)'), + () => AiImagePage.open(Navigator.of(context), c: c, initImage: path))), ]; } + bool get _aiOn => c.settings.components.contains('aiimage'); + + Future _upscalePage() async { + final page = _cur.page; + final file = await readerPageFile(c, src, page); + if (file == null || !mounted) return; + final saved = await upscaleOne(context, c, input: file.$1, nameFrom: file.$2, outDir: file.$3); + if (saved == null || !mounted) return; + // 저장한 뒤 올린 것으로 계속 보기 (설정) - 이 기기의 그림 파일 목록일 때 + final s = src; + if (c.settings.aiUpAfter == 'upscaled' && s is ImageFilesSource && !isDav(s.paths[page])) { + setState(() { + s.paths[page] = saved; + _reset(page: page); + }); + } + } + /// 지금 보는 그림 파일 (이 기기의 그림 파일일 때, AI 그림 컴포넌트가 켜져 있을 때) String? get _aiSource { final s = src; diff --git a/test/ai_image_test.dart b/test/ai_image_test.dart index 972a87a..b02441d 100644 --- a/test/ai_image_test.dart +++ b/test/ai_image_test.dart @@ -65,6 +65,8 @@ void main() { expect(parseSdProgress('[I] decode_first_stage completed, taking 2.56s')!.phase, 'decode'); expect(parseSdProgress('[I] generate_image completed in 49.03s')!.overall, 1); expect(parseSdProgress('[I] loading model from x'), isNull); + // 150: GB/s 로 읽는 줄도 진행 (원문이 오류 글에 섞이지 않게) + expect(parseSdProgress('|##### | 431/686 - 1.86GB/s')!.phase, 'load'); }); test('장치 목록 읽기', () { @@ -143,6 +145,7 @@ void main() { final good = file('m1', '/m1.safetensors', List.filled(5000, 7)); await store.installFile(good); expect(store.isFileInstalled(good), isTrue); + expect(store.isVerified(good), isTrue, reason: '받을 때 SHA-256 을 맞춰 봄 - "확인됨"'); expect(File(store.pathOf(good)).lengthSync(), 5000); final bad = file('m2', '/m2.safetensors', [1, 2, 3], sha: '0' * 64); await expectLater(store.installFile(bad), throwsA(isA())); @@ -230,6 +233,45 @@ void main() { expect(store.failures, isEmpty); }); + test('149 · 관리자: 206 인데 범위 시작이 다르거나, 맞지 않는 416 이면 조각을 버리고 처음부터', () async { + expect(contentRangeStart('bytes 120000-299999/300000'), 120000); + expect(contentRangeStart(null), isNull); + final store = AiStore(tmp.path); + final body = List.generate(40000, (i) => i * 3 % 256); + final port = server.port; + await server.close(force: true); + server = await HttpServer.bind(InternetAddress.loopbackIPv4, port); + final log = []; + var mode = 'wrong206'; + server.listen((req) async { + final range = req.headers.value(HttpHeaders.rangeHeader); + log.add('$mode ${range ?? '-'}'); + if (range != null && mode == 'wrong206') { + // 엉뚱한 범위 (처음부터) 를 206 으로 + req.response + ..statusCode = 206 + ..headers.set(HttpHeaders.contentRangeHeader, 'bytes 0-${body.length - 1}/${body.length}') + ..add(body); + } else if (range != null && mode == '416') { + req.response.statusCode = 416; + } else { + req.response.add(body); + } + await req.response.close(); + }); + for (final m in ['wrong206', '416']) { + mode = m; + final f = file('r$m', '/r$m', body); + // 받다 만 조각 (엉뚱한 내용) 을 미리 둔다 + File('${store.pathOf(f)}.part') + ..createSync(recursive: true) + ..writeAsBytesSync(List.filled(1000, 9)); + await store.installFile(f, retryWait: const Duration(milliseconds: 10)); + expect(File(store.pathOf(f)).readAsBytesSync(), body, reason: m); + } + expect(log, containsAllInOrder(['wrong206 bytes=1000-', 'wrong206 -', '416 bytes=1000-', '416 -'])); + }); + test('149: 한꺼번에 받기 - 기다리는 것은 "차례 기다림"', () async { final store = AiStore(tmp.path); final a = file('qa', '/qa', List.filled(1000, 1)), b2 = file('qb', '/qb', List.filled(1000, 2)); @@ -379,6 +421,24 @@ void main() { expect(steps, isEmpty); }); + test('150 · 151: 취소하면 "취소했습니다" (실패 아님) · 그리는 동안 쓰는 파일 표시', () async { + if (!Platform.isWindows) return markTestSkipped('Windows 전용'); + final dir = Directory.systemTemp.createTempSync('jj_ai_cancel_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final fake = File(p.join(dir.path, 'slow.cmd')) + ..writeAsStringSync('@echo off\r\necho ^|=====^> ^| 431/686 - 1.86GB/s\r\nping -n 8 127.0.0.1 >nul\r\n'); + final engine = LocalSdEngine(device: SdDevice(fake.path, 'cpu', 'CPU'), models: const SdModelPaths(model: 'm')); + final run = AiJobs.instance.run(engine, const ImageGenRequest(prompt: 'x'), outDir: dir.path); + await Future.delayed(const Duration(milliseconds: 600)); + expect(AiJobs.instance.inUse, contains('sd15')); + AiJobs.instance.cancel(); + await expectLater(run, throwsA(isA() + .having((e) => e.cancelled, 'cancelled', isTrue) + .having((e) => e.message, 'message', '취소했습니다'))); + expect(AiJobs.instance.inUse, isEmpty); + expect(AiJobs.instance.busy, isFalse); + }); + test('엔진이 실패하면 마지막 출력으로 알린다', () async { if (!Platform.isWindows) return markTestSkipped('Windows 전용'); final dir = Directory.systemTemp.createTempSync('jj_ai_fail_'); @@ -386,7 +446,9 @@ void main() { final fake = File(p.join(dir.path, 'bad.cmd'))..writeAsStringSync('@echo off\r\necho [E] out of memory\r\nexit /b 3\r\n'); final engine = LocalSdEngine(device: SdDevice(fake.path, 'cpu', 'CPU'), models: const SdModelPaths(model: 'm')); await expectLater(engine.generate(const ImageGenRequest(prompt: 'x'), outDir: dir.path, baseName: 'a'), - throwsA(isA().having((e) => e.message, 'message', contains('out of memory')))); + throwsA(isA() + .having((e) => e.message, 'message', isNot(contains('out of memory'))) + .having((e) => e.detail, 'detail', contains('out of memory')))); }); }); diff --git a/test/ai_upscale_test.dart b/test/ai_upscale_test.dart new file mode 100644 index 0000000..154873d --- /dev/null +++ b/test/ai_upscale_test.dart @@ -0,0 +1,100 @@ +import 'dart:io'; + +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_upscale.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/core/ai_catalog.dart'; +import 'package:path/path.dart' as p; + +void main() { + late Directory tmp; + setUp(() => tmp = Directory.systemTemp.createTempSync('jj_up_')); + tearDown(() => tmp.deleteSync(recursive: true)); + + test('121: 모델 고르기 - 자동은 GPU 면 사진용 · CPU 뿐이면 가볍고 빠른 만화용', () { + expect(pickUpscaleModel('auto', gpu: true), 'esrgan-x4plus'); + expect(pickUpscaleModel('auto', gpu: false), 'esrgan-anime6b'); + expect(pickUpscaleModel('photo', gpu: false), 'esrgan-x4plus'); + expect(pickUpscaleModel('anime', gpu: true), 'esrgan-anime6b'); + for (final id in ['esrgan-x4plus', 'esrgan-anime6b']) { + expect(aiFile(id).license, 'BSD-3-Clause'); + expect(aiFile(id).kind, 'upscale'); + } + }); + + test('121: 저장 형식 · 이름 - 원본 옆 "이름_x2", 있으면 덮지 않고 (2)', () { + expect(upscaleExt('a.jpg', 'same'), 'jpg'); + expect(upscaleExt('a.webp', 'same'), 'png', reason: 'WebP 는 담을 수 없어 PNG'); + expect(upscaleExt('a.png', 'jpg'), 'jpg'); + final src = p.join(tmp.path, 'page.jpg'); + File(src).writeAsStringSync('x'); + final first = upscaledPath(src, scale: 2, ext: 'png'); + expect(p.basename(first), 'page_x2.png'); + File(first).writeAsStringSync('old'); + expect(p.basename(upscaledPath(src, scale: 2, ext: 'png')), 'page_x2 (2).png'); + expect(p.dirname(upscaledPath(src, scale: 3, ext: 'jpg', outDir: p.join(tmp.path, 'out'))), p.join(tmp.path, 'out')); + expect(File(src).readAsStringSync(), 'x', reason: '원본 그대로'); + }); + + test('121: 4배 결과를 2배 · 3배로 줄여 담기 (PNG · JPG)', () async { + final four = img.Image(width: 40, height: 24); + img.fill(four, color: img.ColorRgb8(200, 30, 30)); + final bytes = img.encodePng(four); + final two = img.decodeImage(await finishUpscale(bytes, origW: 10, origH: 6, scale: 2, ext: 'png'))!; + expect((two.width, two.height), (20, 12)); + final three = await finishUpscale(bytes, origW: 10, origH: 6, scale: 3, ext: 'jpg', quality: 80); + expect(three.sublist(0, 2), [0xFF, 0xD8], reason: 'JPG'); + expect(img.decodeImage(three)!.width, 30); + final keep = img.decodeImage(await finishUpscale(bytes, origW: 10, origH: 6, scale: 4, ext: 'png'))!; + expect(keep.width, 40); + }); + + test('121: 여러 장 작업 - 진행 · 남은 장 · 취소하면 지금 장에서 멈춤', () async { + final jobs = AiJobs.instance; + final seen = []; + var cancelled = 0; + final done = await jobs.runTask('올리기', 5, (i, progress) async { + progress(0.5); + seen.add(jobs.remaining); + if (i == 2) jobs.cancel(); + }, onCancel: () => cancelled++); + expect(done, 3); + expect(seen, [5, 4, 3]); + expect(cancelled, 1); + expect(jobs.busy, isFalse); + }); + + test('121: 설정 처음 값 · 저장', () { + final s = AppSettings(); + expect((s.aiUpModel, s.aiUpScale, s.aiUpFormat, s.aiUpDir, s.aiUpAfter), ('auto', 2, 'same', '', 'upscaled')); + final b = AppSettings.fromJson((AppSettings() + ..aiUpModel = 'photo' + ..aiUpScale = 4 + ..aiUpFormat = 'jpg' + ..aiUpJpgQuality = 80 + ..aiUpAfter = 'original') + .toJson()); + expect((b.aiUpModel, b.aiUpScale, b.aiUpFormat, b.aiUpJpgQuality, b.aiUpAfter), ('photo', 4, 'jpg', 80, 'original')); + }); + + // 진짜 엔진 (JJ_TEST_SD_DIR: bin_vulkan/sd-cli.exe · models/RealESRGAN_x4plus_anime_6B.pth) + final root = Platform.environment['JJ_TEST_SD_DIR'] ?? ''; + test('121: 진짜 엔진으로 64×48 → 2배 저장 (CPU, 원본은 그대로)', () async { + final src = File(p.join(tmp.path, 'small.png')); + final im = img.Image(width: 64, height: 48); + img.fillCircle(im, x: 32, y: 24, radius: 16, color: img.ColorRgb8(10, 120, 220)); + src.writeAsBytesSync(img.encodePng(im)); + final before = src.readAsBytesSync(); + final up = AiUpscaler( + device: SdDevice(p.join(root, 'bin_vulkan', 'sd-cli.exe'), 'cpu', 'CPU'), + modelPath: p.join(root, 'models', 'RealESRGAN_x4plus_anime_6B.pth')); + final progress = []; + final saved = await up.upscaleAndSave(src.path, const UpscaleOptions(scale: 2), onProgress: progress.add); + expect(p.basename(saved), 'small_x2.png'); + final out = img.decodeImage(File(saved).readAsBytesSync())!; + expect((out.width, out.height), (128, 96)); + expect(src.readAsBytesSync(), before); + }, timeout: const Timeout(Duration(minutes: 5)), skip: root.isEmpty || !Platform.isWindows); +}