diff --git a/THIRD_PARTY_NOTICES.txt b/THIRD_PARTY_NOTICES.txt index dd22c11..9cec923 100644 --- a/THIRD_PARTY_NOTICES.txt +++ b/THIRD_PARTY_NOTICES.txt @@ -48,7 +48,8 @@ JJ_MKVMaker - 포함된 오픈 소스 구성 요소 및 라이선스 고지 원문: tools\YT-DLP_LICENSE.txt - Deno (tools\deno.exe) MIT https://github.com/denoland/deno 원문: tools\DENO_LICENSE.txt -- rsync (rsync sync.exe) GPL-3.0-or-later https://rsync.samba.org +- rsync (rsync +sync.exe) GPL-3.0-or-later https://rsync.samba.org MSYS2 공식 패키지 rsync 3.5.1 과 실행에 필요한 DLL (msys2-runtime: GPL-3.0 + 예외, OpenSSL: Apache-2.0, popt · lz4 · xxhash · zstd: BSD/MIT 계열, libiconv · libintl · libidn2 · libunistring: LGPL) 소스: https://packages.msys2.org/base/rsync (각 패키지의 소스 패키지) @@ -71,8 +72,10 @@ JJ_MKVMaker - 포함된 오픈 소스 구성 요소 및 라이선스 고지 소스는 위 주소에서 받을 수 있습니다. - rsync (librsync.so 라는 이름의 실행 파일) GPL-3.0-or-later https://rsync.samba.org rsync 3.4.1 공식 소스 (https://download.samba.org/pub/rsync/src/rsync-3.4.1.tar.gz) 를 고치지 않고 - Android NDK 로 빌드 (들어 있는 zlib · popt 사용, 선택 기능 끔). 빌드 방법: app ooluild_rsync_android.sh + Android NDK 로 빌드 (들어 있는 zlib · popt 사용, 선택 기능 끔). 빌드 방법: app/tool/build_rsync_android.sh - libmpv (media_kit_libs_android_video) LGPL-2.1+ https://github.com/media-kit/libmpv-android-video-build +- stable-diffusion.cpp · ggml (libsdcli.so 라는 이름의 실행 파일) MIT https://github.com/leejet/stable-diffusion.cpp + 커밋 228c707 을 고치지 않고 Android NDK 로 빌드 (AI 그림 · 해상도 올리기). 빌드 방법: app/tool/build_sdcpp_android.sh [처음 사용할 때 내려받는 AI 모델] @@ -83,7 +86,22 @@ JJ_MKVMaker - 포함된 오픈 소스 구성 요소 및 라이선스 고지 ※ NLLB 번역 모델은 상업적 용도로 쓸 수 없습니다. 판매·유료 서비스에 쓰려면 상업 이용이 가능한 번역 모델로 바꿔야 합니다. +[AI 그림 · 해상도 올리기 - 환경 설정에서 받는 파일 (공식 배포처 · SHA-256 고정)] + +- stable-diffusion.cpp Windows 실행 파일 (Vulkan · CUDA 12 판) MIT + https://github.com/leejet/stable-diffusion.cpp/releases (master-948-228c707) +- NVIDIA CUDA 12 런타임 DLL (CUDA 판과 함께, NVIDIA 그래픽카드가 있을 때만) NVIDIA CUDA Toolkit EULA (재배포 허용 파일) +- Stable Diffusion v1.5 (v1-5-pruned-emaonly.safetensors) CreativeML OpenRAIL-M + https://huggingface.co/stable-diffusion-v1-5/stable-diffusion-v1-5 + ※ 상업적 사용을 포함해 쓸 수 있지만 라이선스 부속서 A 의 용도 제한을 따라야 합니다: 법을 어기는 일, 미성년자를 해치거나 + 이용하는 일, 남을 해치려는 거짓 정보, 개인 정보로 사람을 해치는 일, 차별 · 괴롭힘, 의료 · 법률 판단을 대신하는 일 등. + 만든 그림에 대한 책임은 쓰는 사람에게 있습니다. 받기 전에 앱에서 이 조건을 보여 주고 동의를 받습니다. +- LCM-LoRA (lcm-lora-sdv1-5) OpenRAIL++ https://huggingface.co/latent-consistency/lcm-lora-sdv1-5 +- TAESD (빠른 디코더) MIT https://huggingface.co/madebyollin/taesd + [인터넷 서비스] - OpenSubtitles.com REST API: 사용자가 발급받은 API 키로 이용하며 OpenSubtitles 이용 약관을 따릅니다. - YouTube 등에서 받은 영상, 토렌트로 받은 파일의 저작권 책임은 이용자에게 있습니다. +- AI 그림의 서버 · 서비스: 앱에는 들어 있지 않으며, 사용자가 직접 추가한 서버 (ComfyUI · Automatic1111 등) 나 서비스 + (OpenAI 이미지 API 형식) 의 약관을 따릅니다. 그때는 그림 · 프롬프트가 기기 밖으로 나갑니다. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 1ec3c6b..fbe8416 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -61,6 +61,8 @@ android { // rsync (librsync.so 라는 이름의 실행 파일): tool/build_rsync_android.sh 로 third_party/rsync/android 에 준비. // 네이티브 라이브러리 폴더에 풀려 실행할 수 있다 (앱 데이터 폴더의 파일은 Android 10+ 에서 실행 불가) sourceSets["main"].jniLibs.srcDir("../../third_party/rsync/android") + // 123 · 121: stable-diffusion.cpp 의 sd-cli (libsdcli.so 라는 이름의 실행 파일): tool/build_sdcpp_android.sh + sourceSets["main"].jniLibs.srcDir("../../third_party/sdcpp/android") // ffmpeg · whisper 등 네이티브 라이브러리를 압축하지 않고 그대로 (설치 후 바로 로드) packaging { diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 351f9c0..4268797 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -28,6 +28,7 @@ "\"{0}\" 안에 만듭니다": "Created inside \"{0}\"", "\"{0}\" 에는 없고 대상 \"{1}\" 에만 있는 항목입니다. 지우기 포함으로 맞추면 대상에서 지워집니다 (되돌릴 수 없음).": "These items are not in \"{0}\" but only in the target \"{1}\". Syncing with delete removes them from the target (cannot be undone).", "\"{0}\" 을(를) \"{1}\" 폴더로 옮길까요?": "Move \"{0}\" to the \"{1}\" folder?", + "\"{0}\" 을(를) 지울까요?": "Delete \"{0}\"?", "\"{0}\" 을(를) 지울까요? 되돌릴 수 없습니다.": "Delete \"{0}\"? This cannot be undone.", "\"{0}\" 을(를) 휴지통으로 보낼까요?": "Move \"{0}\" to the Recycle Bin?", "\"{0}\" 이 비어 있어, 맞추면 대상 \"{1}\" 의 아래 항목이 모두 지워집니다 (되돌릴 수 없음). 원본을 일부러 비운 경우에만 누르세요.": "\"{0}\" is empty, so syncing deletes all the items below from the target \"{1}\" (cannot be undone). Press only if you emptied the source on purpose.", @@ -47,6 +48,7 @@ "(지워짐)": "(deleted)", "(현재 방식은 옵션 없음)": "(no options for the built-in method)", "+ 여기에 새 줄": "+ New line here", + "-1 은 아무렇게나": "-1 = random", ".{0} 재생 프로그램 (exe)": ".{0} player (exe)", "0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = unlimited. Applies to every method: rsync --bwlimit, the built-in method throttles itself, robocopy approximates with /IPG. You can also put it in the options field (that takes priority)", "0 으로": "Reset to 0", @@ -55,7 +57,17 @@ "5초 뒤로": "Back 5 seconds", "5초 앞으로": "Forward 5 seconds", "90° 회전 (시계 방향)": "Rotate 90° (clockwise)", + "AI 그림": "AI images", + "AI 그림 ({0})": "AI images ({0})", + "AI 그림 (지금 여기)": "AI images (you are here)", + "AI 그림 · 해상도 올리기": "AI images · upscaling", + "AI 그림 저장 폴더": "Folder for AI images", + "AI 그림: {0}": "AI images: {0}", + "AI 그림으로 (이 그림을 바탕으로)": "To AI images (based on this picture)", "AI 번역": "AI translation", + "AI 설정 · 모델 받기 · 서비스 추가": "AI settings · download models · add services", + "AI 엔진이 실패했습니다 ({0}): {1}": "The AI engine failed ({0}): {1}", + "AI 엔진이 없습니다. 환경 설정 > AI 그림에서 받아 주세요.": "No AI engine. Download it in Settings > AI images.", "AI 인식": "AI recognition", "AI 자막": "AI subtitles", "AI 자막 {0}개": "AI subtitles for {0}", @@ -73,7 +85,9 @@ "AI 자막을 만듭니다": "makes AI subtitles", "AI 작업 중이라 이번에는 건너뜁니다": "Skipped this time because an AI job is running", "API 키": "API key", + "API 키 (선택)": "API key (optional)", "API 키 (필수, 무료)": "API key (required, free)", + "API 키 또는 아이디:비밀번호 (선택)": "API key or user:password (optional)", "AV1 영상은 이 기기에서 다시 인코딩할 수 없습니다. 코덱을 \"원본 유지\" 로 바꿔 MKV 를 만드세요. (PC 판은 됩니다)": "AV1 video cannot be re-encoded on this device. Change the codec to \"Keep original\" to make the MKV. (The PC version can.)", "Android 가 막은 폴더 (Android/data 등) 이거나 \"모든 파일에 대한 접근\" 권한이 없습니다.": "This folder is blocked by Android (such as Android/data), or \"All files access\" is not allowed.", "Android 는 rsync 를 내려받을 수 없습니다. 직접 지정하세요.": "rsync cannot be downloaded on Android. Specify it yourself.", @@ -107,6 +121,7 @@ "JJ_MKVMaker 로 재생": "Play with JJ_MKVMaker", "JJ_MKVMaker_즐겨찾기.html": "JJ_MKVMaker_bookmarks.html", "JavaScript 사용": "Enable JavaScript", + "LCM-LoRA (SD1.5, 4단계로 빠르게)": "LCM-LoRA (SD1.5, fast in 4 steps)", "MKV · AI 작업": "MKV · AI task", "MKV · 자막 (jj_mkv)": "MKV · subtitles (jj_mkv)", "MKV · 자막 저장 위치": "Where to save MKV · subtitles", @@ -136,6 +151,8 @@ "NAS · 클라우드의 WebDAV 폴더를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 (위쪽 \"SD 카드\" 옆 탭). 비밀번호는 이 기기의 안전 저장소에만 두고 설정 파일에는 쓰지 않습니다. 한 번 넣으면 업데이트 뒤에도 남습니다.": "Open, copy and sync WebDAV folders on a NAS or cloud in the file explorer and the Rsync screen (tab next to \"SD card\" at the top). The password is kept only in this device's secure storage, never in the settings file, and stays after updates.", "NLLB 언어 코드가 아닙니다: {0}": "Not an NLLB language code: {0}", "NLLB-200 번역 (600M)": "NLLB-200 translation (600M)", + "NVIDIA CUDA 12 런타임 (CUDA 판과 함께)": "NVIDIA CUDA 12 runtime (with the CUDA build)", + "OpenAI 이미지 API 형식": "OpenAI image API format", "OpenSubtitles API 키 또는 로그인 정보가 올바르지 않습니다. ({0})": "OpenSubtitles API key or sign-in details are incorrect. ({0})", "OpenSubtitles 무료 API 키가 필요합니다.\n1. opensubtitles.com 가입 (무료)\n2. 프로필 → API consumers → New consumer 에서 키 만들기\n3. 아래 칸 또는 환경 설정에 키 붙여넣기": "A free OpenSubtitles API key is required.\n1. Sign up at opensubtitles.com (free)\n2. Profile → API consumers → New consumer to create a key\n3. Paste the key in the box below or in Settings", "OpenSubtitles 오류 {0}: {1}": "OpenSubtitles error {0}: {1}", @@ -152,6 +169,9 @@ "SD 카드": "SD card", "SD 카드가 꽂혀 있는지, 네트워크 (NAS · VPN) 가 연결되어 있는지, 폴더 권한이 있는지 확인한 뒤 [다시 시도] 를 누르세요.": "Check that the SD card is inserted, the network (NAS · VPN) is connected and you have permission for the folder, then press [Try again].", "SRT 형식이 아니거나 비어 있습니다.": "Not in SRT format or empty.", + "Stable Diffusion v1.5": "Stable Diffusion v1.5", + "Stable Diffusion v1.5 는 CreativeML OpenRAIL-M 라이선스입니다. 상업적 사용을 포함해 쓸 수 있지만, 다음 용도로는 쓸 수 없습니다: 법을 어기는 일, 미성년자를 해치거나 이용하는 일, 남을 해치려는 거짓 정보, 개인 정보로 사람을 해치는 일, 차별 · 괴롭힘, 의료 · 법률 판단을 대신하는 일 등 (라이선스 부속서 A). 만든 그림에 대한 책임은 쓰는 사람에게 있습니다.": "Stable Diffusion v1.5 is licensed under CreativeML OpenRAIL-M. You may use it, including commercially, but not for: breaking the law, harming or exploiting minors, false information meant to harm others, harming people with personal information, discrimination or harassment, replacing medical or legal judgment, etc. (license Attachment A). You are responsible for the images you make.", + "TAESD (빠른 디코더)": "TAESD (fast decoder)", "VLC 가 설치되어 있지 않습니다. 설치하면 목록에 나타납니다 (videolan.org).": "VLC is not installed. Once installed it appears in the list (videolan.org).", "VLC 로 열기": "Open with VLC", "WebDAV · OpenSubtitles 등. 서버 목록과 아이디는 남깁니다": "WebDAV, OpenSubtitles, etc. Server list and user names are kept", @@ -226,6 +246,8 @@ "rsync 양쪽 (⇄)": "rsync both ways (⇄)", "rsync 에 고르기": "Pick for rsync", "rsync 옵션": "rsync options", + "stable-diffusion.cpp (Windows · CPU · Vulkan)": "stable-diffusion.cpp (Windows · CPU · Vulkan)", + "stable-diffusion.cpp (Windows · NVIDIA CUDA 12)": "stable-diffusion.cpp (Windows · NVIDIA CUDA 12)", "v{0} 받는 중": "Downloading v{0}", "v{0} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n{1}": "Downloaded v{0} and verified the file (SHA256).\n{1}", "yt-dlp 를 새 버전으로 바꿨습니다": "Updated yt-dlp to a new version", @@ -238,6 +260,7 @@ "{0} / {1}장": "{0} / {1} images", "{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2} (change at the top of the main screen)", "{0} → {1} 번역 {2}%": "{0} → {1} translation {2}%", + "{0} 라이선스": "{0} license", "{0} 로 번역 중 {1}%": "Translating to {0} {1}%", "{0} 로 표현할 수 없는 글자 {1}개가 \"?\" 로 바뀝니다.\n계속 저장할까요? (UTF-8 을 권장합니다)": "{1} characters that cannot be represented in {0} will become \"?\".\nSave anyway? (UTF-8 is recommended)", "{0} 번역 중": "Translating {0}", @@ -248,10 +271,13 @@ "{0} 순으로 정렬{1}": "Sort by {0}{1}", "{0} 아래에 만듭니다": "Created under {0}", "{0} 안의 파일": "Files in {0}", + "{0} 에 보내는 중": "Sending to {0}", "{0} 에 붙여넣기 ({1}개)": "Paste into {0} ({1})", + "{0} 에서 그리는 중 ({1}초)": "Drawing on {0} ({1} s)", "{0} 에서 받을 파일을 찾지 못했습니다": "Could not find a file to download from {0}", "{0} 외 {1}개": "{0} and {1} more", "{0} 을(를) 설치했습니다.": "Installed {0}.", + "{0} 을(를) 지울까요?": "Delete {0}?", "{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg does not have the {0} encoder.", "{0} 전체": "All of {0}", "{0} 제거": "Remove {0}", @@ -281,6 +307,7 @@ "{0}개 항목을 휴지통으로 보냈습니다.": "Moved {0} items to the Recycle Bin.", "{0}개는 지우지 않았습니다 (취소).": "{0} were not deleted (cancelled).", "{0}개는 휴지통보다 커서 영구 삭제했습니다 (Windows 가 물은 대로).": "{0} were too large for the Recycle Bin and were deleted permanently (as Windows asked).", + "{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).": "{0} didn't fit in the Recycle Bin and were deleted permanently (as chosen when Windows asked).", "{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "Imported {0} into \"Other bookmarks\".", "{0}개를 옮겼습니다 → {1}": "Moved {0} → {1}", "{0}개를 옮겼습니다 → {1} ({2})": "Moved {0} → {1} ({2})", @@ -313,16 +340,19 @@ "가로 고정": "Landscape only", "가로 고정 · 세로 고정, 또는 자동 (기기를 돌리는 대로, 기기의 자동 회전 설정을 따름)": "Lock landscape · lock portrait, or auto (follows how you hold the device and its auto-rotate setting)", "가입 → 프로필 → API consumers → New consumer": "Sign up → Profile → API consumers → New consumer", + "가장 빠른 처리 장치를 찾는 중 (처음 한 번): {0}": "Finding the fastest processor (first time only): {0}", "가져오기": "Import", "가져온 즐겨찾기": "Imported bookmarks", "가져온 즐겨찾기 ({0})": "Imported bookmarks ({0})", "가져올 즐겨찾기가 없습니다.": "No bookmarks to import.", "같은 시리즈 (번호만 다른 파일)": "Same series (files that differ only by number)", + "같은 조건으로 다시 (시드 그대로)": "Again with the same settings (same seed)", "같은 폴더의 모든 동영상": "All videos in the same folder", "같은 폴더의 자막은 자동으로 추가됩니다": "Subtitles in the same folder are added automatically", "같은 폴더이거나 한쪽이 다른 쪽 안에 있습니다. 다른 폴더를 고르세요.": "Same folder, or one is inside the other. Choose different folders.", "같은 항목을 같은 곳으로 복사 · 이동하는 중입니다.": "The same items are already being copied/moved to the same place.", "같은 화질이면 H.264 먼저 받기": "Prefer H.264 at the same quality", + "개수": "Count", "거꾸로": "Reverse", "건너뜀 (출력 폴더의 파일): {0}": "Skipped (file in the output folder): {0}", "검색": "Search", @@ -364,18 +394,31 @@ "권한이 없습니다": "No permission", "그 파일 하나만": "Only that file", "그래도 맞추기 ({0}개 지움)": "Sync anyway (delete {0})", + "그리는 중 {0}/{1}": "Drawing {0}/{1}", + "그리려면 {0} 를 받아야 합니다 (모델은 받은 뒤 이 기기에만 있고, 그림 · 프롬프트는 밖으로 나가지 않습니다).": "To draw, {0} must be downloaded (the model stays on this device, and images and prompts never leave it).", "그리스어": "Greek", + "그릴 것 (프롬프트, 영어가 잘 됩니다)": "What to draw (prompt; English works best)", "그림 {0}장으로 PDF 를 만들었습니다: {1}": "Made a PDF from {0} images: {1}", "그림 {0}장을 PDF 로 만들기": "Make PDF from {0} images", "그림 {0}장을 {1} 페이지 뒤에 넣었습니다 (저장해야 파일에 반영됩니다).": "Added {0} images after page {1} (saved to the file when you save).", "그림 · 만화 보기, PDF 보기 · 페이지 편집, ZIP 목록 · 만화 보기": "Images · comic reading, PDF view · page editing, ZIP list · comic reading", + "그림 · 프롬프트가 기기 밖으로 나갑니다 ({0})": "Images and prompts leave this device ({0})", "그림 보기 대상": "Image viewer file types", + "그림으로 바꾸는 중": "Turning into an image", + "그림을 만들지 못했습니다: {0}": "Couldn't make the image: {0}", + "그림을 바꿔 만들기": "Make from this picture", + "그림을 받을 수 없습니다 ({0})": "Couldn't get the image ({0})", + "그림을 올릴 수 없습니다 ({0})": "Couldn't upload the image ({0})", "그림을 페이지로 넣기 (이 페이지 뒤)": "Add images as pages (after this page)", + "그림이 거의 같고 마지막 단계가 10배쯤 빠릅니다 (받아 두었을 때)": "Almost the same image, and the last step is about 10× faster (when downloaded)", "글을 격자에 반영": "Apply text to grid", "글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 (지금 {0}%)": "Text · button size. Change it any time with − · + on the top bar; press the number in the middle to return to this size (now {0}%)", "금": "Fri", "기계 번역": "Machine translation", "기기": "device", + "기기 안에서 그리기 (stable-diffusion.cpp · SD1.5 + LCM)": "Draw on this device (stable-diffusion.cpp · SD1.5 + LCM)", + "기기 안에서 그리려면 모델을 받아야 합니다 (SD1.5 + LCM, 약 4.4GB).": "To draw on this device, download the model first (SD1.5 + LCM, about 4.4 GB).", + "기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "To draw on this device, download the model first ({0})", "기능 버튼 줄": "Button bar", "기본": "Default", "기본 앱으로 열기": "Open with default app", @@ -412,9 +455,12 @@ "끝에서 처음으로 돌기": "Wrap around at the end", "나가기": "Exit", "나머지는 대기했다가 차례로 받습니다": "The rest wait and download in turn", + "나머지는 지웠습니다.": "The rest were deleted.", "나중에": "Later", "날짜": "Date", + "남은 {0}장": "{0} left", "남은 시간 {0}": "{0} left", + "남은 약 {0}": "about {0} left", "남은 용량": "free", "내려받기": "Download", "내려받기 (Windows 만)": "Download (Windows only)", @@ -484,6 +530,7 @@ "다시 세기": "Recount", "다시 시도": "Try again", "다시 시작": "Restart", + "다시 쓰려면 다시 받아야 합니다.": "You'll need to download it again to use it.", "다시 켤 때 동기화": "Sync on restart", "다시 확인": "Check again", "다운로드": "Downloads", @@ -514,6 +561,7 @@ "다음 (N)": "Next (N)", "다음 페이지 (Alt+→)": "Next page (Alt+→)", "다음부터 묻지 않기 (환경 설정 > 실행 에서 바꿀 수 있음)": "Don't ask again (can be changed in Settings > Run)", + "단계": "Steps", "단축키 해제": "Unregister hotkey", "닫기": "Close", "닫힘 (실행 안 함)": "Closed (not run)", @@ -572,6 +620,7 @@ "동영상을 끌어다 놓으면 목록에 추가됩니다": "Drag and drop videos to add them to the list", "동영상을 세 번 누르면": "Tapping a video three times", "동영상이 있는 폴더 아래 jj_mkv (기본)": "jj_mkv under the video's folder (default)", + "동의하고 받기": "Agree and download", "동작 중 · {0} 까지": "Running · until {0}", "되돌릴 수 없습니다.": "This can't be undone.", "되살리고 다시 시작": "Restore and restart", @@ -602,6 +651,7 @@ "마스터 비밀번호": "Master password", "마스터 비밀번호 넣기": "Enter master password", "마스터 비밀번호 정하기": "Set master password", + "마스터 비밀번호가 필요합니다": "The master password is needed", "마스터 비밀번호가 필요해 기다리는 중": "Waiting for the master password", "마스터 비밀번호로 잠겨 있습니다": "Locked with the master password", "마스터 비밀번호를 끌까요?": "Turn off the master password?", @@ -616,7 +666,9 @@ "마지막 장입니다.": "This is the last page.", "마지막 한 페이지는 지울 수 없습니다.": "The last remaining page cannot be deleted.", "마지막 화면에서 더 밀면 첫 화면으로 (끄면 끝에서 멈춤)": "Swiping past the last screen goes to the first (off: stops at the end)", + "만든 그림 · 저장: {0}": "Made images · saved in: {0}", "만들 자막 언어 (파일명_언어코드.srt)": "Subtitle languages to make (filename_languagecode.srt)", + "만들기": "Make", "만들다 만 파일 {0}개": "{0} incomplete files", "만들다 만 파일 {0}개를 지울까요?": "Delete {0} incomplete files?", "만화 보기": "Read as comic", @@ -639,13 +691,16 @@ "멈춤 · {0}": "Stopped · {0}", "모니터링": "Monitor", "모니터링의 [실행]": "[Run] in Monitoring", + "모델 (선택)": "Model (optional)", "모델 내려받는 중 {0}%": "Downloading model {0}%", "모델 내려받는 중: {0} ({1})": "Downloading model: {0} ({1})", + "모델 읽는 중": "Loading model", "모델 파일이 잘렸습니다 (위치 {0})": "The model file is truncated (position {0})", "모델을 내려받을 수 없습니다 ({0}): {1}": "Cannot download the model ({0}): {1}", "모두 나중에": "All later", "모두 내장 플레이어": "Built-in player for all", "모두 멈춤": "Pause all", + "모두 받았습니다. 512×512 한 장에 폰은 약 1분, PC 는 장치에 따라 1 ~ 5분 걸립니다.": "All downloaded. A 512×512 image takes about 1 min on a phone and 1–5 min on a PC depending on the hardware.", "모두 선택": "Select all", "모두 시작 (멈춘 것 {0}개)": "Start all ({0} paused)", "모두 종료": "Quit all", @@ -663,6 +718,7 @@ "목록을 받는 중": "Getting the list", "목록의 동영상 {0}개 모두": "All {0} videos in the list", "몽골어": "Mongolian", + "무엇을 그릴지 적어 주세요": "Write what to draw", "무엇이 바뀌는지 비교하는 중…": "Comparing what will change…", "무한": "Unlimited", "문서 미리보기": "Document preview", @@ -674,6 +730,7 @@ "미지정": "Unspecified", "바꾸거나 끄려면 지금 마스터 비밀번호 (또는 최상 비밀번호) 를 넣으세요.": "To change or turn it off, enter the current master password (or the top password).", "바꾸기": "Change", + "바꾸는 정도 {0}%": "Change amount {0}%", "바꾸면 바로 저장됩니다": "Changes are saved right away", "바꾼 것이 없습니다 (원본 그대로)": "Nothing changed (original as is)", "바뀌는 것 없음 (양쪽이 같습니다)": "Nothing to change (both sides are the same)", @@ -684,7 +741,12 @@ "바로 시작: 켜진 동기화를 모두 시작 · 골라서 시작: 켤 때 고르기 · 시작 안 함: 모니터링 > lsync 의 시작 버튼으로": "Start right away: start all enabled syncs · Choose: pick at startup · Don't start: use the start button in Monitor > lsync", "바로 재생 (기본)": "Play right away (default)", "바로 전 작업: {0}": "Previous job: {0}", + "바탕 그림 고르기": "Choose a base picture", + "바탕 그림 고르기 (그림 → 그림)": "Choose a base picture (image → image)", + "바탕 그림 빼기": "Remove base picture", "반대로 90° 회전": "Rotate 90° (counterclockwise)", + "받기": "Download", + "받기 화면으로": "Go to downloads", "받는 중": "Downloading", "받는 중이거나 일시정지한 다운로드가 있어 건너뜁니다 (이어받기가 깨지지 않게)": "Skipped because downloads are running or paused (so they can resume)", "받는 중인 다운로드 {0}개": "{0} downloads in progress", @@ -700,12 +762,16 @@ "받은 자막을 한국어로 번역해 함께 추가 (한국어 자막이 없을 때)": "Translate received subtitles to Korean and add them too (when there are no Korean subtitles)", "받은 파일에 libcef.dll 이 없습니다.": "The downloaded file has no libcef.dll.", "받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "The downloaded file is corrupt or a different file (SHA256 mismatch). Installation was stopped.", + "받은 파일이 손상되었습니다 (SHA-256 다름): {0}": "The downloaded file is damaged (SHA-256 mismatch): {0}", + "받을 수 없습니다 ({0}): {1}": "Can't download ({0}): {1}", + "받음": "Downloaded", "받음 {0}": "Received {0}", "받지 못했습니다: {0}": "Could not download: {0}", "밝게": "Brighter", "밝기": "Brightness", "밤 0~6시": "Night 0-6", "방법: {0}": "Method: {0}", + "방식": "Type", "백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {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)", @@ -784,6 +850,8 @@ "빈 폴더 그대로": "Leave empty folders", "빈 폴더 지움 · 원본 폴더는 남김": "Delete empty folders · keep the source folder", "빈 폴더 지움 · 원본 폴더도 지움": "Delete empty folders · delete the source folder too", + "빠른 디코더 (TAESD)": "Fast decoder (TAESD)", + "빼고 싶은 것 (선택)": "What to avoid (optional)", "사용 안 함": "Off", "사용 안 함. \"Sign in to confirm you're not a bot\" 오류가 나오면 설정하세요": "Off. Set this if the \"Sign in to confirm you're not a bot\" error appears", "사용: {0}": "Using: {0}", @@ -808,19 +876,27 @@ "새 폴더": "New folder", "새 폴더 추가": "Add new folder", "새 폴더…": "New folder…", + "새 화면 \"AI 그림\" 을 위쪽 이동 버튼에 넣었습니다 (글 → 그림 · 그림 → 그림, 모델은 환경 설정에서 받기). 끄기: 환경 설정 > 컴포넌트": "Added a new \"AI images\" screen to the top navigation buttons (text → image · image → image; download models in Settings). To turn off: Settings > Components", "새로 고침": "Refresh", "새로 넣은 비밀번호는 아직 저장되지 않았습니다. 앱을 끄면 사라집니다.": "The password you entered has not been saved yet. It will be lost when you close the app.", "새로고침 (F5)": "Reload (F5)", "색 보정": "Color correction", "색온도": "Temperature", + "서버 · 서비스 (기기 밖)": "Servers · services (off device)", "서버 설정 고치기": "Edit server settings", "서버 설정과 이 기기에 저장된 비밀번호를 지웁니다. 서버의 파일은 그대로입니다. 이 서버를 쓰는 동기화 · 복사는 다시 넣을 때까지 연결되지 않습니다.": "Deletes the server setting and the password saved on this device. Files on the server stay as they are. Syncs and copies using this server won't connect until you add it again.", "서버 인증서를 확인할 수 없습니다": "Cannot verify the server certificate", "서버가 https 주소로 옮기라고 합니다 ({0}). 서버 설정의 주소를 https:// 로 바꾸세요.": "The server asks to move to an https address ({0}). Change the server address to https://.", + "서버가 거절했습니다 ({0}): {1}": "The server refused ({0}): {1}", + "서버가 그림을 돌려주지 않았습니다": "The server returned no image", + "서버가 너무 오래 답하지 않습니다": "The server isn't responding for too long", "서버가 다른 주소로 옮기라고 합니다 ({0}). 서버 설정의 주소를 확인하세요.": "The server asks to move to another address ({0}). Check the server address.", "서버에 닿지 않습니다": "Cannot reach the server", "서버에서 이 계정의 권한을 확인하세요.": "Check this account's permissions on the server.", "서버와 같은 네트워크에 있거나 VPN (예: Tailscale) 이 켜져 있어야 합니다. 켠 뒤 [다시 시도] 를 누르세요.": "You need to be on the same network as the server or have a VPN (e.g. Tailscale) on. Turn it on, then tap [Try again].", + "서비스 고치기": "Edit service", + "서비스 설정과 저장된 API 키를 지웁니다.": "Deletes the service settings and its saved API key.", + "서비스 추가": "Add service", "선택": "Select", "선택 {0} / {1}개": "Selected {0} / {1}", "선택 끝": "Done", @@ -884,6 +960,9 @@ "시간 지정 (cron)": "Scheduled (cron)", "시간 형식 오류 {0}곳": "{0} time format errors", "시간순 정렬": "Sort by time", + "시드": "Seed", + "시드 (-1 은 아무렇게나)": "Seed (-1 = random)", + "시드 {0}": "Seed {0}", "시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일": "Series: files that differ only by number, like file_001 · file_002, S01E01 · S01E02", "시스템 막대 보이기 (시계 · 알림 · 뒤로)": "Show system bars (clock, notifications, back)", "시스템 언어 따르기 ({0})": "Follow system language ({0})", @@ -977,6 +1056,7 @@ "업데이트로 바뀐 기본값 알림": "Notify about defaults changed by updates", "없음": "None", "없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다": "None: one is created when you first press [Move] in the details", + "엔진": "Engine", "여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "Drop here to add to the Make MKV video list", "여러 개를 골랐을 때": "When several are selected", "여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "How many to process at once when making several videos into MKV (re-encoding uses a lot of CPU)", @@ -1003,6 +1083,9 @@ "예: /E /COPY:DAT /DCOPY:T /R:2 /W:2. 이동은 /MOVE 를 자동으로 붙임": "e.g. /E /COPY:DAT /DCOPY:T /R:2 /W:2. Move adds /MOVE automatically", "예: 0 9-17 * * 1-5 (평일 9시~17시 59분) · 0 0-5 * * * (매일 0~5시). 분은 무시하고 1시간 단위": "e.g. 0 9-17 * * 1-5 (weekdays 9:00-17:59) · 0 0-5 * * * (daily 0-5). Minutes are ignored, 1-hour units", "예: Ctrl+Shift+X (Ctrl·Shift·Alt·Win + 영문자·숫자·F1~F12)": "e.g. Ctrl+Shift+X (Ctrl·Shift·Alt·Win + letter·digit·F1~F12)", + "예: http://100.x.x.x:7860 (--api 로 켠 Automatic1111 · Forge)": "e.g. http://100.x.x.x:7860 (Automatic1111 · Forge started with --api)", + "예: http://100.x.x.x:8188 (ComfyUI)": "e.g. http://100.x.x.x:8188 (ComfyUI)", + "예: https://api.openai.com (경로 /v1/images/generations 를 붙여 부름)": "e.g. https://api.openai.com (called with the path /v1/images/generations)", "예: https://nas.local:5006/home · https://cloud.example.com/remote.php/dav/files/아이디": "e.g. https://nas.local:5006/home · https://cloud.example.com/remote.php/dav/files/username", "예전 구조의 프로그램 파일 정리 ({0}개): {1}": "Cleaned up program files of the old layout ({0}): {1}", "예전 버전": "an earlier version", @@ -1105,6 +1188,9 @@ "이 WebDAV 서버는 로그인이 필요합니다": "This WebDAV server requires sign-in", "이 {0}의 설정 파일에 저장됩니다": "Saved in this {0}'s settings file", "이 {0}의 안전 저장소에 저장됩니다 (설정 파일에는 쓰지 않음)": "Saved in this {0}'s secure storage (not in the settings file)", + "이 그림을 바탕으로 (그림 → 그림)": "Use this as the base (image → image)", + "이 기기 (SD1.5 + LCM)": "This device (SD1.5 + LCM)", + "이 기기 ({0})": "This device ({0})", "이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "Only an irreversible hash is stored on this device, not the password itself.", "이 기기에서는 AI 번역을 쓸 수 없습니다.": "AI translation is not available on this device.", "이 기기용 파일 없음": "No file for this device", @@ -1121,6 +1207,9 @@ "이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.": "Could not find this browser's bookmarks file.", "이 서버를 쓰는 것 {0}개 (지워도 목록에는 남고, 실행하면 서버가 없다고 알립니다):": "{0} items use this server (they stay in their lists; running them reports that the server is missing):", "이 서버의 저장된 비밀번호를 쓰려면 마스터 비밀번호가 필요합니다. 넣으면 이어서 맞춥니다. 대상 파일은 건드리지 않았습니다.": "The master password is needed to use this server's saved password. Enter it and syncing continues. Target files were not touched.", + "이 서버의 저장된 비밀번호를 쓰려면 마스터 비밀번호를 넣으세요.": "Enter the master password to use this server's saved password.", + "이 서비스 방식은 그림 → 그림을 지원하지 않습니다": "This service type doesn't support image → image", + "이 서비스로 그리면 그림 · 프롬프트가 기기 밖 (이 주소) 으로 나갑니다.": "Drawing with this service sends images and prompts off this device (to this address).", "이 설치본에는 rsync 가 들어 있지 않습니다 (rsync 로 복사할 때 · 오른쪽 [내려받기] 로 받습니다)": "This install has no rsync (downloaded when copying with rsync · or use [Download] on the right)", "이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.": "This install has no rsync. Choose one yourself.", "이 언어 지우기": "Remove this language", @@ -1168,6 +1257,7 @@ "이름 변경": "Rename", "이름을 바꿀 수 없습니다: {0}": "Cannot rename: {0}", "이름을 바꿀 항목 하나를 고르세요.": "Choose one item to rename.", + "이미 그리는 중입니다": "Already drawing", "이미 동영상 목록에 있습니다 ({0}개).": "Already in the video list ({0}).", "이미 목록에 있는 동영상입니다 ({0}개).": "These videos are already in the list ({0}).", "이미 받는 중이거나 받을 수 없는 주소입니다.": "Already downloading or a URL that cannot be downloaded.", @@ -1203,10 +1293,13 @@ "임시 파일 · 남은 조각 정리": "Clean temporary files · leftovers", "자동": "Auto", "자동 (가로로 긴 그림만)": "Auto (wide images only)", + "자동 (가장 빠른 것)": "Auto (fastest)", "자동 (기본)": "Auto (default)", "자동 감지": "Auto-detect", "자동 백업 그대로": "Keep Auto Backup", "자동 설치를 할 수 없는 위치입니다. 페이지에서 직접 받아 주세요.": "Automatic installation is not possible here. Please download it from the page yourself.", + "자동: 잰 결과 {0} 가 가장 빠릅니다": "Auto: measured {0} as the fastest", + "자동: 처음 그릴 때 짧게 재서 가장 빠른 장치를 고릅니다": "Auto: a short test on the first drawing picks the fastest processor", "자막": "Subtitles", "자막 (AI · 인터넷)": "Subtitles (AI · online)", "자막 (S)": "Subtitles (S)", @@ -1273,9 +1366,11 @@ "저장 (MKV 반영) *": "Save (apply to MKV) *", "저장 공간 정리": "Storage cleanup", "저장 공간 정리: 파일 {0}개 · {1}": "Storage cleanup: {0} files · {1}", + "저장 공간이 모자랍니다: {0} 이 필요한데 {1} 남았습니다": "Not enough storage: {0} needed, {1} left", "저장 실패: {0}": "Save failed: {0}", "저장 안 함": "Don't save", "저장 위치": "Save locations", + "저장 폴더": "Save folder", "저장: {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.", @@ -1289,6 +1384,7 @@ "저장하고 검색": "Save and search", "저장하고 계속": "Save and continue", "저장하고 실행": "Save and run", + "저장하는 중": "Saving", "저장하지 못했습니다: {0}": "Could not save: {0}", "저장하지 않은 변경": "Unsaved changes", "저장했습니다": "Saved", @@ -1405,6 +1501,8 @@ "지원하지 않는 문자셋: {0}": "Unsupported charset: {0}", "지정한 파일이 없습니다": "The specified file does not exist", "직접 고른 적이 없는 예전 기본값을 새 기본값으로 바꿨습니다. 원래대로 쓰려면 환경 설정에서 되돌리세요.": "Old default values that you never chose yourself were changed to the new defaults. To keep the old behavior, change them back in Settings.", + "직접 고름": "Chosen manually", + "직접 운영하는 서버 (ComfyUI · Automatic1111, 예: Tailscale 주소) 나 인터넷 서비스 (OpenAI 이미지 API 형식). 넣지 않으면 기기 안에서만 그립니다. 쓸 때는 그림 · 프롬프트가 기기 밖으로 나간다고 늘 알립니다. API 키는 안전 저장소에 둡니다.": "A server you run (ComfyUI · Automatic1111, e.g. a Tailscale address) or an internet service (OpenAI image API format). Without one, drawing happens only on this device. When used, the app always shows that images and prompts leave the device. API keys are kept in secure storage.", "직접 입력": "Custom", "직접 입력…": "Custom…", "직접 지정": "Specify", @@ -1431,14 +1529,17 @@ "찾을 수 없습니다 (이미 지워졌거나 옮겨졌습니다)": "Not found (already deleted or moved)", "채도": "Saturation", "채우기 (가운데 잘라냄)": "Fill (crop the center)", + "처리 장치": "Processor", "처음 사용 시 내려받기 {0}": "Downloads {0} on first use", "처음 시작 시": "At app start", "처음 쓸 때 내려받기 (기본)": "Download on first use (default)", "처음 아이콘 (파랑)": "Original icon (blue)", + "처음대로": "Reset to default", "첫 장입니다.": "This is the first page.", "첫 줄 추가": "Add first line", "청각장애인용": "Hearing impaired", "체코어": "Czech", + "체크포인트 파일 이름 (예: sd_xl_turbo_1.0_fp16.safetensors)": "Checkpoint file name (e.g. sd_xl_turbo_1.0_fp16.safetensors)", "체크한 동영상 {0}개의 {1}": "{1} of the {0} checked videos", "초기화": "Reset", "초기화할 것을 고르세요.": "Choose what to reset.", @@ -1484,6 +1585,7 @@ "크기 · 보정을 원래대로 돌려 코덱도 \"원본 유지\" 로 되돌렸습니다.": "Size and adjustments are back to original, so the codec was set back to \"Keep original\".", "크기를 바꾸면 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "Changing the size requires re-encoding, so the codec was changed from \"Keep original\" to H.264.", "크메르어": "Khmer", + "키 있음": "Key saved", "타갈로그어": "Tagalog", "타밀어": "Tamil", "탐색기": "Explorer", @@ -1581,6 +1683,7 @@ "필수 프로그램 설치": "Install required programs", "필수 프로그램 점검": "Check required programs", "필수 프로그램이 모두 설치되어 있습니다.": "All required programs are installed.", + "필요한 것 모두 받기": "Download everything needed", "하위 폴더가 없습니다": "No subfolders", "한 번 더": "Once more", "한 번: 바로 실행": "Single: open right away", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index b4e119a..1ba0dd7 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -28,6 +28,7 @@ "\"{0}\" 안에 만듭니다": "「{0}」の中に作成します", "\"{0}\" 에는 없고 대상 \"{1}\" 에만 있는 항목입니다. 지우기 포함으로 맞추면 대상에서 지워집니다 (되돌릴 수 없음).": "「{0}」にはなく、対象「{1}」にだけある項目です。削除を含めて同期すると対象から削除されます (元に戻せません)。", "\"{0}\" 을(를) \"{1}\" 폴더로 옮길까요?": "「{0}」を「{1}」フォルダーに移動しますか?", + "\"{0}\" 을(를) 지울까요?": "「{0}」を削除しますか?", "\"{0}\" 을(를) 지울까요? 되돌릴 수 없습니다.": "「{0}」を削除しますか? 元に戻せません。", "\"{0}\" 을(를) 휴지통으로 보낼까요?": "「{0}」をごみ箱に移動しますか?", "\"{0}\" 이 비어 있어, 맞추면 대상 \"{1}\" 의 아래 항목이 모두 지워집니다 (되돌릴 수 없음). 원본을 일부러 비운 경우에만 누르세요.": "「{0}」が空のため、同期すると対象「{1}」の以下の項目がすべて削除されます (元に戻せません)。元フォルダーを意図的に空にした場合のみ押してください。", @@ -47,6 +48,7 @@ "(지워짐)": "(削除)", "(현재 방식은 옵션 없음)": "(現在の方式にはオプションなし)", "+ 여기에 새 줄": "+ ここに新しい行", + "-1 은 아무렇게나": "-1 はランダム", ".{0} 재생 프로그램 (exe)": ".{0} 再生プログラム(exe)", "0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 制限なし。すべての方法に適用: rsync は --bwlimit、現在の方式はアプリが調整、robocopy は /IPG で近似。オプション欄に直接入れても構いません (そちらが優先)", "0 으로": "0 に戻す", @@ -55,7 +57,17 @@ "5초 뒤로": "5秒戻る", "5초 앞으로": "5秒進む", "90° 회전 (시계 방향)": "90° 回転 (時計回り)", + "AI 그림": "AI 画像", + "AI 그림 ({0})": "AI 画像 ({0})", + "AI 그림 (지금 여기)": "AI 画像 (現在の画面)", + "AI 그림 · 해상도 올리기": "AI 画像・高解像度化", + "AI 그림 저장 폴더": "AI 画像の保存フォルダー", + "AI 그림: {0}": "AI 画像: {0}", + "AI 그림으로 (이 그림을 바탕으로)": "AI 画像へ (この画像を元に)", "AI 번역": "AI 翻訳", + "AI 설정 · 모델 받기 · 서비스 추가": "AI 設定・モデルのダウンロード・サービス追加", + "AI 엔진이 실패했습니다 ({0}): {1}": "AI エンジンが失敗しました ({0}): {1}", + "AI 엔진이 없습니다. 환경 설정 > AI 그림에서 받아 주세요.": "AI エンジンがありません。環境設定 > AI 画像でダウンロードしてください。", "AI 인식": "AI 認識", "AI 자막": "AI 字幕", "AI 자막 {0}개": "AI 字幕 {0}個", @@ -73,7 +85,9 @@ "AI 자막을 만듭니다": "AI 字幕を作成します", "AI 작업 중이라 이번에는 건너뜁니다": "AI 作業中のため今回はスキップします", "API 키": "API キー", + "API 키 (선택)": "API キー (任意)", "API 키 (필수, 무료)": "API キー(必須・無料)", + "API 키 또는 아이디:비밀번호 (선택)": "API キーまたは ID:パスワード (任意)", "AV1 영상은 이 기기에서 다시 인코딩할 수 없습니다. 코덱을 \"원본 유지\" 로 바꿔 MKV 를 만드세요. (PC 판은 됩니다)": "AV1 動画はこの端末では再エンコードできません。コーデックを「元のまま」に変えて MKV を作成してください。(PC 版はできます)", "Android 가 막은 폴더 (Android/data 등) 이거나 \"모든 파일에 대한 접근\" 권한이 없습니다.": "Android が制限しているフォルダー (Android/data など) か、「すべてのファイルへのアクセス」権限がありません。", "Android 는 rsync 를 내려받을 수 없습니다. 직접 지정하세요.": "Android では rsync をダウンロードできません。直接指定してください。", @@ -107,6 +121,7 @@ "JJ_MKVMaker 로 재생": "JJ_MKVMaker で再生", "JJ_MKVMaker_즐겨찾기.html": "JJ_MKVMaker_ブックマーク.html", "JavaScript 사용": "JavaScript を使う", + "LCM-LoRA (SD1.5, 4단계로 빠르게)": "LCM-LoRA (SD1.5、4 ステップで高速)", "MKV · AI 작업": "MKV・AI 作業", "MKV · 자막 (jj_mkv)": "MKV・字幕(jj_mkv)", "MKV · 자막 저장 위치": "MKV・字幕の保存先", @@ -136,6 +151,8 @@ "NAS · 클라우드의 WebDAV 폴더를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 (위쪽 \"SD 카드\" 옆 탭). 비밀번호는 이 기기의 안전 저장소에만 두고 설정 파일에는 쓰지 않습니다. 한 번 넣으면 업데이트 뒤에도 남습니다.": "NAS・クラウドの WebDAV フォルダーをファイルエクスプローラー・Rsync 画面で開き、コピー・同期します (上部の「SD カード」の隣のタブ)。パスワードはこの端末の安全な保存領域にだけ保存し、設定ファイルには書きません。一度入力すればアップデート後も残ります。", "NLLB 언어 코드가 아닙니다: {0}": "NLLB の言語コードではありません: {0}", "NLLB-200 번역 (600M)": "NLLB-200 翻訳(600M)", + "NVIDIA CUDA 12 런타임 (CUDA 판과 함께)": "NVIDIA CUDA 12 ランタイム (CUDA 版と一緒に)", + "OpenAI 이미지 API 형식": "OpenAI 画像 API 形式", "OpenSubtitles API 키 또는 로그인 정보가 올바르지 않습니다. ({0})": "OpenSubtitles の API キーまたはログイン情報が正しくありません。({0})", "OpenSubtitles 무료 API 키가 필요합니다.\n1. opensubtitles.com 가입 (무료)\n2. 프로필 → API consumers → New consumer 에서 키 만들기\n3. 아래 칸 또는 환경 설정에 키 붙여넣기": "OpenSubtitles の無料 API キーが必要です。\n1. opensubtitles.com に登録(無料)\n2. プロフィール → API consumers → New consumer でキーを作成\n3. 下の欄または環境設定にキーを貼り付け", "OpenSubtitles 오류 {0}: {1}": "OpenSubtitles エラー {0}: {1}", @@ -152,6 +169,9 @@ "SD 카드": "SD カード", "SD 카드가 꽂혀 있는지, 네트워크 (NAS · VPN) 가 연결되어 있는지, 폴더 권한이 있는지 확인한 뒤 [다시 시도] 를 누르세요.": "SD カードが挿さっているか、ネットワーク (NAS・VPN) が接続されているか、フォルダーの権限があるかを確認してから [再試行] を押してください。", "SRT 형식이 아니거나 비어 있습니다.": "SRT 形式ではないか、空です。", + "Stable Diffusion v1.5": "Stable Diffusion v1.5", + "Stable Diffusion v1.5 는 CreativeML OpenRAIL-M 라이선스입니다. 상업적 사용을 포함해 쓸 수 있지만, 다음 용도로는 쓸 수 없습니다: 법을 어기는 일, 미성년자를 해치거나 이용하는 일, 남을 해치려는 거짓 정보, 개인 정보로 사람을 해치는 일, 차별 · 괴롭힘, 의료 · 법률 판단을 대신하는 일 등 (라이선스 부속서 A). 만든 그림에 대한 책임은 쓰는 사람에게 있습니다.": "Stable Diffusion v1.5 は CreativeML OpenRAIL-M ライセンスです。商用を含めて使えますが、次の用途には使えません: 法律に違反すること、未成年者を害したり利用したりすること、他人を害する目的の虚偽情報、個人情報で人を害すること、差別・嫌がらせ、医療・法律の判断の代わりにすること など (ライセンス付属書 A)。作成した画像の責任は使う人にあります。", + "TAESD (빠른 디코더)": "TAESD (高速デコーダー)", "VLC 가 설치되어 있지 않습니다. 설치하면 목록에 나타납니다 (videolan.org).": "VLC がインストールされていません。インストールするとリストに表示されます(videolan.org)。", "VLC 로 열기": "VLC で開く", "WebDAV · OpenSubtitles 등. 서버 목록과 아이디는 남깁니다": "WebDAV・OpenSubtitles など。サーバー一覧と ID は残します", @@ -226,6 +246,8 @@ "rsync 양쪽 (⇄)": "rsync 双方向 (⇄)", "rsync 에 고르기": "rsync に選ぶ", "rsync 옵션": "rsync オプション", + "stable-diffusion.cpp (Windows · CPU · Vulkan)": "stable-diffusion.cpp (Windows・CPU・Vulkan)", + "stable-diffusion.cpp (Windows · NVIDIA CUDA 12)": "stable-diffusion.cpp (Windows・NVIDIA CUDA 12)", "v{0} 받는 중": "v{0} をダウンロード中", "v{0} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n{1}": "v{0} をダウンロードし、ファイル検証(SHA256)を終えました。\n{1}", "yt-dlp 를 새 버전으로 바꿨습니다": "yt-dlp を新しいバージョンに更新しました", @@ -238,6 +260,7 @@ "{0} / {1}장": "{0} / {1} 枚", "{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2}(メイン画面の上部で変更)", "{0} → {1} 번역 {2}%": "{0} → {1} 翻訳 {2}%", + "{0} 라이선스": "{0} ライセンス", "{0} 로 번역 중 {1}%": "{0} に翻訳中 {1}%", "{0} 로 표현할 수 없는 글자 {1}개가 \"?\" 로 바뀝니다.\n계속 저장할까요? (UTF-8 을 권장합니다)": "{0} で表現できない文字 {1}個が「?」に変わります。\n保存を続けますか?(UTF-8 を推奨します)", "{0} 번역 중": "{0} を翻訳中", @@ -248,10 +271,13 @@ "{0} 순으로 정렬{1}": "{0} 順に並べ替え{1}", "{0} 아래에 만듭니다": "{0} の下に作成します", "{0} 안의 파일": "{0} 内のファイル", + "{0} 에 보내는 중": "{0} に送信中", "{0} 에 붙여넣기 ({1}개)": "{0} に貼り付け ({1} 件)", + "{0} 에서 그리는 중 ({1}초)": "{0} で描画中 ({1} 秒)", "{0} 에서 받을 파일을 찾지 못했습니다": "{0} からダウンロードするファイルが見つかりませんでした", "{0} 외 {1}개": "{0} ほか {1} 件", "{0} 을(를) 설치했습니다.": "{0} をインストールしました。", + "{0} 을(를) 지울까요?": "{0} を削除しますか?", "{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg に {0} エンコーダーがありません。", "{0} 전체": "{0} 全体", "{0} 제거": "{0} の削除", @@ -281,6 +307,7 @@ "{0}개 항목을 휴지통으로 보냈습니다.": "{0} 個の項目をごみ箱に移動しました。", "{0}개는 지우지 않았습니다 (취소).": "{0} 個は削除しませんでした (キャンセル)。", "{0}개는 휴지통보다 커서 영구 삭제했습니다 (Windows 가 물은 대로).": "{0} 個はごみ箱より大きいため完全に削除しました (Windows の確認どおり)。", + "{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).": "{0} 件はごみ箱に入らなかったため完全に削除しました (Windows の確認で選んだとおり)。", "{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "{0}個を「その他のブックマーク」に取り込みました。", "{0}개를 옮겼습니다 → {1}": "{0}個を移動しました → {1}", "{0}개를 옮겼습니다 → {1} ({2})": "{0}個を移動しました → {1}({2})", @@ -313,16 +340,19 @@ "가로 고정": "横向き固定", "가로 고정 · 세로 고정, 또는 자동 (기기를 돌리는 대로, 기기의 자동 회전 설정을 따름)": "横向き固定・縦向き固定、または自動(端末の向きに合わせ、端末の自動回転設定に従う)", "가입 → 프로필 → API consumers → New consumer": "登録 → プロフィール → API consumers → New consumer", + "가장 빠른 처리 장치를 찾는 중 (처음 한 번): {0}": "最も速い処理装置を探しています (初回のみ): {0}", "가져오기": "取り込む", "가져온 즐겨찾기": "取り込んだブックマーク", "가져온 즐겨찾기 ({0})": "取り込んだブックマーク({0})", "가져올 즐겨찾기가 없습니다.": "取り込むブックマークがありません。", "같은 시리즈 (번호만 다른 파일)": "同じシリーズ(番号だけ違うファイル)", + "같은 조건으로 다시 (시드 그대로)": "同じ条件でもう一度 (シードはそのまま)", "같은 폴더의 모든 동영상": "同じフォルダーのすべての動画", "같은 폴더의 자막은 자동으로 추가됩니다": "同じフォルダーの字幕は自動で追加されます", "같은 폴더이거나 한쪽이 다른 쪽 안에 있습니다. 다른 폴더를 고르세요.": "同じフォルダーか、一方がもう一方の中にあります。別のフォルダーを選んでください。", "같은 항목을 같은 곳으로 복사 · 이동하는 중입니다.": "同じ項目を同じ場所へコピー・移動中です。", "같은 화질이면 H.264 먼저 받기": "同じ画質なら H.264 を優先", + "개수": "枚数", "거꾸로": "逆順", "건너뜀 (출력 폴더의 파일): {0}": "スキップ(出力フォルダーのファイル): {0}", "검색": "検索", @@ -364,18 +394,31 @@ "권한이 없습니다": "権限がありません", "그 파일 하나만": "そのファイルだけ", "그래도 맞추기 ({0}개 지움)": "それでも同期 ({0} 個を削除)", + "그리는 중 {0}/{1}": "描画中 {0}/{1}", + "그리려면 {0} 를 받아야 합니다 (모델은 받은 뒤 이 기기에만 있고, 그림 · 프롬프트는 밖으로 나가지 않습니다).": "描くには {0} のダウンロードが必要です (モデルはこの端末にだけ置かれ、画像・プロンプトは外に出ません)。", "그리스어": "ギリシャ語", + "그릴 것 (프롬프트, 영어가 잘 됩니다)": "描くもの (プロンプト、英語が最適です)", "그림 {0}장으로 PDF 를 만들었습니다: {1}": "画像 {0} 枚から PDF を作成しました: {1}", "그림 {0}장을 PDF 로 만들기": "画像 {0} 枚を PDF にする", "그림 {0}장을 {1} 페이지 뒤에 넣었습니다 (저장해야 파일에 반영됩니다).": "画像 {0} 枚を {1} ページの後に追加しました (保存するとファイルに反映されます)。", "그림 · 만화 보기, PDF 보기 · 페이지 편집, ZIP 목록 · 만화 보기": "画像・マンガ表示、PDF 表示・ページ編集、ZIP 一覧・マンガ表示", + "그림 · 프롬프트가 기기 밖으로 나갑니다 ({0})": "画像・プロンプトが端末の外に送られます ({0})", "그림 보기 대상": "画像表示の対象", + "그림으로 바꾸는 중": "画像に変換中", + "그림을 만들지 못했습니다: {0}": "画像を作成できませんでした: {0}", + "그림을 바꿔 만들기": "画像を変えて作成", + "그림을 받을 수 없습니다 ({0})": "画像を受け取れません ({0})", + "그림을 올릴 수 없습니다 ({0})": "画像をアップロードできません ({0})", "그림을 페이지로 넣기 (이 페이지 뒤)": "画像をページとして追加 (このページの後)", + "그림이 거의 같고 마지막 단계가 10배쯤 빠릅니다 (받아 두었을 때)": "画像はほぼ同じで、最後の段階が約 10 倍速くなります (ダウンロード済みの場合)", "글을 격자에 반영": "テキストをグリッドに反映", "글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 (지금 {0}%)": "文字・ボタンの大きさです。上部バーの − ・ + でその都度変更でき、中央の数字を押すとこの大きさに戻ります(現在 {0}%)", "금": "金", "기계 번역": "機械翻訳", "기기": "端末", + "기기 안에서 그리기 (stable-diffusion.cpp · SD1.5 + LCM)": "この端末で描く (stable-diffusion.cpp・SD1.5 + LCM)", + "기기 안에서 그리려면 모델을 받아야 합니다 (SD1.5 + LCM, 약 4.4GB).": "この端末で描くにはモデルのダウンロードが必要です (SD1.5 + LCM、約 4.4GB)。", + "기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "この端末で描くにはモデルのダウンロードが必要です ({0})", "기능 버튼 줄": "機能ボタンの列", "기본": "既定", "기본 앱으로 열기": "既定のアプリで開く", @@ -412,9 +455,12 @@ "끝에서 처음으로 돌기": "最後から最初に戻る", "나가기": "終了する", "나머지는 대기했다가 차례로 받습니다": "残りは待機して順番にダウンロードします", + "나머지는 지웠습니다.": "残りは削除しました。", "나중에": "後で", "날짜": "日付", + "남은 {0}장": "残り {0} 枚", "남은 시간 {0}": "残り時間 {0}", + "남은 약 {0}": "残り約 {0}", "남은 용량": "空き容量", "내려받기": "ダウンロード", "내려받기 (Windows 만)": "ダウンロード (Windows のみ)", @@ -484,6 +530,7 @@ "다시 세기": "再カウント", "다시 시도": "再試行", "다시 시작": "再起動", + "다시 쓰려면 다시 받아야 합니다.": "再び使うには再ダウンロードが必要です。", "다시 켤 때 동기화": "再起動時の同期", "다시 확인": "再確認", "다운로드": "ダウンロード", @@ -514,6 +561,7 @@ "다음 (N)": "次へ(N)", "다음 페이지 (Alt+→)": "次のページ(Alt+→)", "다음부터 묻지 않기 (환경 설정 > 실행 에서 바꿀 수 있음)": "次回から確認しない(環境設定 > 実行 で変更できます)", + "단계": "ステップ", "단축키 해제": "ショートカット解除", "닫기": "閉じる", "닫힘 (실행 안 함)": "閉じた (実行しない)", @@ -572,6 +620,7 @@ "동영상을 끌어다 놓으면 목록에 추가됩니다": "動画をドラッグ&ドロップするとリストに追加されます", "동영상을 세 번 누르면": "動画を 3 回タップすると", "동영상이 있는 폴더 아래 jj_mkv (기본)": "動画があるフォルダーの下の jj_mkv(既定)", + "동의하고 받기": "同意してダウンロード", "동작 중 · {0} 까지": "動作中 · {0} まで", "되돌릴 수 없습니다.": "元に戻せません。", "되살리고 다시 시작": "復元して再起動", @@ -602,6 +651,7 @@ "마스터 비밀번호": "マスターパスワード", "마스터 비밀번호 넣기": "マスターパスワードを入力", "마스터 비밀번호 정하기": "マスターパスワードを設定", + "마스터 비밀번호가 필요합니다": "マスターパスワードが必要です", "마스터 비밀번호가 필요해 기다리는 중": "マスターパスワードが必要なため待機中", "마스터 비밀번호로 잠겨 있습니다": "マスターパスワードでロックされています", "마스터 비밀번호를 끌까요?": "マスターパスワードをオフにしますか?", @@ -616,7 +666,9 @@ "마지막 장입니다.": "最後のページです。", "마지막 한 페이지는 지울 수 없습니다.": "最後の 1 ページは削除できません。", "마지막 화면에서 더 밀면 첫 화면으로 (끄면 끝에서 멈춤)": "最後の画面からさらにスワイプすると最初の画面へ (オフ: 端で止まる)", + "만든 그림 · 저장: {0}": "作成した画像・保存先: {0}", "만들 자막 언어 (파일명_언어코드.srt)": "作成する字幕の言語(ファイル名_言語コード.srt)", + "만들기": "作成", "만들다 만 파일 {0}개": "未完成のファイル {0} 個", "만들다 만 파일 {0}개를 지울까요?": "未完成のファイル {0} 個を削除しますか?", "만화 보기": "マンガ表示", @@ -639,13 +691,16 @@ "멈춤 · {0}": "停止 · {0}", "모니터링": "モニタリング", "모니터링의 [실행]": "モニタリングの [実行]", + "모델 (선택)": "モデル (任意)", "모델 내려받는 중 {0}%": "モデルをダウンロード中 {0}%", "모델 내려받는 중: {0} ({1})": "モデルをダウンロード中: {0}({1})", + "모델 읽는 중": "モデルを読み込み中", "모델 파일이 잘렸습니다 (위치 {0})": "モデルファイルが途中で切れています(位置 {0})", "모델을 내려받을 수 없습니다 ({0}): {1}": "モデルをダウンロードできません({0}): {1}", "모두 나중에": "すべて後で", "모두 내장 플레이어": "すべて内蔵プレーヤー", "모두 멈춤": "すべて停止", + "모두 받았습니다. 512×512 한 장에 폰은 약 1분, PC 는 장치에 따라 1 ~ 5분 걸립니다.": "すべてダウンロード済みです。512×512 1 枚にスマホで約 1 分、PC では装置により 1〜5 分かかります。", "모두 선택": "すべて選択", "모두 시작 (멈춘 것 {0}개)": "すべて開始 (停止中 {0} 件)", "모두 종료": "すべて終了", @@ -663,6 +718,7 @@ "목록을 받는 중": "一覧を取得中", "목록의 동영상 {0}개 모두": "リストの動画 {0}個すべて", "몽골어": "モンゴル語", + "무엇을 그릴지 적어 주세요": "何を描くか入力してください", "무엇이 바뀌는지 비교하는 중…": "何が変わるか比較中…", "무한": "無制限", "문서 미리보기": "文書プレビュー", @@ -674,6 +730,7 @@ "미지정": "未指定", "바꾸거나 끄려면 지금 마스터 비밀번호 (또는 최상 비밀번호) 를 넣으세요.": "変更・オフにするには現在のマスターパスワード (または最上位パスワード) を入力してください。", "바꾸기": "変更", + "바꾸는 정도 {0}%": "変える度合い {0}%", "바꾸면 바로 저장됩니다": "変更するとすぐ保存されます", "바꾼 것이 없습니다 (원본 그대로)": "変更なし(元のまま)", "바뀌는 것 없음 (양쪽이 같습니다)": "変更なし (両方同じです)", @@ -684,7 +741,12 @@ "바로 시작: 켜진 동기화를 모두 시작 · 골라서 시작: 켤 때 고르기 · 시작 안 함: 모니터링 > lsync 의 시작 버튼으로": "すぐ開始: オンの同期をすべて開始 · 選んで開始: 起動時に選ぶ · 開始しない: モニタリング > lsync の開始ボタンで", "바로 재생 (기본)": "すぐ再生(既定)", "바로 전 작업: {0}": "直前の作業: {0}", + "바탕 그림 고르기": "元の画像を選択", + "바탕 그림 고르기 (그림 → 그림)": "元の画像を選択 (画像 → 画像)", + "바탕 그림 빼기": "元の画像を外す", "반대로 90° 회전": "反対に 90° 回転", + "받기": "ダウンロード", + "받기 화면으로": "ダウンロード画面へ", "받는 중": "ダウンロード中", "받는 중이거나 일시정지한 다운로드가 있어 건너뜁니다 (이어받기가 깨지지 않게)": "ダウンロード中または一時停止中のものがあるためスキップします(再開できるように)", "받는 중인 다운로드 {0}개": "ダウンロード中 {0}個", @@ -700,12 +762,16 @@ "받은 자막을 한국어로 번역해 함께 추가 (한국어 자막이 없을 때)": "受け取った字幕を韓国語に翻訳して一緒に追加(韓国語字幕がないとき)", "받은 파일에 libcef.dll 이 없습니다.": "ダウンロードしたファイルに libcef.dll がありません。", "받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "ダウンロードしたファイルが壊れているか別のファイルです(SHA256 不一致)。インストールを中止しました。", + "받은 파일이 손상되었습니다 (SHA-256 다름): {0}": "ダウンロードしたファイルが破損しています (SHA-256 不一致): {0}", + "받을 수 없습니다 ({0}): {1}": "ダウンロードできません ({0}): {1}", + "받음": "ダウンロード済み", "받음 {0}": "受信 {0}", "받지 못했습니다: {0}": "ダウンロードできませんでした: {0}", "밝게": "明るく", "밝기": "明るさ", "밤 0~6시": "夜 0~6 時", "방법: {0}": "方法: {0}", + "방식": "方式", "백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)": "バックグラウンド: ウィンドウだけ隠してダウンロード・変換は続けます。{0} またはトレイアイコンで再表示します。\n完全に終了するには トレイアイコンを右クリック > 終了(どの設定でも常に終了)", "백그라운드로": "バックグラウンドへ", "백그라운드로 계속 (기본)": "バックグラウンドで続ける(既定)", @@ -784,6 +850,8 @@ "빈 폴더 그대로": "空フォルダーはそのまま", "빈 폴더 지움 · 원본 폴더는 남김": "空フォルダーを削除 · 元フォルダーは残す", "빈 폴더 지움 · 원본 폴더도 지움": "空フォルダーを削除 · 元フォルダーも削除", + "빠른 디코더 (TAESD)": "高速デコーダー (TAESD)", + "빼고 싶은 것 (선택)": "避けたいもの (任意)", "사용 안 함": "使用しない", "사용 안 함. \"Sign in to confirm you're not a bot\" 오류가 나오면 설정하세요": "使用しない。「Sign in to confirm you're not a bot」エラーが出たら設定してください", "사용: {0}": "使用: {0}", @@ -808,19 +876,27 @@ "새 폴더": "新しいフォルダー", "새 폴더 추가": "新しいフォルダーを追加", "새 폴더…": "新しいフォルダー…", + "새 화면 \"AI 그림\" 을 위쪽 이동 버튼에 넣었습니다 (글 → 그림 · 그림 → 그림, 모델은 환경 설정에서 받기). 끄기: 환경 설정 > 컴포넌트": "新しい画面「AI 画像」を上部の移動ボタンに追加しました (テキスト → 画像・画像 → 画像、モデルは環境設定でダウンロード)。オフにする: 環境設定 > コンポーネント", "새로 고침": "更新", "새로 넣은 비밀번호는 아직 저장되지 않았습니다. 앱을 끄면 사라집니다.": "入力したパスワードはまだ保存されていません。アプリを終了すると消えます。", "새로고침 (F5)": "再読み込み(F5)", "색 보정": "カラー補正", "색온도": "色温度", + "서버 · 서비스 (기기 밖)": "サーバー・サービス (端末外)", "서버 설정 고치기": "サーバー設定を修正", "서버 설정과 이 기기에 저장된 비밀번호를 지웁니다. 서버의 파일은 그대로입니다. 이 서버를 쓰는 동기화 · 복사는 다시 넣을 때까지 연결되지 않습니다.": "サーバー設定とこの端末に保存したパスワードを削除します。サーバーのファイルはそのままです。このサーバーを使う同期・コピーは、再度追加するまで接続できません。", "서버 인증서를 확인할 수 없습니다": "サーバー証明書を確認できません", "서버가 https 주소로 옮기라고 합니다 ({0}). 서버 설정의 주소를 https:// 로 바꾸세요.": "サーバーが https アドレスへの移動を求めています ({0})。サーバー設定のアドレスを https:// に変更してください。", + "서버가 거절했습니다 ({0}): {1}": "サーバーが拒否しました ({0}): {1}", + "서버가 그림을 돌려주지 않았습니다": "サーバーが画像を返しませんでした", + "서버가 너무 오래 답하지 않습니다": "サーバーの応答が長時間ありません", "서버가 다른 주소로 옮기라고 합니다 ({0}). 서버 설정의 주소를 확인하세요.": "サーバーが別のアドレスへの移動を求めています ({0})。サーバー設定のアドレスを確認してください。", "서버에 닿지 않습니다": "サーバーに接続できません", "서버에서 이 계정의 권한을 확인하세요.": "サーバーでこのアカウントの権限を確認してください。", "서버와 같은 네트워크에 있거나 VPN (예: Tailscale) 이 켜져 있어야 합니다. 켠 뒤 [다시 시도] 를 누르세요.": "サーバーと同じネットワークにいるか、VPN (例: Tailscale) をオンにする必要があります。オンにしてから [再試行] を押してください。", + "서비스 고치기": "サービスを編集", + "서비스 설정과 저장된 API 키를 지웁니다.": "サービス設定と保存した API キーを削除します。", + "서비스 추가": "サービスを追加", "선택": "選択", "선택 {0} / {1}개": "選択 {0} / {1}個", "선택 끝": "選択終了", @@ -884,6 +960,9 @@ "시간 지정 (cron)": "時間指定 (cron)", "시간 형식 오류 {0}곳": "時間形式エラー {0}か所", "시간순 정렬": "時間順に並べ替え", + "시드": "シード", + "시드 (-1 은 아무렇게나)": "シード (-1 はランダム)", + "시드 {0}": "シード {0}", "시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일": "シリーズ: file_001・file_002、S01E01・S01E02 のように番号だけ違うファイル", "시스템 막대 보이기 (시계 · 알림 · 뒤로)": "システムバーを表示 (時計・通知・戻る)", "시스템 언어 따르기 ({0})": "システムの言語に従う ({0})", @@ -977,6 +1056,7 @@ "업데이트로 바뀐 기본값 알림": "アップデートで変わった既定値の通知", "없음": "なし", "없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다": "なし: 詳細の[移動]を初めて押したときにフォルダーを選んで作成します", + "엔진": "エンジン", "여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "ここにドロップすると MKV 作成の動画リストに追加します", "여러 개를 골랐을 때": "複数選んだとき", "여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "複数の動画を MKV にするとき同時に処理する数(再エンコードは CPU を多く使います)", @@ -1003,6 +1083,9 @@ "예: /E /COPY:DAT /DCOPY:T /R:2 /W:2. 이동은 /MOVE 를 자동으로 붙임": "例: /E /COPY:DAT /DCOPY:T /R:2 /W:2。移動では /MOVE を自動で付加", "예: 0 9-17 * * 1-5 (평일 9시~17시 59분) · 0 0-5 * * * (매일 0~5시). 분은 무시하고 1시간 단위": "例: 0 9-17 * * 1-5 (平日 9 時~17 時 59 分) · 0 0-5 * * * (毎日 0~5 時)。分は無視し 1 時間単位", "예: Ctrl+Shift+X (Ctrl·Shift·Alt·Win + 영문자·숫자·F1~F12)": "例: Ctrl+Shift+X(Ctrl・Shift・Alt・Win + 英字・数字・F1〜F12)", + "예: http://100.x.x.x:7860 (--api 로 켠 Automatic1111 · Forge)": "例: http://100.x.x.x:7860 (--api で起動した Automatic1111・Forge)", + "예: http://100.x.x.x:8188 (ComfyUI)": "例: http://100.x.x.x:8188 (ComfyUI)", + "예: https://api.openai.com (경로 /v1/images/generations 를 붙여 부름)": "例: https://api.openai.com (パス /v1/images/generations を付けて呼び出し)", "예: https://nas.local:5006/home · https://cloud.example.com/remote.php/dav/files/아이디": "例: https://nas.local:5006/home · https://cloud.example.com/remote.php/dav/files/ユーザー名", "예전 구조의 프로그램 파일 정리 ({0}개): {1}": "旧構成のプログラムファイルを整理({0}個): {1}", "예전 버전": "以前のバージョン", @@ -1105,6 +1188,9 @@ "이 WebDAV 서버는 로그인이 필요합니다": "この WebDAV サーバーはログインが必要です", "이 {0}의 설정 파일에 저장됩니다": "この{0}の設定ファイルに保存されます", "이 {0}의 안전 저장소에 저장됩니다 (설정 파일에는 쓰지 않음)": "この {0} の安全な保存領域に保存されます (設定ファイルには書きません)", + "이 그림을 바탕으로 (그림 → 그림)": "この画像を元に (画像 → 画像)", + "이 기기 (SD1.5 + LCM)": "この端末 (SD1.5 + LCM)", + "이 기기 ({0})": "この端末 ({0})", "이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "この端末にはパスワード自体ではなく、元に戻せないハッシュだけが残ります。", "이 기기에서는 AI 번역을 쓸 수 없습니다.": "この端末では AI 翻訳を使えません。", "이 기기용 파일 없음": "このデバイス用のファイルなし", @@ -1121,6 +1207,9 @@ "이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.": "このブラウザーのブックマークファイルが見つかりませんでした。", "이 서버를 쓰는 것 {0}개 (지워도 목록에는 남고, 실행하면 서버가 없다고 알립니다):": "このサーバーを使うもの {0} 件 (削除しても一覧には残り、実行するとサーバーがないと通知します):", "이 서버의 저장된 비밀번호를 쓰려면 마스터 비밀번호가 필요합니다. 넣으면 이어서 맞춥니다. 대상 파일은 건드리지 않았습니다.": "このサーバーの保存したパスワードを使うにはマスターパスワードが必要です。入力すると同期を続けます。対象のファイルには触れていません。", + "이 서버의 저장된 비밀번호를 쓰려면 마스터 비밀번호를 넣으세요.": "このサーバーの保存したパスワードを使うにはマスターパスワードを入力してください。", + "이 서비스 방식은 그림 → 그림을 지원하지 않습니다": "このサービス方式は画像 → 画像に対応していません", + "이 서비스로 그리면 그림 · 프롬프트가 기기 밖 (이 주소) 으로 나갑니다.": "このサービスで描くと、画像・プロンプトが端末の外 (このアドレス) に送られます。", "이 설치본에는 rsync 가 들어 있지 않습니다 (rsync 로 복사할 때 · 오른쪽 [내려받기] 로 받습니다)": "このインストールには rsync が含まれていません (rsync でコピーするとき · 右の [ダウンロード] で取得します)", "이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.": "このインストールには rsync が含まれていません。直接指定してください。", "이 언어 지우기": "この言語を削除", @@ -1168,6 +1257,7 @@ "이름 변경": "名前の変更", "이름을 바꿀 수 없습니다: {0}": "名前を変更できません: {0}", "이름을 바꿀 항목 하나를 고르세요.": "名前を変更する項目を 1 つ選んでください。", + "이미 그리는 중입니다": "すでに描画中です", "이미 동영상 목록에 있습니다 ({0}개).": "既に動画リストにあります({0}個)。", "이미 목록에 있는 동영상입니다 ({0}개).": "既にリストにある動画です({0}個)。", "이미 받는 중이거나 받을 수 없는 주소입니다.": "既にダウンロード中か、ダウンロードできない URL です。", @@ -1203,10 +1293,13 @@ "임시 파일 · 남은 조각 정리": "一時ファイル・残った断片を整理", "자동": "自動", "자동 (가로로 긴 그림만)": "自動 (横長の画像のみ)", + "자동 (가장 빠른 것)": "自動 (最速)", "자동 (기본)": "自動 (既定)", "자동 감지": "自動検出", "자동 백업 그대로": "自動バックアップのまま", "자동 설치를 할 수 없는 위치입니다. 페이지에서 직접 받아 주세요.": "自動インストールできない場所です。ページから直接ダウンロードしてください。", + "자동: 잰 결과 {0} 가 가장 빠릅니다": "自動: 計測の結果 {0} が最速です", + "자동: 처음 그릴 때 짧게 재서 가장 빠른 장치를 고릅니다": "自動: 初回の描画時に短く計測して最速の装置を選びます", "자막": "字幕", "자막 (AI · 인터넷)": "字幕(AI・インターネット)", "자막 (S)": "字幕(S)", @@ -1273,9 +1366,11 @@ "저장 (MKV 반영) *": "保存(MKV に反映)*", "저장 공간 정리": "ストレージの整理", "저장 공간 정리: 파일 {0}개 · {1}": "ストレージの整理: ファイル {0}個・{1}", + "저장 공간이 모자랍니다: {0} 이 필요한데 {1} 남았습니다": "保存領域が足りません: {0} 必要ですが残り {1} です", "저장 실패: {0}": "保存失敗: {0}", "저장 안 함": "保存しない", "저장 위치": "保存先", + "저장 폴더": "保存フォルダー", "저장: {0}": "保存: {0}", "저장된 비밀번호 · API 키를 지웠습니다": "保存したパスワード・API キーを削除しました", "저장된 비밀번호 · 설정 · 비밀번호가 필요한 기능을 쓸 때 묻습니다. 잊으면 최상 비밀번호로 초기화하거나, 저장된 비밀번호를 지우고 초기화할 수 있습니다.": "保存したパスワード・設定・パスワードが必要な機能を使うときに尋ねます。忘れたら最上位パスワードで初期化するか、保存したパスワードを削除して初期化できます。", @@ -1289,6 +1384,7 @@ "저장하고 검색": "保存して検索", "저장하고 계속": "保存して続行", "저장하고 실행": "保存して実行", + "저장하는 중": "保存中", "저장하지 못했습니다: {0}": "保存できませんでした: {0}", "저장하지 않은 변경": "未保存の変更", "저장했습니다": "保存しました", @@ -1405,6 +1501,8 @@ "지원하지 않는 문자셋: {0}": "対応していない文字コード: {0}", "지정한 파일이 없습니다": "指定したファイルがありません", "직접 고른 적이 없는 예전 기본값을 새 기본값으로 바꿨습니다. 원래대로 쓰려면 환경 설정에서 되돌리세요.": "自分で選んだことのない以前の既定値を新しい既定値に変えました。元のまま使うには環境設定で戻してください。", + "직접 고름": "手動で選択", + "직접 운영하는 서버 (ComfyUI · Automatic1111, 예: Tailscale 주소) 나 인터넷 서비스 (OpenAI 이미지 API 형식). 넣지 않으면 기기 안에서만 그립니다. 쓸 때는 그림 · 프롬프트가 기기 밖으로 나간다고 늘 알립니다. API 키는 안전 저장소에 둡니다.": "自分で運営するサーバー (ComfyUI・Automatic1111、例: Tailscale のアドレス) またはインターネットサービス (OpenAI 画像 API 形式)。追加しなければこの端末内だけで描きます。使うときは画像・プロンプトが端末の外に送られることを常に表示します。API キーは安全な保存領域に置きます。", "직접 입력": "直接入力", "직접 입력…": "直接入力…", "직접 지정": "直接指定", @@ -1431,14 +1529,17 @@ "찾을 수 없습니다 (이미 지워졌거나 옮겨졌습니다)": "見つかりません (すでに削除または移動されました)", "채도": "彩度", "채우기 (가운데 잘라냄)": "フィル(中央を切り抜き)", + "처리 장치": "処理装置", "처음 사용 시 내려받기 {0}": "初回使用時にダウンロード {0}", "처음 시작 시": "起動時", "처음 쓸 때 내려받기 (기본)": "初めて使うときにダウンロード (既定)", "처음 아이콘 (파랑)": "最初のアイコン(青)", + "처음대로": "既定に戻す", "첫 장입니다.": "最初のページです。", "첫 줄 추가": "最初の行を追加", "청각장애인용": "聴覚障害者用", "체코어": "チェコ語", + "체크포인트 파일 이름 (예: sd_xl_turbo_1.0_fp16.safetensors)": "チェックポイントのファイル名 (例: sd_xl_turbo_1.0_fp16.safetensors)", "체크한 동영상 {0}개의 {1}": "チェックした動画 {0}個の{1}", "초기화": "初期化", "초기화할 것을 고르세요.": "初期化するものを選んでください。", @@ -1484,6 +1585,7 @@ "크기 · 보정을 원래대로 돌려 코덱도 \"원본 유지\" 로 되돌렸습니다.": "サイズ・補正を元に戻したので、コーデックも「元のまま」に戻しました。", "크기를 바꾸면 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "サイズを変えると再エンコードが必要なため、コーデックを「元のまま」から H.264 に変更しました。", "크메르어": "クメール語", + "키 있음": "キーあり", "타갈로그어": "タガログ語", "타밀어": "タミル語", "탐색기": "エクスプローラー", @@ -1581,6 +1683,7 @@ "필수 프로그램 설치": "必須プログラムをインストール", "필수 프로그램 점검": "必須プログラムの点検", "필수 프로그램이 모두 설치되어 있습니다.": "必須プログラムはすべてインストールされています。", + "필요한 것 모두 받기": "必要なものをすべてダウンロード", "하위 폴더가 없습니다": "サブフォルダーがありません", "한 번 더": "もう一度", "한 번: 바로 실행": "1 回: すぐに開く", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 95325fa..6f8e317 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -28,6 +28,7 @@ "\"{0}\" 안에 만듭니다": "在“{0}”中创建", "\"{0}\" 에는 없고 대상 \"{1}\" 에만 있는 항목입니다. 지우기 포함으로 맞추면 대상에서 지워집니다 (되돌릴 수 없음).": "这些项目不在“{0}”中,只在目标“{1}”中。包含删除同步后会从目标中删除 (无法撤销)。", "\"{0}\" 을(를) \"{1}\" 폴더로 옮길까요?": "将“{0}”移到“{1}”文件夹吗?", + "\"{0}\" 을(를) 지울까요?": "要删除“{0}”吗?", "\"{0}\" 을(를) 지울까요? 되돌릴 수 없습니다.": "删除\"{0}\"吗? 此操作无法撤销。", "\"{0}\" 을(를) 휴지통으로 보낼까요?": "将“{0}”移到回收站吗?", "\"{0}\" 이 비어 있어, 맞추면 대상 \"{1}\" 의 아래 항목이 모두 지워집니다 (되돌릴 수 없음). 원본을 일부러 비운 경우에만 누르세요.": "“{0}”为空,同步后会删除目标“{1}”中的以下所有项目 (无法撤销)。仅在你有意清空源文件夹时按下。", @@ -47,6 +48,7 @@ "(지워짐)": "(将删除)", "(현재 방식은 옵션 없음)": "(当前方式没有选项)", "+ 여기에 새 줄": "+ 在此新增一行", + "-1 은 아무렇게나": "-1 为随机", ".{0} 재생 프로그램 (exe)": ".{0} 播放程序(exe)", "0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 不限制。适用于所有方式: rsync 用 --bwlimit,当前方式由应用调节,robocopy 用 /IPG 近似。也可直接写在选项栏中 (优先)", "0 으로": "归零", @@ -55,7 +57,17 @@ "5초 뒤로": "后退 5 秒", "5초 앞으로": "前进 5 秒", "90° 회전 (시계 방향)": "旋转 90° (顺时针)", + "AI 그림": "AI 图片", + "AI 그림 ({0})": "AI 图片 ({0})", + "AI 그림 (지금 여기)": "AI 图片 (当前位置)", + "AI 그림 · 해상도 올리기": "AI 图片 · 提高分辨率", + "AI 그림 저장 폴더": "AI 图片保存文件夹", + "AI 그림: {0}": "AI 图片: {0}", + "AI 그림으로 (이 그림을 바탕으로)": "转到 AI 图片 (以此图片为基础)", "AI 번역": "AI 翻译", + "AI 설정 · 모델 받기 · 서비스 추가": "AI 设置 · 下载模型 · 添加服务", + "AI 엔진이 실패했습니다 ({0}): {1}": "AI 引擎失败 ({0}): {1}", + "AI 엔진이 없습니다. 환경 설정 > AI 그림에서 받아 주세요.": "没有 AI 引擎。请在 设置 > AI 图片 中下载。", "AI 인식": "AI 识别", "AI 자막": "AI 字幕", "AI 자막 {0}개": "AI 字幕 {0} 个", @@ -73,7 +85,9 @@ "AI 자막을 만듭니다": "生成 AI 字幕", "AI 작업 중이라 이번에는 건너뜁니다": "正在进行 AI 任务,本次跳过", "API 키": "API 密钥", + "API 키 (선택)": "API 密钥 (可选)", "API 키 (필수, 무료)": "API 密钥(必填,免费)", + "API 키 또는 아이디:비밀번호 (선택)": "API 密钥或 用户名:密码 (可选)", "AV1 영상은 이 기기에서 다시 인코딩할 수 없습니다. 코덱을 \"원본 유지\" 로 바꿔 MKV 를 만드세요. (PC 판은 됩니다)": "此设备无法重新编码 AV1 视频。请将编解码器改为“保持原样”后制作 MKV。(PC 版可以)", "Android 가 막은 폴더 (Android/data 등) 이거나 \"모든 파일에 대한 접근\" 권한이 없습니다.": "这是 Android 限制的文件夹 (如 Android/data),或没有“所有文件访问”权限。", "Android 는 rsync 를 내려받을 수 없습니다. 직접 지정하세요.": "Android 无法下载 rsync,请自行指定。", @@ -107,6 +121,7 @@ "JJ_MKVMaker 로 재생": "用 JJ_MKVMaker 播放", "JJ_MKVMaker_즐겨찾기.html": "JJ_MKVMaker_书签.html", "JavaScript 사용": "启用 JavaScript", + "LCM-LoRA (SD1.5, 4단계로 빠르게)": "LCM-LoRA (SD1.5,4 步快速生成)", "MKV · AI 작업": "MKV · AI 任务", "MKV · 자막 (jj_mkv)": "MKV · 字幕(jj_mkv)", "MKV · 자막 저장 위치": "MKV · 字幕保存位置", @@ -136,6 +151,8 @@ "NAS · 클라우드의 WebDAV 폴더를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 (위쪽 \"SD 카드\" 옆 탭). 비밀번호는 이 기기의 안전 저장소에만 두고 설정 파일에는 쓰지 않습니다. 한 번 넣으면 업데이트 뒤에도 남습니다.": "在文件浏览器和 Rsync 界面中打开、复制和同步 NAS · 云端的 WebDAV 文件夹 (顶部“SD 卡”旁的标签)。密码只保存在本设备的安全存储中,不写入设置文件,更新后仍会保留。", "NLLB 언어 코드가 아닙니다: {0}": "不是 NLLB 语言代码: {0}", "NLLB-200 번역 (600M)": "NLLB-200 翻译(600M)", + "NVIDIA CUDA 12 런타임 (CUDA 판과 함께)": "NVIDIA CUDA 12 运行时 (与 CUDA 版一起)", + "OpenAI 이미지 API 형식": "OpenAI 图像 API 格式", "OpenSubtitles API 키 또는 로그인 정보가 올바르지 않습니다. ({0})": "OpenSubtitles API 密钥或登录信息不正确。({0})", "OpenSubtitles 무료 API 키가 필요합니다.\n1. opensubtitles.com 가입 (무료)\n2. 프로필 → API consumers → New consumer 에서 키 만들기\n3. 아래 칸 또는 환경 설정에 키 붙여넣기": "需要 OpenSubtitles 免费 API 密钥。\n1. 注册 opensubtitles.com(免费)\n2. 个人资料 → API consumers → New consumer 创建密钥\n3. 将密钥粘贴到下方栏或设置中", "OpenSubtitles 오류 {0}: {1}": "OpenSubtitles 错误 {0}: {1}", @@ -152,6 +169,9 @@ "SD 카드": "SD 卡", "SD 카드가 꽂혀 있는지, 네트워크 (NAS · VPN) 가 연결되어 있는지, 폴더 권한이 있는지 확인한 뒤 [다시 시도] 를 누르세요.": "请确认 SD 卡已插入、网络 (NAS · VPN) 已连接、拥有文件夹权限,然后按 [重试]。", "SRT 형식이 아니거나 비어 있습니다.": "不是 SRT 格式或为空。", + "Stable Diffusion v1.5": "Stable Diffusion v1.5", + "Stable Diffusion v1.5 는 CreativeML OpenRAIL-M 라이선스입니다. 상업적 사용을 포함해 쓸 수 있지만, 다음 용도로는 쓸 수 없습니다: 법을 어기는 일, 미성년자를 해치거나 이용하는 일, 남을 해치려는 거짓 정보, 개인 정보로 사람을 해치는 일, 차별 · 괴롭힘, 의료 · 법률 판단을 대신하는 일 등 (라이선스 부속서 A). 만든 그림에 대한 책임은 쓰는 사람에게 있습니다.": "Stable Diffusion v1.5 采用 CreativeML OpenRAIL-M 许可证。可用于包括商业用途在内的场合,但不得用于: 违法行为、伤害或利用未成年人、意在伤害他人的虚假信息、利用个人信息伤害他人、歧视 · 骚扰、代替医疗 · 法律判断等 (许可证附件 A)。生成图片的责任由使用者承担。", + "TAESD (빠른 디코더)": "TAESD (快速解码器)", "VLC 가 설치되어 있지 않습니다. 설치하면 목록에 나타납니다 (videolan.org).": "未安装 VLC。安装后会出现在列表中(videolan.org)。", "VLC 로 열기": "用 VLC 打开", "WebDAV · OpenSubtitles 등. 서버 목록과 아이디는 남깁니다": "WebDAV · OpenSubtitles 等。保留服务器列表和用户名", @@ -226,6 +246,8 @@ "rsync 양쪽 (⇄)": "rsync 双向 (⇄)", "rsync 에 고르기": "选为 rsync", "rsync 옵션": "rsync 选项", + "stable-diffusion.cpp (Windows · CPU · Vulkan)": "stable-diffusion.cpp (Windows · CPU · Vulkan)", + "stable-diffusion.cpp (Windows · NVIDIA CUDA 12)": "stable-diffusion.cpp (Windows · NVIDIA CUDA 12)", "v{0} 받는 중": "正在下载 v{0}", "v{0} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n{1}": "已下载 v{0} 并完成文件校验(SHA256)。\n{1}", "yt-dlp 를 새 버전으로 바꿨습니다": "已将 yt-dlp 更新为新版本", @@ -238,6 +260,7 @@ "{0} / {1}장": "{0} / {1} 张", "{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2}(在主界面顶部更改)", "{0} → {1} 번역 {2}%": "{0} → {1} 翻译 {2}%", + "{0} 라이선스": "{0} 许可证", "{0} 로 번역 중 {1}%": "正在翻译为 {0} {1}%", "{0} 로 표현할 수 없는 글자 {1}개가 \"?\" 로 바뀝니다.\n계속 저장할까요? (UTF-8 을 권장합니다)": "{1} 个无法用 {0} 表示的字符将变为“?”。\n仍要保存吗?(推荐使用 UTF-8)", "{0} 번역 중": "正在翻译 {0}", @@ -248,10 +271,13 @@ "{0} 순으로 정렬{1}": "按{0}排序{1}", "{0} 아래에 만듭니다": "在 {0} 下创建", "{0} 안의 파일": "{0} 中的文件", + "{0} 에 보내는 중": "正在发送到 {0}", "{0} 에 붙여넣기 ({1}개)": "粘贴到 {0} ({1} 个)", + "{0} 에서 그리는 중 ({1}초)": "正在 {0} 上绘制 ({1} 秒)", "{0} 에서 받을 파일을 찾지 못했습니다": "未在 {0} 找到要下载的文件", "{0} 외 {1}개": "{0} 等 {1} 个", "{0} 을(를) 설치했습니다.": "已安装 {0}。", + "{0} 을(를) 지울까요?": "要删除 {0} 吗?", "{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg 中没有 {0} 编码器。", "{0} 전체": "{0} 全部", "{0} 제거": "移除 {0}", @@ -281,6 +307,7 @@ "{0}개 항목을 휴지통으로 보냈습니다.": "已将 {0} 个项目移到回收站。", "{0}개는 지우지 않았습니다 (취소).": "{0} 个未删除 (已取消)。", "{0}개는 휴지통보다 커서 영구 삭제했습니다 (Windows 가 물은 대로).": "{0} 个超出回收站容量,已永久删除 (按 Windows 的提示)。", + "{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).": "{0} 个项目无法放入回收站,已永久删除 (按 Windows 询问时的选择)。", "{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "已将 {0} 个导入“其他书签”。", "{0}개를 옮겼습니다 → {1}": "已移动 {0} 个 → {1}", "{0}개를 옮겼습니다 → {1} ({2})": "已移动 {0} 个 → {1}({2})", @@ -313,16 +340,19 @@ "가로 고정": "固定横屏", "가로 고정 · 세로 고정, 또는 자동 (기기를 돌리는 대로, 기기의 자동 회전 설정을 따름)": "锁定横屏 · 锁定竖屏,或自动(随设备方向旋转,遵循设备的自动旋转设置)", "가입 → 프로필 → API consumers → New consumer": "注册 → 个人资料 → API consumers → New consumer", + "가장 빠른 처리 장치를 찾는 중 (처음 한 번): {0}": "正在寻找最快的处理器 (仅首次): {0}", "가져오기": "导入", "가져온 즐겨찾기": "导入的书签", "가져온 즐겨찾기 ({0})": "导入的书签({0})", "가져올 즐겨찾기가 없습니다.": "没有可导入的书签。", "같은 시리즈 (번호만 다른 파일)": "同一系列(仅编号不同的文件)", + "같은 조건으로 다시 (시드 그대로)": "以相同条件再来一次 (种子不变)", "같은 폴더의 모든 동영상": "同一文件夹中的所有视频", "같은 폴더의 자막은 자동으로 추가됩니다": "同一文件夹中的字幕会自动添加", "같은 폴더이거나 한쪽이 다른 쪽 안에 있습니다. 다른 폴더를 고르세요.": "是同一文件夹,或一个在另一个里面。请选择其他文件夹。", "같은 항목을 같은 곳으로 복사 · 이동하는 중입니다.": "正在将相同项目复制 · 移动到同一位置。", "같은 화질이면 H.264 먼저 받기": "相同画质时优先 H.264", + "개수": "数量", "거꾸로": "倒序", "건너뜀 (출력 폴더의 파일): {0}": "已跳过(输出文件夹中的文件): {0}", "검색": "搜索", @@ -364,18 +394,31 @@ "권한이 없습니다": "没有权限", "그 파일 하나만": "仅该文件", "그래도 맞추기 ({0}개 지움)": "仍然同步 (删除 {0} 个)", + "그리는 중 {0}/{1}": "正在绘制 {0}/{1}", + "그리려면 {0} 를 받아야 합니다 (모델은 받은 뒤 이 기기에만 있고, 그림 · 프롬프트는 밖으로 나가지 않습니다).": "要绘制,需要下载 {0} (模型只保存在本设备上,图片和提示词不会离开设备)。", "그리스어": "希腊语", + "그릴 것 (프롬프트, 영어가 잘 됩니다)": "要画的内容 (提示词,英文效果最好)", "그림 {0}장으로 PDF 를 만들었습니다: {1}": "已用 {0} 张图片制作 PDF: {1}", "그림 {0}장을 PDF 로 만들기": "将 {0} 张图片制作为 PDF", "그림 {0}장을 {1} 페이지 뒤에 넣었습니다 (저장해야 파일에 반영됩니다).": "已在第 {1} 页后加入 {0} 张图片 (保存后才会写入文件)。", "그림 · 만화 보기, PDF 보기 · 페이지 편집, ZIP 목록 · 만화 보기": "图片 · 漫画阅读,PDF 查看 · 页面编辑,ZIP 列表 · 漫画阅读", + "그림 · 프롬프트가 기기 밖으로 나갑니다 ({0})": "图片和提示词将离开本设备 ({0})", "그림 보기 대상": "图片查看的文件类型", + "그림으로 바꾸는 중": "正在生成图像", + "그림을 만들지 못했습니다: {0}": "无法生成图片: {0}", + "그림을 바꿔 만들기": "基于图片生成", + "그림을 받을 수 없습니다 ({0})": "无法获取图片 ({0})", + "그림을 올릴 수 없습니다 ({0})": "无法上传图片 ({0})", "그림을 페이지로 넣기 (이 페이지 뒤)": "将图片作为页面加入 (本页之后)", + "그림이 거의 같고 마지막 단계가 10배쯤 빠릅니다 (받아 두었을 때)": "图片几乎相同,最后一步快约 10 倍 (已下载时)", "글을 격자에 반영": "将文本应用到网格", "글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 (지금 {0}%)": "文字 · 按钮大小。可随时用顶部栏的 − · + 调整,点击中间的数字恢复为此大小(当前 {0}%)", "금": "五", "기계 번역": "机器翻译", "기기": "设备", + "기기 안에서 그리기 (stable-diffusion.cpp · SD1.5 + LCM)": "在本设备上绘制 (stable-diffusion.cpp · SD1.5 + LCM)", + "기기 안에서 그리려면 모델을 받아야 합니다 (SD1.5 + LCM, 약 4.4GB).": "要在本设备上绘制,需要先下载模型 (SD1.5 + LCM,约 4.4GB)。", + "기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "要在本设备上绘制,需要先下载模型 ({0})", "기능 버튼 줄": "功能按钮栏", "기본": "默认", "기본 앱으로 열기": "用默认应用打开", @@ -412,9 +455,12 @@ "끝에서 처음으로 돌기": "到末尾后回到开头", "나가기": "退出", "나머지는 대기했다가 차례로 받습니다": "其余的等待并依次下载", + "나머지는 지웠습니다.": "其余已删除。", "나중에": "以后再说", "날짜": "日期", + "남은 {0}장": "剩余 {0} 张", "남은 시간 {0}": "剩余时间 {0}", + "남은 약 {0}": "剩余约 {0}", "남은 용량": "剩余空间", "내려받기": "下载", "내려받기 (Windows 만)": "下载 (仅 Windows)", @@ -484,6 +530,7 @@ "다시 세기": "重新统计", "다시 시도": "重试", "다시 시작": "重新启动", + "다시 쓰려면 다시 받아야 합니다.": "再次使用需要重新下载。", "다시 켤 때 동기화": "重新启动时同步", "다시 확인": "重新检查", "다운로드": "下载", @@ -514,6 +561,7 @@ "다음 (N)": "下一个(N)", "다음 페이지 (Alt+→)": "下一页(Alt+→)", "다음부터 묻지 않기 (환경 설정 > 실행 에서 바꿀 수 있음)": "下次不再询问(可在 设置 > 运行 中更改)", + "단계": "步数", "단축키 해제": "注销快捷键", "닫기": "关闭", "닫힘 (실행 안 함)": "已关闭 (未执行)", @@ -572,6 +620,7 @@ "동영상을 끌어다 놓으면 목록에 추가됩니다": "拖放视频即可加入列表", "동영상을 세 번 누르면": "连按视频三次时", "동영상이 있는 폴더 아래 jj_mkv (기본)": "视频所在文件夹下的 jj_mkv(默认)", + "동의하고 받기": "同意并下载", "동작 중 · {0} 까지": "运行中 · 直到 {0}", "되돌릴 수 없습니다.": "无法撤销。", "되살리고 다시 시작": "恢复并重新启动", @@ -602,6 +651,7 @@ "마스터 비밀번호": "主密码", "마스터 비밀번호 넣기": "输入主密码", "마스터 비밀번호 정하기": "设置主密码", + "마스터 비밀번호가 필요합니다": "需要主密码", "마스터 비밀번호가 필요해 기다리는 중": "需要主密码,正在等待", "마스터 비밀번호로 잠겨 있습니다": "已用主密码锁定", "마스터 비밀번호를 끌까요?": "要关闭主密码吗?", @@ -616,7 +666,9 @@ "마지막 장입니다.": "已是最后一页。", "마지막 한 페이지는 지울 수 없습니다.": "无法删除仅剩的一页。", "마지막 화면에서 더 밀면 첫 화면으로 (끄면 끝에서 멈춤)": "在最后一个界面继续滑动会回到第一个界面 (关闭: 停在末尾)", + "만든 그림 · 저장: {0}": "生成的图片 · 保存位置: {0}", "만들 자막 언어 (파일명_언어코드.srt)": "要生成的字幕语言(文件名_语言代码.srt)", + "만들기": "生成", "만들다 만 파일 {0}개": "{0} 个未完成的文件", "만들다 만 파일 {0}개를 지울까요?": "删除 {0} 个未完成的文件吗?", "만화 보기": "漫画阅读", @@ -639,13 +691,16 @@ "멈춤 · {0}": "已停止 · {0}", "모니터링": "监控", "모니터링의 [실행]": "监视中的 [执行]", + "모델 (선택)": "模型 (可选)", "모델 내려받는 중 {0}%": "正在下载模型 {0}%", "모델 내려받는 중: {0} ({1})": "正在下载模型: {0}({1})", + "모델 읽는 중": "正在加载模型", "모델 파일이 잘렸습니다 (위치 {0})": "模型文件被截断(位置 {0})", "모델을 내려받을 수 없습니다 ({0}): {1}": "无法下载模型({0}): {1}", "모두 나중에": "全部稍后", "모두 내장 플레이어": "全部使用内置播放器", "모두 멈춤": "全部暂停", + "모두 받았습니다. 512×512 한 장에 폰은 약 1분, PC 는 장치에 따라 1 ~ 5분 걸립니다.": "已全部下载。生成一张 512×512 图片,手机约 1 分钟,电脑视硬件需 1~5 分钟。", "모두 선택": "全选", "모두 시작 (멈춘 것 {0}개)": "全部开始 (已暂停 {0} 个)", "모두 종료": "全部退出", @@ -663,6 +718,7 @@ "목록을 받는 중": "正在获取列表", "목록의 동영상 {0}개 모두": "列表中的全部 {0} 个视频", "몽골어": "蒙古语", + "무엇을 그릴지 적어 주세요": "请输入要画的内容", "무엇이 바뀌는지 비교하는 중…": "正在比较将要更改的内容…", "무한": "无限制", "문서 미리보기": "文档预览", @@ -674,6 +730,7 @@ "미지정": "未指定", "바꾸거나 끄려면 지금 마스터 비밀번호 (또는 최상 비밀번호) 를 넣으세요.": "如需更改或关闭,请输入当前主密码 (或最高密码)。", "바꾸기": "更改", + "바꾸는 정도 {0}%": "改变程度 {0}%", "바꾸면 바로 저장됩니다": "更改后立即保存", "바꾼 것이 없습니다 (원본 그대로)": "未做更改(保持原样)", "바뀌는 것 없음 (양쪽이 같습니다)": "没有更改 (两边相同)", @@ -684,7 +741,12 @@ "바로 시작: 켜진 동기화를 모두 시작 · 골라서 시작: 켤 때 고르기 · 시작 안 함: 모니터링 > lsync 의 시작 버튼으로": "立即开始: 开始所有已启用的同步 · 选择后开始: 启动时选择 · 不开始: 用 监控 > lsync 的开始按钮", "바로 재생 (기본)": "立即播放(默认)", "바로 전 작업: {0}": "上一个任务: {0}", + "바탕 그림 고르기": "选择基础图片", + "바탕 그림 고르기 (그림 → 그림)": "选择基础图片 (图片 → 图片)", + "바탕 그림 빼기": "移除基础图片", "반대로 90° 회전": "逆时针旋转 90°", + "받기": "下载", + "받기 화면으로": "前往下载画面", "받는 중": "正在下载", "받는 중이거나 일시정지한 다운로드가 있어 건너뜁니다 (이어받기가 깨지지 않게)": "有正在下载或已暂停的下载,已跳过(以便继续下载)", "받는 중인 다운로드 {0}개": "正在下载 {0} 个", @@ -700,12 +762,16 @@ "받은 자막을 한국어로 번역해 함께 추가 (한국어 자막이 없을 때)": "将收到的字幕翻译成韩语并一并添加(没有韩语字幕时)", "받은 파일에 libcef.dll 이 없습니다.": "下载的文件中没有 libcef.dll。", "받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "下载的文件已损坏或不是正确的文件(SHA256 不匹配)。已停止安装。", + "받은 파일이 손상되었습니다 (SHA-256 다름): {0}": "下载的文件已损坏 (SHA-256 不一致): {0}", + "받을 수 없습니다 ({0}): {1}": "无法下载 ({0}): {1}", + "받음": "已下载", "받음 {0}": "已接收 {0}", "받지 못했습니다: {0}": "无法下载: {0}", "밝게": "调亮", "밝기": "亮度", "밤 0~6시": "夜间 0~6 点", "방법: {0}": "方式: {0}", + "방식": "方式", "백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)": "后台: 仅隐藏窗口,下载 · 转换继续进行。用 {0} 或托盘图标重新打开。\n要完全退出: 右键托盘图标 > 退出(无论何种设置都会退出)", "백그라운드로": "转到后台", "백그라운드로 계속 (기본)": "在后台继续(默认)", @@ -784,6 +850,8 @@ "빈 폴더 그대로": "保留空文件夹", "빈 폴더 지움 · 원본 폴더는 남김": "删除空文件夹 · 保留源文件夹", "빈 폴더 지움 · 원본 폴더도 지움": "删除空文件夹 · 源文件夹也删除", + "빠른 디코더 (TAESD)": "快速解码器 (TAESD)", + "빼고 싶은 것 (선택)": "要排除的内容 (可选)", "사용 안 함": "不使用", "사용 안 함. \"Sign in to confirm you're not a bot\" 오류가 나오면 설정하세요": "不使用。如出现“Sign in to confirm you're not a bot”错误请设置", "사용: {0}": "使用: {0}", @@ -808,19 +876,27 @@ "새 폴더": "新建文件夹", "새 폴더 추가": "添加新文件夹", "새 폴더…": "新建文件夹…", + "새 화면 \"AI 그림\" 을 위쪽 이동 버튼에 넣었습니다 (글 → 그림 · 그림 → 그림, 모델은 환경 설정에서 받기). 끄기: 환경 설정 > 컴포넌트": "已将新画面“AI 图片”加入顶部导航按钮 (文字 → 图片 · 图片 → 图片,模型在设置中下载)。关闭: 设置 > 组件", "새로 고침": "刷新", "새로 넣은 비밀번호는 아직 저장되지 않았습니다. 앱을 끄면 사라집니다.": "输入的密码尚未保存。关闭应用后会丢失。", "새로고침 (F5)": "刷新(F5)", "색 보정": "色彩校正", "색온도": "色温", + "서버 · 서비스 (기기 밖)": "服务器 · 服务 (设备外)", "서버 설정 고치기": "修改服务器设置", "서버 설정과 이 기기에 저장된 비밀번호를 지웁니다. 서버의 파일은 그대로입니다. 이 서버를 쓰는 동기화 · 복사는 다시 넣을 때까지 연결되지 않습니다.": "删除服务器设置和本设备上保存的密码。服务器上的文件保持不变。使用此服务器的同步 · 复制在重新添加之前无法连接。", "서버 인증서를 확인할 수 없습니다": "无法验证服务器证书", "서버가 https 주소로 옮기라고 합니다 ({0}). 서버 설정의 주소를 https:// 로 바꾸세요.": "服务器要求改用 https 地址 ({0})。请将服务器设置中的地址改为 https://。", + "서버가 거절했습니다 ({0}): {1}": "服务器拒绝 ({0}): {1}", + "서버가 그림을 돌려주지 않았습니다": "服务器没有返回图片", + "서버가 너무 오래 답하지 않습니다": "服务器长时间没有响应", "서버가 다른 주소로 옮기라고 합니다 ({0}). 서버 설정의 주소를 확인하세요.": "服务器要求改用其他地址 ({0})。请检查服务器设置中的地址。", "서버에 닿지 않습니다": "无法连接服务器", "서버에서 이 계정의 권한을 확인하세요.": "请在服务器上检查此账户的权限。", "서버와 같은 네트워크에 있거나 VPN (예: Tailscale) 이 켜져 있어야 합니다. 켠 뒤 [다시 시도] 를 누르세요.": "需要与服务器在同一网络,或打开 VPN (如 Tailscale)。打开后点 [重试]。", + "서비스 고치기": "编辑服务", + "서비스 설정과 저장된 API 키를 지웁니다.": "删除服务设置及保存的 API 密钥。", + "서비스 추가": "添加服务", "선택": "选择", "선택 {0} / {1}개": "已选 {0} / {1} 个", "선택 끝": "完成选择", @@ -884,6 +960,9 @@ "시간 지정 (cron)": "指定时间 (cron)", "시간 형식 오류 {0}곳": "{0} 处时间格式错误", "시간순 정렬": "按时间排序", + "시드": "种子", + "시드 (-1 은 아무렇게나)": "种子 (-1 为随机)", + "시드 {0}": "种子 {0}", "시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일": "系列: 像 file_001 · file_002、S01E01 · S01E02 这样仅编号不同的文件", "시스템 막대 보이기 (시계 · 알림 · 뒤로)": "显示系统栏 (时钟 · 通知 · 返回)", "시스템 언어 따르기 ({0})": "跟随系统语言 ({0})", @@ -977,6 +1056,7 @@ "업데이트로 바뀐 기본값 알림": "更新所更改默认值的通知", "없음": "无", "없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다": "无: 首次在详细信息中点击[移动]时选择文件夹并创建", + "엔진": "引擎", "여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "拖放到此处即可加入制作 MKV 的视频列表", "여러 개를 골랐을 때": "选择多个时", "여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "将多个视频制作成 MKV 时同时处理的数量(重新编码会占用大量 CPU)", @@ -1003,6 +1083,9 @@ "예: /E /COPY:DAT /DCOPY:T /R:2 /W:2. 이동은 /MOVE 를 자동으로 붙임": "例: /E /COPY:DAT /DCOPY:T /R:2 /W:2。移动时自动加上 /MOVE", "예: 0 9-17 * * 1-5 (평일 9시~17시 59분) · 0 0-5 * * * (매일 0~5시). 분은 무시하고 1시간 단위": "例: 0 9-17 * * 1-5 (工作日 9:00~17:59) · 0 0-5 * * * (每天 0~5 点)。忽略分钟,以 1 小时为单位", "예: Ctrl+Shift+X (Ctrl·Shift·Alt·Win + 영문자·숫자·F1~F12)": "例: Ctrl+Shift+X(Ctrl·Shift·Alt·Win + 字母·数字·F1~F12)", + "예: http://100.x.x.x:7860 (--api 로 켠 Automatic1111 · Forge)": "例: http://100.x.x.x:7860 (使用 --api 启动的 Automatic1111 · Forge)", + "예: http://100.x.x.x:8188 (ComfyUI)": "例: http://100.x.x.x:8188 (ComfyUI)", + "예: https://api.openai.com (경로 /v1/images/generations 를 붙여 부름)": "例: https://api.openai.com (调用时附加路径 /v1/images/generations)", "예: https://nas.local:5006/home · https://cloud.example.com/remote.php/dav/files/아이디": "例: https://nas.local:5006/home · https://cloud.example.com/remote.php/dav/files/用户名", "예전 구조의 프로그램 파일 정리 ({0}개): {1}": "已清理旧结构的程序文件({0} 个): {1}", "예전 버전": "以前的版本", @@ -1105,6 +1188,9 @@ "이 WebDAV 서버는 로그인이 필요합니다": "此 WebDAV 服务器需要登录", "이 {0}의 설정 파일에 저장됩니다": "保存在此{0}的设置文件中", "이 {0}의 안전 저장소에 저장됩니다 (설정 파일에는 쓰지 않음)": "保存在此 {0} 的安全存储中 (不写入设置文件)", + "이 그림을 바탕으로 (그림 → 그림)": "以此图片为基础 (图片 → 图片)", + "이 기기 (SD1.5 + LCM)": "本设备 (SD1.5 + LCM)", + "이 기기 ({0})": "本设备 ({0})", "이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "本设备只保存无法还原的哈希值,而不是密码本身。", "이 기기에서는 AI 번역을 쓸 수 없습니다.": "此设备无法使用 AI 翻译。", "이 기기용 파일 없음": "没有此设备的文件", @@ -1121,6 +1207,9 @@ "이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.": "未找到此浏览器的书签文件。", "이 서버를 쓰는 것 {0}개 (지워도 목록에는 남고, 실행하면 서버가 없다고 알립니다):": "使用此服务器的项目 {0} 个 (删除后仍保留在列表中,运行时会提示服务器不存在):", "이 서버의 저장된 비밀번호를 쓰려면 마스터 비밀번호가 필요합니다. 넣으면 이어서 맞춥니다. 대상 파일은 건드리지 않았습니다.": "使用此服务器保存的密码需要主密码。输入后将继续同步。未改动目标文件。", + "이 서버의 저장된 비밀번호를 쓰려면 마스터 비밀번호를 넣으세요.": "要使用此服务器保存的密码,请输入主密码。", + "이 서비스 방식은 그림 → 그림을 지원하지 않습니다": "此服务方式不支持 图片 → 图片", + "이 서비스로 그리면 그림 · 프롬프트가 기기 밖 (이 주소) 으로 나갑니다.": "使用此服务绘制时,图片和提示词会发送到设备外 (此地址)。", "이 설치본에는 rsync 가 들어 있지 않습니다 (rsync 로 복사할 때 · 오른쪽 [내려받기] 로 받습니다)": "此安装中没有 rsync (使用 rsync 复制时 · 或用右侧 [下载] 获取)", "이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.": "此安装中没有 rsync。请自行指定。", "이 언어 지우기": "删除此语言", @@ -1168,6 +1257,7 @@ "이름 변경": "重命名", "이름을 바꿀 수 없습니다: {0}": "无法重命名: {0}", "이름을 바꿀 항목 하나를 고르세요.": "请选择一个要重命名的项目。", + "이미 그리는 중입니다": "已在绘制中", "이미 동영상 목록에 있습니다 ({0}개).": "已在视频列表中({0} 个)。", "이미 목록에 있는 동영상입니다 ({0}개).": "这些视频已在列表中({0} 个)。", "이미 받는 중이거나 받을 수 없는 주소입니다.": "已在下载中,或该地址无法下载。", @@ -1203,10 +1293,13 @@ "임시 파일 · 남은 조각 정리": "清理临时文件 · 残留碎片", "자동": "自动", "자동 (가로로 긴 그림만)": "自动 (仅横向图片)", + "자동 (가장 빠른 것)": "自动 (最快)", "자동 (기본)": "自动 (默认)", "자동 감지": "自动检测", "자동 백업 그대로": "保持自动备份", "자동 설치를 할 수 없는 위치입니다. 페이지에서 직접 받아 주세요.": "此位置无法自动安装。请从页面手动下载。", + "자동: 잰 결과 {0} 가 가장 빠릅니다": "自动: 测得 {0} 最快", + "자동: 처음 그릴 때 짧게 재서 가장 빠른 장치를 고릅니다": "自动: 首次绘制时短暂测试并选择最快的处理器", "자막": "字幕", "자막 (AI · 인터넷)": "字幕(AI · 网络)", "자막 (S)": "字幕(S)", @@ -1273,9 +1366,11 @@ "저장 (MKV 반영) *": "保存(应用到 MKV)*", "저장 공간 정리": "存储空间清理", "저장 공간 정리: 파일 {0}개 · {1}": "存储空间清理: 文件 {0} 个 · {1}", + "저장 공간이 모자랍니다: {0} 이 필요한데 {1} 남았습니다": "存储空间不足: 需要 {0},剩余 {1}", "저장 실패: {0}": "保存失败: {0}", "저장 안 함": "不保存", "저장 위치": "保存位置", + "저장 폴더": "保存文件夹", "저장: {0}": "保存: {0}", "저장된 비밀번호 · API 키를 지웠습니다": "已删除保存的密码 · API 密钥", "저장된 비밀번호 · 설정 · 비밀번호가 필요한 기능을 쓸 때 묻습니다. 잊으면 최상 비밀번호로 초기화하거나, 저장된 비밀번호를 지우고 초기화할 수 있습니다.": "使用已保存的密码 · 设置 · 需要密码的功能时询问。忘记时可用最高密码重置,或删除已保存的密码后重置。", @@ -1289,6 +1384,7 @@ "저장하고 검색": "保存并搜索", "저장하고 계속": "保存并继续", "저장하고 실행": "保存并执行", + "저장하는 중": "正在保存", "저장하지 못했습니다: {0}": "无法保存: {0}", "저장하지 않은 변경": "未保存的更改", "저장했습니다": "已保存", @@ -1405,6 +1501,8 @@ "지원하지 않는 문자셋: {0}": "不支持的字符集: {0}", "지정한 파일이 없습니다": "指定的文件不存在", "직접 고른 적이 없는 예전 기본값을 새 기본값으로 바꿨습니다. 원래대로 쓰려면 환경 설정에서 되돌리세요.": "您从未自行选择过的旧默认值已改为新的默认值。如需保持原样,请在设置中改回。", + "직접 고름": "手动选择", + "직접 운영하는 서버 (ComfyUI · Automatic1111, 예: Tailscale 주소) 나 인터넷 서비스 (OpenAI 이미지 API 형식). 넣지 않으면 기기 안에서만 그립니다. 쓸 때는 그림 · 프롬프트가 기기 밖으로 나간다고 늘 알립니다. API 키는 안전 저장소에 둡니다.": "自己运营的服务器 (ComfyUI · Automatic1111,例如 Tailscale 地址) 或互联网服务 (OpenAI 图像 API 格式)。不添加则只在本设备上绘制。使用时始终提示图片和提示词会离开设备。API 密钥保存在安全存储中。", "직접 입력": "手动输入", "직접 입력…": "手动输入…", "직접 지정": "手动指定", @@ -1431,14 +1529,17 @@ "찾을 수 없습니다 (이미 지워졌거나 옮겨졌습니다)": "找不到 (已被删除或移动)", "채도": "饱和度", "채우기 (가운데 잘라냄)": "填充(裁切中间)", + "처리 장치": "处理器", "처음 사용 시 내려받기 {0}": "首次使用时下载 {0}", "처음 시작 시": "启动时", "처음 쓸 때 내려받기 (기본)": "首次使用时下载 (默认)", "처음 아이콘 (파랑)": "最初的图标(蓝色)", + "처음대로": "恢复默认", "첫 장입니다.": "已是第一页。", "첫 줄 추가": "添加第一行", "청각장애인용": "听障字幕", "체코어": "捷克语", + "체크포인트 파일 이름 (예: sd_xl_turbo_1.0_fp16.safetensors)": "检查点文件名 (例: sd_xl_turbo_1.0_fp16.safetensors)", "체크한 동영상 {0}개의 {1}": "勾选的 {0} 个视频的{1}", "초기화": "重置", "초기화할 것을 고르세요.": "请选择要重置的内容。", @@ -1484,6 +1585,7 @@ "크기 · 보정을 원래대로 돌려 코덱도 \"원본 유지\" 로 되돌렸습니다.": "大小 · 调整已恢复原样,编解码器也恢复为“保持原样”。", "크기를 바꾸면 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "更改大小需要重新编码,因此编解码器已从“保持原样”改为 H.264。", "크메르어": "高棉语", + "키 있음": "已有密钥", "타갈로그어": "他加禄语", "타밀어": "泰米尔语", "탐색기": "文件管理", @@ -1581,6 +1683,7 @@ "필수 프로그램 설치": "安装必需程序", "필수 프로그램 점검": "检查必需程序", "필수 프로그램이 모두 설치되어 있습니다.": "所有必需程序均已安装。", + "필요한 것 모두 받기": "下载全部所需文件", "하위 폴더가 없습니다": "没有子文件夹", "한 번 더": "再输入一次", "한 번: 바로 실행": "单击: 立即打开", diff --git a/integration_test/windows_check_shot_test.dart b/integration_test/windows_check_shot_test.dart new file mode 100644 index 0000000..497b96d --- /dev/null +++ b/integration_test/windows_check_shot_test.dart @@ -0,0 +1,287 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/master_lock.dart'; +import 'package:jj_mkvmaker/platform/windows/recycle_bin.dart'; +import 'package:jj_mkvmaker/services/image_ai.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/services/secret_store.dart'; +import 'package:jj_mkvmaker/ui/ai_image_page.dart'; +import 'package:jj_mkvmaker/ui/explorer_page.dart'; +import 'package:jj_mkvmaker/ui/master_prompt.dart'; +import 'package:jj_mkvmaker/ui/security_settings.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; +import 'package:path/path.dart' as p; + +/// 기기 감독 (Windows) 확인용: 실제 앱 화면을 띄워 누르고 단계마다 PNG (JJ_SHOT_DIR) 로 남긴다. +/// 65 · 94 · 98 (휴지통 · 휴지통 없는 위치 · 오류 문구), 37 (경로 입력), 124 · 130 (마스터 · 최상), 123 (AI 그림). +/// 시험 폴더 · 메모리 저장소만 쓴다 (사용자 설정 · 비밀번호는 건드리지 않음). 휴지통에 넣은 시험 항목은 끝에 $I 로 골라 지운다. +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots'); + // 시험 폴더: 항목이 아주 많은 시스템 임시 폴더 대신 (JJ_SHOT_TMP, 없으면 시스템 임시 폴더) + Directory tempDir(String prefix) { + final base = Platform.environment['JJ_SHOT_TMP']; + if (base == null || base.isEmpty) return Directory.systemTemp.createTempSync(prefix); + Directory(base).createSync(recursive: true); + return Directory(base).createTempSync(prefix); + } + final key = GlobalKey(); + const sizes = [('narrow', Size(700, 900)), ('wide', Size(1280, 800))]; + + Future settle(WidgetTester t, [int rounds = 12]) async { + for (var i = 0; i < rounds; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 120))); + await t.pump(); + } + } + + Future shot(WidgetTester t, String name) async { + await settle(t, 6); + final b = key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final img = await t.runAsync(() => b.toImage()); + final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png)); + Directory(out).createSync(recursive: true); + File(p.join(out, '$name.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + } + + Future show(WidgetTester t, Size size, Widget home) async { + t.view.physicalSize = size; + t.view.devicePixelRatio = 1; + await t.runAsync(() => t.pumpWidget(MaterialApp( + theme: buildTheme(), + // 창 · 안내 (Navigator 위) 까지 찍히게 + builder: (_, child) => RepaintBoundary(key: key, child: child), + // 크기마다 새 화면 (같은 자리의 앞 화면 상태를 이어 쓰지 않게) + home: KeyedSubtree(key: UniqueKey(), child: home), + ))); + await settle(t); + } + + AppController controller() => AppController(PlatformServices.create()); + + /// 그 글이 화면에 나올 때까지 (목록을 다시 읽는 중일 수 있어) 기다렸다가 누른다 + Future tapText(WidgetTester t, String text) async { + for (var i = 0; i < 60 && find.text(text).evaluate().isEmpty; i++) { + await settle(t, 1); + } + await t.tap(find.text(text).first); + await settle(t, 4); + } + + testWidgets('65 · 94 · 98: 지우기 - 휴지통 · 영구 삭제 · 휴지통 없는 위치 · 오류 문구', (t) async { + final tmp = tempDir('jj_shot_del_'); + final started = DateTime.now(); + try { + for (final (tag, size) in sizes) { + final dir = Directory(p.join(tmp.path, tag))..createSync(); + File(p.join(dir.path, 'recycle_me_$tag.txt')).writeAsStringSync('x'); + File(p.join(dir.path, 'keep_$tag.txt')).writeAsStringSync('x'); + final c = controller(); + c.settings + ..explorerLayout = 'single' + ..explorerClick = 'select' + ..explorerPaths = [dir.path, dir.path]; + await show(t, size, ExplorerPage(c: c)); + await tapText(t, 'recycle_me_$tag.txt'); + await t.sendKeyEvent(LogicalKeyboardKey.delete); + await settle(t); + await shot(t, '65_recycle_confirm_$tag'); + await t.tap(find.text('휴지통으로').last); + await settle(t, 20); + await shot(t, '65_recycle_done_$tag'); + // Shift+Delete: 영구 삭제로 묻는다 (취소) + await tapText(t, 'keep_$tag.txt'); + await t.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await t.sendKeyEvent(LogicalKeyboardKey.delete); + await t.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await settle(t); + await shot(t, '65_shift_delete_confirm_$tag'); + await t.tap(find.text('취소').last); + await settle(t); + } + + // 94: 휴지통이 없는 위치 (네트워크 공유 \\localhost\M$\…) - 처음부터 영구 삭제로 묻는다 (취소) + final unc = '\\\\localhost\\${tmp.path[0]}\$${tmp.path.substring(2)}'; + final share = Directory(p.join(unc, 'share'))..createSync(); + File(p.join(share.path, 'on_share.txt')).writeAsStringSync('x'); + for (final (tag, size) in sizes) { + final c = controller(); + c.settings + ..explorerLayout = 'single' + ..explorerClick = 'select' + ..explorerPaths = [share.path, share.path]; + await show(t, size, ExplorerPage(c: c)); + await settle(t, 20); + await tapText(t, 'on_share.txt'); + await t.sendKeyEvent(LogicalKeyboardKey.delete); + await settle(t); + await shot(t, '94_no_recycle_bin_$tag'); + await t.tap(find.text('취소').last); + await settle(t); + } + + // 98: 휴지통이 받지 않는 긴 경로 - 이유를 읽을 수 있는 말로 + var deep = p.join(tmp.path, 'long'); + while (deep.length < 250) { + deep = p.join(deep, 'd' * 30); + } + Directory('\\\\?\\$deep').createSync(recursive: true); + File('\\\\?\\${p.join(deep, 'long_name_file_that_is_too_long_for_the_recycle_bin.txt')}').writeAsStringSync('x'); + for (final (tag, size) in sizes) { + final c = controller(); + c.settings + ..explorerLayout = 'single' + ..explorerClick = 'select' + ..explorerPaths = [deep, deep]; + await show(t, size, ExplorerPage(c: c)); + await settle(t, 20); + final f = find.textContaining('long_name_file'); + if (f.evaluate().isEmpty) break; + await t.tap(f.first); + await settle(t, 4); + await t.sendKeyEvent(LogicalKeyboardKey.delete); + await settle(t); + await t.tap(find.text('휴지통으로').last); + await settle(t, 20); + await shot(t, '98_error_text_$tag'); + } + } finally { + await t.pumpWidget(const SizedBox()); + // 시험이 휴지통에 넣은 것만 ($I 의 원래 경로가 이 시험 폴더인 것) 휴지통에서 지운다 + final removed = await _purgeRecycled(tmp.path, since: started); + File(p.join(out, 'recycle_bin_cleanup.txt')) + ..createSync(recursive: true) + ..writeAsStringSync('시험 폴더: ${tmp.path}\n휴지통에서 지운 시험 항목: $removed\n남은 시험 항목: ${await _countRecycled(tmp.path, since: started)}\n'); + try { + Directory('\\\\?\\${tmp.path}').deleteSync(recursive: true); + } catch (_) {} + } + }); + + testWidgets('37: 경로 입력 - 폴더로 가기 · \\\\NAS 만 · 없는 경로', (t) async { + final tmp = tempDir('jj_shot_path_'); + try { + final target = Directory(p.join(tmp.path, '가고 싶은 폴더'))..createSync(); + File(p.join(target.path, 'inside.txt')).writeAsStringSync('x'); + for (final (tag, size) in sizes) { + final c = controller(); + c.settings + ..explorerLayout = 'single' + ..explorerPaths = [tmp.path, tmp.path]; + await show(t, size, ExplorerPage(c: c)); + Future enter(String text) async { + await t.tap(find.byTooltip('경로 입력').first); + await settle(t); + await t.enterText(find.byType(TextField).last, text); + await settle(t, 3); + } + + await enter(target.path); + await shot(t, '37_path_dialog_$tag'); + await t.tap(find.widgetWithText(FilledButton, '확인')); + await settle(t, 20); + await shot(t, '37_path_went_$tag'); + await enter('\\\\NAS'); + await t.tap(find.widgetWithText(FilledButton, '확인')); + await settle(t, 6); + await shot(t, '37_unc_server_only_$tag'); + await enter(p.join(tmp.path, '없는 폴더')); + await t.tap(find.widgetWithText(FilledButton, '확인')); + await settle(t, 6); + await shot(t, '37_missing_path_$tag'); + } + } finally { + await t.pumpWidget(const SizedBox()); + tmp.deleteSync(recursive: true); + } + }); + + testWidgets('124 · 130: 마스터 · 최상 비밀번호 창 · 초기화 창 · 보안 설정', (t) async { + for (final (tag, size) in sizes) { + final c = controller(); + final lock = MasterLock(MemorySecretStore()); // 메모리에만 - 사용자 secrets.dat 는 건드리지 않음 + await t.runAsync(() async { + await lock.setMaster('m'); + await lock.setSuper('s'); + }); + lock.unlocked = false; + MasterLock.instance = lock; + await show(t, size, Scaffold(body: ListView(children: [SecuritySettings(c: c)]))); + await shot(t, '124_security_settings_$tag'); + final ctx = t.element(find.byType(SecuritySettings)); + askMaster(ctx, c, lock); + await settle(t); + await shot(t, '124_master_prompt_$tag'); + await t.enterText(find.byType(TextField).last, 'wrong'); + await t.tap(find.text('확인').last); + await settle(t, 20); + await shot(t, '124_master_wrong_$tag'); + await t.enterText(find.byType(TextField).last, 's'); + await t.tap(find.text('확인').last); + await settle(t, 20); + await shot(t, '124_super_reset_$tag'); + await t.tap(find.text('취소').last); + await settle(t); + MasterLock.instance = null; + } + await t.pumpWidget(const SizedBox()); + }); + + testWidgets('123: AI 그림 화면 - 모델 없음 안내 · 서비스 (기기 밖) 경고', (t) async { + for (final (tag, size) in sizes) { + final c = controller(); + await show(t, size, AiImagePage(c: c)); + await shot(t, '123_ai_local_$tag'); + c.settings + ..aiServices = [const AiService(id: 's', name: 'GPU PC (Tailscale)', url: 'http://100.1.2.3:7860', kind: 'a1111')] + ..aiEngine = 's'; + await show(t, size, AiImagePage(c: c)); + await shot(t, '123_ai_service_$tag'); + } + await t.pumpWidget(const SizedBox()); + }); +} + +/// 휴지통의 $I 정보 중 원래 경로가 [dir] 아래이고 [since] 뒤에 생긴 것 (시험이 넣은 것) +Future> _recycledInfos(String dir, {required DateTime since}) async { + final drive = dir.substring(0, 2); + final out = []; + for (final user in Directory('$drive\\\$Recycle.Bin').listSync().whereType()) { + List items; + try { + items = user.listSync(); + } catch (_) { + continue; + } + for (final f in items.whereType()) { + if (!p.basename(f.path).startsWith(r'$I') || f.lastModifiedSync().isBefore(since.subtract(const Duration(seconds: 5)))) continue; + final orig = recycledInfoPath(f.readAsBytesSync()); + if (orig != null && orig.toLowerCase().startsWith(dir.toLowerCase())) out.add(f); + } + } + return out; +} + +Future _countRecycled(String dir, {required DateTime since}) async => (await _recycledInfos(dir, since: since)).length; + +Future _purgeRecycled(String dir, {required DateTime since}) async { + var n = 0; + for (final info in await _recycledInfos(dir, since: since)) { + final data = p.join(p.dirname(info.path), '\$R${p.basename(info.path).substring(2)}'); + try { + final t = FileSystemEntity.typeSync(data); + if (t == FileSystemEntityType.directory) Directory(data).deleteSync(recursive: true); + if (t == FileSystemEntityType.file) File(data).deleteSync(); + info.deleteSync(); + n++; + } catch (_) {} + } + return n; +} diff --git a/lib/app/ai_local.dart b/lib/app/ai_local.dart new file mode 100644 index 0000000..5dd17ca --- /dev/null +++ b/lib/app/ai_local.dart @@ -0,0 +1,459 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:archive/archive_io.dart'; +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +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 'app_controller.dart'; +import 'component_store.dart' show AccumulatorSink; +import 'copy_center.dart' show diskSpace; + +/// 123 · 121: 기기 안 AI 파일 (모델 · 실행 파일) 받기 · 지우기. 앱 데이터 폴더의 ai/ 아래 (업데이트해도 남게). +class AiStore extends ChangeNotifier { + AiStore(this.dataDir, {HttpClient? http}) : _http = http ?? (HttpClient()..userAgent = 'JJMKVMaker'); + final String dataDir; + final HttpClient _http; + + static AiStore? instance; + + String get root => p.join(dataDir, 'ai'); + + /// 받은 파일이 놓이는 곳. LoRA 는 sd-cli 의 과 맞게 이름을 바꿔 둔다. + String pathOf(AiFile f) => switch (f.kind) { + 'engine' => p.join(root, f.id), + 'lora' => p.join(root, 'lora', 'lcm-lora-sdv1-5.safetensors'), + _ => p.join(root, 'models', '${f.id}${p.extension(f.fileName)}'), + }; + + File _mark(AiFile f) => File(p.join(root, 'installed', '${f.id}.json')); + + bool isInstalled(String id) => isFileInstalled(aiFile(id)); + + bool isFileInstalled(AiFile f) => _mark(f).existsSync() && FileSystemEntity.typeSync(pathOf(f)) != FileSystemEntityType.notFound; + + /// 받는 중인 것 (id → 0~1, 모르면 null) + final progress = {}; + final _cancel = {}; + + void cancel(String id) => _cancel.add(id); + + /// 받기 (SHA-256 확인). 저장 공간이 모자라면 먼저 알린다. zip (실행 파일) 은 푼다. + Future install(String id) => installFile(aiFile(id)); + + /// [install] 의 본체 (시험은 카탈로그 밖의 파일로) + Future installFile(AiFile f) async { + final id = f.id; + if (progress.containsKey(id)) return; + _cancel.remove(id); + await Directory(root).create(recursive: true); + final free = await diskSpace(root); + // 받는 파일 + 푸는 자리 (zip 은 두 배쯤) 와 여유 200MB + final need = (f.kind == 'engine' ? f.size * 3 : f.size) + (200 << 20); + if (free != null && free.$1 < need) { + throw ImageAiException(trf('저장 공간이 모자랍니다: {0} 이 필요한데 {1} 남았습니다', [_gb(need), _gb(free.$1)])); + } + progress[id] = 0; + notifyListeners(); + final part = File('${pathOf(f)}.part${f.kind == 'engine' ? '.zip' : ''}'); + try { + await part.parent.create(recursive: true); + await _download(f, part, (d) { + progress[id] = d; + notifyListeners(); + }); + if (f.kind == 'engine') { + final dir = Directory(pathOf(f)); + if (await dir.exists()) await dir.delete(recursive: true); + await extractFileToDisk(part.path, dir.path); + await part.delete(); + } else { + final dst = File(pathOf(f)); + if (await dst.exists()) await dst.delete(); + await part.rename(dst.path); + } + await _mark(f).parent.create(recursive: true); + await _mark(f).writeAsString(jsonEncode({'sha256': f.sha256, 'installed': DateTime.now().toIso8601String()})); + } catch (_) { + try { + if (await part.exists()) await part.delete(); + } catch (_) {} + rethrow; + } finally { + progress.remove(id); + notifyListeners(); + } + } + + Future remove(String id) async { + final f = aiFile(id); + try { + await _mark(f).delete(); + } catch (_) {} + final t = FileSystemEntity.typeSync(pathOf(f)); + if (t == FileSystemEntityType.directory) await Directory(pathOf(f)).delete(recursive: true); + if (t == FileSystemEntityType.file) await File(pathOf(f)).delete(); + notifyListeners(); + } + + static String _gb(int b) => b >= 1 << 30 ? '${(b / (1 << 30)).toStringAsFixed(1)}GB' : '${(b / (1 << 20)).round()}MB'; + static String sizeText(int b) => _gb(b); + + Future _download(AiFile f, File out, void Function(double? d) onProgress) async { + final req = await _http.getUrl(Uri.parse(f.url)); + final res = await req.close(); + if (res.statusCode != 200) throw ImageAiException(trf('받을 수 없습니다 ({0}): {1}', [res.statusCode, f.url])); + final total = res.contentLength > 0 ? res.contentLength : f.size; + final sink = out.openWrite(); + final hash = AccumulatorSink(); + final hasher = sha256.startChunkedConversion(hash); + var done = 0; + try { + await for (final chunk in res) { + if (_cancel.contains(f.id)) throw ImageAiException(tr('취소했습니다')); + sink.add(chunk); + hasher.add(chunk); + done += chunk.length; + onProgress(total > 0 ? done / total : null); + } + } finally { + await sink.close(); + hasher.close(); + } + if (hash.events.single.toString() != f.sha256) { + await out.delete(); + throw ImageAiException(trf('받은 파일이 손상되었습니다 (SHA-256 다름): {0}', [f.name])); + } + } + + // ───────── 실행 파일 ───────── + + /// sd-cli: Android 는 앱에 들어 있는 것 (libsdcli.so - 앱 데이터 폴더의 파일은 실행할 수 없음), Windows 는 받은 것 + Future sdCli({bool cuda = false}) async { + if (Platform.isAndroid) { + try { + final dir = await const MethodChannel('jj_mkvmaker/android').invokeMethod('nativeLibDir'); + final f = dir == null ? null : File(p.join(dir, 'libsdcli.so')); + return f != null && f.existsSync() ? f.path : null; + } catch (_) { + return null; + } + } + final id = cuda ? 'engine-cuda' : 'engine-vulkan'; + if (!isInstalled(id)) return null; + final exe = _find(Directory(pathOf(aiFile(id))), 'sd-cli.exe'); + return exe; + } + + static String? _find(Directory dir, String name) { + if (!dir.existsSync()) return null; + for (final e in dir.listSync(recursive: true, followLinks: false)) { + if (e is File && p.basename(e.path).toLowerCase() == name) return e.path; + } + return null; + } + + /// CUDA 판은 런타임 (cudart) DLL 을 같은 폴더에 둔다 + Future placeCudaRuntime() async { + if (!isInstalled('engine-cuda') || !isInstalled('engine-cudart')) return; + final exe = await sdCli(cuda: true); + if (exe == null) return; + for (final e in Directory(pathOf(aiFile('engine-cudart'))).listSync(recursive: true)) { + if (e is File && e.path.toLowerCase().endsWith('.dll')) { + final dst = File(p.join(p.dirname(exe), p.basename(e.path))); + if (!dst.existsSync()) await e.copy(dst.path); + } + } + } + + /// NVIDIA 그래픽카드가 있는지 (CUDA 판을 받기 목록에 보일지) + static Future hasNvidia() async { + // 위젯 시험에서는 실제 프로그램을 부르지 않는다 (가짜 시간 안에서 끝나지 않음) + if (!Platform.isWindows || Platform.environment.containsKey('FLUTTER_TEST')) return false; + try { + final r = await Process.run('nvidia-smi', ['-L']); + return r.exitCode == 0 && '${r.stdout}'.contains('GPU'); + } catch (_) { + return false; + } + } + + /// 그림 만들기에 쓸 모델 파일 (없으면 null) + SdModelPaths? sd15Paths({required bool taesd}) { + if (!sd15LcmFiles.every(isInstalled)) return null; + final lora = pathOf(aiFile('lcm-lora-sd15')); + return SdModelPaths( + model: pathOf(aiFile('sd15')), + loraDir: p.dirname(lora), + loraName: p.basenameWithoutExtension(lora), + taesd: taesd && isInstalled('taesd') ? pathOf(aiFile('taesd')) : null, + ); + } +} + +/// 처리 장치 하나 (실행 파일 + sd-cli --backend 값) +class SdDevice { + final String exe; + final String backend; + final String label; + const SdDevice(this.exe, this.backend, this.label); + String get key => '${p.basename(p.dirname(exe))}:$backend'; +} + +/// 기기 안 엔진 (stable-diffusion.cpp 의 sd-cli 를 따로 띄움 - 메모리가 모자라 꺼져도 앱은 살아 있고, 취소는 프로세스 끝내기) +class LocalSdEngine implements ImageAiEngine { + LocalSdEngine({required this.device, required this.models, this.threads = 0}); + final SdDevice device; + final SdModelPaths models; + final int threads; + Process? _proc; + bool _cancelled = false; + + @override + String get label => trf('이 기기 ({0})', [device.label]); + @override + bool get leavesDevice => false; + + @override + void cancel() { + _cancelled = true; + _proc?.kill(); + } + + @override + Future> generate(ImageGenRequest r, {required String outDir, required String baseName, AiProgress? onProgress}) async { + _cancelled = false; + await Directory(outDir).create(recursive: true); + final seed = resolveSeed(r.seed); + final req = ImageGenRequest( + prompt: r.prompt, + negative: r.negative, + width: r.width, + height: r.height, + steps: r.steps, + cfg: r.cfg, + seed: seed, + count: r.count, + initImage: r.initImage, + strength: r.strength, + model: r.model, + ); + 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 made = Directory(outDir) + .listSync() + .whereType() + .where((f) => RegExp('^${RegExp.escape(baseName)}_\\d+\\.png\$').hasMatch(p.basename(f.path))) + .toList() + ..sort((a, b) => a.path.compareTo(b.path)); + if (made.isEmpty) throw ImageAiException(trf('그림을 만들지 못했습니다: {0}', [lines.join('\n')])); + return [for (final (i, f) in made.indexed) GeneratedImage(f.path, seed + i)]; + } +} + +/// sd-cli 를 돌리고 진행을 알린다. 실패하면 마지막 출력 몇 줄로 [ImageAiException] +Future> runSdCli(String exe, List args, + {void Function(SdProgress)? onProgress, void Function(Process)? onStart}) async { + final proc = await Process.start(exe, args, workingDirectory: p.dirname(exe)); + onStart?.call(proc); + final tail = []; + var saved = false; + void feed(String chunk) { + if (chunk.contains('images saved')) saved = true; + for (final piece in chunk.split(RegExp(r'[\r\n]+'))) { + if (piece.trim().isEmpty) continue; + final prog = parseSdProgress(piece); + if (prog != null) { + onProgress?.call(prog); + } else { + tail.add(piece.trim()); + if (tail.length > 8) tail.removeAt(0); + } + } + } + + final a = proc.stdout.transform(const Utf8Decoder(allowMalformed: true)).listen(feed).asFuture(); + final b = proc.stderr.transform(const Utf8Decoder(allowMalformed: true)).listen(feed).asFuture(); + final code = await proc.exitCode; + await Future.wait([a, b]); + // 그림을 다 저장한 뒤 끝내면서 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')])); + } + return tail; +} + +/// 처리 장치 후보 (Windows: CUDA 판 · Vulkan 판의 GPU · CPU, Android: CPU) +Future> sdDevices(AiStore store) async { + final out = []; + final cuda = await store.sdCli(cuda: true); + if (cuda != null) { + await store.placeCudaRuntime(); + out.add(SdDevice(cuda, 'cuda0', 'NVIDIA CUDA')); + } + final exe = await store.sdCli(); + if (exe == null) return out; + if (Platform.isWindows) { + try { + final r = await Process.run(exe, ['--list-devices'], workingDirectory: p.dirname(exe)); + final devs = parseSdDevices('${r.stdout}\n${r.stderr}'); + // Vulkan 장치 중 첫째 (보통 따로 단 그래픽카드) + for (final (name, desc) in devs) { + if (name.startsWith('vulkan')) { + out.add(SdDevice(exe, name, 'Vulkan · $desc')); + break; + } + } + } catch (_) {} + } + out.add(SdDevice(exe, 'cpu', 'CPU')); + return out; +} + +/// 123: 진행 중인 AI 작업 (작업 알림 · 모니터링 · 화면이 함께 본다) +class AiJobs extends ChangeNotifier { + static final instance = AiJobs(); + + /// 지금 하는 일 (없으면 null) · 진행 (0~1) · 남은 장 수 · 예상 남은 시간 + String? title; + double? progress; + int remaining = 0; + Duration? eta; + ImageAiEngine? _engine; + + bool get busy => title != null; + + void start(String t, ImageAiEngine engine, int count) { + title = t; + _engine = engine; + remaining = count; + progress = 0; + eta = null; + notifyListeners(); + } + + void update({double? progress, int? remaining, Duration? eta, String? title}) { + this.progress = progress ?? this.progress; + this.remaining = remaining ?? this.remaining; + this.eta = eta ?? this.eta; + this.title = title ?? this.title; + notifyListeners(); + } + + void finish() { + title = null; + progress = null; + remaining = 0; + eta = null; + _engine = null; + notifyListeners(); + } + + void cancel() => _engine?.cancel(); + + /// 그리기 한 번 (한 번에 하나). 진행 · 남은 장 · 예상 시간을 알리고, 그림마다 옆에 json (프롬프트 · 시드 · 모델) 을 남긴다. + Future> run(ImageAiEngine engine, ImageGenRequest r, {required String outDir}) async { + if (busy) throw ImageAiException(tr('이미 그리는 중입니다')); + final started = DateTime.now(); + final stamp = started.toIso8601String().replaceAll(RegExp(r'[-:T]'), '').split('.').first; + start(trf('AI 그림 ({0})', [engine.label]), engine, r.count); + try { + final made = await engine.generate(r, outDir: outDir, baseName: 'ai_$stamp', onProgress: (prog, step) { + Duration? left; + if (prog != null && prog > 0.05) { + final spent = DateTime.now().difference(started); + left = spent * ((1 - prog) / prog); + } + update( + progress: prog, + title: trf('AI 그림: {0}', [step]), + remaining: prog == null ? null : (r.count - (prog * r.count).floor()).clamp(1, r.count), + eta: left, + ); + }); + for (final g in made) { + try { + await File('${p.withoutExtension(g.path)}.json').writeAsString(r.encode(engine: engine.label, actualSeed: g.seed)); + } catch (_) {} + } + return made; + } finally { + finish(); + } + } + + /// 알림 · 모니터링에 보일 한 줄 + String? get statusLine { + final t = title; + if (t == null) return null; + final left = eta == null ? '' : ' · ${trf('남은 약 {0}', [_dur(eta!)])}'; + return '$t${remaining > 1 ? ' · ${trf('남은 {0}장', [remaining])}' : ''}$left'; + } + + static String _dur(Duration d) => d.inMinutes >= 1 ? trf('{0}분', [d.inMinutes + (d.inSeconds % 60 >= 30 ? 1 : 0)]) : trf('{0}초', [d.inSeconds]); +} + +/// 그릴 처리 장치. 'auto' 면 한 번 짧게 재서 (256×256 · 1단계) 가장 빠른 것을 기억한다 (장치 목록이 바뀌면 다시 잰다). +Future pickAiDevice(AppController c, AiStore store, {void Function(String step)? onStep}) async { + final devices = await sdDevices(store); + if (devices.isEmpty) return null; + final s = c.settings; + if (s.aiDevice != 'auto') return devices.where((d) => d.key == s.aiDevice).firstOrNull ?? devices.first; + if (devices.length == 1) return devices.first; + final signature = devices.map((d) => d.key).join(','); + if (s.aiAutoDeviceFor == signature) { + final known = devices.where((d) => d.key == s.aiAutoDevice).firstOrNull; + if (known != null) return known; + } + final models = store.sd15Paths(taesd: s.aiTaesd); + if (models == null) return devices.first; + final tmp = await Directory.systemTemp.createTemp('jj_ai_probe_'); + SdDevice? best; + Duration? bestTime; + try { + for (final d in devices) { + onStep?.call(trf('가장 빠른 처리 장치를 찾는 중 (처음 한 번): {0}', [d.label])); + final sw = Stopwatch()..start(); + try { + await runSdCli(d.exe, sdCliArgs(const ImageGenRequest(prompt: 'test', width: 256, height: 256, steps: 1, seed: 1), models, + out: p.join(tmp.path, 'probe.png'), backend: d.backend)); + } catch (_) { + continue; // 이 장치는 안 됨 (메모리 부족 등) + } + sw.stop(); + if (bestTime == null || sw.elapsed < bestTime) { + best = d; + bestTime = sw.elapsed; + } + } + } finally { + try { + await tmp.delete(recursive: true); + } catch (_) {} + onStep?.call(''); + } + best ??= devices.last; + final chosen = best; + await c.updateSettings((x) => x + ..aiAutoDevice = chosen.key + ..aiAutoDeviceFor = signature); + return chosen; +} diff --git a/lib/app/components.dart b/lib/app/components.dart index acc8b3e..7d3c683 100644 --- a/lib/app/components.dart +++ b/lib/app/components.dart @@ -32,13 +32,16 @@ class AppComponent { static const docs = AppComponent('docs', '문서 미리보기', 'DOC · DOCX · HWP · XLS · XLSX 를 PDF 로 바꿔 보기 (변환기를 받음)', Icons.description_outlined, page: false, needsDownload: true, builtIn: false); - static const all = [mkv, browser, explorer, rsync, downloads, viewer, docs]; + /// 123: 글 → 그림 · 그림 → 그림 (기기 안 SD1.5 + LCM, 또는 사용자가 추가한 서버 · 서비스). 모델은 환경 설정에서 받는다 + static const aiImage = AppComponent('aiimage', 'AI 그림', '글 → 그림 · 그림 → 그림 (기기 안에서, 모델은 환경 설정에서 받기)', Icons.auto_awesome_outlined); + + static const all = [mkv, browser, explorer, rsync, downloads, aiImage, viewer, docs]; /// 문서 미리보기 대상 (PDF 로 바꿔 봄) static const docExtensions = ['doc', 'docx', 'hwp', 'hwpx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'rtf']; /// 화면 있는 것의 기본 순서 (좌우로 밀기 · 위쪽 버튼) - static const defaultOrder = ['mkv', 'browser', 'explorer', 'rsync', 'downloads']; + static const defaultOrder = ['mkv', 'browser', 'explorer', 'rsync', 'downloads', 'aiimage']; static AppComponent? byId(String id) => all.where((c) => c.id == id).firstOrNull; diff --git a/lib/app/settings.dart b/lib/app/settings.dart index 28b8451..3999d4f 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -11,6 +11,7 @@ import '../core/download_detect.dart'; import '../core/encode_options.dart'; import '../core/playlist.dart'; import '../core/webdav.dart'; +import '../services/image_ai.dart' show AiService; import '../services/app_shell.dart' show appIconOf; import '../services/secret_store.dart'; @@ -442,6 +443,34 @@ class AppSettings { /// WebDAV 서버 (파일 탐색기 · Rsync 화면 위쪽 "SD 카드" 옆 탭). 비밀번호는 이 설정 파일에만. List webdavServers = []; + // ───────── 123 · 121: AI 그림 · 해상도 올리기 ───────── + + /// 그리는 엔진: 'local' 이 기기 안 (기본) · 아니면 [aiServices] 의 id (사용자가 추가한 서버 · 서비스) + String aiEngine = 'local'; + + /// 기기 안 처리 장치: 'auto' (잰 결과로 빠른 쪽) 또는 장치 열쇠 (예: 'engine-cuda:cuda0', 'engine-vulkan:cpu') + String aiDevice = 'auto'; + + /// 'auto' 가 잰 가장 빠른 장치와 그때의 장치 목록 (목록이 바뀌면 다시 잰다) + String aiAutoDevice = ''; + String aiAutoDeviceFor = ''; + + /// 빠른 디코더 (TAESD) - 그림이 거의 같고 디코드가 10배쯤 빠르다 (받아 두었을 때) + bool aiTaesd = true; + int aiWidth = 512; + int aiHeight = 512; + int aiSteps = 4; + double aiCfg = 1; + int aiCount = 1; + double aiStrength = 0.6; + String aiNegative = ''; + + /// 저장 폴더 (비어 있으면 사진 폴더의 JJ_MKVMaker_AI) + String aiSaveDir = ''; + + /// 사용자가 추가한 그림 서버 · 서비스 (API 키는 안전 저장소에) + List aiServices = []; + /// 실시간 동기화 확인 간격 (초). Windows 는 바뀌면 바로, 그 밖은 이 간격으로 살핀다. int liveSyncIntervalSec = 30; @@ -572,6 +601,7 @@ class AppSettings { 'copyMethodFolder': copyMethodFolder, 'rsyncPaths': rsyncPaths, 'components': components, + 'aiImageV1': true, 'navOrder': navOrder, 'swipeNav': swipeNav, 'imageExts': imageExts, @@ -597,6 +627,22 @@ class AppSettings { 'zipComic': zipComic, // 예전 기본값 (만화 보기 켜짐) 은 지시와 반대였으므로 한 번 꺼진 상태 (목록) 로 'zipComicV2': true, + 'aiEngine': aiEngine, + 'aiDevice': aiDevice, + 'aiAutoDevice': aiAutoDevice, + 'aiAutoDeviceFor': aiAutoDeviceFor, + 'aiTaesd': aiTaesd, + 'aiWidth': aiWidth, + 'aiHeight': aiHeight, + 'aiSteps': aiSteps, + 'aiCfg': aiCfg, + 'aiCount': aiCount, + 'aiStrength': aiStrength, + 'aiNegative': aiNegative, + 'aiSaveDir': aiSaveDir, + 'aiServices': [ + for (final x in aiServices) {...x.toJson(), if (plainSecrets) 'apiKey': x.apiKey}, + ], 'webdavServers': [ for (final x in webdavServers) {...x.toJson(), if (plainSecrets) 'password': x.password}, ], @@ -725,7 +771,11 @@ class AppSettings { ..copyMethodFolder = _method(j['copyMethodFolder']) ..rsyncPaths = [for (final x in (j['rsyncPaths'] as List?) ?? const []) '$x'] ..components = j['components'] is List - ? [for (final x in j['components'] as List) if (AppComponent.byId('$x') != null) '$x'] + ? [ + for (final x in j['components'] as List) if (AppComponent.byId('$x') != null) '$x', + // 123: 새 화면 "AI 그림" 은 예전 설정에도 한 번 켜서 넣는다 (끄기: 환경 설정 > 컴포넌트) + if (j['aiImageV1'] != true && !(j['components'] as List).contains('aiimage')) 'aiimage', + ] : AppComponent.defaultInstalled ..navOrder = j['navOrder'] is List ? [for (final x in j['navOrder'] as List) if (AppComponent.defaultOrder.contains('$x')) '$x'] @@ -755,6 +805,23 @@ class AppSettings { ..allFilesHintShown = j['allFilesHint2'] == true ..subtitleScale = ((j['subtitleScale'] as num?)?.toDouble() ?? 1.0).clamp(0.5, 2.5) ..zipComic = j['zipComic'] == true && j['zipComicV2'] == true + ..aiEngine = j['aiEngine'] as String? ?? 'local' + ..aiDevice = j['aiDevice'] as String? ?? 'auto' + ..aiAutoDevice = j['aiAutoDevice'] as String? ?? '' + ..aiAutoDeviceFor = j['aiAutoDeviceFor'] as String? ?? '' + ..aiTaesd = j['aiTaesd'] != false + ..aiWidth = ((j['aiWidth'] as num?)?.toInt() ?? 512).clamp(64, 2048) + ..aiHeight = ((j['aiHeight'] as num?)?.toInt() ?? 512).clamp(64, 2048) + ..aiSteps = ((j['aiSteps'] as num?)?.toInt() ?? 4).clamp(1, 150) + ..aiCfg = ((j['aiCfg'] as num?)?.toDouble() ?? 1).clamp(0, 30) + ..aiCount = ((j['aiCount'] as num?)?.toInt() ?? 1).clamp(1, 100) + ..aiStrength = ((j['aiStrength'] as num?)?.toDouble() ?? 0.6).clamp(0, 1) + ..aiNegative = j['aiNegative'] as String? ?? '' + ..aiSaveDir = j['aiSaveDir'] as String? ?? '' + ..aiServices = [ + for (final x in (j['aiServices'] as List?) ?? const []) + if (x is Map) AiService.fromJson(x), + ] ..webdavServers = [ for (final x in (j['webdavServers'] as List?) ?? const []) if (x is Map) DavServer.fromJson(x), @@ -827,6 +894,12 @@ class AppSettings { for (final x in servers) x is Map ? (Map.of(x)..remove('password')) : x, ]; } + final ai = r['aiServices']; + if (ai is List) { + r['aiServices'] = [ + for (final x in ai) x is Map ? (Map.of(x)..remove('apiKey')) : x, + ]; + } return r; } @@ -836,6 +909,7 @@ class AppSettings { if (j['explorerV2'] != true && j['explorerLayout'] == 'dual' && s.explorerLayout == 'auto') 'explorerLayout', if (j['explorerV2'] != true && j['explorerClick'] == 'select' && s.explorerClick == 'open') 'explorerClick', if (j['explorerOrientV3'] != true && j['explorerOrientation'] == 'auto') 'explorerOrientation', + if (j['aiImageV1'] != true && j['components'] is List && !(j['components'] as List).contains('aiimage')) 'aiImage', // 53 · 28: 앱 안 브라우저 쿠키를 쓰던 사람에게 넘기는 사이트가 넓어졌음을 한 번 알린다 if (j['cookieScopeV2'] != true && j.isNotEmpty && (j['ytCookiesBrowser'] ?? internalBrowserCookies) == internalBrowserCookies) 'cookieScope', @@ -916,6 +990,7 @@ class SettingsStore { 'openSubtitlesPassword': 'os.password', }; static String _davKey(String id) => 'dav.pw.$id'; + static String _aiKey(String id) => 'ai.key.$id'; static const _davServers = 'dav.servers'; static String _os(AppSettings s, String k) => switch (k) { @@ -930,6 +1005,8 @@ class SettingsStore { if (_os(s, e.key).isNotEmpty) e.value: _os(s, e.key), for (final x in s.webdavServers) if (x.password.isNotEmpty) _davKey(x.id): x.password, + for (final x in s.aiServices) + if (x.apiKey.isNotEmpty) _aiKey(x.id): x.apiKey, // 서버 목록 (비밀번호 없이): 예전 판이 설정 파일에서 서버 목록을 지워도 되살릴 수 있게 (40) if (s.webdavServers.isNotEmpty) _davServers: jsonEncode([for (final x in s.webdavServers) x.toJson()]), }; @@ -939,6 +1016,12 @@ class SettingsStore { for (final e in _osKeys.entries) { if (e.value == key) j[e.key] = value; } + if (key.startsWith('ai.key.')) { + final id = key.substring('ai.key.'.length); + for (final x in (j['aiServices'] as List?) ?? const []) { + if (x is Map && '${x['id']}' == id) x['apiKey'] = value; + } + } if (key.startsWith('dav.pw.')) { final id = key.substring('dav.pw.'.length); for (final x in (j['webdavServers'] as List?) ?? const []) { @@ -962,6 +1045,9 @@ class SettingsStore { for (final x in s.webdavServers) x.password.isNotEmpty ? x : x.copyWith(password: stored[_davKey(x.id)] ?? ''), ]; + s.aiServices = [ + for (final x in s.aiServices) x.apiKey.isNotEmpty ? x : x.copyWith(apiKey: stored[_aiKey(x.id)] ?? ''), + ]; } /// 읽은 설정에 안전 저장소의 비밀 값을 채운다. 파일에 예전 평문 값이 있으면 저장소로 옮기고 파일에서 지운다 (54). @@ -993,6 +1079,9 @@ class SettingsStore { for (final x in s.webdavServers) { if (x.password.isNotEmpty) _filePlain[_davKey(x.id)] = x.password; } + for (final x in s.aiServices) { + if (x.apiKey.isNotEmpty) _filePlain[_aiKey(x.id)] = x.apiKey; + } _fill(s, stored); secretIssue.value = !_secretsOk ? const SecretIssue(SecretIssueKind.readFailed) @@ -1025,7 +1114,7 @@ class SettingsStore { /// 깨진 설정 파일의 보관본에서 비밀 값을 지운다 (JSON 으로 읽을 수 없으니 글자로) static String redactSecrets(String text) => text.replaceAllMapped( - RegExp(r'"(openSubtitlesKey|openSubtitlesUser|openSubtitlesPassword|password)"\s*:\s*"(?:[^"\\]|\\.)*"'), + RegExp(r'"(openSubtitlesKey|openSubtitlesUser|openSubtitlesPassword|password|apiKey)"\s*:\s*"(?:[^"\\]|\\.)*"'), (m) => '"${m[1]}": ""'); /// 읽기 오류를 알릴 글 (파일 내용은 넣지 않음 - JSON 오류 메시지에는 원문 일부가 들어간다) @@ -1163,7 +1252,7 @@ class SettingsStore { if (!_secretsOk) return {...values.keys.where((k) => _known[k] != values[k])}; final unsaved = {}; for (final k in {..._known.keys, ...values.keys}) { - if (!k.startsWith('os.') && !k.startsWith('dav.')) continue; + if (!k.startsWith('os.') && !k.startsWith('dav.') && !k.startsWith('ai.')) continue; final v = values[k]; if (v == _known[k]) continue; try { diff --git a/lib/core/ai_catalog.dart b/lib/core/ai_catalog.dart new file mode 100644 index 0000000..b87b2c9 --- /dev/null +++ b/lib/core/ai_catalog.dart @@ -0,0 +1,131 @@ +/// 123 · 121: 기기 안 AI (그림 만들기 · 해상도 올리기) 에 받는 파일. 공식 배포처 주소와 SHA-256 을 고정한다. +/// 상업 사용이 막힌 라이선스 (CC-BY-NC 계열) 는 넣지 않는다. +class AiFile { + final String id; + final String name; + final String url; + final String sha256; + final int size; + + /// 'model' 그림 모델 · 'lora' · 'taesd' 빠른 디코더 · 'engine' 실행 파일 (zip) · 'upscale' 해상도 올리기 모델 + final String kind; + + /// 라이선스 이름 · 주소 · 받기 전에 보여 줄 조건 (있으면) + final String license; + final String licenseUrl; + final String? terms; + + /// 'windows' · 'android' (비어 있으면 모두) + final List platforms; + + /// 'nvidia' 그래픽카드가 있을 때만 받기 목록에 보인다 + final String? needs; + + const AiFile({ + required this.id, + required this.name, + required this.url, + required this.sha256, + required this.size, + required this.kind, + required this.license, + required this.licenseUrl, + this.terms, + this.platforms = const [], + this.needs, + }); + + /// 받아 두는 파일 이름 + String get fileName => Uri.parse(url).pathSegments.last; +} + +/// stable-diffusion.cpp 판 (Windows 실행 파일 · Android 빌드가 같은 판) +const sdCppVersion = 'master-948-228c707'; +const _sdRel = 'https://github.com/leejet/stable-diffusion.cpp/releases/download/$sdCppVersion'; + +/// CreativeML OpenRAIL-M 의 사용 제한 (받기 화면 · 라이선스 화면에 보인다, 62) +const openRailTerms = 'Stable Diffusion v1.5 는 CreativeML OpenRAIL-M 라이선스입니다. 상업적 사용을 포함해 쓸 수 있지만, ' + '다음 용도로는 쓸 수 없습니다: 법을 어기는 일, 미성년자를 해치거나 이용하는 일, 남을 해치려는 거짓 정보, ' + '개인 정보로 사람을 해치는 일, 차별 · 괴롭힘, 의료 · 법률 판단을 대신하는 일 등 (라이선스 부속서 A). ' + '만든 그림에 대한 책임은 쓰는 사람에게 있습니다.'; + +const aiCatalog = [ + AiFile( + id: 'sd15', + name: 'Stable Diffusion v1.5', + url: 'https://huggingface.co/stable-diffusion-v1-5/stable-diffusion-v1-5/resolve/main/v1-5-pruned-emaonly.safetensors', + sha256: '6ce0161689b3853acaa03779ec93eafe75a02f4ced659bee03f50797806fa2fa', + size: 4265146304, + kind: 'model', + license: 'CreativeML OpenRAIL-M', + licenseUrl: 'https://huggingface.co/stable-diffusion-v1-5/stable-diffusion-v1-5/blob/main/LICENSE.md', + terms: openRailTerms, + ), + AiFile( + id: 'lcm-lora-sd15', + name: 'LCM-LoRA (SD1.5, 4단계로 빠르게)', + url: 'https://huggingface.co/latent-consistency/lcm-lora-sdv1-5/resolve/main/pytorch_lora_weights.safetensors', + sha256: '8f90d840e075ff588a58e22c6586e2ae9a6f7922996ee6649a7f01072333afe4', + size: 134621556, + kind: 'lora', + license: 'OpenRAIL++', + licenseUrl: 'https://huggingface.co/latent-consistency/lcm-lora-sdv1-5', + ), + AiFile( + id: 'taesd', + name: 'TAESD (빠른 디코더)', + url: 'https://huggingface.co/madebyollin/taesd/resolve/main/diffusion_pytorch_model.safetensors', + sha256: 'db169d69145ec4ff064e49d99c95fa05d3eb04ee453de35824a6d0f325513549', + size: 9793292, + kind: 'taesd', + license: 'MIT', + licenseUrl: 'https://huggingface.co/madebyollin/taesd', + ), + AiFile( + id: 'engine-vulkan', + name: 'stable-diffusion.cpp (Windows · CPU · Vulkan)', + url: '$_sdRel/sd-master-228c707-bin-win-vulkan-x64.zip', + sha256: '6de279c833a47ca5ede5fe16415dcf8a2a718ca3ececfbe024fc2f86c7728e89', + size: 30098372, + kind: 'engine', + license: 'MIT', + licenseUrl: 'https://github.com/leejet/stable-diffusion.cpp/blob/master/LICENSE', + platforms: ['windows'], + ), + AiFile( + id: 'engine-cuda', + name: 'stable-diffusion.cpp (Windows · NVIDIA CUDA 12)', + url: '$_sdRel/sd-master-228c707-bin-win-cuda12-x64.zip', + sha256: '14e7a91053809b2c22a6259d5264d3696447828d2afaaa5a4280dbf41f472ba6', + size: 337979057, + kind: 'engine', + license: 'MIT', + licenseUrl: 'https://github.com/leejet/stable-diffusion.cpp/blob/master/LICENSE', + platforms: ['windows'], + needs: 'nvidia', + ), + AiFile( + id: 'engine-cudart', + name: 'NVIDIA CUDA 12 런타임 (CUDA 판과 함께)', + url: '$_sdRel/cudart-sd-bin-win-cu12-x64.zip', + sha256: 'fe20366827d357c00797eebb58244dddab7fd9a348d70090c3871004c320f38d', + size: 563452046, + kind: 'engine', + license: 'NVIDIA CUDA Toolkit EULA (재배포 허용 파일)', + licenseUrl: 'https://docs.nvidia.com/cuda/eula/index.html', + platforms: ['windows'], + needs: 'nvidia', + ), +]; + +AiFile aiFile(String id) => aiCatalog.firstWhere((f) => f.id == id); + +/// 그림 만들기 "SD1.5 + LCM" 에 꼭 필요한 파일 (TAESD 는 고를 수 있음) +const sd15LcmFiles = ['sd15', 'lcm-lora-sd15']; + +/// 앱 안 라이선스 화면 (showLicensePage) 에 넣을 AI 구성 요소 (패키지 이름, 글) +List<(String, String)> aiLicenseTexts() => [ + ('stable-diffusion.cpp', 'MIT License - https://github.com/leejet/stable-diffusion.cpp ($sdCppVersion)'), + for (final f in aiCatalog) + if (f.kind != 'engine') (f.name, '${f.license} - ${f.licenseUrl}${f.terms == null ? '' : '\n\n${f.terms}'}'), + ]; diff --git a/lib/core/sd_cli.dart b/lib/core/sd_cli.dart new file mode 100644 index 0000000..a21bc84 --- /dev/null +++ b/lib/core/sd_cli.dart @@ -0,0 +1,163 @@ +import 'dart:convert'; + +/// 123: AI 그림 한 번 만들기 (글 → 그림, [initImage] 가 있으면 그림 → 그림) +class ImageGenRequest { + final String prompt; + final String negative; + final int width; + final int height; + final int steps; + final double cfg; + + /// 시드 (-1 이면 엔진이 아무렇게나 - 만든 뒤 실제 시드를 남긴다) + final int seed; + final int count; + + /// 그림 → 그림: 바탕 그림 · 바꾸는 정도 (0 거의 그대로 ~ 1 새로 그림) + final String? initImage; + final double strength; + + /// 기기 안 엔진의 모델 (카탈로그 id) · 인터넷 · 자기 서버 엔진의 모델 이름 + final String model; + + const ImageGenRequest({ + required this.prompt, + this.negative = '', + this.width = 512, + this.height = 512, + this.steps = 4, + this.cfg = 1, + this.seed = -1, + this.count = 1, + this.initImage, + this.strength = 0.6, + this.model = 'sd15-lcm', + }); + + bool get imageToImage => initImage != null && initImage!.isNotEmpty; + + /// 다시 만들 수 있게 결과 옆에 남기는 정보 (그림 파일 이름.json) + Map toJson({required String engine, int? actualSeed}) => { + 'app': 'JJ_MKVMaker', + 'engine': engine, + 'model': model, + 'prompt': prompt, + 'negative': negative, + 'width': width, + 'height': height, + 'steps': steps, + 'cfg': cfg, + 'seed': actualSeed ?? seed, + if (imageToImage) 'initImage': initImage, + if (imageToImage) 'strength': strength, + 'created': DateTime.now().toIso8601String(), + }; + + static ImageGenRequest fromJson(Map j) => ImageGenRequest( + prompt: '${j['prompt'] ?? ''}', + negative: '${j['negative'] ?? ''}', + width: (j['width'] as num?)?.toInt() ?? 512, + height: (j['height'] as num?)?.toInt() ?? 512, + steps: (j['steps'] as num?)?.toInt() ?? 4, + cfg: (j['cfg'] as num?)?.toDouble() ?? 1, + seed: (j['seed'] as num?)?.toInt() ?? -1, + initImage: j['initImage'] as String?, + strength: (j['strength'] as num?)?.toDouble() ?? 0.6, + model: '${j['model'] ?? 'sd15-lcm'}', + ); + + String encode({required String engine, int? actualSeed}) => + const JsonEncoder.withIndent(' ').convert(toJson(engine: engine, actualSeed: actualSeed)); +} + +/// sd-cli 에 넘길 모델 파일들 +class SdModelPaths { + final String model; + + /// LCM-LoRA 가 있는 폴더 · 파일 이름 (확장자 없이) - 없으면 LoRA 없이 + final String? loraDir; + final String? loraName; + + /// 빠른 디코더 (TAESD) - 없으면 원래 VAE + final String? taesd; + const SdModelPaths({required this.model, this.loraDir, this.loraName, this.taesd}); +} + +/// sd-cli 인수. [backend]: 'cpu' · 'vulkan0' · 'cuda0' 등 (null 이면 엔진이 고름), [out]: 결과 (%d 로 여러 장) +List sdCliArgs(ImageGenRequest r, SdModelPaths m, {required String out, String? backend, int threads = 0}) { + final lora = m.loraDir != null && m.loraName != null; + return [ + '-m', m.model, + // 공식 모델 파일을 그대로 받아 f16 으로 (변환한 gguf 는 이 판에서 잡음이 나옴) + '--type', 'f16', + if (lora) ...['--lora-model-dir', m.loraDir!], + if (m.taesd != null) ...['--taesd', m.taesd!], + '-p', lora ? '${r.prompt}' : r.prompt, + if (r.negative.trim().isNotEmpty) ...['-n', r.negative], + '-W', '${r.width}', + '-H', '${r.height}', + '--steps', '${r.steps}', + '--cfg-scale', r.cfg.toString(), + if (lora) ...['--sampling-method', 'lcm'], + '--seed', '${r.seed}', + '-b', '${r.count}', + if (r.imageToImage) ...['-i', r.initImage!, '--strength', r.strength.toString()], + if (backend != null && backend.isNotEmpty) ...['--backend', backend], + if (threads > 0) ...['-t', '$threads'], + '-o', out, + ]; +} + +/// 해상도 올리기 (121): sd-cli -M upscale +List sdUpscaleArgs({required String model, required String input, required String out, int repeats = 1, String? backend}) => [ + '-M', 'upscale', + '--upscale-model', model, + '-i', input, + if (repeats > 1) ...['--upscale-repeats', '$repeats'], + if (backend != null && backend.isNotEmpty) ...['--backend', backend], + '-o', out, + ]; + +/// sd-cli 진행 줄 하나를 읽은 결과 +class SdProgress { + /// 'load' 모델 읽기 · 'sample' 그리기 · 'decode' 그림으로 · 'done' + final String phase; + final int step; + final int steps; + + /// 한 단계에 걸린 초 (그리기일 때) + final double? secondsPerStep; + const SdProgress(this.phase, this.step, this.steps, {this.secondsPerStep}); + + /// 전체 진행 (0 ~ 1): 읽기 0 ~ 0.1 · 그리기 0.1 ~ 0.9 · 그림으로 0.9 ~ 1 + double get overall => switch (phase) { + 'load' => 0.1 * (steps == 0 ? 0 : step / steps), + 'sample' => 0.1 + 0.8 * (steps == 0 ? 0 : step / steps), + 'decode' => 0.9, + _ => 1, + }; +} + +final _bar = RegExp(r'\|[=#> ]*\|\s*(\d+)/(\d+)\s*-\s*([\d.]+)\s*(s/it|it/s|MB/s)'); + +/// sd-cli 출력 (줄바꿈 · \r 로 나뉜 조각) 에서 진행을 읽는다. 진행이 아니면 null +SdProgress? parseSdProgress(String text) { + final t = text.trim(); + if (t.contains('decode_first_stage') || t.contains('decoding')) return const SdProgress('decode', 0, 0); + if (t.contains('generate_image completed') || t.contains('images saved')) return const SdProgress('done', 1, 1); + final m = _bar.allMatches(t).lastOrNull; + 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); + 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); +} + +/// sd-cli --list-devices 결과 → 고를 수 있는 처리 장치 ('Vulkan0' → 'vulkan0' 이름, 설명) +List<(String, String)> parseSdDevices(String out) => [ + for (final line in const LineSplitter().convert(out)) + if (RegExp(r'^(CPU|Vulkan\d+|CUDA\d+)\t').hasMatch(line)) + (line.split('\t').first.toLowerCase(), line.split('\t').skip(1).join(' ').trim()), + ]; diff --git a/lib/main.dart b/lib/main.dart index 8155279..dc90003 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart' show LicenseEntryWithLineBreaks, LicenseRegistry; import 'package:flutter/services.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:path/path.dart' as p; @@ -17,6 +18,8 @@ import 'ui/settings_problem.dart'; import 'ui/exit_guard.dart' show confirmStopCopies; import 'app/copy_center.dart'; import 'ui/migration_notice.dart'; +import 'app/ai_local.dart'; +import 'core/ai_catalog.dart' show aiLicenseTexts; import 'platform/windows/data_dir_override.dart'; import 'ui/master_prompt.dart'; import 'ui/secret_issue.dart'; @@ -72,6 +75,10 @@ Future _prepare() async { Future main(List args) async { WidgetsFlutterBinding.ensureInitialized(); + // 123: AI 그림 구성 요소 · 모델의 라이선스를 앱 안 라이선스 화면에 (OpenRAIL-M 용도 제한 포함) + LicenseRegistry.addLicense(() => Stream.fromIterable([ + for (final (name, text) in aiLicenseTexts()) LicenseEntryWithLineBreaks([name], text), + ])); applyDataDirOverride(); // 시험판: 사용자 데이터와 다른 폴더 (JJ_MKVMAKER_DATA) final dataDir = await _prepare(); if (Platform.isAndroid) return runAndroid(dataDir); @@ -110,6 +117,7 @@ Future main(List args) async { // 버전별 설정 보관 (업데이트 · 예전 버전으로 되돌리기): 시작할 때 설정 파일이 없었는지 (새로 설치) final freshInstall = !File(p.join(dataDir, 'settings.json')).existsSync(); ComponentStore.dataDirectory = dataDir; + AiStore.instance = AiStore(dataDir); // 123 · 121: AI 모델 · 엔진 (앱 데이터 폴더의 ai) VersionSnapshot.instance = VersionSnapshot(dataDir); await controller.init(); await setupMasterLock(controller); // 124: 저장된 비밀번호를 쓰기 전에 (실시간 동기화보다 먼저) @@ -467,6 +475,7 @@ Future runAndroid(String dataDir) async { marker.write(); if (autoBackup) controller.note(tr('Android 자동 백업에서 되살린 설정으로 시작합니다')); ComponentStore.dataDirectory = dataDir; + AiStore.instance = AiStore(dataDir); // 123 · 121: AI 모델 · 엔진 (앱 데이터 폴더의 ai) String? sharedSnapshots; try { final root = await const MethodChannel('jj_mkvmaker/android').invokeMethod('storageRoot'); diff --git a/lib/platform/android/android_keep_alive.dart b/lib/platform/android/android_keep_alive.dart index 00caf81..f0f80ea 100644 --- a/lib/platform/android/android_keep_alive.dart +++ b/lib/platform/android/android_keep_alive.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/services.dart'; import '../../app/app_controller.dart'; +import '../../app/ai_local.dart'; import '../../app/download_manager.dart'; import '../../app/copy_center.dart'; import '../../app/live_sync.dart'; @@ -25,6 +26,7 @@ class AndroidKeepAlive { AndroidKeepAlive(this.c, this.downloads) { c.addListener(_changed); downloads.addListener(_changed); + AiJobs.instance.addListener(_changed); // 123: AI 그림도 화면을 닫아도 계속 _changed(); } @@ -62,9 +64,15 @@ class AndroidKeepAlive { _copies = copies?..addListener(_changed); } final copying = copies?.activeCount ?? 0; - final (text, progress) = + final ai = AiJobs.instance.statusLine; + var (text, progress) = status(c.currentJob, c.busy, running, downloads.overallProgress, syncs: syncs, syncing: live?.anyRunning ?? false, copies: copying, stopped: live?.problems.length ?? 0); + if (ai != null) { + // 진행 알림에 남은 장 수와 예상 시간 (발열 · 배터리 때문에 쉬어 가지는 않는다 - 사용자 결정) + text = text == null ? ai : '$ai · $text'; + if (!c.busy && running == 0) progress = ((AiJobs.instance.progress ?? 0) * 100).round().clamp(0, 100); + } try { if (text == null) { if (_on) await _ch.invokeMethod('stopKeepAlive'); diff --git a/lib/platform/windows/recycle_bin.dart b/lib/platform/windows/recycle_bin.dart index 8db8d73..f047aca 100644 --- a/lib/platform/windows/recycle_bin.dart +++ b/lib/platform/windows/recycle_bin.dart @@ -112,6 +112,25 @@ bool? recycledInfoExists(String original, {required DateTime since}) { return readable ? false : null; } +/// 휴지통이 받을 수 있는 경로 길이 (MAX_PATH - 끝의 NUL) +const recycleMaxPath = 259; + +/// [path] (폴더면 그 안까지) 에 휴지통이 받지 못하는 긴 경로가 있으면 그 경로, 없으면 null +String? longPathInside(String path) { + final full = File(path).absolute.path; + if (full.length > recycleMaxPath) return full; + if (FileSystemEntity.typeSync(full, followLinks: false) != FileSystemEntityType.directory) return null; + try { + for (final e in Directory(full).listSync(recursive: true, followLinks: false)) { + if (e.path.length > recycleMaxPath) return e.path; + } + } catch (_) { + // 긴 경로 때문에 목록을 다 읽지 못함 - 그것도 긴 경로가 있다는 뜻 + return full; + } + return null; +} + /// SHFileOperation 의 오류 번호를 사람이 읽을 말로 (98) String recycleErrorText(int code) => switch (code) { 0x02 || 0x03 => tr('찾을 수 없습니다 (이미 지워졌거나 옮겨졌습니다)'), @@ -130,6 +149,10 @@ String recycleErrorText(int code) => switch (code) { RecycleResult moveToRecycleBin(String path) { if (!Platform.isWindows) throw FileSystemException(tr('휴지통이 없습니다'), path); if (FileSystemEntity.typeSync(path, followLinks: false) == FileSystemEntityType.notFound) return RecycleResult.recycled; + // 휴지통은 260자 넘는 경로를 받지 않고, 그때 Windows 는 묻지 않고 영구 삭제해 버린다 (시험에서 확인). + // 그래서 보내기 전에 (폴더면 안의 것까지) 긴 경로가 있으면 지우지 않고 이유를 알린다 - 영구 삭제는 사용자가 따로 고른다. + final long = longPathInside(path); + if (long != null) throw FileSystemException(recycleErrorText(0x7C), long); final shell32 = DynamicLibrary.open('shell32.dll'); final op = shell32.lookupFunction), int Function(Pointer<_ShFileOp>)>('SHFileOperationW'); final before = _binCount(path); diff --git a/lib/services/image_ai.dart b/lib/services/image_ai.dart new file mode 100644 index 0000000..2f7dd28 --- /dev/null +++ b/lib/services/image_ai.dart @@ -0,0 +1,286 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:path/path.dart' as p; + +import '../core/secret_gate.dart'; +import '../core/sd_cli.dart'; +import '../l10n/tr.dart'; + +/// 만든 그림 하나 (파일 · 실제 시드) +class GeneratedImage { + final String path; + final int seed; + const GeneratedImage(this.path, this.seed); +} + +class ImageAiException implements Exception { + final String message; + const ImageAiException(this.message); + @override + String toString() => message; +} + +/// 진행 (0 ~ 1, 모르면 null) 과 지금 하는 일 +typedef AiProgress = void Function(double? progress, String step); + +/// 123: 그림 만들기 엔진. 기기 안 (stable-diffusion.cpp) 과 사용자가 추가한 서버 · 인터넷 서비스가 같은 모양으로 쓰인다. +abstract class ImageAiEngine { + /// 화면에 보일 이름 + String get label; + + /// 그림 · 프롬프트가 기기 밖으로 나가는지 (그러면 화면에 늘 알린다) + bool get leavesDevice; + + /// [outDir] 에 [baseName]_1.png … 로 저장하고 돌려준다 + Future> generate(ImageGenRequest r, {required String outDir, required String baseName, AiProgress? onProgress}); + + void cancel(); +} + +/// 시드: 정하지 않았으면 (-1) 여기서 정해 결과에 남긴다 (다시 만들 수 있게) +int resolveSeed(int seed, [Random? random]) => seed >= 0 ? seed : (random ?? Random()).nextInt(1 << 31); + +/// 사용자가 추가한 그림 서비스 · 자기 서버 (123). API 키는 설정 파일이 아니라 안전 저장소에 둔다 (124). +class AiService { + final String id; + final String name; + final String url; + + /// 'a1111' Automatic1111 (Forge 포함) API · 'comfy' ComfyUI · 'openai' OpenAI 이미지 API 형식 + final String kind; + + /// 서버의 모델 (A1111 은 비워 두면 서버에 고른 것, ComfyUI 는 체크포인트 파일 이름, OpenAI 형식은 모델 이름) + final String model; + final String apiKey; + const AiService({required this.id, required this.name, required this.url, required this.kind, this.model = '', this.apiKey = ''}); + + static const kinds = ['a1111', 'comfy', 'openai']; + + String get label => name.isEmpty ? url : name; + + AiService copyWith({String? name, String? url, String? kind, String? model, String? apiKey}) => AiService( + id: id, + name: name ?? this.name, + url: url ?? this.url, + kind: kind ?? this.kind, + model: model ?? this.model, + apiKey: apiKey ?? this.apiKey, + ); + + /// 설정 파일에는 키를 쓰지 않는다 (있는지만) + Map toJson() => {'id': id, 'name': name, 'url': url, 'kind': kind, 'model': model, 'hasKey': apiKey.isNotEmpty}; + + factory AiService.fromJson(Map j) => AiService( + id: '${j['id']}', + name: '${j['name'] ?? ''}', + url: '${j['url'] ?? ''}', + kind: kinds.contains(j['kind']) ? j['kind'] as String : 'a1111', + model: '${j['model'] ?? ''}', + apiKey: '${j['apiKey'] ?? ''}', + ); +} + +/// 사용자가 추가한 서버 · 서비스로 그리기 (기기 밖으로 나감) +class RemoteImageEngine implements ImageAiEngine { + RemoteImageEngine(this.service, {HttpClient? http}) : _http = http ?? HttpClient(); + final AiService service; + final HttpClient _http; + bool _cancel = false; + + @override + String get label => service.label; + @override + bool get leavesDevice => true; + + @override + void cancel() => _cancel = true; + + Uri _uri(String path, [Map? q]) { + final base = service.url.endsWith('/') ? service.url.substring(0, service.url.length - 1) : service.url; + return Uri.parse('$base$path').replace(queryParameters: q); + } + + void _auth(HttpClientRequest req) { + final k = service.apiKey; + if (k.isEmpty) return; + // A1111 의 --api-auth 는 "아이디:비밀번호" (Basic), 그 밖은 Bearer 키 + req.headers.set(HttpHeaders.authorizationHeader, + service.kind == 'a1111' && k.contains(':') ? 'Basic ${base64Encode(utf8.encode(k))}' : 'Bearer $k'); + } + + Future _json(String method, Uri uri, [Object? body]) async { + if (_cancel) throw ImageAiException(tr('취소했습니다')); + final req = await _http.openUrl(method, uri); + _auth(req); + if (body != null) { + final bytes = utf8.encode(jsonEncode(body)); + req.headers.contentType = ContentType.json; + req.contentLength = bytes.length; + req.add(bytes); + } + final res = await req.close(); + final text = await res.transform(utf8.decoder).join(); + if (res.statusCode >= 300) throw ImageAiException(trf('서버가 거절했습니다 ({0}): {1}', [res.statusCode, _short(text)])); + return text.isEmpty ? null : jsonDecode(text); + } + + static String _short(String s) => s.length > 300 ? '${s.substring(0, 300)}…' : s; + + @override + Future> generate(ImageGenRequest r, {required String outDir, required String baseName, AiProgress? onProgress}) async { + _cancel = false; + // 124: 저장된 API 키를 쓰기 전에 (마스터 비밀번호를 정해 두었으면) + if (service.apiKey.isNotEmpty && !await SecretGate.pass(force: true)) throw ImageAiException(tr(secretGateMessage)); + final seed = resolveSeed(r.seed); + onProgress?.call(null, trf('{0} 에 보내는 중', [service.label])); + final List> images = switch (service.kind) { + 'comfy' => await _comfy(r, seed, onProgress), + 'openai' => await _openai(r), + _ => await _a1111(r, seed), + }; + await Directory(outDir).create(recursive: true); + return [ + for (final (i, bytes) in images.indexed) + GeneratedImage(await _save(outDir, '${baseName}_${i + 1}.png', bytes), seed + i), + ]; + } + + static Future _save(String dir, String name, List bytes) async { + final f = File(p.join(dir, name)); + await f.writeAsBytes(bytes, flush: true); + return f.path; + } + + Future>> _a1111(ImageGenRequest r, int seed) async { + final body = { + 'prompt': r.prompt, + 'negative_prompt': r.negative, + 'width': r.width, + 'height': r.height, + 'steps': r.steps, + 'cfg_scale': r.cfg, + 'seed': seed, + 'batch_size': r.count, + if (service.model.isNotEmpty) 'override_settings': {'sd_model_checkpoint': service.model}, + if (r.imageToImage) 'init_images': [base64Encode(await File(r.initImage!).readAsBytes())], + if (r.imageToImage) 'denoising_strength': r.strength, + }; + final j = await _json('POST', _uri(r.imageToImage ? '/sdapi/v1/img2img' : '/sdapi/v1/txt2img'), body); + final list = (j is Map ? j['images'] : null) as List?; + if (list == null || list.isEmpty) throw ImageAiException(tr('서버가 그림을 돌려주지 않았습니다')); + return [for (final x in list) base64Decode('$x'.split(',').last)]; + } + + Future>> _openai(ImageGenRequest r) async { + if (r.imageToImage) throw ImageAiException(tr('이 서비스 방식은 그림 → 그림을 지원하지 않습니다')); + final j = await _json('POST', _uri('/v1/images/generations'), { + if (service.model.isNotEmpty) 'model': service.model, + 'prompt': r.negative.isEmpty ? r.prompt : '${r.prompt}\n(avoid: ${r.negative})', + 'n': r.count, + 'size': '${r.width}x${r.height}', + 'response_format': 'b64_json', + }); + final data = (j is Map ? j['data'] : null) as List?; + if (data == null || data.isEmpty) throw ImageAiException(tr('서버가 그림을 돌려주지 않았습니다')); + return [for (final x in data) base64Decode('${(x as Map)['b64_json']}')]; + } + + /// ComfyUI: 기본 작업 흐름 (체크포인트 → 글 → KSampler → VAE → 저장) 을 보내고 끝날 때까지 기다린다 + Future>> _comfy(ImageGenRequest r, int seed, AiProgress? onProgress) async { + String? uploaded; + if (r.imageToImage) uploaded = await _comfyUpload(r.initImage!); + final flow = comfyWorkflow(r, seed, checkpoint: service.model, initImage: uploaded); + final j = await _json('POST', _uri('/prompt'), {'prompt': flow, 'client_id': 'jj_mkvmaker'}); + final id = '${(j as Map)['prompt_id']}'; + for (var i = 0; i < 1800; i++) { + if (_cancel) { + try { + await _json('POST', _uri('/interrupt')); + } catch (_) {} + throw ImageAiException(tr('취소했습니다')); + } + await Future.delayed(const Duration(seconds: 1)); + onProgress?.call(null, trf('{0} 에서 그리는 중 ({1}초)', [service.label, i + 1])); + final h = await _json('GET', _uri('/history/$id')); + final entry = h is Map ? h[id] : null; + final outputs = entry is Map ? entry['outputs'] : null; + if (outputs is! Map || outputs.isEmpty) continue; + final out = >[]; + for (final node in outputs.values) { + for (final img in ((node as Map)['images'] as List? ?? const [])) { + final m = img as Map; + out.add(await _bytes(_uri('/view', {'filename': '${m['filename']}', 'subfolder': '${m['subfolder'] ?? ''}', 'type': '${m['type'] ?? 'output'}'}))); + } + } + if (out.isNotEmpty) return out; + } + throw ImageAiException(tr('서버가 너무 오래 답하지 않습니다')); + } + + Future> _bytes(Uri uri) async { + final req = await _http.getUrl(uri); + _auth(req); + final res = await req.close(); + final b = BytesBuilder(copy: false); + await for (final c in res) { + b.add(c); + } + if (res.statusCode >= 300) throw ImageAiException(trf('그림을 받을 수 없습니다 ({0})', [res.statusCode])); + return b.takeBytes(); + } + + Future _comfyUpload(String path) async { + final boundary = 'jj${DateTime.now().microsecondsSinceEpoch}'; + final req = await _http.postUrl(_uri('/upload/image')); + _auth(req); + req.headers.set(HttpHeaders.contentTypeHeader, 'multipart/form-data; boundary=$boundary'); + final head = utf8.encode('--$boundary\r\nContent-Disposition: form-data; name="image"; filename="${p.basename(path)}"\r\n' + 'Content-Type: application/octet-stream\r\n\r\n'); + final tail = utf8.encode('\r\n--$boundary\r\nContent-Disposition: form-data; name="overwrite"\r\n\r\ntrue\r\n--$boundary--\r\n'); + final data = await File(path).readAsBytes(); + req.contentLength = head.length + data.length + tail.length; + req + ..add(head) + ..add(data) + ..add(tail); + final res = await req.close(); + final text = await res.transform(utf8.decoder).join(); + if (res.statusCode >= 300) throw ImageAiException(trf('그림을 올릴 수 없습니다 ({0})', [res.statusCode])); + return '${(jsonDecode(text) as Map)['name']}'; + } +} + +/// ComfyUI 에 보낼 기본 작업 흐름 (API 형식) +Map comfyWorkflow(ImageGenRequest r, int seed, {required String checkpoint, String? initImage}) { + final latent = initImage == null + ? {'class_type': 'EmptyLatentImage', 'inputs': {'width': r.width, 'height': r.height, 'batch_size': r.count}} + : {'class_type': 'VAEEncode', 'inputs': {'pixels': ['10', 0], 'vae': ['4', 2]}}; + return { + '4': {'class_type': 'CheckpointLoaderSimple', 'inputs': {'ckpt_name': checkpoint}}, + '6': {'class_type': 'CLIPTextEncode', 'inputs': {'text': r.prompt, 'clip': ['4', 1]}}, + '7': {'class_type': 'CLIPTextEncode', 'inputs': {'text': r.negative, 'clip': ['4', 1]}}, + '5': latent, + if (initImage != null) '10': {'class_type': 'LoadImage', 'inputs': {'image': initImage}}, + '3': { + 'class_type': 'KSampler', + 'inputs': { + 'seed': seed, + 'steps': r.steps, + 'cfg': r.cfg, + 'sampler_name': 'euler', + 'scheduler': 'normal', + 'denoise': initImage == null ? 1.0 : r.strength, + 'model': ['4', 0], + 'positive': ['6', 0], + 'negative': ['7', 0], + 'latent_image': ['5', 0], + }, + }, + '8': {'class_type': 'VAEDecode', 'inputs': {'samples': ['3', 0], 'vae': ['4', 2]}}, + '9': {'class_type': 'SaveImage', 'inputs': {'filename_prefix': 'jj_mkvmaker', 'images': ['8', 0]}}, + }; +} diff --git a/lib/ui/ai_image_page.dart b/lib/ui/ai_image_page.dart new file mode 100644 index 0000000..beba1d3 --- /dev/null +++ b/lib/ui/ai_image_page.dart @@ -0,0 +1,348 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; + +import '../app/ai_local.dart'; +import '../app/app_controller.dart'; +import '../core/reader_sources.dart'; +import '../core/sd_cli.dart'; +import '../l10n/tr.dart'; +import '../services/image_ai.dart'; +import 'ai_settings.dart'; +import 'app_actions.dart'; +import 'reader_page.dart'; +import 'theme.dart'; + +/// 123: AI 그림 - 글 → 그림 · 그림 → 그림. 기기 안 (SD1.5 + LCM) 이 기본이고, 사용자가 추가한 서버 · 서비스도 같은 화면에서. +/// 결과는 새 파일로 저장하고 (원본을 덮지 않음), 옆에 프롬프트 · 시드 · 모델을 json 으로 남긴다. +class AiImagePage extends StatefulWidget { + const AiImagePage({super.key, required this.c, this.initImage}); + final AppController c; + + /// 보기 화면 · 탐색기의 [AI 그림으로] - 그림 → 그림의 바탕 그림 + final String? initImage; + + static const routeName = 'aiimage'; + + static Future open(NavigatorState nav, {required AppController c, String? initImage}) async { + var found = false; + if (initImage == null) { + nav.popUntil((r) { + if (r.settings.name == routeName) found = true; + return found || r.isFirst; + }); + if (found) return; + } + await nav.push(MaterialPageRoute( + settings: const RouteSettings(name: routeName), + builder: (_) => AiImagePage(c: c, initImage: initImage), + )); + } + + @override + State createState() => _AiImagePageState(); +} + +/// 결과를 저장할 폴더 (비어 있으면 사진 폴더의 JJ_MKVMaker_AI) +String aiSaveDir(String setting) { + if (setting.isNotEmpty) return setting; + if (Platform.isAndroid) return '/storage/emulated/0/Pictures/JJ_MKVMaker_AI'; + final home = Platform.environment['USERPROFILE'] ?? Platform.environment['HOME'] ?? Directory.systemTemp.path; + return p.join(home, 'Pictures', 'JJ_MKVMaker_AI'); +} + +class _AiImagePageState extends State { + AppController get c => widget.c; + final _prompt = TextEditingController(); + late final _negative = TextEditingController(text: c.settings.aiNegative); + final _seed = TextEditingController(text: '-1'); + late String? _init = widget.initImage; + final _results = []; + String? _error; + String _step = ''; + + AiStore get store => AiStore.instance ??= AiStore(Directory.systemTemp.path); + + @override + void dispose() { + _prompt.dispose(); + _negative.dispose(); + _seed.dispose(); + super.dispose(); + } + + AiService? get _service => c.settings.aiServices.where((x) => x.id == c.settings.aiEngine).firstOrNull; + + bool get _local => _service == null; + + bool get _modelsReady => store.sd15Paths(taesd: false) != null; + + Future _generate() async { + final s = c.settings; + if (_prompt.text.trim().isEmpty) { + setState(() => _error = tr('무엇을 그릴지 적어 주세요')); + return; + } + if (_local && !_modelsReady) { + await openAiModels(context, c); + if (!_modelsReady) return; + } + await c.updateSettings((x) => x.aiNegative = _negative.text); + final req = ImageGenRequest( + prompt: _prompt.text.trim(), + negative: _negative.text.trim(), + width: s.aiWidth, + height: s.aiHeight, + steps: s.aiSteps, + cfg: s.aiCfg, + seed: int.tryParse(_seed.text.trim()) ?? -1, + count: s.aiCount, + initImage: _init, + strength: s.aiStrength, + model: _local ? 'sd15-lcm' : (_service!.model.isEmpty ? _service!.kind : _service!.model), + ); + setState(() { + _error = null; + _step = ''; + }); + try { + final ImageAiEngine engine; + if (_local) { + final device = await pickAiDevice(c, store, onStep: (t) => mounted ? setState(() => _step = t) : null); + if (device == null) throw ImageAiException(tr('AI 엔진이 없습니다. 환경 설정 > AI 그림에서 받아 주세요.')); + engine = LocalSdEngine(device: device, models: store.sd15Paths(taesd: s.aiTaesd)!); + } else { + engine = RemoteImageEngine(_service!); + } + final made = await AiJobs.instance.run(engine, req, outDir: aiSaveDir(s.aiSaveDir)); + if (!mounted) return; + setState(() => _results.insertAll(0, made)); + } catch (e) { + if (mounted) setState(() => _error = '$e'); + } + } + + Future _pickInit() async { + final files = await c.services.storage.pickFiles(title: tr('바탕 그림 고르기'), extensions: c.settings.imageExts); + if (files.isNotEmpty && mounted) setState(() => _init = files.first); + } + + /// 결과 옆의 json 을 읽어 같은 조건으로 (시드 그대로) + void _reuse(GeneratedImage g) { + try { + final j = File('${p.withoutExtension(g.path)}.json').readAsStringSync(); + final r = ImageGenRequest.fromJson(Map.from(jsonDecode(j) as Map)); + setState(() { + _prompt.text = r.prompt; + _negative.text = r.negative; + _seed.text = '${g.seed}'; + _init = r.initImage; + }); + } catch (_) { + setState(() => _seed.text = '${g.seed}'); + } + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: Listenable.merge([c, AiJobs.instance, store]), + builder: (context, _) { + final s = c.settings; + final jobs = AiJobs.instance; + final compact = isCompact(context); + return Scaffold( + body: SwipeNav( + current: 'aiimage', + child: Column(children: [ + AppTopBar( + nav: const AppNavButtons(onAiPage: true), + actions: AppActions(c: c), + middle: Text(tr('AI 그림'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + ), + Expanded( + child: ListView(padding: EdgeInsets.all(compact ? 8 : 16), children: [ + _engineRow(), + if (!_local) + // 인터넷 서비스 · 자기 서버를 쓸 때는 늘 알린다 + _notice(Icons.public, Colors.orangeAccent, + trf('그림 · 프롬프트가 기기 밖으로 나갑니다 ({0})', [_service!.label])), + if (_local && !_modelsReady) + _notice(Icons.download_outlined, JjColors.accent, + trf('기기 안에서 그리려면 모델을 받아야 합니다 ({0})', [AiStore.sizeText(aiMissingSize(store))]), + action: TextButton(onPressed: () => openAiModels(context, c), child: Text(tr('받기 화면으로')))), + const SizedBox(height: 8), + TextField( + controller: _prompt, + minLines: 2, + maxLines: 5, + decoration: InputDecoration(labelText: tr('그릴 것 (프롬프트, 영어가 잘 됩니다)'), border: const OutlineInputBorder()), + ), + const SizedBox(height: 8), + TextField( + controller: _negative, + decoration: InputDecoration(labelText: tr('빼고 싶은 것 (선택)'), border: const OutlineInputBorder()), + ), + const SizedBox(height: 8), + Wrap(spacing: 12, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ + _drop(tr('크기'), '${s.aiWidth}x${s.aiHeight}', const ['512x512', '512x768', '768x512', '768x768', '1024x1024'], + (v) => c.updateSettings((x) { + final wh = v.split('x'); + x.aiWidth = int.parse(wh[0]); + x.aiHeight = int.parse(wh[1]); + })), + _drop(tr('단계'), s.aiSteps, const [2, 4, 6, 8, 12, 20, 30], (v) => c.updateSettings((x) => x.aiSteps = v)), + _drop(tr('개수'), s.aiCount, const [1, 2, 4, 8, 16], (v) => c.updateSettings((x) => x.aiCount = v)), + // 141: 이름이 잘리지 않게 짧게 · 넉넉히 (설명은 아래 줄) + SizedBox( + width: 190, + child: TextField( + controller: _seed, + keyboardType: TextInputType.number, + decoration: InputDecoration(labelText: tr('시드'), helperText: tr('-1 은 아무렇게나'), isDense: true), + ), + ), + ]), + const SizedBox(height: 8), + _initRow(s.aiStrength), + const SizedBox(height: 12), + if (jobs.busy) ...[ + LinearProgressIndicator(value: jobs.progress), + const SizedBox(height: 4), + Row(children: [ + Expanded(child: Text(jobs.statusLine ?? '', style: const TextStyle(fontSize: 12))), + OutlinedButton(onPressed: jobs.cancel, child: Text(tr('취소'))), + ]), + ] else + Row(children: [ + FilledButton.icon( + onPressed: _generate, + icon: const Icon(Icons.auto_awesome), + label: Text(_init == null ? tr('만들기') : tr('그림을 바꿔 만들기')), + ), + const SizedBox(width: 12), + if (_step.isNotEmpty) Expanded(child: Text(_step, style: const TextStyle(fontSize: 12))), + ]), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(_error!, style: const TextStyle(color: Colors.redAccent)), + ), + const SizedBox(height: 12), + if (_results.isNotEmpty) Text(trf('만든 그림 · 저장: {0}', [aiSaveDir(s.aiSaveDir)]), style: const TextStyle(fontSize: 12)), + const SizedBox(height: 6), + Wrap(spacing: 8, runSpacing: 8, children: [for (final g in _results) _thumb(g)]), + ]), + ), + ]), + ), + ); + }, + ); + } + + Widget _engineRow() { + final s = c.settings; + return Row(children: [ + Text(tr('엔진'), style: const TextStyle(fontSize: 13)), + const SizedBox(width: 8), + Flexible( + child: DropdownButton( + isExpanded: true, + value: _service == null ? 'local' : s.aiEngine, + items: [ + DropdownMenuItem(value: 'local', child: Text(tr('이 기기 (SD1.5 + LCM)'))), + for (final x in s.aiServices) DropdownMenuItem(value: x.id, child: Text('☁ ${x.label}')), + ], + onChanged: (v) => c.updateSettings((x) => x.aiEngine = v ?? 'local'), + ), + ), + IconButton( + tooltip: tr('AI 설정 · 모델 받기 · 서비스 추가'), + icon: const Icon(Icons.tune), + onPressed: () => openAiModels(context, c), + ), + ]); + } + + Widget _initRow(double strength) => Wrap(spacing: 8, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [ + // 바탕 그림이 무엇인지 바로 보이게 작은 그림으로 + if (_init != null) + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Image.file(File(_init!), width: 64, height: 64, fit: BoxFit.cover, + errorBuilder: (_, _, _) => const SizedBox(width: 64, height: 64, child: Icon(Icons.broken_image_outlined))), + ), + OutlinedButton.icon( + onPressed: _pickInit, + icon: const Icon(Icons.image_outlined, size: 18), + label: Text(_init == null ? tr('바탕 그림 고르기 (그림 → 그림)') : p.basename(_init!)), + ), + if (_init != null) ...[ + IconButton(tooltip: tr('바탕 그림 빼기'), icon: const Icon(Icons.close), onPressed: () => setState(() => _init = null)), + Text(trf('바꾸는 정도 {0}%', [(strength * 100).round()]), style: const TextStyle(fontSize: 12)), + SizedBox( + width: 200, + child: Slider( + value: strength, + min: 0.05, + max: 1, + onChanged: (v) => c.updateSettings((x) => x.aiStrength = v), + ), + ), + ], + ]); + + Widget _thumb(GeneratedImage g) => 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)), + child: Image.file(File(g.path), width: 160, height: 160, fit: BoxFit.cover), + ), + Row(mainAxisSize: MainAxisSize.min, children: [ + Text(trf('시드 {0}', [g.seed]), style: const TextStyle(fontSize: 11, color: JjColors.textDim)), + IconButton( + tooltip: tr('같은 조건으로 다시 (시드 그대로)'), + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.replay, size: 16), + onPressed: () => _reuse(g), + ), + IconButton( + tooltip: tr('이 그림을 바탕으로 (그림 → 그림)'), + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.edit_outlined, size: 16), + onPressed: () => setState(() => _init = g.path), + ), + ]), + ]); + + Widget _drop(String label, T value, List items, void Function(T) on) => Row(mainAxisSize: MainAxisSize.min, children: [ + Text(label, style: const TextStyle(fontSize: 13)), + const SizedBox(width: 6), + DropdownButton( + value: items.contains(value) ? value : null, + hint: Text('$value'), + items: [for (final x in items) DropdownMenuItem(value: x, child: Text('$x'))], + onChanged: (v) => v == null ? null : on(v), + ), + ]); + + Widget _notice(IconData icon, Color color, String text, {Widget? action}) => Container( + margin: const EdgeInsets.only(top: 8), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withValues(alpha: 0.5)), + ), + child: Row(children: [ + Icon(icon, color: color, size: 20), + const SizedBox(width: 8), + Expanded(child: Text(text, style: const TextStyle(fontSize: 13))), + ?action, + ]), + ); +} diff --git a/lib/ui/ai_settings.dart b/lib/ui/ai_settings.dart new file mode 100644 index 0000000..8d1f3a4 --- /dev/null +++ b/lib/ui/ai_settings.dart @@ -0,0 +1,362 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../app/ai_local.dart'; +import '../app/app_controller.dart'; +import '../core/ai_catalog.dart'; +import '../core/secret_gate.dart'; +import '../l10n/tr.dart'; +import '../services/image_ai.dart'; +import 'ai_image_page.dart' show aiSaveDir; +import 'confirm.dart'; +import 'folder_picker.dart'; +import 'setting_tile.dart'; +import 'theme.dart'; + +/// 기기 안에서 그리는 데 아직 받지 않은 파일 (Windows 는 실행 파일까지, TAESD 포함) - AI 그림 화면 · 받기 화면이 같은 값을 보인다 (139) +List aiMissingFiles(AiStore store) => [ + if (Platform.isWindows) aiFile('engine-vulkan'), + for (final id in [...sd15LcmFiles, 'taesd']) aiFile(id), + ].where((f) => !store.isInstalled(f.id)).toList(); + +int aiMissingSize(AiStore store) => aiMissingFiles(store).fold(0, (a, f) => a + f.size); + +/// AI 그림 화면의 [받기 화면으로] · 설정 버튼: AI 설정 (모델 받기 · 처리 장치 · 서비스) 을 따로 연다 +Future openAiModels(BuildContext context, AppController c) => Navigator.of(context).push(MaterialPageRoute( + builder: (ctx) => Scaffold( + appBar: AppBar( + // 140: 뒤로 버튼 이름도 화면 언어로 + leading: IconButton(tooltip: tr('뒤로'), icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.maybePop(ctx)), + title: Text(tr('AI 그림 · 해상도 올리기')), + ), + body: ListView(padding: const EdgeInsets.all(12), children: [AiSettings(c: c)]), + ), + )); + +/// 123 · 121: 환경 설정 > AI 그림 · 해상도 올리기 +class AiSettings extends StatefulWidget { + const AiSettings({super.key, required this.c}); + final AppController c; + + @override + State createState() => _AiSettingsState(); +} + +class _AiSettingsState extends State { + AppController get c => widget.c; + AiStore get store => AiStore.instance ??= AiStore(Directory.systemTemp.path); + bool _nvidia = false; + List _devices = const []; + String? _error; + + @override + void initState() { + super.initState(); + _refresh(); + } + + Future _refresh() async { + final nv = await AiStore.hasNvidia(); + final devs = await sdDevices(store); + if (mounted) { + setState(() { + _nvidia = nv; + _devices = devs; + }); + } + } + + String get _platform => Platform.isAndroid ? 'android' : 'windows'; + + List get _files => [ + for (final f in aiCatalog) + if ((f.platforms.isEmpty || f.platforms.contains(_platform)) && (f.needs != 'nvidia' || _nvidia)) f, + ]; + + /// 받기 전에 라이선스 조건 (있으면) 을 보여 주고 동의를 받는다 + Future _agree(AiFile f) async { + if (f.terms == null) return true; + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + scrollable: true, + title: Text(trf('{0} 라이선스', [f.license])), + content: SizedBox( + width: 520, + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(tr(f.terms!)), + const SizedBox(height: 8), + SelectableText(f.licenseUrl, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + ]), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('동의하고 받기'))), + ], + ), + ); + return ok == true; + } + + Future _install(List files) async { + for (final f in files) { + if (store.isInstalled(f.id) || !await _agree(f)) continue; + try { + setState(() => _error = null); + await store.install(f.id); + } catch (e) { + if (mounted) setState(() => _error = '$e'); + return; + } + } + await _refresh(); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: Listenable.merge([c, store]), + builder: (context, _) { + final s = c.settings; + final missing = aiMissingFiles(store); + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + SettingTile( + leading: const Icon(Icons.auto_awesome_outlined), + title: Text(tr('기기 안에서 그리기 (stable-diffusion.cpp · SD1.5 + LCM)')), + subtitle: Text(missing.isEmpty + ? tr('모두 받았습니다. 512×512 한 장에 폰은 약 1분, PC 는 장치에 따라 1 ~ 5분 걸립니다.') + : trf('그리려면 {0} 를 받아야 합니다 (모델은 받은 뒤 이 기기에만 있고, 그림 · 프롬프트는 밖으로 나가지 않습니다).', + [AiStore.sizeText(missing.fold(0, (a, f) => a + f.size))])), + trailing: missing.isEmpty + ? null + : FilledButton.tonal(onPressed: () => _install(missing), child: Text(tr('필요한 것 모두 받기'))), + ), + if (_error != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text(_error!, style: const TextStyle(color: Colors.redAccent)), + ), + for (final f in _files) _fileRow(f), + const Divider(), + SettingTile( + leading: const Icon(Icons.memory), + title: Text(tr('처리 장치')), + subtitle: Text(s.aiDevice == 'auto' + ? (s.aiAutoDevice.isEmpty + ? tr('자동: 처음 그릴 때 짧게 재서 가장 빠른 장치를 고릅니다') + : trf('자동: 잰 결과 {0} 가 가장 빠릅니다', [_devices.where((d) => d.key == s.aiAutoDevice).firstOrNull?.label ?? s.aiAutoDevice])) + : tr('직접 고름')), + trailing: DropdownButton( + value: s.aiDevice == 'auto' || _devices.any((d) => d.key == s.aiDevice) ? s.aiDevice : 'auto', + items: [ + DropdownMenuItem(value: 'auto', child: Text(tr('자동 (가장 빠른 것)'))), + for (final d in _devices) DropdownMenuItem(value: d.key, child: Text(d.label)), + ], + onChanged: (v) => c.updateSettings((x) => x + ..aiDevice = v ?? 'auto' + ..aiAutoDeviceFor = ''), // 다시 고르면 다음에 다시 잼 + ), + ), + SwitchListTile( + value: s.aiTaesd, + onChanged: (v) => c.updateSettings((x) => x.aiTaesd = v), + title: Text(tr('빠른 디코더 (TAESD)')), + subtitle: Text(tr('그림이 거의 같고 마지막 단계가 10배쯤 빠릅니다 (받아 두었을 때)')), + ), + SettingTile( + leading: const Icon(Icons.folder_outlined), + title: Text(tr('저장 폴더')), + subtitle: Text(aiSaveDir(s.aiSaveDir)), + trailing: Wrap(spacing: 6, children: [ + OutlinedButton( + onPressed: () async { + final d = await pickFolder(context, tr('AI 그림 저장 폴더'), aiSaveDir(s.aiSaveDir)); + if (d != null) await c.updateSettings((x) => x.aiSaveDir = d); + }, + child: Text(tr('바꾸기')), + ), + if (s.aiSaveDir.isNotEmpty) + TextButton(onPressed: () => c.updateSettings((x) => x.aiSaveDir = ''), child: Text(tr('처음대로'))), + ]), + ), + const Divider(), + SettingTile( + leading: const Icon(Icons.public), + title: Text(tr('서버 · 서비스 (기기 밖)')), + subtitle: Text(tr('직접 운영하는 서버 (ComfyUI · Automatic1111, 예: Tailscale 주소) 나 인터넷 서비스 (OpenAI 이미지 API 형식). ' + '넣지 않으면 기기 안에서만 그립니다. 쓸 때는 그림 · 프롬프트가 기기 밖으로 나간다고 늘 알립니다. API 키는 안전 저장소에 둡니다.')), + trailing: TextButton.icon( + onPressed: () => editAiService(context, c), + icon: const Icon(Icons.add, size: 18), + label: Text(tr('서비스 추가')), + ), + ), + for (final x in s.aiServices) + Padding( + padding: const EdgeInsets.only(left: 16), + child: SettingTile( + dense: true, + leading: const Icon(Icons.cloud_outlined), + title: Text(x.label), + subtitle: Text('${_kindName(x.kind)} · ${x.url}${x.apiKey.isNotEmpty ? ' · ${tr('키 있음')}' : ''}', + maxLines: 1, overflow: TextOverflow.ellipsis), + trailing: Row(mainAxisSize: MainAxisSize.min, children: [ + IconButton( + tooltip: tr('고치기'), + icon: const Icon(Icons.edit_outlined, size: 18), + onPressed: () => editAiService(context, c, old: x), + ), + IconButton( + tooltip: tr('지우기'), + icon: const Icon(Icons.delete_outline, size: 18), + onPressed: () async { + final ok = await confirmAction(context, + title: trf('"{0}" 을(를) 지울까요?', [x.label]), body: tr('서비스 설정과 저장된 API 키를 지웁니다.'), ok: tr('지우기')); + if (!ok) return; + await c.updateSettings((y) { + y.aiServices = [for (final z in y.aiServices) if (z.id != x.id) z]; + if (y.aiEngine == x.id) y.aiEngine = 'local'; + }); + }, + ), + ]), + ), + ), + ]); + }, + ); + } + + Widget _fileRow(AiFile f) { + final installed = store.isInstalled(f.id); + final prog = store.progress[f.id]; + final busy = store.progress.containsKey(f.id); + return Padding( + padding: const EdgeInsets.only(left: 16), + child: SettingTile( + dense: true, + leading: Icon(installed ? Icons.check_circle : Icons.download_outlined, + color: installed ? JjColors.success : JjColors.textDim, size: 20), + title: Text(tr(f.name)), + subtitle: busy + ? LinearProgressIndicator(value: prog) + : Text('${AiStore.sizeText(f.size)} · ${f.license}${installed ? ' · ${tr('받음')}' : ''}'), + trailing: busy + ? TextButton(onPressed: () => store.cancel(f.id), child: Text(tr('취소'))) + : installed + ? TextButton( + onPressed: () async { + final ok = await confirmAction(context, + title: trf('{0} 을(를) 지울까요?', [tr(f.name)]), body: tr('다시 쓰려면 다시 받아야 합니다.'), ok: tr('지우기')); + if (ok) { + await store.remove(f.id); + await _refresh(); + } + }, + child: Text(tr('지우기'))) + : OutlinedButton(onPressed: () => _install([f]), child: Text(tr('받기'))), + ), + ); + } +} + +String _kindName(String kind) => switch (kind) { + 'comfy' => 'ComfyUI', + 'openai' => tr('OpenAI 이미지 API 형식'), + _ => 'Automatic1111 · Forge', + }; + +/// 서비스 추가 · 고치기. 저장된 API 키가 칸에 채워지므로 고칠 때는 먼저 마스터 비밀번호 (127 과 같음) +Future editAiService(BuildContext context, AppController c, {AiService? old}) async { + if (old != null && old.apiKey.isNotEmpty && !await SecretGate.pass(force: true)) return; + if (!context.mounted) return; + final name = TextEditingController(text: old?.name ?? ''); + final url = TextEditingController(text: old?.url ?? 'http://'); + final model = TextEditingController(text: old?.model ?? ''); + final key = TextEditingController(text: old?.apiKey ?? ''); + var kind = old?.kind ?? 'a1111'; + var hide = true; + final saved = await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, set) => AlertDialog( + scrollable: true, + title: Text(old == null ? tr('서비스 추가') : tr('서비스 고치기')), + content: SizedBox( + width: 480, + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Container( + padding: const EdgeInsets.all(8), + color: Colors.orangeAccent.withValues(alpha: 0.1), + child: Text(tr('이 서비스로 그리면 그림 · 프롬프트가 기기 밖 (이 주소) 으로 나갑니다.'), style: const TextStyle(fontSize: 12)), + ), + TextField(controller: name, decoration: InputDecoration(labelText: tr('이름'))), + DropdownButtonFormField( + initialValue: kind, + decoration: InputDecoration(labelText: tr('방식')), + items: [for (final k in AiService.kinds) DropdownMenuItem(value: k, child: Text(_kindName(k)))], + onChanged: (v) => set(() => kind = v ?? 'a1111'), + ), + TextField( + controller: url, + keyboardType: TextInputType.url, + decoration: InputDecoration( + labelText: tr('주소'), + helperText: switch (kind) { + 'comfy' => tr('예: http://100.x.x.x:8188 (ComfyUI)'), + 'openai' => tr('예: https://api.openai.com (경로 /v1/images/generations 를 붙여 부름)'), + _ => tr('예: http://100.x.x.x:7860 (--api 로 켠 Automatic1111 · Forge)'), + }, + ), + ), + TextField( + controller: model, + decoration: InputDecoration( + labelText: tr('모델 (선택)'), + helperText: kind == 'comfy' ? tr('체크포인트 파일 이름 (예: sd_xl_turbo_1.0_fp16.safetensors)') : null, + ), + ), + TextField( + controller: key, + obscureText: hide, + decoration: InputDecoration( + labelText: kind == 'a1111' ? tr('API 키 또는 아이디:비밀번호 (선택)') : tr('API 키 (선택)'), + suffixIcon: IconButton(icon: Icon(hide ? Icons.visibility : Icons.visibility_off), onPressed: () => set(() => hide = !hide)), + ), + ), + ]), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))), + FilledButton( + onPressed: () { + final u = url.text.trim(); + if (!u.startsWith('http://') && !u.startsWith('https://') || u.length < 10) return; + Navigator.pop( + ctx, + AiService( + id: old?.id ?? 'ai${DateTime.now().millisecondsSinceEpoch}', + name: name.text.trim(), + url: u, + kind: kind, + model: model.text.trim(), + apiKey: key.text.trim(), + ), + ); + }, + child: Text(tr('저장')), + ), + ], + ), + ), + ); + for (final x in [name, url, model, key]) { + x.dispose(); + } + if (saved == null) return; + await c.updateSettings((x) => x.aiServices = [ + for (final y in x.aiServices) if (y.id != saved.id) y, + saved, + ]); +} diff --git a/lib/ui/app_actions.dart b/lib/ui/app_actions.dart index 0e4b075..44e7fd8 100644 --- a/lib/ui/app_actions.dart +++ b/lib/ui/app_actions.dart @@ -9,6 +9,7 @@ import '../services/app_shell.dart' show appIconButtonAsset; import '../services/system_usage.dart'; import 'browser_page.dart'; import 'downloads_page.dart'; +import 'ai_image_page.dart'; import 'explorer_page.dart'; import 'settings_page.dart'; import 'theme.dart'; @@ -94,15 +95,19 @@ class AppNavButtons extends StatelessWidget { /// Rsync 화면 자신 final bool onRsyncPage; + + /// AI 그림 화면 자신 (123) + final bool onAiPage; const AppNavButtons( {super.key, this.onDownloadsPage = false, this.onBrowserPage = false, this.onExplorerPage = false, - this.onRsyncPage = false}); + this.onRsyncPage = false, + this.onAiPage = false}); - /// 버튼 줄의 가장 넓은 폭 (JJ · 화면 5개 · 뒤로) - static const double width = 7 * 40; + /// 버튼 줄의 가장 넓은 폭 (JJ · 화면 6개 · 뒤로) + static const double width = 8 * 40; /// MKV 화면 (맨 처음 화면) 까지 돌아가기 static void toMkv(BuildContext context) => Navigator.of(context).popUntil((r) => r.isFirst); @@ -129,6 +134,8 @@ class AppNavButtons extends StatelessWidget { await ExplorerPage.openRsync(nav, c: c); case 'downloads': if (downloads != null) await DownloadsPage.open(nav, downloads); + case 'aiimage': + await AiImagePage.open(nav, c: c); } } @@ -187,7 +194,9 @@ class AppNavButtons extends StatelessWidget { ? 'explorer' : onRsyncPage ? 'rsync' - : atRoot + : onAiPage + ? 'aiimage' + : atRoot ? 'mkv' : ''; final pages = scope == null ? const [] : pagesOf(scope); @@ -204,6 +213,7 @@ class AppNavButtons extends StatelessWidget { 'explorer' => here ? tr('파일 탐색기 (지금 여기)') : tr('파일 탐색기'), 'rsync' => here ? tr('Rsync (지금 여기)') : 'Rsync', 'downloads' => here ? tr('다운로드 목록 (지금 여기)') : tr('다운로드 목록'), + 'aiimage' => here ? tr('AI 그림 (지금 여기)') : tr('AI 그림'), _ => id, }; // 좁은 화면 (폰): 아이콘 아래에 짧은 이름 - 글자 없는 작은 아이콘만 늘어서 무엇인지 몰랐던 것 @@ -249,6 +259,7 @@ class AppNavButtons extends StatelessWidget { 'explorer' => tr('탐색기'), 'rsync' => 'Rsync', 'downloads' => tr('다운로드'), + 'aiimage' => tr('AI 그림'), _ => id, }; return Row(mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 0e32422..dfda755 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -26,6 +26,7 @@ import 'monitor_page.dart'; import 'player_page.dart'; import 'rsync_setup.dart'; import 'theme.dart'; +import 'file_error.dart'; import 'dav_external.dart'; import '../platform/windows/recycle_bin.dart'; import '../core/sync_preview.dart'; @@ -404,50 +405,14 @@ class _ExplorerPageState extends State { // ───────── 읽을 수 없을 때 (12) ───────── /// 오류 → (무엇이 문제인지, 무엇을 하면 되는지) - (String, String) _explain(String e, {required bool dav, bool noPassword = false}) { - final s = e.toLowerCase(); - if (dav && (s.contains('socketexception') || s.contains('connection refused') || s.contains('failed host lookup') || - s.contains('network is unreachable') || s.contains('no route') || s.contains('timed out') || - s.contains('timeoutexception') || s.contains('connection reset') || s.contains('connection closed'))) { - return ( - tr('서버에 닿지 않습니다'), - tr('서버와 같은 네트워크에 있거나 VPN (예: Tailscale) 이 켜져 있어야 합니다. 켠 뒤 [다시 시도] 를 누르세요.'), - ); - } - if (s.contains(' 401') || s.contains('status: 401') || s.contains('아이디 · 비밀번호')) { - // 54: 비밀번호가 틀린 것이 아니라 저장된 것이 없다 (예전 판 설치 · 앱 다시 설치 등) - if (noPassword) { - return ( - tr('저장된 비밀번호가 없습니다. 한 번만 다시 넣어 주세요'), - tr('[서버 설정 고치기] 에서 비밀번호를 넣으면 이 기기의 안전 저장소에 남아 다음부터는 다시 넣지 않아도 됩니다.'), - ); - } - return (tr('아이디 또는 비밀번호가 맞지 않습니다'), tr('[서버 설정 고치기] 에서 아이디 · 비밀번호를 확인하세요.')); - } - if (s.contains('handshake') || s.contains('certificate')) { - return ( - tr('서버 인증서를 확인할 수 없습니다'), - tr('집 NAS 처럼 자체 서명 인증서면 [서버 설정 고치기] 에서 "인증서 확인 안 함" 을 켜세요.'), - ); - } - if (s.contains(' 403') || s.contains('권한 없음')) return (tr('이 폴더를 볼 권한이 없습니다'), tr('서버에서 이 계정의 권한을 확인하세요.')); - if (s.contains(' 404') || s.contains('없는 경로')) return (tr('폴더가 없습니다'), tr('다른 곳에서 지웠거나 옮겼을 수 있습니다. 위 폴더로 가 보세요.')); - if (s.contains('permission denied') || s.contains('pathaccessexception') || s.contains('errno = 13')) { - return ( - tr('이 폴더를 읽을 권한이 없습니다'), - Platform.isAndroid ? tr('Android 가 막은 폴더 (Android/data 등) 이거나 "모든 파일에 대한 접근" 권한이 없습니다.') : '', - ); - } - return (tr('읽을 수 없습니다'), e); - } - Widget? _errorNotice(_Pane pane, String dir) { final err = pane.errors[dir] ?? pane.errors[pane.root]; if (err == null) return null; final where = pane.errors[dir] != null ? dir : pane.root; final dav = isDav(where); final server = dav ? DavRegistry.server(DavPath.parse(where).server) : null; - final (title, body) = _explain(err, dav: dav, noPassword: server != null && server.password.isEmpty); + final (title, body) = explainFileError(err, dav: dav, noPassword: server != null && server.password.isEmpty); + final locked = isLockedError(err); return _notice( icon: dav ? Icons.cloud_off_outlined : Icons.error_outline, title: '${_displayPath(where)}: $title', @@ -455,6 +420,8 @@ class _ExplorerPageState extends State { actions: [ FilledButton.tonal( onPressed: () async { + // 133: 마스터를 취소해 막혔으면 여기서 다시 묻는다 + if (locked && !await SecretGate.pass(force: true)) return; pane.errors.remove(where); pane.changed(); await _load(pane, where, force: true); @@ -462,9 +429,9 @@ class _ExplorerPageState extends State { if (isSameOrInside(d, where) && d != where) await _load(pane, d, force: true); } }, - child: Text(tr('다시 시도')), + child: Text(locked ? tr('마스터 비밀번호 넣기') : tr('다시 시도')), ), - if (dav) TextButton(onPressed: () => _editDav(pane, pane.root), child: Text(tr('서버 설정 고치기'))), + if (dav && !locked) TextButton(onPressed: () => _editDav(pane, pane.root), child: Text(tr('서버 설정 고치기'))), ], ); } @@ -1334,7 +1301,8 @@ class _ExplorerPageState extends State { ], if (failed.length > 30) Text(trf('… 외 {0}개', [failed.length - 30])), const SizedBox(height: 6), - Text(tr('다른 프로그램이 쓰고 있거나 권한이 없을 수 있습니다. 나머지는 지웠습니다.'), style: const TextStyle(fontSize: 12)), + // 이유를 이미 위에 적었으므로 짐작하는 말은 빼고, 실제로 지운 것이 있을 때만 "나머지는 지웠습니다" + if (failed.length < paths.length) Text(tr('나머지는 지웠습니다.'), style: const TextStyle(fontSize: 12)), ]), actions: [FilledButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('확인')))], ), @@ -1342,7 +1310,8 @@ class _ExplorerPageState extends State { } else if (!permanent) { _snack([ if (recycled > 0) trf('{0}개 항목을 휴지통으로 보냈습니다.', [recycled]), - if (nuked > 0) trf('{0}개는 휴지통보다 커서 영구 삭제했습니다 (Windows 가 물은 대로).', [nuked]), + // 휴지통보다 커서 Windows 가 물었고 사용자가 영구 삭제를 골랐다 + if (nuked > 0) trf('{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).', [nuked]), if (kept > 0) trf('{0}개는 지우지 않았습니다 (취소).', [kept]), ].join(' ')); } diff --git a/lib/ui/file_error.dart b/lib/ui/file_error.dart new file mode 100644 index 0000000..69c6d00 --- /dev/null +++ b/lib/ui/file_error.dart @@ -0,0 +1,53 @@ +import 'dart:io'; + +import '../core/secret_gate.dart'; +import '../l10n/tr.dart'; + +/// 파일 · WebDAV 오류를 사람이 읽을 말로 (제목, 할 일). 원문 예외는 알아볼 수 없을 때만 그대로. +/// 파일 탐색기 · 폴더 고르기 · 그 밖에 WebDAV 를 읽는 화면이 함께 쓴다 (12 · 134). +(String, String) explainFileError(String e, {required bool dav, bool noPassword = false}) { +// 128 · 133: 마스터 비밀번호를 기다리는 중 (고장이 아님) +if (isLockedError(e)) { + return (tr('마스터 비밀번호가 필요합니다'), tr('이 서버의 저장된 비밀번호를 쓰려면 마스터 비밀번호를 넣으세요.')); +} + final s = e.toLowerCase(); + if (dav && (s.contains('socketexception') || s.contains('connection refused') || s.contains('failed host lookup') || + s.contains('network is unreachable') || s.contains('no route') || s.contains('timed out') || + s.contains('timeoutexception') || s.contains('connection reset') || s.contains('connection closed'))) { + return ( + tr('서버에 닿지 않습니다'), + tr('서버와 같은 네트워크에 있거나 VPN (예: Tailscale) 이 켜져 있어야 합니다. 켠 뒤 [다시 시도] 를 누르세요.'), + ); + } + if (s.contains(' 401') || s.contains('status: 401') || s.contains('아이디 · 비밀번호')) { + // 54: 비밀번호가 틀린 것이 아니라 저장된 것이 없다 (예전 판 설치 · 앱 다시 설치 등) + if (noPassword) { + return ( + tr('저장된 비밀번호가 없습니다. 한 번만 다시 넣어 주세요'), + tr('[서버 설정 고치기] 에서 비밀번호를 넣으면 이 기기의 안전 저장소에 남아 다음부터는 다시 넣지 않아도 됩니다.'), + ); + } + return (tr('아이디 또는 비밀번호가 맞지 않습니다'), tr('[서버 설정 고치기] 에서 아이디 · 비밀번호를 확인하세요.')); + } + if (s.contains('handshake') || s.contains('certificate')) { + return ( + tr('서버 인증서를 확인할 수 없습니다'), + tr('집 NAS 처럼 자체 서명 인증서면 [서버 설정 고치기] 에서 "인증서 확인 안 함" 을 켜세요.'), + ); + } + if (s.contains(' 403') || s.contains('권한 없음')) return (tr('이 폴더를 볼 권한이 없습니다'), tr('서버에서 이 계정의 권한을 확인하세요.')); + if (s.contains(' 404') || s.contains('없는 경로')) return (tr('폴더가 없습니다'), tr('다른 곳에서 지웠거나 옮겼을 수 있습니다. 위 폴더로 가 보세요.')); + if (s.contains('permission denied') || s.contains('pathaccessexception') || s.contains('errno = 13')) { + return ( + tr('이 폴더를 읽을 권한이 없습니다'), + Platform.isAndroid ? tr('Android 가 막은 폴더 (Android/data 등) 이거나 "모든 파일에 대한 접근" 권한이 없습니다.') : '', + ); + } + return (tr('읽을 수 없습니다'), e); +} + +/// 마스터 비밀번호를 넣지 않아 막힌 오류인지 (128 · 133) +bool isLockedError(Object e) { + final s = '$e'; + return s.contains(secretGateMessage) || s.contains(tr(secretGateMessage)); +} diff --git a/lib/ui/folder_picker.dart b/lib/ui/folder_picker.dart index 887d1d7..5936505 100644 --- a/lib/ui/folder_picker.dart +++ b/lib/ui/folder_picker.dart @@ -3,10 +3,12 @@ import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import '../core/secret_gate.dart'; import '../core/vfs.dart'; import '../core/webdav.dart'; import '../l10n/tr.dart'; import 'android_file_browser.dart'; +import 'file_error.dart'; /// 폴더 고르기. Android 는 앱 안 화면 (내장 저장소 · SD 카드 · USB 를 실제 경로로 고를 수 있음), PC 는 Windows 폴더 선택 창 Future pickFolder(BuildContext context, String title, [String? initial]) => Platform.isAndroid @@ -77,6 +79,28 @@ class _DavFolderDialogState extends State<_DavFolderDialog> { } } + /// 134: 원문 예외 대신 알아볼 수 있는 말 + 할 일. 133: 마스터 때문에 막혔으면 [마스터 비밀번호 넣기] + Widget _errorView(String error) { + final (title, body) = explainFileError(error, dav: true); + final locked = isLockedError(error); + return Center( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Icon(locked ? Icons.lock_outline : Icons.cloud_off_outlined, color: Colors.redAccent), + const SizedBox(height: 8), + Text(title, textAlign: TextAlign.center, style: const TextStyle(color: Colors.redAccent, fontWeight: FontWeight.w600)), + if (body.isNotEmpty) Padding(padding: const EdgeInsets.only(top: 4), child: Text(body, textAlign: TextAlign.center)), + const SizedBox(height: 10), + FilledButton.tonal( + onPressed: () async { + if (locked && !await SecretGate.pass(force: true)) return; + await _open(_dir); + }, + child: Text(locked ? tr('마스터 비밀번호 넣기') : tr('다시 시도')), + ), + ]), + ); + } + @override Widget build(BuildContext context) { final atRoot = DavPath.parse(_dir).rel.replaceAll('/', '').isEmpty; @@ -90,7 +114,7 @@ class _DavFolderDialogState extends State<_DavFolderDialog> { const Divider(), Expanded( child: _error != null - ? Center(child: Text(_error!, style: const TextStyle(color: Colors.redAccent))) + ? _errorView(_error!) : _folders == null ? const Center(child: CircularProgressIndicator()) : ListView(children: [ diff --git a/lib/ui/migration_notice.dart b/lib/ui/migration_notice.dart index e7499af..ea73dd8 100644 --- a/lib/ui/migration_notice.dart +++ b/lib/ui/migration_notice.dart @@ -17,6 +17,8 @@ Future showMigrationNotice(BuildContext context, AppController c) async { 'explorerClick' => tr('파일 탐색기 누르기: 한 번 누르면 선택 → 한 번 누르면 바로 실행. 되돌리기: 환경 설정 > 파일 탐색기 > 누르기'), 'cookieScope' => tr('앱 안 브라우저의 로그인 쿠키: YouTube · Google 에 더해 Instagram · X · 네이버 (치지직) 로그인도 ' '다운로드 (yt-dlp) 에 씁니다. 고르기 · 끄기 · 지우기: 환경 설정 > 다운로드 > YouTube 쿠키'), + 'aiImage' => tr('새 화면 "AI 그림" 을 위쪽 이동 버튼에 넣었습니다 (글 → 그림 · 그림 → 그림, 모델은 환경 설정에서 받기). ' + '끄기: 환경 설정 > 컴포넌트'), 'explorerOrientation' => tr( '파일 탐색기 두 창 배치: 화면 모양 따라 → 좌우. 되돌리기: 파일 탐색기의 [좌우 ⇆ / 위아래 ⇅] 버튼 또는 창 배치'), 'zipComic' => tr('ZIP · CBZ 두 번 누르기: 만화 보기 → 목록. 되돌리기: 환경 설정 > 파일 탐색기 > ZIP · CBZ 를 만화로 보기'), diff --git a/lib/ui/reader_page.dart b/lib/ui/reader_page.dart index 10727c2..af4a52a 100644 --- a/lib/ui/reader_page.dart +++ b/lib/ui/reader_page.dart @@ -9,6 +9,7 @@ import '../app/app_controller.dart'; import '../core/reader_sources.dart'; import '../core/vfs.dart'; import '../l10n/tr.dart'; +import 'ai_image_page.dart'; import 'theme.dart'; /// 그림 · 만화 · PDF 보기를 연다. [start] 처음 볼 장. 닫으면 [source] 를 정리한다. @@ -530,6 +531,10 @@ class _ReaderPageState extends State { one(_Act(const Icon(Icons.rotate_left), tr('반대로 90° 회전'), () => _rotate(3), on: _turns != 0)), one(_Act(const RotatedBox(quarterTurns: 1, child: Icon(Icons.flip)), tr('상하 반전'), () => setState(() => _flipV = !_flipV), on: _flipV)), + // 123: 이 그림을 바탕으로 AI 그림 (그림 → 그림, 원본은 그대로) + if (_aiSource case final path?) + one(_Act(const Icon(Icons.auto_awesome_outlined), tr('AI 그림으로 (이 그림을 바탕으로)'), + () => AiImagePage.open(Navigator.of(context), c: c, initImage: path))), one(_Act(const Icon(Icons.fit_screen_outlined), tr('한 쪽 맞추기'), () => _updateSettings((x) => x.readerFit = 'page'), on: s.readerFit == 'page')), one(_Act(const Icon(Icons.width_full_outlined), tr('좌우 맞추기 (세로로 밀어 봄)'), @@ -550,6 +555,14 @@ class _ReaderPageState extends State { ]; } + /// 지금 보는 그림 파일 (이 기기의 그림 파일일 때, AI 그림 컴포넌트가 켜져 있을 때) + String? get _aiSource { + final s = src; + if (!c.settings.components.contains('aiimage') || s is! ImageFilesSource || _v.isEmpty) return null; + final path = s.paths[_cur.page]; + return isDav(path) ? null : path; + } + Widget _barButton(_Act a, {bool compact = false}) => IconButton( tooltip: a.tip, color: a.on ? JjColors.accent : Colors.white, diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index 63c72e6..88b731c 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -33,6 +33,7 @@ import 'setting_tile.dart'; import 'component_settings.dart'; import 'browser_page.dart' show deleteExportedCookies; import 'security_settings.dart'; +import 'ai_settings.dart'; import '../app/master_lock.dart'; import 'toast_status.dart'; @@ -502,6 +503,9 @@ class _SettingsPageState extends State { _textTile(tr('비밀번호 (선택)'), s.openSubtitlesPassword, trf('이 {0}의 안전 저장소에 저장됩니다 (설정 파일에는 쓰지 않음)', [desk ? 'PC' : tr('기기')]), (v) => c.updateSettings((x) => x.openSubtitlesPassword = v), obscure: true), ]), + // 123 · 121: 모델 받기 · 처리 장치 · 서버 · 서비스 + if (s.components.contains('aiimage')) + _group('ai', Icons.auto_awesome_outlined, tr('AI 그림'), [AiSettings(c: c)]), _group('play', Icons.play_circle_outline, tr('재생'), [ SettingTile( title: Text(tr('로그인이 필요한 WebDAV 동영상을 다른 앱으로 열 때')), @@ -1054,7 +1058,7 @@ class _SettingsPageState extends State { final _groupKeys = {}; static const _groupIds = [ - 'general', 'security', 'components', 'display', 'mkv', 'subtitle', 'play', 'browser', 'files', 'rsync', 'download', 'run', // + 'general', 'security', 'components', 'display', 'mkv', 'subtitle', 'ai', 'play', 'browser', 'files', 'rsync', 'download', 'run', // 'cleanup', 'about', ]; @@ -1065,6 +1069,7 @@ class _SettingsPageState extends State { 'components' => tr('컴포넌트'), 'mkv' => tr('MKV 만들기'), 'subtitle' => tr('자막 (AI · 인터넷)'), + 'ai' => tr('AI 그림'), 'play' => tr('재생'), 'browser' => tr('웹 브라우저'), 'files' => tr('파일 탐색기'), @@ -1080,7 +1085,7 @@ class _SettingsPageState extends State { padding: const EdgeInsets.only(bottom: 8), child: Wrap(spacing: 8, runSpacing: 8, children: [ for (final id in _groupIds) - if (id != 'run' || desk) + if ((id != 'run' || desk) && (id != 'ai' || c.settings.components.contains('aiimage'))) ActionChip( label: Text(_groupTitle(id), style: const TextStyle(fontSize: 13)), onPressed: () { diff --git a/lib/ui/webdav_settings.dart b/lib/ui/webdav_settings.dart index b9c8c20..214cb1f 100644 --- a/lib/ui/webdav_settings.dart +++ b/lib/ui/webdav_settings.dart @@ -7,6 +7,7 @@ import '../core/secret_gate.dart'; import '../core/webdav.dart'; import '../l10n/tr.dart'; import 'confirm.dart'; +import 'file_error.dart'; import 'theme.dart'; import 'setting_tile.dart'; @@ -52,22 +53,7 @@ class WebDavSettings extends StatelessWidget { tooltip: tr('지우기'), icon: const Icon(Icons.delete_outline, size: 18, color: JjColors.textDim), // 46: 확인 뒤 (저장된 비밀번호도 함께 지워진다) - onPressed: () async { - // 124: 이 서버를 쓰는 실시간 동기화 쌍 · 기억된 작업을 먼저 알린다 - final users = davServerUsers(c.settings, s.id); - final ok = await confirmAction( - context, - title: trf('WebDAV 서버 "{0}" 을(를) 지울까요?', [s.label]), - body: tr('서버 설정과 이 기기에 저장된 비밀번호를 지웁니다. 서버의 파일은 그대로입니다. ' - '이 서버를 쓰는 동기화 · 복사는 다시 넣을 때까지 연결되지 않습니다.') + - (users.isEmpty - ? '' - : '\n\n${trf('이 서버를 쓰는 것 {0}개 (지워도 목록에는 남고, 실행하면 서버가 없다고 알립니다):', [users.length])}\n' - '${users.take(8).map((u) => '• $u').join('\n')}${users.length > 8 ? '\n…' : ''}'), - ok: tr('지우기'), - ); - if (ok) await c.updateSettings((x) => x.webdavServers = [for (final y in x.webdavServers) if (y.id != s.id) y]); - }, + onPressed: () => deleteDavServer(context, c, s), ), ]), ), @@ -76,6 +62,26 @@ class WebDavSettings extends StatelessWidget { } } +/// 46 · 124: 서버 지우기 (쓰는 곳을 알린 뒤 확인). 지웠으면 true. 설정의 목록 · 고치기 창 (143) 이 함께 쓴다 +Future deleteDavServer(BuildContext context, AppController c, DavServer s) async { + // 124: 이 서버를 쓰는 실시간 동기화 쌍 · 기억된 작업을 먼저 알린다 + final users = davServerUsers(c.settings, s.id); + final ok = await confirmAction( + context, + title: trf('WebDAV 서버 "{0}" 을(를) 지울까요?', [s.label]), + body: tr('서버 설정과 이 기기에 저장된 비밀번호를 지웁니다. 서버의 파일은 그대로입니다. ' + '이 서버를 쓰는 동기화 · 복사는 다시 넣을 때까지 연결되지 않습니다.') + + (users.isEmpty + ? '' + : '\n\n${trf('이 서버를 쓰는 것 {0}개 (지워도 목록에는 남고, 실행하면 서버가 없다고 알립니다):', [users.length])}\n' + '${users.take(8).map((u) => '• $u').join('\n')}${users.length > 8 ? '\n…' : ''}'), + ok: tr('지우기'), + ); + if (!ok) return false; + await c.updateSettings((x) => x.webdavServers = [for (final y in x.webdavServers) if (y.id != s.id) y]); + return true; +} + /// 56: 암호화하지 않는 http:// 주소인지 bool isPlainHttp(String url) => url.trim().toLowerCase().startsWith('http://'); @@ -120,6 +126,8 @@ Future editDavServer(BuildContext context, AppController c, {DavServ builder: (ctx) => StatefulBuilder( builder: (ctx, set) { Future test() async { + // 142: 자판을 내려 결과가 가리지 않게 + FocusScope.of(ctx).unfocus(); set(() { testing = true; result = null; @@ -134,10 +142,9 @@ Future editDavServer(BuildContext context, AppController c, {DavServ } catch (e) { passed = false; toHttps = e is DavException && e.wantsHttps; - final s = '$e'.toLowerCase(); - result = pass.text.isEmpty && (s.contains('401') || s.contains('아이디 · 비밀번호')) - ? tr('저장된 비밀번호가 없습니다. 한 번만 다시 넣어 주세요') - : trf('연결 실패: {0}', [e]); + // 142: 원문 예외 대신 알아볼 수 있는 말 (탐색기와 같은 말) + final (title, body) = explainFileError('$e', dav: true, noPassword: pass.text.isEmpty); + result = body.isEmpty ? title : '$title\n$body'; } finally { client.close(); } @@ -218,6 +225,15 @@ Future editDavServer(BuildContext context, AppController c, {DavServ ]), ), actions: [ + // 143: 고치기 창에서도 지울 수 있게 + if (old != null) + TextButton( + onPressed: () async { + if (await deleteDavServer(ctx, c, old) && ctx.mounted) Navigator.pop(ctx); + }, + style: TextButton.styleFrom(foregroundColor: Colors.redAccent), + child: Text(tr('지우기')), + ), TextButton(onPressed: !ok || testing ? null : test, child: Text(tr('연결 확인'))), TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))), FilledButton(onPressed: ok ? () => Navigator.pop(ctx, current()) : null, child: Text(tr('저장'))), diff --git a/lib/ui/work_panel.dart b/lib/ui/work_panel.dart index 58811ff..1058232 100644 --- a/lib/ui/work_panel.dart +++ b/lib/ui/work_panel.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../app/app_controller.dart'; +import '../app/ai_local.dart'; import '../app/download_manager.dart'; import '../core/models.dart'; import '../services/downloader.dart'; @@ -95,7 +96,7 @@ class WorkPanel extends StatelessWidget { @override Widget build(BuildContext context) => ListenableBuilder( - listenable: Listenable.merge([c, ?downloads]), + listenable: Listenable.merge([c, ?downloads, AiJobs.instance]), builder: (context, _) => Container( color: JjColors.panel, child: Column( @@ -185,9 +186,14 @@ class WorkPanel extends StatelessWidget { List _jobs() => [ _title(tr('작업')), - if (!c.busy) + // 123: AI 그림 (남은 장 · 예상 시간) + if (AiJobs.instance.statusLine case final ai?) ...[ + Text('▶ $ai', style: const TextStyle(fontSize: 12, color: JjColors.accent)), + LinearProgressIndicator(value: AiJobs.instance.progress), + ], + if (!c.busy && !AiJobs.instance.busy) _Dim(tr('쉬는 중')) - else ...[ + else if (c.busy) ...[ Text( '▶ ${c.currentJob ?? tr('작업 중')}', style: const TextStyle(fontSize: 12, color: JjColors.accent), diff --git a/test/ai_image_test.dart b/test/ai_image_test.dart new file mode 100644 index 0000000..0e2718c --- /dev/null +++ b/test/ai_image_test.dart @@ -0,0 +1,315 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:archive/archive_io.dart'; +import 'package:crypto/crypto.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/ai_local.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/core/ai_catalog.dart'; +import 'package:jj_mkvmaker/core/sd_cli.dart'; +import 'package:jj_mkvmaker/services/image_ai.dart'; +import 'package:jj_mkvmaker/services/secret_store.dart'; +import 'package:path/path.dart' as p; + +/// 1×1 PNG +final _png = base64Decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='); + +void main() { + group('123: sd-cli 인수 · 진행 읽기', () { + const m = SdModelPaths(model: 'm.safetensors', loraDir: 'lora', loraName: 'lcm-lora-sdv1-5', taesd: 'taesd.safetensors'); + + test('글 → 그림: LCM-LoRA · f16 · TAESD · 장치 · 여러 장', () { + final a = sdCliArgs(const ImageGenRequest(prompt: 'a cat', negative: 'blurry', seed: 7, count: 2), m, + out: 'o_%d.png', backend: 'vulkan0', threads: 8); + expect(a.join(' '), contains('-m m.safetensors --type f16 --lora-model-dir lora --taesd taesd.safetensors')); + expect(a[a.indexOf('-p') + 1], 'a cat'); + expect(a[a.indexOf('-n') + 1], 'blurry'); + for (final pair in [ + ['--sampling-method', 'lcm'], + ['--seed', '7'], + ['-b', '2'], + ['--backend', 'vulkan0'], + ['-t', '8'], + ['-o', 'o_%d.png'], + ['--cfg-scale', '1.0'], + ]) { + expect(a[a.indexOf(pair[0]) + 1], pair[1], reason: pair[0]); + } + expect(a, isNot(contains('-i'))); + }); + + test('그림 → 그림 · LoRA 없이 · 빼고 싶은 것 없으면 -n 없음', () { + final a = sdCliArgs(const ImageGenRequest(prompt: 'x', initImage: 'in.png', strength: 0.4), const SdModelPaths(model: 'm'), + out: 'o.png'); + expect(a[a.indexOf('-i') + 1], 'in.png'); + expect(a[a.indexOf('--strength') + 1], '0.4'); + expect(a[a.indexOf('-p') + 1], 'x'); + expect(a, isNot(contains('-n'))); + expect(a, isNot(contains('--sampling-method'))); + }); + + test('해상도 올리기 인수 (121)', () { + expect(sdUpscaleArgs(model: 'esr.pth', input: 'a.png', out: 'b.png', repeats: 2).join(' '), + '-M upscale --upscale-model esr.pth -i a.png --upscale-repeats 2 -o b.png'); + }); + + test('진행: 읽기 · 그리기 (단계당 초) · 디코드 · 끝', () { + final load = parseSdProgress(' |####################### | 90/196 - 628.27MB/s\x1b[K')!; + expect((load.phase, load.step, load.steps), ('load', 90, 196)); + expect(load.overall, closeTo(0.1 * 90 / 196, 1e-9)); + final s = parseSdProgress(' |=========================> | 2/4 - 25.11s/it')!; + expect((s.phase, s.step, s.steps, s.secondsPerStep), ('sample', 2, 4, 25.11)); + expect(s.overall, closeTo(0.5, 1e-9)); + expect(parseSdProgress('|===> | 1/4 - 2.00it/s')!.secondsPerStep, 0.5); + 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); + }); + + test('장치 목록 읽기', () { + expect(parseSdDevices('ggml_vulkan: 1 = ...\nVulkan0\tNVIDIA GeForce MX150\nVulkan1\tIntel UHD\nCPU\tIntel i7\n'), [ + ('vulkan0', 'NVIDIA GeForce MX150'), + ('vulkan1', 'Intel UHD'), + ('cpu', 'Intel i7'), + ]); + }); + + test('다시 만들 수 있게 남기는 json (시드 · 모델 · 프롬프트)', () { + const r = ImageGenRequest(prompt: '고양이', negative: 'x', width: 768, height: 512, steps: 6, seed: -1, initImage: 'a.png', strength: 0.3); + final back = ImageGenRequest.fromJson(jsonDecode(r.encode(engine: '이 기기', actualSeed: 42)) as Map); + expect((back.prompt, back.negative, back.width, back.height, back.steps, back.seed, back.initImage, back.strength), + ('고양이', 'x', 768, 512, 6, 42, 'a.png', 0.3)); + expect(resolveSeed(5), 5); + expect(resolveSeed(-1), inInclusiveRange(0, (1 << 31) - 1)); + }); + }); + + group('123: 받는 파일 목록', () { + test('공식 배포처 · SHA-256 고정 · 비상업 라이선스 없음 · CUDA 는 NVIDIA 때만', () { + for (final f in aiCatalog) { + expect(f.url, startsWith('https://'), reason: f.id); + expect(RegExp(r'^[0-9a-f]{64}$').hasMatch(f.sha256), isTrue, reason: f.id); + expect(f.size, greaterThan(0), reason: f.id); + expect(f.license.toUpperCase(), isNot(contains('NC')), reason: f.id); + expect(f.licenseUrl, startsWith('https://')); + } + expect(aiFile('sd15').terms, contains('OpenRAIL-M')); + expect(aiFile('engine-cuda').needs, 'nvidia'); + expect(aiFile('engine-cudart').needs, 'nvidia'); + expect(aiFile('engine-vulkan').needs, isNull); + expect(aiLicenseTexts().map((e) => e.$1), contains('stable-diffusion.cpp')); + expect(aiLicenseTexts().firstWhere((e) => e.$1 == 'Stable Diffusion v1.5').$2, contains('부속서 A')); + }); + }); + + group('123: 받기 (SHA-256 확인 · 풀기)', () { + late Directory tmp; + late HttpServer server; + final files = >{}; + setUp(() async { + tmp = Directory.systemTemp.createTempSync('jj_ai_store_'); + server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + server.listen((req) async { + final b = files[req.uri.path]; + if (b == null) { + req.response.statusCode = 404; + } else { + req.response.add(b); + } + await req.response.close(); + }); + }); + tearDown(() async { + await server.close(force: true); + tmp.deleteSync(recursive: true); + }); + + AiFile file(String id, String path, List body, {String kind = 'model', String? sha}) { + files[path] = body; + return AiFile( + id: id, + name: id, + url: 'http://127.0.0.1:${server.port}$path', + sha256: sha ?? sha256.convert(body).toString(), + size: body.length, + kind: kind, + license: 'MIT', + licenseUrl: 'https://x'); + } + + test('맞는 파일은 받아 둠 · SHA-256 이 다르면 지우고 알림 · 지우기', () async { + final store = AiStore(tmp.path); + final good = file('m1', '/m1.safetensors', List.filled(5000, 7)); + await store.installFile(good); + expect(store.isFileInstalled(good), isTrue); + 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())); + expect(store.isFileInstalled(bad), isFalse); + expect(tmp.listSync(recursive: true).whereType().where((f) => f.path.contains('.part')), isEmpty); + }); + + test('실행 파일 zip 은 풀어 둔다', () async { + final store = AiStore(tmp.path); + final arc = Archive()..addFile(ArchiveFile.bytes('sd-cli.exe', utf8.encode('fake'))); + final zip = ZipEncoder().encode(arc); + final eng = file('engine-x', '/e.zip', zip, kind: 'engine'); + await store.installFile(eng); + expect(File(p.join(store.pathOf(eng), 'sd-cli.exe')).existsSync(), isTrue); + }); + }); + + group('123: 사용자가 추가한 서버 · 서비스', () { + late HttpServer server; + final seen = []; + Map? lastBody; + setUp(() async { + seen.clear(); + server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + var polls = 0; + server.listen((req) async { + seen.add('${req.method} ${req.uri.path} ${req.headers.value('authorization') ?? ''}'); + final text = await utf8.decoder.bind(req).join(); + if (text.isNotEmpty && req.headers.contentType?.mimeType == 'application/json') lastBody = jsonDecode(text) as Map; + final res = req.response..headers.contentType = ContentType.json; + switch (req.uri.path) { + case '/sdapi/v1/txt2img' || '/sdapi/v1/img2img': + res.write(jsonEncode({'images': [base64Encode(_png), base64Encode(_png)]})); + case '/v1/images/generations': + res.write(jsonEncode({'data': [{'b64_json': base64Encode(_png)}]})); + case '/prompt': + res.write(jsonEncode({'prompt_id': 'p1'})); + case '/history/p1': + polls++; + res.write(polls < 2 + ? '{}' + : jsonEncode({'p1': {'outputs': {'9': {'images': [{'filename': 'a.png', 'subfolder': '', 'type': 'output'}]}}}})); + case '/view': + res.headers.contentType = ContentType('image', 'png'); + res.add(_png); + default: + res.statusCode = 404; + } + await res.close(); + }); + }); + tearDown(() => server.close(force: true)); + + Future> run(AiService s, ImageGenRequest r) async { + final dir = Directory.systemTemp.createTempSync('jj_ai_remote_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final made = await RemoteImageEngine(s).generate(r, outDir: dir.path, baseName: 'ai'); + for (final g in made) { + expect(File(g.path).readAsBytesSync(), _png); + } + return made; + } + + test('Automatic1111: 글 → 그림 · 시드 · 아이디:비밀번호는 Basic', () async { + final s = AiService(id: 'a', name: 'GPU PC', url: 'http://127.0.0.1:${server.port}/', kind: 'a1111', apiKey: 'u:p'); + final made = await run(s, const ImageGenRequest(prompt: 'cat', seed: 10, count: 2)); + expect(made.map((g) => g.seed), [10, 11]); + expect(seen.single, startsWith('POST /sdapi/v1/txt2img Basic ')); + expect(lastBody!['seed'], 10); + expect(lastBody!['batch_size'], 2); + expect(RemoteImageEngine(s).leavesDevice, isTrue); + }); + + test('OpenAI 형식: Bearer 키 · 크기', () async { + final s = AiService(id: 'o', name: 'svc', url: 'http://127.0.0.1:${server.port}', kind: 'openai', model: 'm', apiKey: 'sk-test'); + await run(s, const ImageGenRequest(prompt: 'cat', width: 512, height: 768)); + expect(seen.single, 'POST /v1/images/generations Bearer sk-test'); + expect(lastBody!['size'], '512x768'); + expect(lastBody!['model'], 'm'); + }); + + test('ComfyUI: 작업 흐름을 보내고 끝날 때까지 기다려 결과를 받는다', () async { + final s = AiService(id: 'c', name: 'comfy', url: 'http://127.0.0.1:${server.port}', kind: 'comfy', model: 'sd15.safetensors'); + final made = await run(s, const ImageGenRequest(prompt: 'cat', seed: 3)); + expect(made, hasLength(1)); + final flow = lastBody!['prompt'] as Map; + expect((flow['4'] as Map)['inputs'], {'ckpt_name': 'sd15.safetensors'}); + expect(((flow['3'] as Map)['inputs'] as Map)['seed'], 3); + expect(seen.where((x) => x.startsWith('GET /history')).length, greaterThanOrEqualTo(2)); + }); + + test('설정 파일에는 API 키를 쓰지 않고 안전 저장소에 (124)', () async { + final dir = Directory.systemTemp.createTempSync('jj_ai_key_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final secrets = MemorySecretStore(); + final st = SettingsStore(p.join(dir.path, 'settings.json'), secrets); + final s = await st.load(); + s.aiServices = [const AiService(id: 'x', name: 'n', url: 'http://h', kind: 'a1111', apiKey: 'sk-secret')]; + await st.save(s); + expect(File(p.join(dir.path, 'settings.json')).readAsStringSync(), isNot(contains('sk-secret'))); + expect(secrets.values['ai.key.x'], 'sk-secret'); + final again = await SettingsStore(p.join(dir.path, 'settings.json'), secrets).load(); + expect(again.aiServices.single.apiKey, 'sk-secret'); + s.aiServices = []; + await st.save(s); + expect(secrets.values.containsKey('ai.key.x'), isFalse, reason: '지운 서비스의 키는 저장소에서도 지움'); + }); + }); + + group('123: 기기 안 엔진 (가짜 sd-cli 로)', () { + test('진행을 알리고, 여러 장을 시드와 함께 돌려주고, 옆에 json 을 남긴다', () async { + if (!Platform.isWindows) return markTestSkipped('Windows 전용 (가짜 sd-cli 가 .cmd)'); + final dir = Directory.systemTemp.createTempSync('jj_ai_local_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final png = File(p.join(dir.path, 'src.png'))..writeAsBytesSync(_png); + // 받은 인수의 -o (예: ...\ai_X_%d.png) 에 0 · 1 번을 만든다 + final fake = File(p.join(dir.path, 'fake-sd.cmd')) + ..writeAsStringSync('@echo off\r\n' + 'echo ^|=====^> ^| 1/2 - 3.00s/it\r\n' + 'echo ^|==========^| 2/2 - 3.00s/it\r\n' + 'echo [I] decode_first_stage completed\r\n' + ':loop\r\nif "%~1"=="" goto done\r\nif "%~1"=="-o" (set OUT=%~2)\r\nshift\r\ngoto loop\r\n:done\r\n' + 'setlocal enabledelayedexpansion\r\nset A=!OUT:%%d=0!\r\nset B=!OUT:%%d=1!\r\n' + 'copy /y "${png.path}" "%A%" >nul\r\ncopy /y "${png.path}" "%B%" >nul\r\n' + 'echo [I] generate_image completed\r\n'); + final engine = LocalSdEngine(device: SdDevice(fake.path, 'cpu', 'CPU'), models: const SdModelPaths(model: 'm')); + final steps = []; + final out = p.join(dir.path, 'out'); + final made = await AiJobs.instance.run(engine, const ImageGenRequest(prompt: 'cat', seed: 100, count: 2), outDir: out); + expect(made.map((g) => g.seed), [100, 101]); + for (final g in made) { + expect(File(g.path).existsSync(), isTrue); + final j = jsonDecode(File('${p.withoutExtension(g.path)}.json').readAsStringSync()) as Map; + expect(j['prompt'], 'cat'); + expect(j['model'], 'sd15-lcm'); + } + expect(made.first.seed, (jsonDecode(File('${p.withoutExtension(made.first.path)}.json').readAsStringSync()) as Map)['seed']); + expect(AiJobs.instance.busy, isFalse); + expect(steps, isEmpty); + }); + + test('엔진이 실패하면 마지막 출력으로 알린다', () async { + if (!Platform.isWindows) return markTestSkipped('Windows 전용'); + final dir = Directory.systemTemp.createTempSync('jj_ai_fail_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final fake = File(p.join(dir.path, 'bad.cmd'))..writeAsStringSync('@echo off\r\necho [E] out of memory\r\nexit /b 3\r\n'); + final engine = LocalSdEngine(device: SdDevice(fake.path, 'cpu', 'CPU'), models: const SdModelPaths(model: 'm')); + await expectLater(engine.generate(const ImageGenRequest(prompt: 'x'), outDir: dir.path, baseName: 'a'), + throwsA(isA().having((e) => e.message, 'message', contains('out of memory')))); + }); + }); + + test('123: AI 설정 처음 값 · 저장 · 예전 설정에 AI 그림 화면을 한 번 넣음', () { + final s = AppSettings(); + expect((s.aiEngine, s.aiDevice, s.aiTaesd, s.aiWidth, s.aiHeight, s.aiSteps, s.aiCfg, s.aiCount), + ('local', 'auto', true, 512, 512, 4, 1.0, 1)); + expect(s.components, contains('aiimage')); + final b = AppSettings.fromJson((AppSettings() + ..aiWidth = 768 + ..aiSteps = 8 + ..aiTaesd = false) + .toJson()); + expect((b.aiWidth, b.aiSteps, b.aiTaesd), (768, 8, false)); + final old = AppSettings.fromJson({'components': ['mkv', 'browser']}); + expect(old.components, contains('aiimage')); + expect(old.migrated, contains('aiImage')); + final off = AppSettings.fromJson((AppSettings()..components = ['mkv']).toJson()); + expect(off.components, isNot(contains('aiimage')), reason: '사용자가 끈 것은 그대로'); + }); +} diff --git a/test/ai_page_test.dart b/test/ai_page_test.dart new file mode 100644 index 0000000..b8fc39e --- /dev/null +++ b/test/ai_page_test.dart @@ -0,0 +1,62 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/ai_local.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/image_ai.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/ai_image_page.dart'; + +void main() { + late Directory tmp; + setUp(() { + tmp = Directory.systemTemp.createTempSync('jj_ai_page_'); + AiStore.instance = AiStore(tmp.path); + }); + tearDown(() { + AiStore.instance = null; + tmp.deleteSync(recursive: true); + }); + + Future open(WidgetTester t, {AiService? service}) async { + t.view.physicalSize = const Size(1200, 1000); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + const pp = MethodChannel('plugins.flutter.io/path_provider'); + t.binding.defaultBinaryMessenger.setMockMethodCallHandler(pp, (_) async => tmp.path); + addTearDown(() => t.binding.defaultBinaryMessenger.setMockMethodCallHandler(pp, null)); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + if (service != null) { + c.settings + ..aiServices = [service] + ..aiEngine = service.id; + } + await t.pumpWidget(MaterialApp(home: AiImagePage(c: c))); + await t.pump(); + return c; + } + + testWidgets('123: 기기 안 - 모델이 없으면 받기 안내 · 기기 밖 경고 없음', (t) async { + await open(t); + expect(find.textContaining('모델을 받아야 합니다'), findsOneWidget); + expect(find.text('받기 화면으로'), findsOneWidget); + expect(find.textContaining('기기 밖으로 나갑니다'), findsNothing); + }); + + testWidgets('123: 서비스를 고르면 "그림 · 프롬프트가 기기 밖으로 나갑니다" 를 늘 보인다', (t) async { + await open(t, service: const AiService(id: 's1', name: 'GPU PC', url: 'http://100.1.2.3:7860', kind: 'a1111')); + expect(find.textContaining('그림 · 프롬프트가 기기 밖으로 나갑니다 (GPU PC)'), findsOneWidget); + expect(find.textContaining('모델을 받아야 합니다'), findsNothing); + }); + + testWidgets('123: 프롬프트 없이 만들기 → 안내', (t) async { + await open(t, service: const AiService(id: 's1', name: 'GPU PC', url: 'http://100.1.2.3:7860', kind: 'a1111')); + await t.tap(find.text('만들기')); + await t.pump(); + expect(find.text('무엇을 그릴지 적어 주세요'), findsOneWidget); + }); +} diff --git a/test/ai_real_test.dart b/test/ai_real_test.dart new file mode 100644 index 0000000..185ee5c --- /dev/null +++ b/test/ai_real_test.dart @@ -0,0 +1,36 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/ai_local.dart'; +import 'package:jj_mkvmaker/core/sd_cli.dart'; +import 'package:path/path.dart' as p; + +/// 123: 진짜 stable-diffusion.cpp 와 모델로 앱의 엔진 코드를 돌린다 (JJ_TEST_SD_DIR 가 있을 때만 - 모델이 4GB). +/// JJ_TEST_SD_DIR 아래: bin_vulkan/sd-cli.exe, models/v1-5-pruned-emaonly.safetensors, models/lcm-lora-sdv1-5.safetensors, models/taesd.safetensors +void main() { + final root = Platform.environment['JJ_TEST_SD_DIR'] ?? ''; + test('진짜 엔진: 256×256 · 1단계 · 두 장 (CPU + TAESD) - 앱이 넘기는 인수 그대로', () async { + final dir = Directory.systemTemp.createTempSync('jj_ai_real_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final models = SdModelPaths( + model: p.join(root, 'models', 'v1-5-pruned-emaonly.safetensors'), + loraDir: p.join(root, 'models'), + loraName: 'lcm-lora-sdv1-5', + taesd: p.join(root, 'models', 'taesd.safetensors'), + ); + final engine = LocalSdEngine(device: SdDevice(p.join(root, 'bin_vulkan', 'sd-cli.exe'), 'cpu', 'CPU'), models: models); + final progress = []; + final made = await engine.generate(const ImageGenRequest(prompt: 'a red apple on a table', width: 256, height: 256, steps: 1, seed: 5, count: 2), + outDir: dir.path, baseName: 'real', onProgress: (v, _) { + if (v != null) progress.add(v); + }); + expect(made.map((g) => g.seed), [5, 6]); + for (final g in made) { + final bytes = File(g.path).readAsBytesSync(); + expect(bytes.sublist(1, 4), 'PNG'.codeUnits); + expect(bytes.length, greaterThan(10000), reason: '잡음 · 빈 그림이 아니라 실제 그림 크기'); + } + expect(progress, isNotEmpty); + expect(progress.last, closeTo(1, 0.001)); + }, timeout: const Timeout(Duration(minutes: 10)), skip: root.isEmpty || !Platform.isWindows); +} diff --git a/test/components_test.dart b/test/components_test.dart index e6041b4..e7fb2d9 100644 --- a/test/components_test.dart +++ b/test/components_test.dart @@ -19,7 +19,7 @@ void main() { expect(s.components, containsAll(['mkv', 'browser', 'explorer', 'rsync', 'downloads', 'viewer'])); expect(s.components, isNot(contains('docs'))); // 변환기를 받아야 하는 것은 처음엔 꺼짐 expect([for (final p in AppComponent.pages(s.components, s.navOrder)) p.id], - ['mkv', 'browser', 'explorer', 'rsync', 'downloads']); + ['mkv', 'browser', 'explorer', 'rsync', 'downloads', 'aiimage']); s ..navOrder = ['explorer', 'mkv'] ..components = ['mkv', 'explorer', 'downloads']; @@ -73,10 +73,10 @@ void main() { await tester.tap(find.byTooltip('아래로').first); await tester.pump(); expect(c.settings.navOrder.take(2), ['mkv', 'browser']); - // 마지막 (다운로드) 를 위로 → 4번째 + // 마지막 (AI 그림) 을 위로 → 5번째 await tester.tap(find.byTooltip('위로').last); await tester.pump(); - expect(c.settings.navOrder, ['mkv', 'browser', 'explorer', 'downloads', 'rsync']); + expect(c.settings.navOrder, ['mkv', 'browser', 'explorer', 'rsync', 'aiimage', 'downloads']); }); testWidgets('화면 가운데를 좌우로 밀면 순서대로 다음 · 이전 화면 (끝 다음은 처음)', (tester) async { diff --git a/test/recycle_bin_test.dart b/test/recycle_bin_test.dart index 73f1b77..97933e4 100644 --- a/test/recycle_bin_test.dart +++ b/test/recycle_bin_test.dart @@ -53,6 +53,32 @@ void main() { } }, skip: !Platform.isWindows); + test('긴 경로 (260자 넘음) 는 휴지통으로 보내지 않고 이유를 알린다 - 묻지 않고 영구 삭제되던 것 (폴더 안에 있어도)', () { + if (!Platform.isWindows) return; + // 긴 경로를 만들고 지우려면 앞에 \\?\ 를 붙인다 + String lp(String s) => r'\\?\' + s; + final tmp = Directory.systemTemp.createTempSync('jj_rb_long_'); + addTearDown(() => Directory(lp(tmp.path)).deleteSync(recursive: true)); + var deep = p.join(tmp.path, 'outer'); + while (deep.length < 250) { + deep = p.join(deep, 'd' * 30); + } + final file = p.join(deep, 'a_file_name_that_makes_it_longer.txt'); + Directory(lp(deep)).createSync(recursive: true); + File(lp(file)).writeAsStringSync('x'); + expect(file.length, greaterThan(259)); + expect(longPathInside(file), file); + expect(() => moveToRecycleBin(file), throwsA(isA().having((e) => e.message, 'message', contains('경로가 너무 깁니다')))); + expect(File(lp(file)).existsSync(), isTrue, reason: '지우지 않음'); + // 폴더 자체는 짧아도 안에 긴 경로가 있으면 + final outer = p.join(tmp.path, 'outer'); + expect(longPathInside(outer), isNotNull); + expect(() => moveToRecycleBin(outer), throwsA(isA())); + expect(Directory(outer).existsSync(), isTrue); + final short = File(p.join(tmp.path, 'short.txt'))..writeAsStringSync('x'); + expect(longPathInside(short.path), isNull, reason: '짧은 파일 경로'); + }); + test(r'휴지통 정보 파일 ($I) 에서 원래 경로 읽기 - Windows 10 이상 (판 2) · 예전 (판 1)', () { List le(int v, int n) => [for (var i = 0; i < n; i++) v >> (8 * i) & 0xff]; List utf16(String s) => [for (final u in s.codeUnits) ...le(u, 2)]; diff --git a/test/security_ui_test.dart b/test/security_ui_test.dart index 704849f..3c56401 100644 --- a/test/security_ui_test.dart +++ b/test/security_ui_test.dart @@ -7,6 +7,8 @@ import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/ui/webdav_settings.dart'; +import 'package:jj_mkvmaker/ui/folder_picker.dart'; +import 'package:jj_mkvmaker/ui/file_error.dart'; void main() { testWidgets('127: 비밀번호가 저장된 서버를 고치려면 먼저 마스터 (넣지 않으면 창이 열리지 않아 비밀번호가 보이지 않음)', (t) async { @@ -43,4 +45,43 @@ void main() { expect(forced, isFalse); }); + + test('134: 서버에 닿지 않으면 원문 대신 알아볼 수 있는 말 · 133: 마스터로 막힌 것은 따로', () { + final (t1, b1) = explainFileError( + 'SocketException: Connection refused (OS Error: 연결 거부, errno = 111), address = 127.0.0.1, port = 57220', + dav: true); + expect(t1, '서버에 닿지 않습니다'); + expect(b1, contains('Tailscale')); + expect(isLockedError(DavLockedException(secretGateMessage)), isTrue); + expect(explainFileError(secretGateMessage, dav: true).$1, '마스터 비밀번호가 필요합니다'); + expect(isLockedError('SocketException'), isFalse); + }); + + testWidgets('133: WebDAV 폴더 고르기에서 마스터로 막히면 그 자리에서 [마스터 비밀번호 넣기]', (t) async { + var allow = false, asked = 0; + SecretGate.check = (force) async { + if (force) asked++; + return allow; + }; + addTearDown(() => SecretGate.check = null); + DavRegistry.configure([const DavServer(id: 'nas', name: 'NAS', url: 'http://127.0.0.1:9/dav', user: 'u', password: 'p')]); + addTearDown(() => DavRegistry.configure([])); + await t.pumpWidget(const MaterialApp(home: Scaffold(body: SizedBox()))); + final ctx = t.element(find.byType(SizedBox)); + pickFolderOrDav(ctx, '원본'); + await t.pumpAndSettle(); + await t.tap(find.text('NAS')); + for (var i = 0; i < 10; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); + await t.pump(); + } + expect(find.text('마스터 비밀번호가 필요합니다'), findsOneWidget); + expect(find.textContaining('SocketException'), findsNothing); + await t.tap(find.text('마스터 비밀번호 넣기')); + for (var i = 0; i < 5; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); + await t.pump(); + } + expect(asked, 1, reason: '사용자가 누른 것이니 다시 묻는다'); + }); } diff --git a/tool/build_sdcpp_android.sh b/tool/build_sdcpp_android.sh new file mode 100644 index 0000000..4679cbc --- /dev/null +++ b/tool/build_sdcpp_android.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# 123 · 121: Android (arm64) 용 stable-diffusion.cpp 의 sd-cli 를 소스에서 빌드해 +# third_party/sdcpp/android/arm64-v8a/libsdcli.so 로 둔다 (APK 의 jniLibs 로 들어가 앱의 네이티브 라이브러리 폴더에서 실행된다 - +# Android 10+ 는 앱 데이터 폴더의 파일을 실행할 수 없으므로 받아서 쓰지 않는다). +# 소스: https://github.com/leejet/stable-diffusion.cpp (MIT) 커밋 고정 - Windows 실행 파일 (ai_catalog.dart 의 sdCppVersion) 과 같은 판 +# CPU (arm64, dotprod · i8mm · fp16) 만. OpenMP 는 끈다. +# 사용법 (Git Bash): sh tool/build_sdcpp_android.sh - Android NDK · SDK 의 cmake (tools/android-sdk) 가 필요 +set -e +APP=$(cd "$(dirname "$0")/.." && pwd) +COMMIT=228c707fde018221de74674f1c2f480a9d2b228e +SHORT=228c707 +NDK_ROOT=${ANDROID_NDK:-$(ls -d "$APP"/../tools/android-sdk/ndk/* | sort | tail -1)} +CMAKE_DIR=$(ls -d "$APP"/../tools/android-sdk/cmake/* | sort | tail -1)/bin +case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) HOST=windows-x86_64; EXE=.exe ;; Darwin) HOST=darwin-x86_64; EXE= ;; *) HOST=linux-x86_64; EXE= ;; esac +TC=$NDK_ROOT/toolchains/llvm/prebuilt/$HOST/bin +WORK=${SDCPP_WORK:-${TMPDIR:-/tmp}/jj_sdcpp_android} +if [ ! -d "$WORK/src/.git" ]; then + rm -rf "$WORK" && mkdir -p "$WORK" + git clone --recursive https://github.com/leejet/stable-diffusion.cpp.git "$WORK/src" +fi +cd "$WORK/src" +git fetch --depth 1 origin "$COMMIT" 2>/dev/null || git fetch origin +git checkout -q "$COMMIT" 2>/dev/null || git checkout -q "$SHORT" +git submodule update --init --recursive +PATH="$CMAKE_DIR:$PATH" +cmake -S . -B build-android -G Ninja -DCMAKE_MAKE_PROGRAM="$CMAKE_DIR/ninja$EXE" \ + -DCMAKE_TOOLCHAIN_FILE="$NDK_ROOT/build/cmake/android.toolchain.cmake" \ + -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-28 -DCMAKE_BUILD_TYPE=Release \ + -DGGML_OPENMP=OFF -DGGML_CPU_ARM_ARCH=armv8.2-a+dotprod+i8mm+fp16 > cmake.log +cmake --build build-android --target sd-cli -j 8 > build.log 2>&1 +OUT="$APP/third_party/sdcpp/android/arm64-v8a" +mkdir -p "$OUT" +"$TC/llvm-strip$EXE" -o "$OUT/libsdcli.so" build-android/bin/sd-cli +ls -l "$OUT/libsdcli.so"