From 506803ca65480667026c99f1ee86f69918e6e1cc Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:14:56 +0900 Subject: [PATCH] =?UTF-8?q?=EC=84=A4=EC=A0=95=20A=20=EB=B6=84=EC=95=BC=205?= =?UTF-8?q?:=20AI=20=EA=B7=B8=EB=A6=BC=20=C2=B7=20=ED=95=B4=EC=83=81?= =?UTF-8?q?=EB=8F=84=20=EC=98=AC=EB=A6=AC=EA=B8=B0=20(AiImageSettings)=20-?= =?UTF-8?q?=20=ED=82=A4=2027=20=C2=B7=20aiServices=20=EC=9D=98=20=EB=B9=84?= =?UTF-8?q?=EB=B0=80=20=EA=B0=92=20=EC=B2=98=EB=A6=AC=20(plainSecrets)=20?= =?UTF-8?q?=EA=B7=B8=EB=8C=80=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- integration_test/windows_check_shot_test.dart | 4 +- lib/app/ai_local.dart | 12 +- lib/app/settings.dart | 116 +------------- lib/app/settings/ai_image_settings.dart | 150 ++++++++++++++++++ lib/app/settings/settings_store.dart | 8 +- lib/ui/ai_image_page.dart | 52 +++--- lib/ui/ai_settings.dart | 88 +++++----- lib/ui/ai_upscale_ui.dart | 48 +++--- lib/ui/master_prompt.dart | 2 +- lib/ui/reader/reader_ai.dart | 2 +- lib/ui/reader/reader_bar_items.dart | 2 +- test/ai_image_test.dart | 16 +- test/ai_page_test.dart | 4 +- test/ai_quality_test.dart | 16 +- test/ai_upscale_test.dart | 26 +-- test/master_lock_test.dart | 4 +- test/settings_store_test.dart | 2 +- test/upscale_job_test.dart | 6 +- 18 files changed, 302 insertions(+), 256 deletions(-) create mode 100644 lib/app/settings/ai_image_settings.dart diff --git a/integration_test/windows_check_shot_test.dart b/integration_test/windows_check_shot_test.dart index 1c14a29..d06f0ed 100644 --- a/integration_test/windows_check_shot_test.dart +++ b/integration_test/windows_check_shot_test.dart @@ -258,8 +258,8 @@ void main() { 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'; + ..aiImage.services = [const AiService(id: 's', name: 'GPU PC (Tailscale)', url: 'http://100.1.2.3:7860', kind: 'a1111')] + ..aiImage.engine = 's'; await show(t, size, AiImagePage(c: c)); await shot(t, '123_ai_service_$tag'); } diff --git a/lib/app/ai_local.dart b/lib/app/ai_local.dart index afb86ae..3eb37ce 100644 --- a/lib/app/ai_local.dart +++ b/lib/app/ai_local.dart @@ -448,14 +448,14 @@ Future pickAiDevice(AppController c, AiStore store, {void Function(St 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 (s.aiImage.device != 'auto') return devices.where((d) => d.key == s.aiImage.device).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 (s.aiImage.autoDeviceFor == signature) { + final known = devices.where((d) => d.key == s.aiImage.autoDevice).firstOrNull; if (known != null) return known; } - final models = store.sd15Paths(taesd: s.aiTaesd); + final models = store.sd15Paths(taesd: s.aiImage.taesd); if (models == null) return devices.first; final tmp = await Directory.systemTemp.createTemp('jj_ai_probe_'); SdDevice? best; @@ -485,7 +485,7 @@ Future pickAiDevice(AppController c, AiStore store, {void Function(St best ??= devices.last; final chosen = best; await c.updateSettings((x) => x - ..aiAutoDevice = chosen.key - ..aiAutoDeviceFor = signature); + ..aiImage.autoDevice = chosen.key + ..aiImage.autoDeviceFor = signature); return chosen; } diff --git a/lib/app/settings.dart b/lib/app/settings.dart index 9ea232a..e2578eb 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -11,11 +11,11 @@ import '../core/ftp.dart'; import '../core/sftp.dart'; import '../core/smb.dart'; import '../core/webdav.dart'; -import '../services/image_ai.dart' show AiService; import '../services/app_shell.dart' show appIconOf; import '../core/remote_idle.dart'; import 'settings/records.dart'; import 'settings/settings_group.dart'; +import 'settings/ai_image_settings.dart'; import 'settings/copy_settings.dart'; import 'settings/tor_settings.dart'; import 'settings/network_settings.dart'; @@ -24,6 +24,7 @@ import 'settings/explorer_settings.dart'; export 'settings/records.dart'; export 'settings/settings_group.dart'; export 'settings/settings_store.dart'; +export 'settings/ai_image_settings.dart'; export 'settings/copy_settings.dart'; export 'settings/tor_settings.dart'; export 'settings/network_settings.dart'; @@ -346,67 +347,23 @@ class AppSettings { // ───────── 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 = ''; - /// 159: 마지막 프롬프트와 만든 그림 목록 (최근 것부터, 업데이트 · 다시 열어도 남게) - String aiPrompt = ''; - List aiRecent = []; - /// 저장 폴더 (비어 있으면 사진 폴더의 JJ_MKVMaker_AI) - String aiSaveDir = ''; - /// 사용자가 추가한 그림 서버 · 서비스 (API 키는 안전 저장소에) - List aiServices = []; - /// 라이선스 조건에 동의한 받는 파일 (149-④: 한 번 동의하면 다시 묻지 않음) - List aiAgreed = []; // 121: 해상도 올리기 - /// 모델 'auto' (GPU 면 사진용, CPU 뿐이면 가볍고 빠른 만화용) · 'photo' · 'anime' - String aiUpModel = 'auto'; - /// 176: 해상도 올리기 메모리 (Android CPU 타일 크기) - 'low' 20% · 'normal' 30% (처음 값) · 'high' 45% (기기 메모리 중) - String aiUpMemory = 'normal'; - /// 156-2: 해상도 올리기에 걸린 시간 (처리 장치|모델 → 원본 100만 화소당 초) - "한 장 약 n초" 를 미리 알리려고 - Map aiUpSecPerMp = {}; - /// 배율 2 · 3 · 4 - int aiUpScale = 2; - /// 관리자 판단 (10/17): 보기 화면의 [품질 올리기] 단추 (그림 → 그림 약하게 + 해상도 올리기). 처음 값: 끔 - bool aiQualityButton = false; - /// [품질 올리기] 다듬기 정도 (그림 → 그림 바꾸는 정도): 'weak' 0.15 · 'normal' 0.25 (처음 값) · 'strong' 0.4 (관리자 지시 - 판단은 설정으로) - String aiQualityStrength = 'normal'; - /// 저장 형식 'same' (원본과 같게, 못 쓰는 형식은 PNG) · 'png' · 'jpg' · JPG 품질 - String aiUpFormat = 'same'; - int aiUpJpgQuality = 92; - /// 저장 위치 (비어 있으면 원본 옆 "이름_x2") - String aiUpDir = ''; - /// 저장한 뒤 'upscaled' 올린 것으로 계속 보기 · 'original' 원본으로 - String aiUpAfter = 'upscaled'; /// 백그라운드로 실행 (Android): 켜면 ← 로 닫거나 최근 앱에서 밀어도 동기화 · MKV 만들기 · 다운로드를 계속한다 (알림에 표시). @@ -474,8 +431,11 @@ class AppSettings { /// 복사 · 이동 (파일 탐색기) · rsync · 실시간 동기화 (Rsync 화면) final copy = CopySettings(); + /// AI 그림 · 해상도 올리기 (123 · 121) + final aiImage = AiImageSettings(); + /// 설정 분야들 (설정 A) - 저마다 자기 키를 읽고 쓴다. toJson · fromJson 은 이 목록을 돌며 합치기만 한다 - List get groups => [explorer, network, tor, copy]; + List get groups => [explorer, network, tor, copy, aiImage]; /// [plainSecrets]: 안전 저장소를 쓸 수 없을 때만 (예전 파일에 있던 비밀 값을 잃지 않게) 예전처럼 파일에 둔다 Map toJson({bool plainSecrets = false}) { @@ -578,35 +538,6 @@ 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, - 'aiPrompt': aiPrompt, - 'aiRecent': aiRecent, - 'aiSaveDir': aiSaveDir, - 'aiAgreed': aiAgreed, - 'aiUpModel': aiUpModel, - 'aiUpMemory': aiUpMemory, - 'aiUpSecPerMp': aiUpSecPerMp, - 'aiUpScale': aiUpScale, - 'aiQualityButton': aiQualityButton, - 'aiQualityStrength': aiQualityStrength, - 'aiUpFormat': aiUpFormat, - 'aiUpJpgQuality': aiUpJpgQuality, - 'aiUpDir': aiUpDir, - 'aiUpAfter': aiUpAfter, - '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}, ], @@ -761,41 +692,6 @@ 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? ?? '' - ..aiPrompt = j['aiPrompt'] as String? ?? '' - ..aiRecent = [for (final x in (j['aiRecent'] as List?) ?? const []) '$x'] - ..aiSaveDir = j['aiSaveDir'] as String? ?? '' - ..aiAgreed = [for (final x in (j['aiAgreed'] as List?) ?? const []) '$x'] - ..aiUpModel = const ['photo', 'anime'].contains(j['aiUpModel']) ? j['aiUpModel'] as String : 'auto' - ..aiUpMemory = const ['low', 'high'].contains(j['aiUpMemory']) ? j['aiUpMemory'] as String : 'normal' - ..aiUpSecPerMp = { - if (j['aiUpSecPerMp'] case final Map m) - for (final e in m.entries) - if (e.value is num && (e.value as num) > 0) '${e.key}': (e.value as num).toDouble(), - } - // 1 = 크기는 그대로 선명하게만 (관리자 판단 10/17 - 사용자 질문에 답이 없어 넣되 처음 값은 2배 그대로) - ..aiUpScale = const [1, 3, 4].contains(j['aiUpScale']) ? j['aiUpScale'] as int : 2 - ..aiQualityButton = j['aiQualityButton'] == true - ..aiQualityStrength = const ['weak', 'normal', 'strong'].contains(j['aiQualityStrength']) ? j['aiQualityStrength'] as String : 'normal' - ..aiUpFormat = const ['png', 'jpg'].contains(j['aiUpFormat']) ? j['aiUpFormat'] as String : 'same' - ..aiUpJpgQuality = ((j['aiUpJpgQuality'] as num?)?.toInt() ?? 92).clamp(50, 100) - ..aiUpDir = j['aiUpDir'] as String? ?? '' - ..aiUpAfter = j['aiUpAfter'] == 'original' ? 'original' : 'upscaled' - ..aiServices = [ - for (final x in (j['aiServices'] as List?) ?? const []) - if (x is Map) AiService.fromJson(x), - ] ..webdavServers = [ for (final x in (j['webdavServers'] as List?) ?? const []) if (x is Map) DavServer.fromJson(x), diff --git a/lib/app/settings/ai_image_settings.dart b/lib/app/settings/ai_image_settings.dart new file mode 100644 index 0000000..893e9bc --- /dev/null +++ b/lib/app/settings/ai_image_settings.dart @@ -0,0 +1,150 @@ + +import '../../services/image_ai.dart'; +import 'settings_group.dart'; + +/// 약속 (설정 A): 환경 설정 > AI 그림 · 해상도 올리기 (123 · 121) - 자기 키 (이름 · 처음 값 · 읽기 · 쓰기) 를 스스로 갖는다. +/// 설정 하나를 더할 때는 이 파일만 고친다. 저장 파일의 키 이름은 예전 그대로 (평평한 키) +class AiImageSettings implements SettingsGroup { + /// 그리는 엔진: 'local' 이 기기 안 (기본) · 아니면 [services] 의 id (사용자가 추가한 서버 · 서비스) + String engine = 'local'; + + /// 기기 안 처리 장치: 'auto' (잰 결과로 빠른 쪽) 또는 장치 열쇠 (예: 'engine-cuda:cuda0', 'engine-vulkan:cpu') + String device = 'auto'; + + /// 'auto' 가 잰 가장 빠른 장치와 그때의 장치 목록 (목록이 바뀌면 다시 잰다) + String autoDevice = ''; + + String autoDeviceFor = ''; + + /// 빠른 디코더 (TAESD) - 그림이 거의 같고 디코드가 10배쯤 빠르다 (받아 두었을 때) + bool taesd = true; + + int width = 512; + + int height = 512; + + int steps = 4; + + double cfg = 1; + + int count = 1; + + double strength = 0.6; + + String negative = ''; + + /// 159: 마지막 프롬프트와 만든 그림 목록 (최근 것부터, 업데이트 · 다시 열어도 남게) + String prompt = ''; + + List recent = []; + + /// 저장 폴더 (비어 있으면 사진 폴더의 JJ_MKVMaker_AI) + String saveDir = ''; + + /// 사용자가 추가한 그림 서버 · 서비스 (API 키는 안전 저장소에) + List services = []; + + /// 라이선스 조건에 동의한 받는 파일 (149-④: 한 번 동의하면 다시 묻지 않음) + List agreed = []; + + /// 모델 'auto' (GPU 면 사진용, CPU 뿐이면 가볍고 빠른 만화용) · 'photo' · 'anime' + String upModel = 'auto'; + + /// 176: 해상도 올리기 메모리 (Android CPU 타일 크기) - 'low' 20% · 'normal' 30% (처음 값) · 'high' 45% (기기 메모리 중) + String upMemory = 'normal'; + + /// 156-2: 해상도 올리기에 걸린 시간 (처리 장치|모델 → 원본 100만 화소당 초) - "한 장 약 n초" 를 미리 알리려고 + Map upSecPerMp = {}; + + /// 배율 2 · 3 · 4 + int upScale = 2; + + /// 관리자 판단 (10/17): 보기 화면의 [품질 올리기] 단추 (그림 → 그림 약하게 + 해상도 올리기). 처음 값: 끔 + bool qualityButton = false; + + /// [품질 올리기] 다듬기 정도 (그림 → 그림 바꾸는 정도): 'weak' 0.15 · 'normal' 0.25 (처음 값) · 'strong' 0.4 (관리자 지시 - 판단은 설정으로) + String qualityStrength = 'normal'; + + /// 저장 형식 'same' (원본과 같게, 못 쓰는 형식은 PNG) · 'png' · 'jpg' · JPG 품질 + String upFormat = 'same'; + + int upJpgQuality = 92; + + /// 저장 위치 (비어 있으면 원본 옆 "이름_x2") + String upDir = ''; + + /// 저장한 뒤 'upscaled' 올린 것으로 계속 보기 · 'original' 원본으로 + String upAfter = 'upscaled'; + + @override + void write(Map j, {required bool plainSecrets}) { + j['aiEngine'] = engine; + j['aiDevice'] = device; + j['aiAutoDevice'] = autoDevice; + j['aiAutoDeviceFor'] = autoDeviceFor; + j['aiTaesd'] = taesd; + j['aiWidth'] = width; + j['aiHeight'] = height; + j['aiSteps'] = steps; + j['aiCfg'] = cfg; + j['aiCount'] = count; + j['aiStrength'] = strength; + j['aiNegative'] = negative; + j['aiPrompt'] = prompt; + j['aiRecent'] = recent; + j['aiSaveDir'] = saveDir; + j['aiServices'] = [ + for (final x in services) {...x.toJson(), if (plainSecrets) 'apiKey': x.apiKey}, + ]; + j['aiAgreed'] = agreed; + j['aiUpModel'] = upModel; + j['aiUpMemory'] = upMemory; + j['aiUpSecPerMp'] = upSecPerMp; + j['aiUpScale'] = upScale; + j['aiQualityButton'] = qualityButton; + j['aiQualityStrength'] = qualityStrength; + j['aiUpFormat'] = upFormat; + j['aiUpJpgQuality'] = upJpgQuality; + j['aiUpDir'] = upDir; + j['aiUpAfter'] = upAfter; + } + + @override + void read(Map j) { + engine = j['aiEngine'] as String? ?? 'local'; + device = j['aiDevice'] as String? ?? 'auto'; + autoDevice = j['aiAutoDevice'] as String? ?? ''; + autoDeviceFor = j['aiAutoDeviceFor'] as String? ?? ''; + taesd = j['aiTaesd'] != false; + width = ((j['aiWidth'] as num?)?.toInt() ?? 512).clamp(64, 2048); + height = ((j['aiHeight'] as num?)?.toInt() ?? 512).clamp(64, 2048); + steps = ((j['aiSteps'] as num?)?.toInt() ?? 4).clamp(1, 150); + cfg = ((j['aiCfg'] as num?)?.toDouble() ?? 1).clamp(0, 30); + count = ((j['aiCount'] as num?)?.toInt() ?? 1).clamp(1, 100); + strength = ((j['aiStrength'] as num?)?.toDouble() ?? 0.6).clamp(0, 1); + negative = j['aiNegative'] as String? ?? ''; + prompt = j['aiPrompt'] as String? ?? ''; + recent = [for (final x in (j['aiRecent'] as List?) ?? const []) '$x']; + saveDir = j['aiSaveDir'] as String? ?? ''; + agreed = [for (final x in (j['aiAgreed'] as List?) ?? const []) '$x']; + upModel = const ['photo', 'anime'].contains(j['aiUpModel']) ? j['aiUpModel'] as String : 'auto'; + upMemory = const ['low', 'high'].contains(j['aiUpMemory']) ? j['aiUpMemory'] as String : 'normal'; + upSecPerMp = { + if (j['aiUpSecPerMp'] case final Map m) + for (final e in m.entries) + if (e.value is num && (e.value as num) > 0) '${e.key}': (e.value as num).toDouble(), + }; + // 1 = 크기는 그대로 선명하게만 (관리자 판단 10/17 - 사용자 질문에 답이 없어 넣되 처음 값은 2배 그대로) + upScale = const [1, 3, 4].contains(j['aiUpScale']) ? j['aiUpScale'] as int : 2; + qualityButton = j['aiQualityButton'] == true; + qualityStrength = const ['weak', 'normal', 'strong'].contains(j['aiQualityStrength']) ? j['aiQualityStrength'] as String : 'normal'; + upFormat = const ['png', 'jpg'].contains(j['aiUpFormat']) ? j['aiUpFormat'] as String : 'same'; + upJpgQuality = ((j['aiUpJpgQuality'] as num?)?.toInt() ?? 92).clamp(50, 100); + upDir = j['aiUpDir'] as String? ?? ''; + upAfter = j['aiUpAfter'] == 'original' ? 'original' : 'upscaled'; + services = [ + for (final x in (j['aiServices'] as List?) ?? const []) + if (x is Map) AiService.fromJson(x), + ]; + } +} diff --git a/lib/app/settings/settings_store.dart b/lib/app/settings/settings_store.dart index 8d9301a..3bba188 100644 --- a/lib/app/settings/settings_store.dart +++ b/lib/app/settings/settings_store.dart @@ -67,7 +67,7 @@ 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) + for (final x in s.aiImage.services) if (x.apiKey.isNotEmpty) _aiKey(x.id): x.apiKey, for (final x in s.sftpServers) ...{ if (x.password.isNotEmpty) _sftpPw(x.id): x.password, @@ -116,8 +116,8 @@ 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)] ?? ''), + s.aiImage.services = [ + for (final x in s.aiImage.services) x.apiKey.isNotEmpty ? x : x.copyWith(apiKey: stored[_aiKey(x.id)] ?? ''), ]; s.sftpServers = [ for (final x in s.sftpServers) @@ -166,7 +166,7 @@ class SettingsStore { for (final x in s.webdavServers) { if (x.password.isNotEmpty) _filePlain[_davKey(x.id)] = x.password; } - for (final x in s.aiServices) { + for (final x in s.aiImage.services) { if (x.apiKey.isNotEmpty) _filePlain[_aiKey(x.id)] = x.apiKey; } _fill(s, stored); diff --git a/lib/ui/ai_image_page.dart b/lib/ui/ai_image_page.dart index e276bf3..da225c0 100644 --- a/lib/ui/ai_image_page.dart +++ b/lib/ui/ai_image_page.dart @@ -59,14 +59,14 @@ String aiSaveDir(String setting) { class _AiImagePageState extends State with RouteAware { AppController get c => widget.c; - late final _prompt = TextEditingController(text: c.settings.aiPrompt); + late final _prompt = TextEditingController(text: c.settings.aiImage.prompt); final _promptFocus = FocusNode(); - late final _negative = TextEditingController(text: c.settings.aiNegative); + late final _negative = TextEditingController(text: c.settings.aiImage.negative); final _seed = TextEditingController(text: '-1'); late String? _init = widget.initImage; /// 159: 지난 결과를 되살린다 (설정의 목록 + 그림 옆 json 의 시드). 지워진 파일은 빼고 보여 준다 late final _results = [ - for (final path in c.settings.aiRecent) + for (final path in c.settings.aiImage.recent) if (File(path).existsSync()) GeneratedImage(path, _seedOf(path)), ]; String? _error; @@ -83,7 +83,7 @@ class _AiImagePageState extends State with RouteAware { /// 목록을 설정에 남긴다 (최근 100장) void _saveRecent() { final paths = [for (final g in _results) g.path].take(100).toList(); - c.updateSettings((x) => x.aiRecent = paths); + c.updateSettings((x) => x.aiImage.recent = paths); } /// 150: 엔진 원문 ([자세히] 를 눌러야 보임) · 취소 (회색 한 줄, 오류 아님) @@ -115,7 +115,7 @@ class _AiImagePageState extends State with RouteAware { super.dispose(); } - AiService? get _service => c.settings.aiServices.where((x) => x.id == c.settings.aiEngine).firstOrNull; + AiService? get _service => c.settings.aiImage.services.where((x) => x.id == c.settings.aiImage.engine).firstOrNull; bool get _local => _service == null; @@ -135,19 +135,19 @@ class _AiImagePageState extends State with RouteAware { if (!_modelsReady) return; } await c.updateSettings((x) => x - ..aiNegative = _negative.text - ..aiPrompt = _prompt.text); + ..aiImage.negative = _negative.text + ..aiImage.prompt = _prompt.text); final req = ImageGenRequest( prompt: _prompt.text.trim(), negative: _negative.text.trim(), - width: s.aiWidth, - height: s.aiHeight, - steps: s.aiSteps, - cfg: s.aiCfg, + width: s.aiImage.width, + height: s.aiImage.height, + steps: s.aiImage.steps, + cfg: s.aiImage.cfg, seed: int.tryParse(_seed.text.trim()) ?? -1, - count: s.aiCount, + count: s.aiImage.count, initImage: _init, - strength: s.aiStrength, + strength: s.aiImage.strength, model: _local ? 'sd15-lcm' : (_service!.model.isEmpty ? _service!.kind : _service!.model), ); setState(() { @@ -162,11 +162,11 @@ class _AiImagePageState extends State with RouteAware { 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)!); + engine = LocalSdEngine(device: device, models: store.sd15Paths(taesd: s.aiImage.taesd)!); } else { engine = RemoteImageEngine(_service!); } - final made = await AiJobs.instance.run(engine, req, outDir: aiSaveDir(s.aiSaveDir)); + final made = await AiJobs.instance.run(engine, req, outDir: aiSaveDir(s.aiImage.saveDir)); if (!mounted) return; setState(() => _results.insertAll(0, made)); _saveRecent(); @@ -247,14 +247,14 @@ class _AiImagePageState extends State with RouteAware { ), 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'], + _drop(tr('크기'), '${s.aiImage.width}x${s.aiImage.height}', 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]); + x.aiImage.width = int.parse(wh[0]); + x.aiImage.height = 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)), + _drop(tr('단계'), s.aiImage.steps, const [2, 4, 6, 8, 12, 20, 30], (v) => c.updateSettings((x) => x.aiImage.steps = v)), + _drop(tr('개수'), s.aiImage.count, const [1, 2, 4, 8, 16], (v) => c.updateSettings((x) => x.aiImage.count = v)), // 141: 이름이 잘리지 않게 짧게 · 넉넉히 (설명은 아래 줄) SizedBox( width: 190, @@ -266,7 +266,7 @@ class _AiImagePageState extends State with RouteAware { ), ]), const SizedBox(height: 8), - _initRow(s.aiStrength), + _initRow(s.aiImage.strength), const SizedBox(height: 12), if (jobs.busy) ...[ LinearProgressIndicator(value: jobs.progress), @@ -299,7 +299,7 @@ class _AiImagePageState extends State with RouteAware { ]), ), const SizedBox(height: 12), - if (_results.isNotEmpty) Text(trf('만든 그림 · 저장: {0}', [aiSaveDir(s.aiSaveDir)]), style: const TextStyle(fontSize: 12)), + if (_results.isNotEmpty) Text(trf('만든 그림 · 저장: {0}', [aiSaveDir(s.aiImage.saveDir)]), style: const TextStyle(fontSize: 12)), const SizedBox(height: 6), Wrap(spacing: 8, runSpacing: 8, children: [for (final g in _results) _thumb(g)]), ]), @@ -319,12 +319,12 @@ class _AiImagePageState extends State with RouteAware { Flexible( child: DropdownButton( isExpanded: true, - value: _service == null ? 'local' : s.aiEngine, + value: _service == null ? 'local' : s.aiImage.engine, items: [ DropdownMenuItem(value: 'local', child: Text(tr('이 기기 (SD1.5 + LCM)'))), - for (final x in s.aiServices) DropdownMenuItem(value: x.id, child: Text('☁ ${x.label}')), + for (final x in s.aiImage.services) DropdownMenuItem(value: x.id, child: Text('☁ ${x.label}')), ], - onChanged: (v) => c.updateSettings((x) => x.aiEngine = v ?? 'local'), + onChanged: (v) => c.updateSettings((x) => x.aiImage.engine = v ?? 'local'), ), ), IconButton( @@ -357,7 +357,7 @@ class _AiImagePageState extends State with RouteAware { value: strength, min: 0.05, max: 1, - onChanged: (v) => c.updateSettings((x) => x.aiStrength = v), + onChanged: (v) => c.updateSettings((x) => x.aiImage.strength = v), ), ), ], diff --git a/lib/ui/ai_settings.dart b/lib/ui/ai_settings.dart index b8efea0..d661216 100644 --- a/lib/ui/ai_settings.dart +++ b/lib/ui/ai_settings.dart @@ -83,13 +83,13 @@ class _AiSettingsState extends State { subtitle: Text(upscaleMemoryLines(mem).join('\n')), isThreeLine: true, trailing: DropdownButton( - value: c.settings.aiUpMemory, + value: c.settings.aiImage.upMemory, items: [ DropdownMenuItem(value: 'low', child: Text(tr('적게 (느림)'))), DropdownMenuItem(value: 'normal', child: Text(tr('보통'))), DropdownMenuItem(value: 'high', child: Text(tr('많이 (빠름)'))), ], - onChanged: (v) => c.updateSettings((x) => x.aiUpMemory = v ?? 'normal'), + onChanged: (v) => c.updateSettings((x) => x.aiImage.upMemory = v ?? 'normal'), ), ); } @@ -102,7 +102,7 @@ class _AiSettingsState extends State { /// 받기 전에 라이선스 조건 (있으면) 을 보여 주고 동의를 받는다 Future _agree(AiFile f) async { // 149-④: 한 번 동의했으면 다시 묻지 않는다 - if (f.terms == null || c.settings.aiAgreed.contains(f.id)) return true; + if (f.terms == null || c.settings.aiImage.agreed.contains(f.id)) return true; final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( @@ -127,7 +127,7 @@ class _AiSettingsState extends State { ], ), ); - if (ok == true) await c.updateSettings((x) => x.aiAgreed = {...x.aiAgreed, f.id}.toList()); + if (ok == true) await c.updateSettings((x) => x.aiImage.agreed = {...x.aiImage.agreed, f.id}.toList()); return ok == true; } @@ -198,42 +198,42 @@ class _AiSettingsState extends State { SettingTile( leading: const Icon(Icons.memory), title: Text(tr('처리 장치')), - subtitle: Text(s.aiDevice == 'auto' - ? (s.aiAutoDevice.isEmpty + subtitle: Text(s.aiImage.device == 'auto' + ? (s.aiImage.autoDevice.isEmpty ? tr('자동: 처음 그릴 때 짧게 재서 가장 빠른 장치를 고릅니다') - : trf('자동: 잰 결과 {0} 가 가장 빠릅니다', [_devices.where((d) => d.key == s.aiAutoDevice).firstOrNull?.label ?? s.aiAutoDevice])) + : trf('자동: 잰 결과 {0} 가 가장 빠릅니다', [_devices.where((d) => d.key == s.aiImage.autoDevice).firstOrNull?.label ?? s.aiImage.autoDevice])) : tr('직접 고름')), trailing: DropdownButton( - value: s.aiDevice == 'auto' || _devices.any((d) => d.key == s.aiDevice) ? s.aiDevice : 'auto', + value: s.aiImage.device == 'auto' || _devices.any((d) => d.key == s.aiImage.device) ? s.aiImage.device : '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 = ''), // 다시 고르면 다음에 다시 잼 + ..aiImage.device = v ?? 'auto' + ..aiImage.autoDeviceFor = ''), // 다시 고르면 다음에 다시 잼 ), ), SwitchListTile( - value: s.aiTaesd, - onChanged: (v) => c.updateSettings((x) => x.aiTaesd = v), + value: s.aiImage.taesd, + onChanged: (v) => c.updateSettings((x) => x.aiImage.taesd = v), title: Text(tr('빠른 디코더 (TAESD)')), subtitle: Text(tr('그림이 거의 같고 마지막 단계가 10배쯤 빠릅니다 (받아 두었을 때)')), ), SettingTile( leading: const Icon(Icons.folder_outlined), title: Text(tr('저장 폴더')), - subtitle: Text(aiSaveDir(s.aiSaveDir)), + subtitle: Text(aiSaveDir(s.aiImage.saveDir)), 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); + final d = await pickFolder(context, tr('AI 그림 저장 폴더'), aiSaveDir(s.aiImage.saveDir)); + if (d != null) await c.updateSettings((x) => x.aiImage.saveDir = d); }, child: Text(tr('바꾸기')), ), - if (s.aiSaveDir.isNotEmpty) - TextButton(onPressed: () => c.updateSettings((x) => x.aiSaveDir = ''), child: Text(tr('처음대로'))), + if (s.aiImage.saveDir.isNotEmpty) + TextButton(onPressed: () => c.updateSettings((x) => x.aiImage.saveDir = ''), child: Text(tr('처음대로'))), ]), ), const Divider(), @@ -243,13 +243,13 @@ class _AiSettingsState extends State { title: Text(tr('해상도 올리기 모델')), subtitle: Text(tr('자동: 그래픽카드 (GPU) 가 있으면 사진용, CPU 뿐이면 가볍고 빠른 만화용. 원본은 덮어쓰지 않고 새 파일로 저장합니다')), trailing: DropdownButton( - value: s.aiUpModel, + value: s.aiImage.upModel, items: [ DropdownMenuItem(value: 'auto', child: Text(tr('자동'))), DropdownMenuItem(value: 'photo', child: Text(tr('사진'))), DropdownMenuItem(value: 'anime', child: Text(tr('만화 · 그림'))), ], - onChanged: (v) => c.updateSettings((x) => x.aiUpModel = v ?? 'auto'), + onChanged: (v) => c.updateSettings((x) => x.aiImage.upModel = v ?? 'auto'), ), ), // 176: Android CPU - 메모리를 많이 쓸수록 빠르다 (그래픽카드 · Windows 는 엔진이 정함) @@ -258,13 +258,13 @@ class _AiSettingsState extends State { leading: const Icon(Icons.zoom_out_map), title: Text(tr('배율')), trailing: DropdownButton( - value: s.aiUpScale, + value: s.aiImage.upScale, // 1배 = 크기는 그대로 두고 모델로 선명하게만 (이름_x1) items: [ for (final n in const [1, 2, 3, 4]) DropdownMenuItem(value: n, child: Text(n == 1 ? tr('1배 (선명하게)') : trf('{0}배', [n]))), ], - onChanged: (v) => c.updateSettings((x) => x.aiUpScale = v ?? 2), + onChanged: (v) => c.updateSettings((x) => x.aiImage.upScale = v ?? 2), ), ), // 관리자 판단 (10/17): [품질 올리기] 한 단추 - 처음 값 끔 @@ -273,75 +273,75 @@ class _AiSettingsState extends State { title: Text(tr('품질 올리기 단추 보이기')), subtitle: Text(tr('보기 화면에 [품질 올리기]: AI 그림 (그림 → 그림, 약하게) 으로 다듬은 뒤 해상도 올리기를 이어서. ' '새 파일 (이름_q) 로 저장하며 AI 그림 모델이 필요합니다')), - trailing: Switch(value: s.aiQualityButton, onChanged: (v) => c.updateSettings((x) => x.aiQualityButton = v)), + trailing: Switch(value: s.aiImage.qualityButton, onChanged: (v) => c.updateSettings((x) => x.aiImage.qualityButton = v)), ), - if (s.aiQualityButton) + if (s.aiImage.qualityButton) SettingTile( leading: const Icon(Icons.tune), title: Text(tr('품질 올리기 다듬기 정도')), subtitle: Text(tr('강할수록 많이 다듬지만 원본과 달라질 수 있습니다')), trailing: DropdownButton( - value: s.aiQualityStrength, + value: s.aiImage.qualityStrength, items: [ DropdownMenuItem(value: 'weak', child: Text(tr('약하게 (0.15)'))), DropdownMenuItem(value: 'normal', child: Text(tr('보통 (0.25, 기본)'))), DropdownMenuItem(value: 'strong', child: Text(tr('강하게 (0.4)'))), ], - onChanged: (v) => c.updateSettings((x) => x.aiQualityStrength = v ?? 'normal'), + onChanged: (v) => c.updateSettings((x) => x.aiImage.qualityStrength = v ?? 'normal'), ), ), SettingTile( leading: const Icon(Icons.image_outlined), title: Text(tr('저장 형식')), - subtitle: s.aiUpFormat == 'jpg' ? Text(trf('JPG 품질 {0}', [s.aiUpJpgQuality])) : Text(tr('WebP 등은 PNG 로 저장합니다')), + subtitle: s.aiImage.upFormat == 'jpg' ? Text(trf('JPG 품질 {0}', [s.aiImage.upJpgQuality])) : Text(tr('WebP 등은 PNG 로 저장합니다')), trailing: DropdownButton( - value: s.aiUpFormat, + value: s.aiImage.upFormat, items: [ DropdownMenuItem(value: 'same', child: Text(tr('원본과 같은 형식'))), const DropdownMenuItem(value: 'png', child: Text('PNG')), const DropdownMenuItem(value: 'jpg', child: Text('JPG')), ], - onChanged: (v) => c.updateSettings((x) => x.aiUpFormat = v ?? 'same'), + onChanged: (v) => c.updateSettings((x) => x.aiImage.upFormat = v ?? 'same'), ), ), - if (s.aiUpFormat == 'jpg') + if (s.aiImage.upFormat == 'jpg') Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Slider( - value: s.aiUpJpgQuality.toDouble(), + value: s.aiImage.upJpgQuality.toDouble(), min: 50, max: 100, divisions: 50, - label: '${s.aiUpJpgQuality}', - onChanged: (v) => c.updateSettings((x) => x.aiUpJpgQuality = v.round()), + label: '${s.aiImage.upJpgQuality}', + onChanged: (v) => c.updateSettings((x) => x.aiImage.upJpgQuality = v.round()), ), ), SettingTile( leading: const Icon(Icons.drive_file_move_outline), title: Text(tr('저장 위치')), - subtitle: Text(s.aiUpDir.isEmpty ? tr('원본 옆에 "이름_x2" (ZIP 은 ZIP 옆 "ZIP이름_AI" 폴더)') : s.aiUpDir), + subtitle: Text(s.aiImage.upDir.isEmpty ? tr('원본 옆에 "이름_x2" (ZIP 은 ZIP 옆 "ZIP이름_AI" 폴더)') : s.aiImage.upDir), trailing: Wrap(spacing: 6, children: [ OutlinedButton( onPressed: () async { - final d = await pickFolder(context, tr('해상도를 올린 그림 저장 폴더'), s.aiUpDir.isEmpty ? null : s.aiUpDir); - if (d != null) await c.updateSettings((x) => x.aiUpDir = d); + final d = await pickFolder(context, tr('해상도를 올린 그림 저장 폴더'), s.aiImage.upDir.isEmpty ? null : s.aiImage.upDir); + if (d != null) await c.updateSettings((x) => x.aiImage.upDir = d); }, child: Text(tr('폴더 고르기')), ), - if (s.aiUpDir.isNotEmpty) - TextButton(onPressed: () => c.updateSettings((x) => x.aiUpDir = ''), child: Text(tr('원본 옆으로'))), + if (s.aiImage.upDir.isNotEmpty) + TextButton(onPressed: () => c.updateSettings((x) => x.aiImage.upDir = ''), child: Text(tr('원본 옆으로'))), ]), ), SettingTile( leading: const Icon(Icons.visibility_outlined), title: Text(tr('저장한 뒤')), trailing: DropdownButton( - value: s.aiUpAfter, + value: s.aiImage.upAfter, items: [ DropdownMenuItem(value: 'upscaled', child: Text(tr('올린 것으로 계속 보기'))), DropdownMenuItem(value: 'original', child: Text(tr('원본으로'))), ], - onChanged: (v) => c.updateSettings((x) => x.aiUpAfter = v ?? 'upscaled'), + onChanged: (v) => c.updateSettings((x) => x.aiImage.upAfter = v ?? 'upscaled'), ), ), const Divider(), @@ -356,7 +356,7 @@ class _AiSettingsState extends State { label: Text(tr('서비스 추가')), ), ), - for (final x in s.aiServices) + for (final x in s.aiImage.services) Padding( padding: const EdgeInsets.only(left: 16), child: SettingTile( @@ -379,8 +379,8 @@ class _AiSettingsState extends State { 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'; + y.aiImage.services = [for (final z in y.aiImage.services) if (z.id != x.id) z]; + if (y.aiImage.engine == x.id) y.aiImage.engine = 'local'; }); }, ), @@ -560,8 +560,8 @@ Future editAiService(BuildContext context, AppController c, {AiService? ol x.dispose(); } if (saved == null) return; - await c.updateSettings((x) => x.aiServices = [ - for (final y in x.aiServices) if (y.id != saved.id) y, + await c.updateSettings((x) => x.aiImage.services = [ + for (final y in x.aiImage.services) if (y.id != saved.id) y, saved, ]); } diff --git a/lib/ui/ai_upscale_ui.dart b/lib/ui/ai_upscale_ui.dart index 2ed72dd..6574cdd 100644 --- a/lib/ui/ai_upscale_ui.dart +++ b/lib/ui/ai_upscale_ui.dart @@ -26,11 +26,11 @@ import 'notice.dart'; UpscaleOptions upscaleOptionsOf(AppController c, {String? outDir}) { final s = c.settings; return UpscaleOptions( - model: s.aiUpModel, - scale: s.aiUpScale, - format: s.aiUpFormat, - jpgQuality: s.aiUpJpgQuality, - outDir: outDir ?? s.aiUpDir, + model: s.aiImage.upModel, + scale: s.aiImage.upScale, + format: s.aiImage.upFormat, + jpgQuality: s.aiImage.upJpgQuality, + outDir: outDir ?? s.aiImage.upDir, ); } @@ -40,9 +40,9 @@ Future<(String input, String nameFrom, String outDir)?> readerPageFile(AppContro final s = c.settings; if (src is ImageFilesSource) { final path = src.paths[i]; - if (!isDav(path)) return (path, path, s.aiUpDir); + if (!isDav(path)) return (path, path, s.aiImage.upDir); final local = await vLocalCopy(path, src.tempDir); - return (local, p.join(aiSaveDir(s.aiSaveDir), vBasename(path)), s.aiUpDir.isEmpty ? aiSaveDir(s.aiSaveDir) : s.aiUpDir); + return (local, p.join(aiSaveDir(s.aiImage.saveDir), vBasename(path)), s.aiImage.upDir.isEmpty ? aiSaveDir(s.aiImage.saveDir) : s.aiImage.upDir); } if (src is ZipImagesSource) { final e = src.entries[i]; @@ -50,9 +50,9 @@ Future<(String input, String nameFrom, String outDir)?> readerPageFile(AppContro final name = p.basename(e.name); final f = File(p.join(tmp.path, name))..writeAsBytesSync(e.readBytes() ?? const []); final beside = isDav(src.path) - ? aiSaveDir(s.aiSaveDir) + ? aiSaveDir(s.aiImage.saveDir) : p.join(p.dirname(src.path), '${p.basenameWithoutExtension(src.path)}_AI'); - final out = s.aiUpDir.isEmpty ? beside : s.aiUpDir; + final out = s.aiImage.upDir.isEmpty ? beside : s.aiImage.upDir; return (f.path, p.join(out, name), out); } return null; // PDF 등은 아직 @@ -68,13 +68,13 @@ Future _prepare(BuildContext context, AppController c) async { final store = AiStore.instance ??= AiStore(Directory.systemTemp.path); Future ready() => prepareUpscaler(store, - modelSetting: c.settings.aiUpModel, device: c.settings.aiDevice, autoDevice: c.settings.aiAutoDevice, memory: c.settings.aiUpMemory); + modelSetting: c.settings.aiImage.upModel, device: c.settings.aiImage.device, autoDevice: c.settings.aiImage.autoDevice, memory: c.settings.aiImage.upMemory); final up = await ready(); if (up != null || !context.mounted) return up; // 엔진 (Windows 는 받아야 함) · 모델 중 없는 것 - final engine = await pickUpscaleDevice(store, device: c.settings.aiDevice) == null && Platform.isWindows ? aiFile('engine-vulkan') : null; + final engine = await pickUpscaleDevice(store, device: c.settings.aiImage.device) == null && Platform.isWindows ? aiFile('engine-vulkan') : null; if (!context.mounted) return null; - final setting = c.settings.aiUpModel; + final setting = c.settings.aiImage.upModel; final anime = aiFile('esrgan-anime6b'), photo = aiFile('esrgan-x4plus'); final choices = setting == 'photo' ? [photo] @@ -166,7 +166,7 @@ Future _recordSpeed(AppController c, AiUpscaler up, String input, Duration final mp = megapixels(await File(input).readAsBytes()); if (mp == null || mp <= 0) return; final v = took.inMilliseconds / 1000 / mp; - await c.updateSettings((x) => x.aiUpSecPerMp = {...x.aiUpSecPerMp, upscaleSpeedKey(up): v}); + await c.updateSettings((x) => x.aiImage.upSecPerMp = {...x.aiImage.upSecPerMp, upscaleSpeedKey(up): v}); } catch (_) {} } @@ -174,7 +174,7 @@ Future _recordSpeed(AppController c, AiUpscaler up, String input, Duration Future _estimateText(AppController c, AiUpscaler up, String input) async { try { final mp = megapixels(await File(input).readAsBytes()); - final d = mp == null ? null : upscaleEstimate(c.settings.aiUpSecPerMp, up, mp); + final d = mp == null ? null : upscaleEstimate(c.settings.aiImage.upSecPerMp, up, mp); return d == null ? null : trf('이 기기에서 한 장 약 {0} (지난번 걸린 시간 기준)', [AiJobs.durationText(d)]); } catch (_) { return null; @@ -199,7 +199,7 @@ Future upscaleOne(BuildContext context, AppController c, {required Stri var background = false; var dialogOpen = true; // 관리자: CPU 뿐이라 자동이 만화용을 고르면 사진은 질감이 뭉개질 수 있다 - 알리고 바로 바꿀 수 있게 - final cpuAnime = c.settings.aiUpModel == 'auto' && up.device.backend == 'cpu' && up.modelId == 'esrgan-anime6b'; + final cpuAnime = c.settings.aiImage.upModel == 'auto' && up.device.backend == 'cpu' && up.modelId == 'esrgan-anime6b'; final estimate = await _estimateText(c, up, input); if (!context.mounted) return null; final nav = Navigator.of(context); @@ -213,7 +213,7 @@ Future upscaleOne(BuildContext context, AppController c, {required Stri child: ValueListenableBuilder( valueListenable: progress, builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(trf('{0} · {1}배 · {2}', [p.basename(nameFrom), c.settings.aiUpScale, up.device.label])), + Text(trf('{0} · {1}배 · {2}', [p.basename(nameFrom), c.settings.aiImage.upScale, up.device.label])), const SizedBox(height: 10), LinearProgressIndicator(value: v), const SizedBox(height: 4), @@ -284,7 +284,7 @@ Future upscaleOne(BuildContext context, AppController c, {required Stri } if (dialogOpen) nav.pop(); // 진행 창 if (toPhoto && context.mounted) { - await c.updateSettings((x) => x.aiUpModel = 'photo'); + await c.updateSettings((x) => x.aiImage.upModel = 'photo'); if (!context.mounted) return null; return upscaleOne(context, c, input: input, nameFrom: nameFrom, outDir: outDir); } @@ -369,7 +369,7 @@ Future upscaleAll(BuildContext context, AppController c, ReaderSource src) try { final first = n == 0 ? null : await readerPageFile(c, src, 0); final mp = first == null ? null : megapixels(await File(first.$1).readAsBytes()); - final d = mp == null ? null : upscaleEstimate(c.settings.aiUpSecPerMp, up, mp); + final d = mp == null ? null : upscaleEstimate(c.settings.aiImage.upSecPerMp, up, mp); if (d != null) { estimate = trf('이 기기에서 한 장 약 {0} · 모두 약 {1} (지난번 걸린 시간 기준)', [AiJobs.durationText(d), AiJobs.durationText(d * n)]); @@ -380,9 +380,9 @@ Future upscaleAll(BuildContext context, AppController c, ReaderSource src) context, title: trf('{0}장 모두 해상도 올리기', [n]), body: [ - c.settings.aiUpScale == 1 + c.settings.aiImage.upScale == 1 ? tr('크기는 그대로 두고 선명하게 해 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.') - : trf('{0}배로 올려 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.', [c.settings.aiUpScale]), + : trf('{0}배로 올려 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.', [c.settings.aiImage.upScale]), ?estimate, ].join('\n\n'), ok: tr('시작'), @@ -433,7 +433,7 @@ Future qualityUp(BuildContext context, AppController c, {required Strin return null; } final store = AiStore.instance ??= AiStore(Directory.systemTemp.path); - final models = store.sd15Paths(taesd: c.settings.aiTaesd); + final models = store.sd15Paths(taesd: c.settings.aiImage.taesd); if (models == null) { await showDialog( context: context, @@ -506,10 +506,10 @@ Future qualityUp(BuildContext context, AppController c, {required Strin negative: 'blurry, lowres, noise, jpeg artifacts, deformed', width: w, height: h, - steps: c.settings.aiSteps, - cfg: c.settings.aiCfg, + steps: c.settings.aiImage.steps, + cfg: c.settings.aiImage.cfg, initImage: input, - strength: qualityStrength(c.settings.aiQualityStrength), + strength: qualityStrength(c.settings.aiImage.qualityStrength), ), outDir: tmp.path, baseName: 'q', diff --git a/lib/ui/master_prompt.dart b/lib/ui/master_prompt.dart index ad3515c..8bfc0d4 100644 --- a/lib/ui/master_prompt.dart +++ b/lib/ui/master_prompt.dart @@ -145,7 +145,7 @@ Future clearStoredSecrets(AppController c) => c.updateSettings((s) => s ..smbServers = [for (final x in s.smbServers) x.copyWith(password: '')] ..ftpServers = [for (final x in s.ftpServers) x.copyWith(password: '')] // AI 서비스 API 키도 (문구 "비밀번호 · API 키" 와 같게 - 예전에는 남았다, 관리자 결정 10/17) - ..aiServices = [for (final x in s.aiServices) x.copyWith(apiKey: '')] + ..aiImage.services = [for (final x in s.aiImage.services) x.copyWith(apiKey: '')] ..openSubtitlesKey = '' ..openSubtitlesPassword = '').then((_) async { // 관리자 판단 (10/21): 다른 판 (새 판) 이 안전 저장소에 넣은 이 판이 모르는 비밀번호 · 키도 (마스터 · 최상은 남김) diff --git a/lib/ui/reader/reader_ai.dart b/lib/ui/reader/reader_ai.dart index 511ff2c..c03745b 100644 --- a/lib/ui/reader/reader_ai.dart +++ b/lib/ui/reader/reader_ai.dart @@ -21,7 +21,7 @@ Future upscaleReaderPage(BuildContext context, AppController c, ReaderS final saved = await upscaleOne(context, c, input: file.$1, nameFrom: file.$2, outDir: file.$3); if (saved == null || !context.mounted) return null; final s = src; - if (c.settings.aiUpAfter == 'upscaled' && s is ImageFilesSource && !isDav(s.paths[page])) return saved; + if (c.settings.aiImage.upAfter == 'upscaled' && s is ImageFilesSource && !isDav(s.paths[page])) return saved; return null; } diff --git a/lib/ui/reader/reader_bar_items.dart b/lib/ui/reader/reader_bar_items.dart index 305934d..ef9a3b2 100644 --- a/lib/ui/reader/reader_bar_items.dart +++ b/lib/ui/reader/reader_bar_items.dart @@ -124,7 +124,7 @@ List readerBarItems(BuildContext context, AppController c, Reader one(ReaderAct(const Icon(Icons.hd_outlined), tr('AI 해상도 올리기 (이 장)'), on.upscalePage)), one(ReaderAct(const Icon(Icons.burst_mode_outlined), tr('AI 해상도 올리기 (모든 장)'), () => upscaleAll(context, c, src))), // 관리자 판단 (10/17): [품질 올리기] - 설정으로 켰을 때만 - if (c.settings.aiQualityButton) one(ReaderAct(const Icon(Icons.auto_fix_high), tr('품질 올리기 (이 장)'), on.qualityPage)), + if (c.settings.aiImage.qualityButton) one(ReaderAct(const Icon(Icons.auto_fix_high), tr('품질 올리기 (이 장)'), on.qualityPage)), ], // 123: 이 그림을 바탕으로 AI 그림 (그림 → 그림, 원본은 그대로) if (st.aiSource case final path?) diff --git a/test/ai_image_test.dart b/test/ai_image_test.dart index 62a0980..44a4c32 100644 --- a/test/ai_image_test.dart +++ b/test/ai_image_test.dart @@ -380,13 +380,13 @@ void main() { 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')]; + s.aiImage.services = [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 = []; + expect(again.aiImage.services.single.apiKey, 'sk-secret'); + s.aiImage.services = []; await st.save(s); expect(secrets.values.containsKey('ai.key.x'), isFalse, reason: '지운 서비스의 키는 저장소에서도 지움'); }); @@ -475,15 +475,15 @@ void main() { test('123: AI 설정 처음 값 · 저장 · 예전 설정에 AI 그림 화면을 한 번 넣음', () { final s = AppSettings(); - expect((s.aiEngine, s.aiDevice, s.aiTaesd, s.aiWidth, s.aiHeight, s.aiSteps, s.aiCfg, s.aiCount), + expect((s.aiImage.engine, s.aiImage.device, s.aiImage.taesd, s.aiImage.width, s.aiImage.height, s.aiImage.steps, s.aiImage.cfg, s.aiImage.count), ('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) + ..aiImage.width = 768 + ..aiImage.steps = 8 + ..aiImage.taesd = false) .toJson()); - expect((b.aiWidth, b.aiSteps, b.aiTaesd), (768, 8, false)); + expect((b.aiImage.width, b.aiImage.steps, b.aiImage.taesd), (768, 8, false)); final old = AppSettings.fromJson({'components': ['mkv', 'browser']}); expect(old.components, contains('aiimage')); expect(old.migrated, contains('aiImage')); diff --git a/test/ai_page_test.dart b/test/ai_page_test.dart index fb9ad49..e67e7e9 100644 --- a/test/ai_page_test.dart +++ b/test/ai_page_test.dart @@ -32,8 +32,8 @@ void main() { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); if (service != null) { c.settings - ..aiServices = [service] - ..aiEngine = service.id; + ..aiImage.services = [service] + ..aiImage.engine = service.id; } await t.pumpWidget(MaterialApp(home: AiImagePage(c: c))); await t.pump(); diff --git a/test/ai_quality_test.dart b/test/ai_quality_test.dart index 7315aa2..b725982 100644 --- a/test/ai_quality_test.dart +++ b/test/ai_quality_test.dart @@ -22,16 +22,16 @@ void main() { tearDown(() => tmp.deleteSync(recursive: true)); test('설정: 배율 1 을 받아들이고 처음 값은 2 · 품질 올리기 단추는 처음 끔 · 저장', () { - expect(AppSettings().aiUpScale, 2); - expect(AppSettings.fromJson({'aiUpScale': 1}).aiUpScale, 1); - expect(AppSettings.fromJson({'aiUpScale': 7}).aiUpScale, 2); - expect(AppSettings().aiQualityButton, isFalse); - expect(AppSettings.fromJson((AppSettings()..aiQualityButton = true).toJson()).aiQualityButton, isTrue); + expect(AppSettings().aiImage.upScale, 2); + expect(AppSettings.fromJson({'aiUpScale': 1}).aiImage.upScale, 1); + expect(AppSettings.fromJson({'aiUpScale': 7}).aiImage.upScale, 2); + expect(AppSettings().aiImage.qualityButton, isFalse); + expect(AppSettings.fromJson((AppSettings()..aiImage.qualityButton = true).toJson()).aiImage.qualityButton, isTrue); // 다듬기 정도: 약하게 0.15 · 보통 0.25 (처음) · 강하게 0.4, 모르는 값은 보통 - expect(AppSettings().aiQualityStrength, 'normal'); + expect(AppSettings().aiImage.qualityStrength, 'normal'); expect([for (final s in ['weak', 'normal', 'strong']) qualityStrength(s)], [0.15, 0.25, 0.4]); - expect(AppSettings.fromJson({'aiQualityStrength': 'x'}).aiQualityStrength, 'normal'); - expect(AppSettings.fromJson((AppSettings()..aiQualityStrength = 'strong').toJson()).aiQualityStrength, 'strong'); + expect(AppSettings.fromJson({'aiQualityStrength': 'x'}).aiImage.qualityStrength, 'normal'); + expect(AppSettings.fromJson((AppSettings()..aiImage.qualityStrength = 'strong').toJson()).aiImage.qualityStrength, 'strong'); }); test('1배: 4배 결과를 원본 크기로 줄인다 (선명하게만)', () async { diff --git a/test/ai_upscale_test.dart b/test/ai_upscale_test.dart index a3560e0..ee76ca3 100644 --- a/test/ai_upscale_test.dart +++ b/test/ai_upscale_test.dart @@ -75,15 +75,15 @@ void main() { test('121: 설정 처음 값 · 저장', () { final s = AppSettings(); - expect((s.aiUpModel, s.aiUpScale, s.aiUpFormat, s.aiUpDir, s.aiUpAfter), ('auto', 2, 'same', '', 'upscaled')); + expect((s.aiImage.upModel, s.aiImage.upScale, s.aiImage.upFormat, s.aiImage.upDir, s.aiImage.upAfter), ('auto', 2, 'same', '', 'upscaled')); final b = AppSettings.fromJson((AppSettings() - ..aiUpModel = 'photo' - ..aiUpScale = 4 - ..aiUpFormat = 'jpg' - ..aiUpJpgQuality = 80 - ..aiUpAfter = 'original') + ..aiImage.upModel = 'photo' + ..aiImage.upScale = 4 + ..aiImage.upFormat = 'jpg' + ..aiImage.upJpgQuality = 80 + ..aiImage.upAfter = 'original') .toJson()); - expect((b.aiUpModel, b.aiUpScale, b.aiUpFormat, b.aiUpJpgQuality, b.aiUpAfter), ('photo', 4, 'jpg', 80, 'original')); + expect((b.aiImage.upModel, b.aiImage.upScale, b.aiImage.upFormat, b.aiImage.upJpgQuality, b.aiImage.upAfter), ('photo', 4, 'jpg', 80, 'original')); }); // 진짜 엔진 (JJ_TEST_SD_DIR: bin_vulkan/sd-cli.exe · models/RealESRGAN_x4plus_anime_6B.pth) @@ -127,11 +127,11 @@ void main() { test('159: 프롬프트 · 만든 그림 목록이 설정에 남는다', () { final b = AppSettings.fromJson((AppSettings() - ..aiPrompt = 'a cat' - ..aiRecent = ['C:/a/ai_1.png', 'C:/a/ai_2.png']) + ..aiImage.prompt = 'a cat' + ..aiImage.recent = ['C:/a/ai_1.png', 'C:/a/ai_2.png']) .toJson()); - expect(b.aiPrompt, 'a cat'); - expect(b.aiRecent, ['C:/a/ai_1.png', 'C:/a/ai_2.png']); + expect(b.aiImage.prompt, 'a cat'); + expect(b.aiImage.recent, ['C:/a/ai_1.png', 'C:/a/ai_2.png']); }); group('156-2: 가짜 AI 결과 막기', () { @@ -186,8 +186,8 @@ void main() { expect(upscaleEstimate({}, up, 0.2), isNull); // S10 실측: 512×384 (0.197MP) 에 약 87초 → 100만 화소당 약 442초 expect(upscaleEstimate({'cpu|esrgan-anime6b': 442}, up, 0.196608)!.inSeconds, 87); - final b = AppSettings.fromJson((AppSettings()..aiUpSecPerMp = {'cpu|esrgan-anime6b': 442.5}).toJson()); - expect(b.aiUpSecPerMp, {'cpu|esrgan-anime6b': 442.5}); + final b = AppSettings.fromJson((AppSettings()..aiImage.upSecPerMp = {'cpu|esrgan-anime6b': 442.5}).toJson()); + expect(b.aiImage.upSecPerMp, {'cpu|esrgan-anime6b': 442.5}); }); }); diff --git a/test/master_lock_test.dart b/test/master_lock_test.dart index b43a028..b6befe9 100644 --- a/test/master_lock_test.dart +++ b/test/master_lock_test.dart @@ -287,7 +287,7 @@ void main() { ..openSubtitlesPassword = 'op' ..webdavServers = [const DavServer(id: 'd1', name: 'n', url: 'https://x', user: 'u', password: 'dp')] ..sftpServers = [const SftpServer(id: 's1', name: 'n', host: 'h', user: 'u', password: 'sp', privateKey: 'PRIVATE KEY', keyPassphrase: 'kp')] - ..aiServices = [const AiService(id: 'a1', name: 'n', url: 'https://ai', kind: 'openai', apiKey: 'ak')]); + ..aiImage.services = [const AiService(id: 'a1', name: 'n', url: 'https://ai', kind: 'openai', apiKey: 'ak')]); await store.save(c.settings); bool secret(String k) => k.startsWith('os.') || k.startsWith('dav.pw.') || k.startsWith('sftp.') || k.startsWith('ai.'); expect(secrets.values.keys.where(secret).length, 7, reason: '넣은 비밀 값이 저장소에 있다 (os 2 · dav 1 · sftp 3 · ai 1)'); @@ -295,7 +295,7 @@ void main() { await store.save(c.settings); expect(secrets.values.keys.where(secret), isEmpty); // 목록 · 아이디 · 믿은 서버 키는 남김 - expect((c.settings.webdavServers.single.user, c.settings.sftpServers.single.user, c.settings.aiServices.single.url), ('u', 'u', 'https://ai')); + expect((c.settings.webdavServers.single.user, c.settings.sftpServers.single.user, c.settings.aiImage.services.single.url), ('u', 'u', 'https://ai')); }); test('관리자 판단 (10/21): "앱 설정 전체" 는 이 판이 모르는 설정 키도 지우고, 다른 판의 비밀 값은 "저장된 비밀번호" 를 골랐을 때만', () async { diff --git a/test/settings_store_test.dart b/test/settings_store_test.dart index 5b1c6e2..f5fe23c 100644 --- a/test/settings_store_test.dart +++ b/test/settings_store_test.dart @@ -78,7 +78,7 @@ void main() { ..sftpServers = [const SftpServer(id: 's1', name: 'n', host: 'h', user: 'u', password: 'sp', privateKey: 'KEY', keyPassphrase: 'kp')] ..smbServers = [const SmbServer(id: 'm1', name: 'n', host: 'h', share: 'v', password: 'mp')] ..ftpServers = [const FtpServer(id: 'f1', name: 'n', host: 'h', password: 'fp')] - ..aiServices = [const AiService(id: 'a1', name: 'n', url: 'https://ai', kind: 'openai', apiKey: 'ak')]; + ..aiImage.services = [const AiService(id: 'a1', name: 'n', url: 'https://ai', kind: 'openai', apiKey: 'ak')]; await store.save(s); final made = secrets.values.keys.where((k) => !k.startsWith('sec.')).toList()..sort(); expect(made, hasLength(11), reason: '$made'); // os 3 · dav.servers · dav.pw · sftp.pw · key · kp · smb.pw · ftp.pw · ai.key diff --git a/test/upscale_job_test.dart b/test/upscale_job_test.dart index 7d190d6..06908d4 100644 --- a/test/upscale_job_test.dart +++ b/test/upscale_job_test.dart @@ -43,9 +43,9 @@ void main() { }); test('176: 해상도 올리기 메모리 설정 - 적게 20% · 보통 30% (처음 값) · 많이 45%, 저장', () { - expect(AppSettings().aiUpMemory, 'normal'); - expect(AppSettings.fromJson((AppSettings()..aiUpMemory = 'high').toJson()).aiUpMemory, 'high'); - expect(AppSettings.fromJson({'aiUpMemory': 'x'}).aiUpMemory, 'normal'); + expect(AppSettings().aiImage.upMemory, 'normal'); + expect(AppSettings.fromJson((AppSettings()..aiImage.upMemory = 'high').toJson()).aiImage.upMemory, 'high'); + expect(AppSettings.fromJson({'aiUpMemory': 'x'}).aiImage.upMemory, 'normal'); // S10 (약 15GB): 적게 320 · 보통 384 · 많이 512 expect([for (final m in ['low', 'normal', 'high']) androidUpscaleTile(memTotalMb: 15000, share: upscaleMemoryShare(m))], [320, 384, 512]); expect(upscaleTileMemoryMb(512), 6600);