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
parent
92556f62ed
commit
986688eafa
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) があるときは常に禁止します。オフにすると常に禁止します",
|
||||
"확인됨": "確認済み",
|
||||
"확장 설치": "拡張機能のインストール",
|
||||
"확장자": "拡張子",
|
||||
"확장자별 재생 프로그램": "拡張子ごとの再生プログラム",
|
||||
|
|
|
|||
|
|
@ -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) 时始终阻止。关闭后始终阻止",
|
||||
"확인됨": "已校验",
|
||||
"확장 설치": "安装扩展",
|
||||
"확장자": "扩展名",
|
||||
"확장자별 재생 프로그램": "按扩展名设置播放程序",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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('받기'))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'))));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue