AI 해상도 올리기 (121) · AI 마무리 (150~153) · 받기 범위 확인 · 확인됨 표시

- 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 <noreply@anthropic.com>
묶음3
RyuJongJin 2026-10-09 15:31:13 +09:00
parent 92556f62ed
commit 986688eafa
16 changed files with 1023 additions and 32 deletions

View File

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

View File

@ -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) があるときは常に禁止します。オフにすると常に禁止します",
"확인됨": "確認済み",
"확장 설치": "拡張機能のインストール",
"확장자": "拡張子",
"확장자별 재생 프로그램": "拡張子ごとの再生プログラム",

View File

@ -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) 时始终阻止。关闭后始终阻止",
"확인됨": "已校验",
"확장 설치": "安装扩展",
"확장자": "扩展名",
"확장자별 재생 프로그램": "按扩展名设置播放程序",

View File

@ -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<void>();
} else if (res.statusCode == 416) {
// 조각이 서버 파일과 맞지 않음 (크기가 다름) - 조각을 버리고 처음부터 (다시 시도만 반복하다 막히지 않게)
await res.drain<void>();
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<void>();
if (out.existsSync()) out.deleteSync();
throw HttpException('unexpected range - restart');
} else if (res.statusCode != 206) {
await res.drain<void>();
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<String> 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<File>()
@ -430,7 +464,9 @@ Future<List<String>> runSdCli(String exe, List<String> 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<String> 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<int> runTask(String title, int count, Future<void> Function(int i, void Function(double? p) progress) body,
{required void Function() onCancel, Set<String> 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<List<GeneratedImage>> 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);
}

143
lib/app/ai_upscale.dart Normal file
View File

@ -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<Uint8List> 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<String> 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<String> 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<String> 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<AiUpscaler?> 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)));
}

View File

@ -474,6 +474,23 @@ class AppSettings {
/// 라이선스 조건에 동의한 받는 파일 (149-④: 한 번 동의하면 다시 묻지 않음)
List<String> 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),

View File

@ -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 = <AiFile>[
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',
};

View File

@ -70,7 +70,10 @@ class ZipImagesSource extends ReaderSource {
final List<ArchiveFile> 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<ZipImagesSource> open(String path, {required String tempDir, required List<String> 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

View File

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

View File

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

View File

@ -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<AiImagePage> {
class _AiImagePageState extends State<AiImagePage> 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<AiImagePage> {
late String? _init = widget.initImage;
final _results = <GeneratedImage>[];
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<AiImagePage> {
);
setState(() {
_error = null;
_detail = '';
_showDetail = false;
_cancelled = false;
_step = '';
});
try {
@ -120,6 +141,14 @@ class _AiImagePageState extends State<AiImagePage> {
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<AiImagePage> {
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<AiImagePage> {
],
]);
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<AiImagePage> {
),
]),
]);
}
Widget _drop<T>(String label, T value, List<T> items, void Function(T) on) => Row(mainAxisSize: MainAxisSize.min, children: [
Text(label, style: const TextStyle(fontSize: 13)),

View File

@ -125,7 +125,7 @@ class _AiSettingsState extends State<AiSettings> {
@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<AiSettings> {
]),
),
const Divider(),
// 121: 해상도 올리기 (보기 화면 위쪽 막대의 [AI 해상도 올리기])
SettingTile(
leading: const Icon(Icons.hd_outlined),
title: Text(tr('해상도 올리기 모델')),
subtitle: Text(tr('자동: 그래픽카드 (GPU) 가 있으면 사진용, CPU 뿐이면 가볍고 빠른 만화용. 원본은 덮어쓰지 않고 새 파일로 저장합니다')),
trailing: DropdownButton<String>(
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<int>(
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<String>(
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<String>(
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<AiSettings> {
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<AiSettings> {
? 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<AiSettings> {
}
},
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('받기'))),
),
);
}

233
lib/ui/ai_upscale_ui.dart Normal file
View File

@ -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<AiUpscaler?> _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<String?> 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<double?>(null);
var cancelled = false;
final nav = Navigator.of(context);
unawaited(showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: Text(tr('AI 해상도 올리기')),
content: SizedBox(
width: 420,
child: ValueListenableBuilder<double?>(
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<bool>(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<void> 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>[];
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<bool>(
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),
),
);
}
}

View File

@ -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<ReaderPage> {
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<ReaderPage> {
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<void> _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;

View File

@ -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<ImageAiException>()));
@ -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<int>.generate(40000, (i) => i * 3 % 256);
final port = server.port;
await server.close(force: true);
server = await HttpServer.bind(InternetAddress.loopbackIPv4, port);
final log = <String>[];
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<void>.delayed(const Duration(milliseconds: 600));
expect(AiJobs.instance.inUse, contains('sd15'));
AiJobs.instance.cancel();
await expectLater(run, throwsA(isA<ImageAiException>()
.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<ImageAiException>().having((e) => e.message, 'message', contains('out of memory'))));
throwsA(isA<ImageAiException>()
.having((e) => e.message, 'message', isNot(contains('out of memory')))
.having((e) => e.detail, 'detail', contains('out of memory'))));
});
});

100
test/ai_upscale_test.dart Normal file
View File

@ -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 = <int>[];
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 = <double?>[];
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);
}