설정 A 분야 5: AI 그림 · 해상도 올리기 (AiImageSettings) - 키 27 · aiServices 의 비밀 값 처리 (plainSecrets) 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-22 23:14:56 +09:00
parent 059c8cba59
commit 506803ca65
18 changed files with 302 additions and 256 deletions

View File

@ -258,8 +258,8 @@ void main() {
await show(t, size, AiImagePage(c: c)); await show(t, size, AiImagePage(c: c));
await shot(t, '123_ai_local_$tag'); await shot(t, '123_ai_local_$tag');
c.settings c.settings
..aiServices = [const AiService(id: 's', name: 'GPU PC (Tailscale)', url: 'http://100.1.2.3:7860', kind: 'a1111')] ..aiImage.services = [const AiService(id: 's', name: 'GPU PC (Tailscale)', url: 'http://100.1.2.3:7860', kind: 'a1111')]
..aiEngine = 's'; ..aiImage.engine = 's';
await show(t, size, AiImagePage(c: c)); await show(t, size, AiImagePage(c: c));
await shot(t, '123_ai_service_$tag'); await shot(t, '123_ai_service_$tag');
} }

View File

@ -448,14 +448,14 @@ Future<SdDevice?> pickAiDevice(AppController c, AiStore store, {void Function(St
final devices = await sdDevices(store); final devices = await sdDevices(store);
if (devices.isEmpty) return null; if (devices.isEmpty) return null;
final s = c.settings; 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; if (devices.length == 1) return devices.first;
final signature = devices.map((d) => d.key).join(','); final signature = devices.map((d) => d.key).join(',');
if (s.aiAutoDeviceFor == signature) { if (s.aiImage.autoDeviceFor == signature) {
final known = devices.where((d) => d.key == s.aiAutoDevice).firstOrNull; final known = devices.where((d) => d.key == s.aiImage.autoDevice).firstOrNull;
if (known != null) return known; 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; if (models == null) return devices.first;
final tmp = await Directory.systemTemp.createTemp('jj_ai_probe_'); final tmp = await Directory.systemTemp.createTemp('jj_ai_probe_');
SdDevice? best; SdDevice? best;
@ -485,7 +485,7 @@ Future<SdDevice?> pickAiDevice(AppController c, AiStore store, {void Function(St
best ??= devices.last; best ??= devices.last;
final chosen = best; final chosen = best;
await c.updateSettings((x) => x await c.updateSettings((x) => x
..aiAutoDevice = chosen.key ..aiImage.autoDevice = chosen.key
..aiAutoDeviceFor = signature); ..aiImage.autoDeviceFor = signature);
return chosen; return chosen;
} }

View File

@ -11,11 +11,11 @@ import '../core/ftp.dart';
import '../core/sftp.dart'; import '../core/sftp.dart';
import '../core/smb.dart'; import '../core/smb.dart';
import '../core/webdav.dart'; import '../core/webdav.dart';
import '../services/image_ai.dart' show AiService;
import '../services/app_shell.dart' show appIconOf; import '../services/app_shell.dart' show appIconOf;
import '../core/remote_idle.dart'; import '../core/remote_idle.dart';
import 'settings/records.dart'; import 'settings/records.dart';
import 'settings/settings_group.dart'; import 'settings/settings_group.dart';
import 'settings/ai_image_settings.dart';
import 'settings/copy_settings.dart'; import 'settings/copy_settings.dart';
import 'settings/tor_settings.dart'; import 'settings/tor_settings.dart';
import 'settings/network_settings.dart'; import 'settings/network_settings.dart';
@ -24,6 +24,7 @@ import 'settings/explorer_settings.dart';
export 'settings/records.dart'; export 'settings/records.dart';
export 'settings/settings_group.dart'; export 'settings/settings_group.dart';
export 'settings/settings_store.dart'; export 'settings/settings_store.dart';
export 'settings/ai_image_settings.dart';
export 'settings/copy_settings.dart'; export 'settings/copy_settings.dart';
export 'settings/tor_settings.dart'; export 'settings/tor_settings.dart';
export 'settings/network_settings.dart'; export 'settings/network_settings.dart';
@ -346,67 +347,23 @@ class AppSettings {
// ───────── 123 · 121: AI 그림 · 해상도 올리기 ───────── // ───────── 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<String> aiRecent = [];
/// 저장 폴더 (비어 있으면 사진 폴더의 JJ_MKVMaker_AI)
String aiSaveDir = '';
/// 사용자가 추가한 그림 서버 · 서비스 (API 키는 안전 저장소에)
List<AiService> aiServices = [];
/// 라이선스 조건에 동의한 받는 파일 (149-④: 한 번 동의하면 다시 묻지 않음)
List<String> aiAgreed = [];
// 121: 해상도 올리기 // 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<String, double> 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 만들기 · 다운로드를 계속한다 (알림에 표시). /// 백그라운드로 실행 (Android): 켜면 ← 로 닫거나 최근 앱에서 밀어도 동기화 · MKV 만들기 · 다운로드를 계속한다 (알림에 표시).
@ -474,8 +431,11 @@ class AppSettings {
/// 복사 · 이동 (파일 탐색기) · rsync · 실시간 동기화 (Rsync 화면) /// 복사 · 이동 (파일 탐색기) · rsync · 실시간 동기화 (Rsync 화면)
final copy = CopySettings(); final copy = CopySettings();
/// AI 그림 · 해상도 올리기 (123 · 121)
final aiImage = AiImageSettings();
/// 설정 분야들 (설정 A) - 저마다 자기 키를 읽고 쓴다. toJson · fromJson 은 이 목록을 돌며 합치기만 한다 /// 설정 분야들 (설정 A) - 저마다 자기 키를 읽고 쓴다. toJson · fromJson 은 이 목록을 돌며 합치기만 한다
List<SettingsGroup> get groups => [explorer, network, tor, copy]; List<SettingsGroup> get groups => [explorer, network, tor, copy, aiImage];
/// [plainSecrets]: 안전 저장소를 쓸 수 없을 때만 (예전 파일에 있던 비밀 값을 잃지 않게) 예전처럼 파일에 둔다 /// [plainSecrets]: 안전 저장소를 쓸 수 없을 때만 (예전 파일에 있던 비밀 값을 잃지 않게) 예전처럼 파일에 둔다
Map<String, Object?> toJson({bool plainSecrets = false}) { Map<String, Object?> toJson({bool plainSecrets = false}) {
@ -578,35 +538,6 @@ class AppSettings {
'zipComic': zipComic, 'zipComic': zipComic,
// 예전 기본값 (만화 보기 켜짐) 은 지시와 반대였으므로 한 번 꺼진 상태 (목록) 로 // 예전 기본값 (만화 보기 켜짐) 은 지시와 반대였으므로 한 번 꺼진 상태 (목록) 로
'zipComicV2': true, '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': [ 'webdavServers': [
for (final x in webdavServers) {...x.toJson(), if (plainSecrets) 'password': x.password}, for (final x in webdavServers) {...x.toJson(), if (plainSecrets) 'password': x.password},
], ],
@ -761,41 +692,6 @@ class AppSettings {
..allFilesHintShown = j['allFilesHint2'] == true ..allFilesHintShown = j['allFilesHint2'] == true
..subtitleScale = ((j['subtitleScale'] as num?)?.toDouble() ?? 1.0).clamp(0.5, 2.5) ..subtitleScale = ((j['subtitleScale'] as num?)?.toDouble() ?? 1.0).clamp(0.5, 2.5)
..zipComic = j['zipComic'] == true && j['zipComicV2'] == true ..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 = [ ..webdavServers = [
for (final x in (j['webdavServers'] as List?) ?? const []) for (final x in (j['webdavServers'] as List?) ?? const [])
if (x is Map) DavServer.fromJson(x), if (x is Map) DavServer.fromJson(x),

View File

@ -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<String> recent = [];
/// 저장 폴더 (비어 있으면 사진 폴더의 JJ_MKVMaker_AI)
String saveDir = '';
/// 사용자가 추가한 그림 서버 · 서비스 (API 키는 안전 저장소에)
List<AiService> services = [];
/// 라이선스 조건에 동의한 받는 파일 (149-④: 한 번 동의하면 다시 묻지 않음)
List<String> 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<String, double> 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<String, Object?> 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<String, dynamic> 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),
];
}
}

View File

@ -67,7 +67,7 @@ class SettingsStore {
if (_os(s, e.key).isNotEmpty) e.value: _os(s, e.key), if (_os(s, e.key).isNotEmpty) e.value: _os(s, e.key),
for (final x in s.webdavServers) for (final x in s.webdavServers)
if (x.password.isNotEmpty) _davKey(x.id): x.password, 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, if (x.apiKey.isNotEmpty) _aiKey(x.id): x.apiKey,
for (final x in s.sftpServers) ...{ for (final x in s.sftpServers) ...{
if (x.password.isNotEmpty) _sftpPw(x.id): x.password, if (x.password.isNotEmpty) _sftpPw(x.id): x.password,
@ -116,8 +116,8 @@ class SettingsStore {
for (final x in s.webdavServers) for (final x in s.webdavServers)
x.password.isNotEmpty ? x : x.copyWith(password: stored[_davKey(x.id)] ?? ''), x.password.isNotEmpty ? x : x.copyWith(password: stored[_davKey(x.id)] ?? ''),
]; ];
s.aiServices = [ s.aiImage.services = [
for (final x in s.aiServices) x.apiKey.isNotEmpty ? x : x.copyWith(apiKey: stored[_aiKey(x.id)] ?? ''), for (final x in s.aiImage.services) x.apiKey.isNotEmpty ? x : x.copyWith(apiKey: stored[_aiKey(x.id)] ?? ''),
]; ];
s.sftpServers = [ s.sftpServers = [
for (final x in s.sftpServers) for (final x in s.sftpServers)
@ -166,7 +166,7 @@ class SettingsStore {
for (final x in s.webdavServers) { for (final x in s.webdavServers) {
if (x.password.isNotEmpty) _filePlain[_davKey(x.id)] = x.password; 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; if (x.apiKey.isNotEmpty) _filePlain[_aiKey(x.id)] = x.apiKey;
} }
_fill(s, stored); _fill(s, stored);

View File

@ -59,14 +59,14 @@ String aiSaveDir(String setting) {
class _AiImagePageState extends State<AiImagePage> with RouteAware { class _AiImagePageState extends State<AiImagePage> with RouteAware {
AppController get c => widget.c; 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(); 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'); final _seed = TextEditingController(text: '-1');
late String? _init = widget.initImage; late String? _init = widget.initImage;
/// 159: 지난 결과를 되살린다 (설정의 목록 + 그림 옆 json 의 시드). 지워진 파일은 빼고 보여 준다 /// 159: 지난 결과를 되살린다 (설정의 목록 + 그림 옆 json 의 시드). 지워진 파일은 빼고 보여 준다
late final _results = <GeneratedImage>[ late final _results = <GeneratedImage>[
for (final path in c.settings.aiRecent) for (final path in c.settings.aiImage.recent)
if (File(path).existsSync()) GeneratedImage(path, _seedOf(path)), if (File(path).existsSync()) GeneratedImage(path, _seedOf(path)),
]; ];
String? _error; String? _error;
@ -83,7 +83,7 @@ class _AiImagePageState extends State<AiImagePage> with RouteAware {
/// 목록을 설정에 남긴다 (최근 100장) /// 목록을 설정에 남긴다 (최근 100장)
void _saveRecent() { void _saveRecent() {
final paths = [for (final g in _results) g.path].take(100).toList(); 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: 엔진 원문 ([자세히] 를 눌러야 보임) · 취소 (회색 한 줄, 오류 아님) /// 150: 엔진 원문 ([자세히] 를 눌러야 보임) · 취소 (회색 한 줄, 오류 아님)
@ -115,7 +115,7 @@ class _AiImagePageState extends State<AiImagePage> with RouteAware {
super.dispose(); 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; bool get _local => _service == null;
@ -135,19 +135,19 @@ class _AiImagePageState extends State<AiImagePage> with RouteAware {
if (!_modelsReady) return; if (!_modelsReady) return;
} }
await c.updateSettings((x) => x await c.updateSettings((x) => x
..aiNegative = _negative.text ..aiImage.negative = _negative.text
..aiPrompt = _prompt.text); ..aiImage.prompt = _prompt.text);
final req = ImageGenRequest( final req = ImageGenRequest(
prompt: _prompt.text.trim(), prompt: _prompt.text.trim(),
negative: _negative.text.trim(), negative: _negative.text.trim(),
width: s.aiWidth, width: s.aiImage.width,
height: s.aiHeight, height: s.aiImage.height,
steps: s.aiSteps, steps: s.aiImage.steps,
cfg: s.aiCfg, cfg: s.aiImage.cfg,
seed: int.tryParse(_seed.text.trim()) ?? -1, seed: int.tryParse(_seed.text.trim()) ?? -1,
count: s.aiCount, count: s.aiImage.count,
initImage: _init, initImage: _init,
strength: s.aiStrength, strength: s.aiImage.strength,
model: _local ? 'sd15-lcm' : (_service!.model.isEmpty ? _service!.kind : _service!.model), model: _local ? 'sd15-lcm' : (_service!.model.isEmpty ? _service!.kind : _service!.model),
); );
setState(() { setState(() {
@ -162,11 +162,11 @@ class _AiImagePageState extends State<AiImagePage> with RouteAware {
if (_local) { if (_local) {
final device = await pickAiDevice(c, store, onStep: (t) => mounted ? setState(() => _step = t) : null); final device = await pickAiDevice(c, store, onStep: (t) => mounted ? setState(() => _step = t) : null);
if (device == null) throw ImageAiException(tr('AI 엔진이 없습니다. 환경 설정 > AI 그림에서 받아 주세요.')); 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 { } else {
engine = RemoteImageEngine(_service!); 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; if (!mounted) return;
setState(() => _results.insertAll(0, made)); setState(() => _results.insertAll(0, made));
_saveRecent(); _saveRecent();
@ -247,14 +247,14 @@ class _AiImagePageState extends State<AiImagePage> with RouteAware {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Wrap(spacing: 12, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ Wrap(spacing: 12, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [
_drop<String>(tr('크기'), '${s.aiWidth}x${s.aiHeight}', const ['512x512', '512x768', '768x512', '768x768', '1024x1024'], _drop<String>(tr('크기'), '${s.aiImage.width}x${s.aiImage.height}', const ['512x512', '512x768', '768x512', '768x768', '1024x1024'],
(v) => c.updateSettings((x) { (v) => c.updateSettings((x) {
final wh = v.split('x'); final wh = v.split('x');
x.aiWidth = int.parse(wh[0]); x.aiImage.width = int.parse(wh[0]);
x.aiHeight = int.parse(wh[1]); x.aiImage.height = int.parse(wh[1]);
})), })),
_drop<int>(tr('단계'), s.aiSteps, const [2, 4, 6, 8, 12, 20, 30], (v) => c.updateSettings((x) => x.aiSteps = v)), _drop<int>(tr('단계'), s.aiImage.steps, const [2, 4, 6, 8, 12, 20, 30], (v) => c.updateSettings((x) => x.aiImage.steps = v)),
_drop<int>(tr('개수'), s.aiCount, const [1, 2, 4, 8, 16], (v) => c.updateSettings((x) => x.aiCount = v)), _drop<int>(tr('개수'), s.aiImage.count, const [1, 2, 4, 8, 16], (v) => c.updateSettings((x) => x.aiImage.count = v)),
// 141: 이름이 잘리지 않게 짧게 · 넉넉히 (설명은 아래 줄) // 141: 이름이 잘리지 않게 짧게 · 넉넉히 (설명은 아래 줄)
SizedBox( SizedBox(
width: 190, width: 190,
@ -266,7 +266,7 @@ class _AiImagePageState extends State<AiImagePage> with RouteAware {
), ),
]), ]),
const SizedBox(height: 8), const SizedBox(height: 8),
_initRow(s.aiStrength), _initRow(s.aiImage.strength),
const SizedBox(height: 12), const SizedBox(height: 12),
if (jobs.busy) ...[ if (jobs.busy) ...[
LinearProgressIndicator(value: jobs.progress), LinearProgressIndicator(value: jobs.progress),
@ -299,7 +299,7 @@ class _AiImagePageState extends State<AiImagePage> with RouteAware {
]), ]),
), ),
const SizedBox(height: 12), 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), const SizedBox(height: 6),
Wrap(spacing: 8, runSpacing: 8, children: [for (final g in _results) _thumb(g)]), Wrap(spacing: 8, runSpacing: 8, children: [for (final g in _results) _thumb(g)]),
]), ]),
@ -319,12 +319,12 @@ class _AiImagePageState extends State<AiImagePage> with RouteAware {
Flexible( Flexible(
child: DropdownButton<String>( child: DropdownButton<String>(
isExpanded: true, isExpanded: true,
value: _service == null ? 'local' : s.aiEngine, value: _service == null ? 'local' : s.aiImage.engine,
items: [ items: [
DropdownMenuItem(value: 'local', child: Text(tr('이 기기 (SD1.5 + LCM)'))), 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( IconButton(
@ -357,7 +357,7 @@ class _AiImagePageState extends State<AiImagePage> with RouteAware {
value: strength, value: strength,
min: 0.05, min: 0.05,
max: 1, max: 1,
onChanged: (v) => c.updateSettings((x) => x.aiStrength = v), onChanged: (v) => c.updateSettings((x) => x.aiImage.strength = v),
), ),
), ),
], ],

View File

@ -83,13 +83,13 @@ class _AiSettingsState extends State<AiSettings> {
subtitle: Text(upscaleMemoryLines(mem).join('\n')), subtitle: Text(upscaleMemoryLines(mem).join('\n')),
isThreeLine: true, isThreeLine: true,
trailing: DropdownButton<String>( trailing: DropdownButton<String>(
value: c.settings.aiUpMemory, value: c.settings.aiImage.upMemory,
items: [ items: [
DropdownMenuItem(value: 'low', child: Text(tr('적게 (느림)'))), DropdownMenuItem(value: 'low', child: Text(tr('적게 (느림)'))),
DropdownMenuItem(value: 'normal', child: Text(tr('보통'))), DropdownMenuItem(value: 'normal', child: Text(tr('보통'))),
DropdownMenuItem(value: 'high', 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<AiSettings> {
/// 받기 전에 라이선스 조건 (있으면) 을 보여 주고 동의를 받는다 /// 받기 전에 라이선스 조건 (있으면) 을 보여 주고 동의를 받는다
Future<bool> _agree(AiFile f) async { Future<bool> _agree(AiFile f) async {
// 149-④: 한 번 동의했으면 다시 묻지 않는다 // 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<bool>( final ok = await showDialog<bool>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
@ -127,7 +127,7 @@ class _AiSettingsState extends State<AiSettings> {
], ],
), ),
); );
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; return ok == true;
} }
@ -198,42 +198,42 @@ class _AiSettingsState extends State<AiSettings> {
SettingTile( SettingTile(
leading: const Icon(Icons.memory), leading: const Icon(Icons.memory),
title: Text(tr('처리 장치')), title: Text(tr('처리 장치')),
subtitle: Text(s.aiDevice == 'auto' subtitle: Text(s.aiImage.device == 'auto'
? (s.aiAutoDevice.isEmpty ? (s.aiImage.autoDevice.isEmpty
? tr('자동: 처음 그릴 때 짧게 재서 가장 빠른 장치를 고릅니다') ? 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('직접 고름')), : tr('직접 고름')),
trailing: DropdownButton<String>( trailing: DropdownButton<String>(
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: [ items: [
DropdownMenuItem(value: 'auto', child: Text(tr('자동 (가장 빠른 것)'))), DropdownMenuItem(value: 'auto', child: Text(tr('자동 (가장 빠른 것)'))),
for (final d in _devices) DropdownMenuItem(value: d.key, child: Text(d.label)), for (final d in _devices) DropdownMenuItem(value: d.key, child: Text(d.label)),
], ],
onChanged: (v) => c.updateSettings((x) => x onChanged: (v) => c.updateSettings((x) => x
..aiDevice = v ?? 'auto' ..aiImage.device = v ?? 'auto'
..aiAutoDeviceFor = ''), // 다시 고르면 다음에 다시 잼 ..aiImage.autoDeviceFor = ''), // 다시 고르면 다음에 다시 잼
), ),
), ),
SwitchListTile( SwitchListTile(
value: s.aiTaesd, value: s.aiImage.taesd,
onChanged: (v) => c.updateSettings((x) => x.aiTaesd = v), onChanged: (v) => c.updateSettings((x) => x.aiImage.taesd = v),
title: Text(tr('빠른 디코더 (TAESD)')), title: Text(tr('빠른 디코더 (TAESD)')),
subtitle: Text(tr('그림이 거의 같고 마지막 단계가 10배쯤 빠릅니다 (받아 두었을 때)')), subtitle: Text(tr('그림이 거의 같고 마지막 단계가 10배쯤 빠릅니다 (받아 두었을 때)')),
), ),
SettingTile( SettingTile(
leading: const Icon(Icons.folder_outlined), leading: const Icon(Icons.folder_outlined),
title: Text(tr('저장 폴더')), title: Text(tr('저장 폴더')),
subtitle: Text(aiSaveDir(s.aiSaveDir)), subtitle: Text(aiSaveDir(s.aiImage.saveDir)),
trailing: Wrap(spacing: 6, children: [ trailing: Wrap(spacing: 6, children: [
OutlinedButton( OutlinedButton(
onPressed: () async { onPressed: () async {
final d = await pickFolder(context, tr('AI 그림 저장 폴더'), aiSaveDir(s.aiSaveDir)); final d = await pickFolder(context, tr('AI 그림 저장 폴더'), aiSaveDir(s.aiImage.saveDir));
if (d != null) await c.updateSettings((x) => x.aiSaveDir = d); if (d != null) await c.updateSettings((x) => x.aiImage.saveDir = d);
}, },
child: Text(tr('바꾸기')), child: Text(tr('바꾸기')),
), ),
if (s.aiSaveDir.isNotEmpty) if (s.aiImage.saveDir.isNotEmpty)
TextButton(onPressed: () => c.updateSettings((x) => x.aiSaveDir = ''), child: Text(tr('처음대로'))), TextButton(onPressed: () => c.updateSettings((x) => x.aiImage.saveDir = ''), child: Text(tr('처음대로'))),
]), ]),
), ),
const Divider(), const Divider(),
@ -243,13 +243,13 @@ class _AiSettingsState extends State<AiSettings> {
title: Text(tr('해상도 올리기 모델')), title: Text(tr('해상도 올리기 모델')),
subtitle: Text(tr('자동: 그래픽카드 (GPU) 가 있으면 사진용, CPU 뿐이면 가볍고 빠른 만화용. 원본은 덮어쓰지 않고 새 파일로 저장합니다')), subtitle: Text(tr('자동: 그래픽카드 (GPU) 가 있으면 사진용, CPU 뿐이면 가볍고 빠른 만화용. 원본은 덮어쓰지 않고 새 파일로 저장합니다')),
trailing: DropdownButton<String>( trailing: DropdownButton<String>(
value: s.aiUpModel, value: s.aiImage.upModel,
items: [ items: [
DropdownMenuItem(value: 'auto', child: Text(tr('자동'))), DropdownMenuItem(value: 'auto', child: Text(tr('자동'))),
DropdownMenuItem(value: 'photo', child: Text(tr('사진'))), DropdownMenuItem(value: 'photo', child: Text(tr('사진'))),
DropdownMenuItem(value: 'anime', 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 는 엔진이 정함) // 176: Android CPU - 메모리를 많이 쓸수록 빠르다 (그래픽카드 · Windows 는 엔진이 정함)
@ -258,13 +258,13 @@ class _AiSettingsState extends State<AiSettings> {
leading: const Icon(Icons.zoom_out_map), leading: const Icon(Icons.zoom_out_map),
title: Text(tr('배율')), title: Text(tr('배율')),
trailing: DropdownButton<int>( trailing: DropdownButton<int>(
value: s.aiUpScale, value: s.aiImage.upScale,
// 1배 = 크기는 그대로 두고 모델로 선명하게만 (이름_x1) // 1배 = 크기는 그대로 두고 모델로 선명하게만 (이름_x1)
items: [ items: [
for (final n in const [1, 2, 3, 4]) for (final n in const [1, 2, 3, 4])
DropdownMenuItem(value: n, child: Text(n == 1 ? tr('1배 (선명하게)') : trf('{0}배', [n]))), 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): [품질 올리기] 한 단추 - 처음 값 끔 // 관리자 판단 (10/17): [품질 올리기] 한 단추 - 처음 값 끔
@ -273,75 +273,75 @@ class _AiSettingsState extends State<AiSettings> {
title: Text(tr('품질 올리기 단추 보이기')), title: Text(tr('품질 올리기 단추 보이기')),
subtitle: Text(tr('보기 화면에 [품질 올리기]: AI 그림 (그림 → 그림, 약하게) 으로 다듬은 뒤 해상도 올리기를 이어서. ' subtitle: Text(tr('보기 화면에 [품질 올리기]: AI 그림 (그림 → 그림, 약하게) 으로 다듬은 뒤 해상도 올리기를 이어서. '
'새 파일 (이름_q) 로 저장하며 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( SettingTile(
leading: const Icon(Icons.tune), leading: const Icon(Icons.tune),
title: Text(tr('품질 올리기 다듬기 정도')), title: Text(tr('품질 올리기 다듬기 정도')),
subtitle: Text(tr('강할수록 많이 다듬지만 원본과 달라질 수 있습니다')), subtitle: Text(tr('강할수록 많이 다듬지만 원본과 달라질 수 있습니다')),
trailing: DropdownButton<String>( trailing: DropdownButton<String>(
value: s.aiQualityStrength, value: s.aiImage.qualityStrength,
items: [ items: [
DropdownMenuItem(value: 'weak', child: Text(tr('약하게 (0.15)'))), DropdownMenuItem(value: 'weak', child: Text(tr('약하게 (0.15)'))),
DropdownMenuItem(value: 'normal', child: Text(tr('보통 (0.25, 기본)'))), DropdownMenuItem(value: 'normal', child: Text(tr('보통 (0.25, 기본)'))),
DropdownMenuItem(value: 'strong', child: Text(tr('강하게 (0.4)'))), 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( SettingTile(
leading: const Icon(Icons.image_outlined), leading: const Icon(Icons.image_outlined),
title: Text(tr('저장 형식')), 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<String>( trailing: DropdownButton<String>(
value: s.aiUpFormat, value: s.aiImage.upFormat,
items: [ items: [
DropdownMenuItem(value: 'same', child: Text(tr('원본과 같은 형식'))), DropdownMenuItem(value: 'same', child: Text(tr('원본과 같은 형식'))),
const DropdownMenuItem(value: 'png', child: Text('PNG')), const DropdownMenuItem(value: 'png', child: Text('PNG')),
const DropdownMenuItem(value: 'jpg', child: Text('JPG')), 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(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Slider( child: Slider(
value: s.aiUpJpgQuality.toDouble(), value: s.aiImage.upJpgQuality.toDouble(),
min: 50, min: 50,
max: 100, max: 100,
divisions: 50, divisions: 50,
label: '${s.aiUpJpgQuality}', label: '${s.aiImage.upJpgQuality}',
onChanged: (v) => c.updateSettings((x) => x.aiUpJpgQuality = v.round()), onChanged: (v) => c.updateSettings((x) => x.aiImage.upJpgQuality = v.round()),
), ),
), ),
SettingTile( SettingTile(
leading: const Icon(Icons.drive_file_move_outline), leading: const Icon(Icons.drive_file_move_outline),
title: Text(tr('저장 위치')), 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: [ trailing: Wrap(spacing: 6, children: [
OutlinedButton( OutlinedButton(
onPressed: () async { onPressed: () async {
final d = await pickFolder(context, tr('해상도를 올린 그림 저장 폴더'), s.aiUpDir.isEmpty ? null : s.aiUpDir); final d = await pickFolder(context, tr('해상도를 올린 그림 저장 폴더'), s.aiImage.upDir.isEmpty ? null : s.aiImage.upDir);
if (d != null) await c.updateSettings((x) => x.aiUpDir = d); if (d != null) await c.updateSettings((x) => x.aiImage.upDir = d);
}, },
child: Text(tr('폴더 고르기')), child: Text(tr('폴더 고르기')),
), ),
if (s.aiUpDir.isNotEmpty) if (s.aiImage.upDir.isNotEmpty)
TextButton(onPressed: () => c.updateSettings((x) => x.aiUpDir = ''), child: Text(tr('원본 옆으로'))), TextButton(onPressed: () => c.updateSettings((x) => x.aiImage.upDir = ''), child: Text(tr('원본 옆으로'))),
]), ]),
), ),
SettingTile( SettingTile(
leading: const Icon(Icons.visibility_outlined), leading: const Icon(Icons.visibility_outlined),
title: Text(tr('저장한 뒤')), title: Text(tr('저장한 뒤')),
trailing: DropdownButton<String>( trailing: DropdownButton<String>(
value: s.aiUpAfter, value: s.aiImage.upAfter,
items: [ items: [
DropdownMenuItem(value: 'upscaled', child: Text(tr('올린 것으로 계속 보기'))), DropdownMenuItem(value: 'upscaled', child: Text(tr('올린 것으로 계속 보기'))),
DropdownMenuItem(value: 'original', 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(), const Divider(),
@ -356,7 +356,7 @@ class _AiSettingsState extends State<AiSettings> {
label: Text(tr('서비스 추가')), label: Text(tr('서비스 추가')),
), ),
), ),
for (final x in s.aiServices) for (final x in s.aiImage.services)
Padding( Padding(
padding: const EdgeInsets.only(left: 16), padding: const EdgeInsets.only(left: 16),
child: SettingTile( child: SettingTile(
@ -379,8 +379,8 @@ class _AiSettingsState extends State<AiSettings> {
title: trf('"{0}" 을(를) 지울까요?', [x.label]), body: tr('서비스 설정과 저장된 API 키를 지웁니다.'), ok: tr('지우기')); title: trf('"{0}" 을(를) 지울까요?', [x.label]), body: tr('서비스 설정과 저장된 API 키를 지웁니다.'), ok: tr('지우기'));
if (!ok) return; if (!ok) return;
await c.updateSettings((y) { await c.updateSettings((y) {
y.aiServices = [for (final z in y.aiServices) if (z.id != x.id) z]; y.aiImage.services = [for (final z in y.aiImage.services) if (z.id != x.id) z];
if (y.aiEngine == x.id) y.aiEngine = 'local'; if (y.aiImage.engine == x.id) y.aiImage.engine = 'local';
}); });
}, },
), ),
@ -560,8 +560,8 @@ Future<void> editAiService(BuildContext context, AppController c, {AiService? ol
x.dispose(); x.dispose();
} }
if (saved == null) return; if (saved == null) return;
await c.updateSettings((x) => x.aiServices = [ await c.updateSettings((x) => x.aiImage.services = [
for (final y in x.aiServices) if (y.id != saved.id) y, for (final y in x.aiImage.services) if (y.id != saved.id) y,
saved, saved,
]); ]);
} }

View File

@ -26,11 +26,11 @@ import 'notice.dart';
UpscaleOptions upscaleOptionsOf(AppController c, {String? outDir}) { UpscaleOptions upscaleOptionsOf(AppController c, {String? outDir}) {
final s = c.settings; final s = c.settings;
return UpscaleOptions( return UpscaleOptions(
model: s.aiUpModel, model: s.aiImage.upModel,
scale: s.aiUpScale, scale: s.aiImage.upScale,
format: s.aiUpFormat, format: s.aiImage.upFormat,
jpgQuality: s.aiUpJpgQuality, jpgQuality: s.aiImage.upJpgQuality,
outDir: outDir ?? s.aiUpDir, outDir: outDir ?? s.aiImage.upDir,
); );
} }
@ -40,9 +40,9 @@ Future<(String input, String nameFrom, String outDir)?> readerPageFile(AppContro
final s = c.settings; final s = c.settings;
if (src is ImageFilesSource) { if (src is ImageFilesSource) {
final path = src.paths[i]; 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); 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) { if (src is ZipImagesSource) {
final e = src.entries[i]; 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 name = p.basename(e.name);
final f = File(p.join(tmp.path, name))..writeAsBytesSync(e.readBytes() ?? const []); final f = File(p.join(tmp.path, name))..writeAsBytesSync(e.readBytes() ?? const []);
final beside = isDav(src.path) final beside = isDav(src.path)
? aiSaveDir(s.aiSaveDir) ? aiSaveDir(s.aiImage.saveDir)
: p.join(p.dirname(src.path), '${p.basenameWithoutExtension(src.path)}_AI'); : 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 (f.path, p.join(out, name), out);
} }
return null; // PDF 등은 아직 return null; // PDF 등은 아직
@ -68,13 +68,13 @@ Future<AiUpscaler?> _prepare(BuildContext context, AppController c) async {
final store = AiStore.instance ??= AiStore(Directory.systemTemp.path); final store = AiStore.instance ??= AiStore(Directory.systemTemp.path);
Future<AiUpscaler?> ready() => Future<AiUpscaler?> ready() =>
prepareUpscaler(store, 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(); final up = await ready();
if (up != null || !context.mounted) return up; if (up != null || !context.mounted) return up;
// 엔진 (Windows 는 받아야 함) · 모델 중 없는 것 // 엔진 (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; 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 anime = aiFile('esrgan-anime6b'), photo = aiFile('esrgan-x4plus');
final choices = setting == 'photo' final choices = setting == 'photo'
? [photo] ? [photo]
@ -166,7 +166,7 @@ Future<void> _recordSpeed(AppController c, AiUpscaler up, String input, Duration
final mp = megapixels(await File(input).readAsBytes()); final mp = megapixels(await File(input).readAsBytes());
if (mp == null || mp <= 0) return; if (mp == null || mp <= 0) return;
final v = took.inMilliseconds / 1000 / mp; 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 (_) {} } catch (_) {}
} }
@ -174,7 +174,7 @@ Future<void> _recordSpeed(AppController c, AiUpscaler up, String input, Duration
Future<String?> _estimateText(AppController c, AiUpscaler up, String input) async { Future<String?> _estimateText(AppController c, AiUpscaler up, String input) async {
try { try {
final mp = megapixels(await File(input).readAsBytes()); 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)]); return d == null ? null : trf('이 기기에서 한 장 약 {0} (지난번 걸린 시간 기준)', [AiJobs.durationText(d)]);
} catch (_) { } catch (_) {
return null; return null;
@ -199,7 +199,7 @@ Future<String?> upscaleOne(BuildContext context, AppController c, {required Stri
var background = false; var background = false;
var dialogOpen = true; var dialogOpen = true;
// 관리자: CPU 뿐이라 자동이 만화용을 고르면 사진은 질감이 뭉개질 수 있다 - 알리고 바로 바꿀 수 있게 // 관리자: 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); final estimate = await _estimateText(c, up, input);
if (!context.mounted) return null; if (!context.mounted) return null;
final nav = Navigator.of(context); final nav = Navigator.of(context);
@ -213,7 +213,7 @@ Future<String?> upscaleOne(BuildContext context, AppController c, {required Stri
child: ValueListenableBuilder<double?>( child: ValueListenableBuilder<double?>(
valueListenable: progress, valueListenable: progress,
builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ 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), const SizedBox(height: 10),
LinearProgressIndicator(value: v), LinearProgressIndicator(value: v),
const SizedBox(height: 4), const SizedBox(height: 4),
@ -284,7 +284,7 @@ Future<String?> upscaleOne(BuildContext context, AppController c, {required Stri
} }
if (dialogOpen) nav.pop(); // 진행 창 if (dialogOpen) nav.pop(); // 진행 창
if (toPhoto && context.mounted) { if (toPhoto && context.mounted) {
await c.updateSettings((x) => x.aiUpModel = 'photo'); await c.updateSettings((x) => x.aiImage.upModel = 'photo');
if (!context.mounted) return null; if (!context.mounted) return null;
return upscaleOne(context, c, input: input, nameFrom: nameFrom, outDir: outDir); return upscaleOne(context, c, input: input, nameFrom: nameFrom, outDir: outDir);
} }
@ -369,7 +369,7 @@ Future<void> upscaleAll(BuildContext context, AppController c, ReaderSource src)
try { try {
final first = n == 0 ? null : await readerPageFile(c, src, 0); final first = n == 0 ? null : await readerPageFile(c, src, 0);
final mp = first == null ? null : megapixels(await File(first.$1).readAsBytes()); 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) { if (d != null) {
estimate = trf('이 기기에서 한 장 약 {0} · 모두 약 {1} (지난번 걸린 시간 기준)', estimate = trf('이 기기에서 한 장 약 {0} · 모두 약 {1} (지난번 걸린 시간 기준)',
[AiJobs.durationText(d), AiJobs.durationText(d * n)]); [AiJobs.durationText(d), AiJobs.durationText(d * n)]);
@ -380,9 +380,9 @@ Future<void> upscaleAll(BuildContext context, AppController c, ReaderSource src)
context, context,
title: trf('{0}장 모두 해상도 올리기', [n]), title: trf('{0}장 모두 해상도 올리기', [n]),
body: [ body: [
c.settings.aiUpScale == 1 c.settings.aiImage.upScale == 1
? tr('크기는 그대로 두고 선명하게 해 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.') ? tr('크기는 그대로 두고 선명하게 해 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.')
: trf('{0}배로 올려 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.', [c.settings.aiUpScale]), : trf('{0}배로 올려 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.', [c.settings.aiImage.upScale]),
?estimate, ?estimate,
].join('\n\n'), ].join('\n\n'),
ok: tr('시작'), ok: tr('시작'),
@ -433,7 +433,7 @@ Future<String?> qualityUp(BuildContext context, AppController c, {required Strin
return null; return null;
} }
final store = AiStore.instance ??= AiStore(Directory.systemTemp.path); 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) { if (models == null) {
await showDialog<void>( await showDialog<void>(
context: context, context: context,
@ -506,10 +506,10 @@ Future<String?> qualityUp(BuildContext context, AppController c, {required Strin
negative: 'blurry, lowres, noise, jpeg artifacts, deformed', negative: 'blurry, lowres, noise, jpeg artifacts, deformed',
width: w, width: w,
height: h, height: h,
steps: c.settings.aiSteps, steps: c.settings.aiImage.steps,
cfg: c.settings.aiCfg, cfg: c.settings.aiImage.cfg,
initImage: input, initImage: input,
strength: qualityStrength(c.settings.aiQualityStrength), strength: qualityStrength(c.settings.aiImage.qualityStrength),
), ),
outDir: tmp.path, outDir: tmp.path,
baseName: 'q', baseName: 'q',

View File

@ -145,7 +145,7 @@ Future<void> clearStoredSecrets(AppController c) => c.updateSettings((s) => s
..smbServers = [for (final x in s.smbServers) x.copyWith(password: '')] ..smbServers = [for (final x in s.smbServers) x.copyWith(password: '')]
..ftpServers = [for (final x in s.ftpServers) x.copyWith(password: '')] ..ftpServers = [for (final x in s.ftpServers) x.copyWith(password: '')]
// AI 서비스 API 키도 (문구 "비밀번호 · API 키" 와 같게 - 예전에는 남았다, 관리자 결정 10/17) // 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 = '' ..openSubtitlesKey = ''
..openSubtitlesPassword = '').then((_) async { ..openSubtitlesPassword = '').then((_) async {
// 관리자 판단 (10/21): 다른 판 (새 판) 이 안전 저장소에 넣은 이 판이 모르는 비밀번호 · 키도 (마스터 · 최상은 남김) // 관리자 판단 (10/21): 다른 판 (새 판) 이 안전 저장소에 넣은 이 판이 모르는 비밀번호 · 키도 (마스터 · 최상은 남김)

View File

@ -21,7 +21,7 @@ Future<String?> upscaleReaderPage(BuildContext context, AppController c, ReaderS
final saved = await upscaleOne(context, c, input: file.$1, nameFrom: file.$2, outDir: file.$3); final saved = await upscaleOne(context, c, input: file.$1, nameFrom: file.$2, outDir: file.$3);
if (saved == null || !context.mounted) return null; if (saved == null || !context.mounted) return null;
final s = src; 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; return null;
} }

View File

@ -124,7 +124,7 @@ List<ReaderBarItem> readerBarItems(BuildContext context, AppController c, Reader
one(ReaderAct(const Icon(Icons.hd_outlined), tr('AI 해상도 올리기 (이 장)'), on.upscalePage)), 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))), one(ReaderAct(const Icon(Icons.burst_mode_outlined), tr('AI 해상도 올리기 (모든 장)'), () => upscaleAll(context, c, src))),
// 관리자 판단 (10/17): [품질 올리기] - 설정으로 켰을 때만 // 관리자 판단 (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 그림 (그림 → 그림, 원본은 그대로) // 123: 이 그림을 바탕으로 AI 그림 (그림 → 그림, 원본은 그대로)
if (st.aiSource case final path?) if (st.aiSource case final path?)

View File

@ -380,13 +380,13 @@ void main() {
final secrets = MemorySecretStore(); final secrets = MemorySecretStore();
final st = SettingsStore(p.join(dir.path, 'settings.json'), secrets); final st = SettingsStore(p.join(dir.path, 'settings.json'), secrets);
final s = await st.load(); 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); await st.save(s);
expect(File(p.join(dir.path, 'settings.json')).readAsStringSync(), isNot(contains('sk-secret'))); expect(File(p.join(dir.path, 'settings.json')).readAsStringSync(), isNot(contains('sk-secret')));
expect(secrets.values['ai.key.x'], 'sk-secret'); expect(secrets.values['ai.key.x'], 'sk-secret');
final again = await SettingsStore(p.join(dir.path, 'settings.json'), secrets).load(); final again = await SettingsStore(p.join(dir.path, 'settings.json'), secrets).load();
expect(again.aiServices.single.apiKey, 'sk-secret'); expect(again.aiImage.services.single.apiKey, 'sk-secret');
s.aiServices = []; s.aiImage.services = [];
await st.save(s); await st.save(s);
expect(secrets.values.containsKey('ai.key.x'), isFalse, reason: '지운 서비스의 키는 저장소에서도 지움'); expect(secrets.values.containsKey('ai.key.x'), isFalse, reason: '지운 서비스의 키는 저장소에서도 지움');
}); });
@ -475,15 +475,15 @@ void main() {
test('123: AI 설정 처음 값 · 저장 · 예전 설정에 AI 그림 화면을 한 번 넣음', () { test('123: AI 설정 처음 값 · 저장 · 예전 설정에 AI 그림 화면을 한 번 넣음', () {
final s = AppSettings(); 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)); ('local', 'auto', true, 512, 512, 4, 1.0, 1));
expect(s.components, contains('aiimage')); expect(s.components, contains('aiimage'));
final b = AppSettings.fromJson((AppSettings() final b = AppSettings.fromJson((AppSettings()
..aiWidth = 768 ..aiImage.width = 768
..aiSteps = 8 ..aiImage.steps = 8
..aiTaesd = false) ..aiImage.taesd = false)
.toJson()); .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']}); final old = AppSettings.fromJson({'components': ['mkv', 'browser']});
expect(old.components, contains('aiimage')); expect(old.components, contains('aiimage'));
expect(old.migrated, contains('aiImage')); expect(old.migrated, contains('aiImage'));

View File

@ -32,8 +32,8 @@ void main() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
if (service != null) { if (service != null) {
c.settings c.settings
..aiServices = [service] ..aiImage.services = [service]
..aiEngine = service.id; ..aiImage.engine = service.id;
} }
await t.pumpWidget(MaterialApp(home: AiImagePage(c: c))); await t.pumpWidget(MaterialApp(home: AiImagePage(c: c)));
await t.pump(); await t.pump();

View File

@ -22,16 +22,16 @@ void main() {
tearDown(() => tmp.deleteSync(recursive: true)); tearDown(() => tmp.deleteSync(recursive: true));
test('설정: 배율 1 을 받아들이고 처음 값은 2 · 품질 올리기 단추는 처음 끔 · 저장', () { test('설정: 배율 1 을 받아들이고 처음 값은 2 · 품질 올리기 단추는 처음 끔 · 저장', () {
expect(AppSettings().aiUpScale, 2); expect(AppSettings().aiImage.upScale, 2);
expect(AppSettings.fromJson({'aiUpScale': 1}).aiUpScale, 1); expect(AppSettings.fromJson({'aiUpScale': 1}).aiImage.upScale, 1);
expect(AppSettings.fromJson({'aiUpScale': 7}).aiUpScale, 2); expect(AppSettings.fromJson({'aiUpScale': 7}).aiImage.upScale, 2);
expect(AppSettings().aiQualityButton, isFalse); expect(AppSettings().aiImage.qualityButton, isFalse);
expect(AppSettings.fromJson((AppSettings()..aiQualityButton = true).toJson()).aiQualityButton, isTrue); expect(AppSettings.fromJson((AppSettings()..aiImage.qualityButton = true).toJson()).aiImage.qualityButton, isTrue);
// 다듬기 정도: 약하게 0.15 · 보통 0.25 (처음) · 강하게 0.4, 모르는 값은 보통 // 다듬기 정도: 약하게 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([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({'aiQualityStrength': 'x'}).aiImage.qualityStrength, 'normal');
expect(AppSettings.fromJson((AppSettings()..aiQualityStrength = 'strong').toJson()).aiQualityStrength, 'strong'); expect(AppSettings.fromJson((AppSettings()..aiImage.qualityStrength = 'strong').toJson()).aiImage.qualityStrength, 'strong');
}); });
test('1배: 4배 결과를 원본 크기로 줄인다 (선명하게만)', () async { test('1배: 4배 결과를 원본 크기로 줄인다 (선명하게만)', () async {

View File

@ -75,15 +75,15 @@ void main() {
test('121: 설정 처음 값 · 저장', () { test('121: 설정 처음 값 · 저장', () {
final s = AppSettings(); 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() final b = AppSettings.fromJson((AppSettings()
..aiUpModel = 'photo' ..aiImage.upModel = 'photo'
..aiUpScale = 4 ..aiImage.upScale = 4
..aiUpFormat = 'jpg' ..aiImage.upFormat = 'jpg'
..aiUpJpgQuality = 80 ..aiImage.upJpgQuality = 80
..aiUpAfter = 'original') ..aiImage.upAfter = 'original')
.toJson()); .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) // 진짜 엔진 (JJ_TEST_SD_DIR: bin_vulkan/sd-cli.exe · models/RealESRGAN_x4plus_anime_6B.pth)
@ -127,11 +127,11 @@ void main() {
test('159: 프롬프트 · 만든 그림 목록이 설정에 남는다', () { test('159: 프롬프트 · 만든 그림 목록이 설정에 남는다', () {
final b = AppSettings.fromJson((AppSettings() final b = AppSettings.fromJson((AppSettings()
..aiPrompt = 'a cat' ..aiImage.prompt = 'a cat'
..aiRecent = ['C:/a/ai_1.png', 'C:/a/ai_2.png']) ..aiImage.recent = ['C:/a/ai_1.png', 'C:/a/ai_2.png'])
.toJson()); .toJson());
expect(b.aiPrompt, 'a cat'); expect(b.aiImage.prompt, 'a cat');
expect(b.aiRecent, ['C:/a/ai_1.png', 'C:/a/ai_2.png']); expect(b.aiImage.recent, ['C:/a/ai_1.png', 'C:/a/ai_2.png']);
}); });
group('156-2: 가짜 AI 결과 막기', () { group('156-2: 가짜 AI 결과 막기', () {
@ -186,8 +186,8 @@ void main() {
expect(upscaleEstimate({}, up, 0.2), isNull); expect(upscaleEstimate({}, up, 0.2), isNull);
// S10 실측: 512×384 (0.197MP) 에 약 87초 → 100만 화소당 약 442초 // S10 실측: 512×384 (0.197MP) 에 약 87초 → 100만 화소당 약 442초
expect(upscaleEstimate({'cpu|esrgan-anime6b': 442}, up, 0.196608)!.inSeconds, 87); expect(upscaleEstimate({'cpu|esrgan-anime6b': 442}, up, 0.196608)!.inSeconds, 87);
final b = AppSettings.fromJson((AppSettings()..aiUpSecPerMp = {'cpu|esrgan-anime6b': 442.5}).toJson()); final b = AppSettings.fromJson((AppSettings()..aiImage.upSecPerMp = {'cpu|esrgan-anime6b': 442.5}).toJson());
expect(b.aiUpSecPerMp, {'cpu|esrgan-anime6b': 442.5}); expect(b.aiImage.upSecPerMp, {'cpu|esrgan-anime6b': 442.5});
}); });
}); });

View File

@ -287,7 +287,7 @@ void main() {
..openSubtitlesPassword = 'op' ..openSubtitlesPassword = 'op'
..webdavServers = [const DavServer(id: 'd1', name: 'n', url: 'https://x', user: 'u', password: 'dp')] ..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')] ..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); await store.save(c.settings);
bool secret(String k) => k.startsWith('os.') || k.startsWith('dav.pw.') || k.startsWith('sftp.') || k.startsWith('ai.'); 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)'); 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); await store.save(c.settings);
expect(secrets.values.keys.where(secret), isEmpty); 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 { test('관리자 판단 (10/21): "앱 설정 전체" 는 이 판이 모르는 설정 키도 지우고, 다른 판의 비밀 값은 "저장된 비밀번호" 를 골랐을 때만', () async {

View File

@ -78,7 +78,7 @@ void main() {
..sftpServers = [const SftpServer(id: 's1', name: 'n', host: 'h', user: 'u', password: 'sp', privateKey: 'KEY', keyPassphrase: 'kp')] ..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')] ..smbServers = [const SmbServer(id: 'm1', name: 'n', host: 'h', share: 'v', password: 'mp')]
..ftpServers = [const FtpServer(id: 'f1', name: 'n', host: 'h', password: 'fp')] ..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); await store.save(s);
final made = secrets.values.keys.where((k) => !k.startsWith('sec.')).toList()..sort(); 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 expect(made, hasLength(11), reason: '$made'); // os 3 · dav.servers · dav.pw · sftp.pw · key · kp · smb.pw · ftp.pw · ai.key

View File

@ -43,9 +43,9 @@ void main() {
}); });
test('176: 해상도 올리기 메모리 설정 - 적게 20% · 보통 30% (처음 값) · 많이 45%, 저장', () { test('176: 해상도 올리기 메모리 설정 - 적게 20% · 보통 30% (처음 값) · 많이 45%, 저장', () {
expect(AppSettings().aiUpMemory, 'normal'); expect(AppSettings().aiImage.upMemory, 'normal');
expect(AppSettings.fromJson((AppSettings()..aiUpMemory = 'high').toJson()).aiUpMemory, 'high'); expect(AppSettings.fromJson((AppSettings()..aiImage.upMemory = 'high').toJson()).aiImage.upMemory, 'high');
expect(AppSettings.fromJson({'aiUpMemory': 'x'}).aiUpMemory, 'normal'); expect(AppSettings.fromJson({'aiUpMemory': 'x'}).aiImage.upMemory, 'normal');
// S10 (약 15GB): 적게 320 · 보통 384 · 많이 512 // S10 (약 15GB): 적게 320 · 보통 384 · 많이 512
expect([for (final m in ['low', 'normal', 'high']) androidUpscaleTile(memTotalMb: 15000, share: upscaleMemoryShare(m))], [320, 384, 512]); expect([for (final m in ['low', 'normal', 'high']) androidUpscaleTile(memTotalMb: 15000, share: upscaleMemoryShare(m))], [320, 384, 512]);
expect(upscaleTileMemoryMb(512), 6600); expect(upscaleTileMemoryMb(512), 6600);