import 'dart:io'; import 'package:flutter/material.dart'; import '../app/ai_jobs.dart'; import '../app/ai_local.dart'; import '../app/ai_local_engine.dart'; import '../app/app_controller.dart'; import '../app/copy_center.dart' show diskSpace; import '../core/ai_catalog.dart'; import '../core/secret_gate.dart'; import '../l10n/tr.dart'; import '../services/image_ai.dart'; import 'ai_image_page.dart' show aiSaveDir; import 'confirm.dart'; import 'folder_picker.dart'; import 'license_texts.dart' show showLicenseDocument; import 'setting_tile.dart'; import 'theme.dart'; import '../app/ai_upscale.dart' show deviceMemTotalMb, upscaleMemoryLines; /// 기기 안에서 그리는 데 아직 받지 않은 파일 (Windows 는 실행 파일까지, TAESD 포함) - AI 그림 화면 · 받기 화면이 같은 값을 보인다 (139) List aiMissingFiles(AiStore store) => [ if (Platform.isWindows) aiFile('engine-vulkan'), for (final id in [...sd15LcmFiles, 'taesd']) aiFile(id), ].where((f) => !store.isInstalled(f.id)).toList(); int aiMissingSize(AiStore store) => aiMissingFiles(store).fold(0, (a, f) => a + f.size); /// AI 그림 화면의 [받기 화면으로] · 설정 버튼: AI 설정 (모델 받기 · 처리 장치 · 서비스) 을 따로 연다 Future openAiModels(BuildContext context, AppController c) => Navigator.of(context).push(MaterialPageRoute( builder: (ctx) => Scaffold( appBar: AppBar( // 140: 뒤로 버튼 이름도 화면 언어로 leading: IconButton(tooltip: tr('뒤로'), icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.maybePop(ctx)), title: Text(tr('AI 그림 · 해상도 올리기')), ), body: ListView(padding: const EdgeInsets.all(12), children: [AiSettings(c: c)]), ), )); /// 123 · 121: 환경 설정 > AI 그림 · 해상도 올리기 class AiSettings extends StatefulWidget { const AiSettings({super.key, required this.c}); final AppController c; @override State createState() => _AiSettingsState(); } class _AiSettingsState extends State { AppController get c => widget.c; AiStore get store => AiStore.instance ??= AiStore(Directory.systemTemp.path); bool _nvidia = false; List _devices = const []; String? _error; @override void initState() { super.initState(); _refresh(); } Future _refresh() async { final nv = await AiStore.hasNvidia(); final devs = await sdDevices(store); if (mounted) { setState(() { _nvidia = nv; _devices = devs; }); } } String get _platform => Platform.isAndroid ? 'android' : 'windows'; /// 176: 해상도 올리기 메모리 (적게 · 보통 · 많이) - 이 기기에서의 타일 · 예상 메모리 Widget _upMemoryTile() { final mem = deviceMemTotalMb(); return SettingTile( leading: const Icon(Icons.memory), title: Text(tr('해상도 올리기 메모리')), subtitle: Text(upscaleMemoryLines(mem).join('\n')), isThreeLine: true, trailing: DropdownButton( value: c.settings.aiUpMemory, 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'), ), ); } List get _files => [ for (final f in aiCatalog) if ((f.platforms.isEmpty || f.platforms.contains(_platform)) && (f.needs != 'nvidia' || _nvidia)) f, ]; /// 받기 전에 라이선스 조건 (있으면) 을 보여 주고 동의를 받는다 Future _agree(AiFile f) async { // 149-④: 한 번 동의했으면 다시 묻지 않는다 if (f.terms == null || c.settings.aiAgreed.contains(f.id)) return true; final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( scrollable: true, title: Text(trf('{0} 라이선스', [tr(f.license)])), content: SizedBox( width: 520, child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(tr(f.terms!)), const SizedBox(height: 8), SelectableText(f.licenseUrl, style: TextStyle(fontSize: 12, color: JjColors.textDim)), if (f.licenseAsset != null) TextButton( onPressed: () => showLicenseDocument(ctx, trf('{0} 라이선스', [tr(f.license)]), f.licenseAsset!), child: Text(tr('라이선스 원문 보기')), ), ]), ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('동의하고 받기'))), ], ), ); if (ok == true) await c.updateSettings((x) => x.aiAgreed = {...x.aiAgreed, f.id}.toList()); return ok == true; } /// 받기: 조건 동의를 먼저 모두 받은 뒤 차례로 (기다리는 것은 "차례 기다림") Future _install(List files) async { final agreed = []; for (final f in files) { if (store.isFileInstalled(f)) continue; if (await _agree(f)) agreed.add(f); } if (agreed.isEmpty) return; try { setState(() => _error = null); await store.installAll(agreed); } on ImageAiException catch (e) { if (mounted) setState(() => _error = e.message); } catch (_) { // 쉬운 말로만 (원문 · 주소는 보이지 않게) if (mounted) setState(() => _error = tr('받지 못했습니다. 잠시 뒤 [이어 받기] 를 눌러 주세요')); } await _refresh(); } @override Widget build(BuildContext context) { return ListenableBuilder( listenable: Listenable.merge([c, store, AiJobs.instance]), builder: (context, _) { final s = c.settings; final missing = aiMissingFiles(store); return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SettingTile( leading: const Icon(Icons.auto_awesome_outlined), title: Text(tr('기기 안에서 그리기 (stable-diffusion.cpp · SD1.5 + LCM)')), subtitle: Text(missing.isEmpty ? tr('모두 받았습니다. 512×512 한 장에 폰은 약 1분, PC 는 장치에 따라 1 ~ 5분 걸립니다.') : trf('그리려면 {0} 를 받아야 합니다 (모델은 받은 뒤 이 기기에만 있고, 그림 · 프롬프트는 밖으로 나가지 않습니다).', [AiStore.sizeText(missing.fold(0, (a, f) => a + f.size))])), trailing: missing.isEmpty ? null : FilledButton.tonal(onPressed: () => _install(missing), child: Text(tr('필요한 것 모두 받기'))), ), // 149-③: 받기 전에 필요한 공간과 지금 남은 공간을 늘 한 줄로 if (missing.isNotEmpty) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 4), child: FutureBuilder<(int, int)?>( future: diskSpace(store.root), builder: (context, snap) { final need = missing.fold(0, (a, f) => a + store.neededSpace(f)); final free = snap.data?.$1; return Text( free == null ? trf('필요한 공간 {0}', [AiStore.sizeText(need)]) : trf('필요한 공간 {0} · 남은 공간 {1}', [AiStore.sizeText(need), AiStore.sizeText(free)]), style: TextStyle(fontSize: 12, color: free != null && free < need ? JjColors.alert : JjColors.textDim), ); }, ), ), if (_error != null) Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Text(_error!, style: TextStyle(color: JjColors.alert)), ), for (final f in _files) _fileRow(f), const Divider(), SettingTile( leading: const Icon(Icons.memory), title: Text(tr('처리 장치')), subtitle: Text(s.aiDevice == 'auto' ? (s.aiAutoDevice.isEmpty ? tr('자동: 처음 그릴 때 짧게 재서 가장 빠른 장치를 고릅니다') : trf('자동: 잰 결과 {0} 가 가장 빠릅니다', [_devices.where((d) => d.key == s.aiAutoDevice).firstOrNull?.label ?? s.aiAutoDevice])) : tr('직접 고름')), trailing: DropdownButton( value: s.aiDevice == 'auto' || _devices.any((d) => d.key == s.aiDevice) ? s.aiDevice : 'auto', items: [ DropdownMenuItem(value: 'auto', child: Text(tr('자동 (가장 빠른 것)'))), for (final d in _devices) DropdownMenuItem(value: d.key, child: Text(d.label)), ], onChanged: (v) => c.updateSettings((x) => x ..aiDevice = v ?? 'auto' ..aiAutoDeviceFor = ''), // 다시 고르면 다음에 다시 잼 ), ), SwitchListTile( value: s.aiTaesd, onChanged: (v) => c.updateSettings((x) => x.aiTaesd = v), title: Text(tr('빠른 디코더 (TAESD)')), subtitle: Text(tr('그림이 거의 같고 마지막 단계가 10배쯤 빠릅니다 (받아 두었을 때)')), ), SettingTile( leading: const Icon(Icons.folder_outlined), title: Text(tr('저장 폴더')), subtitle: Text(aiSaveDir(s.aiSaveDir)), trailing: Wrap(spacing: 6, children: [ OutlinedButton( onPressed: () async { final d = await pickFolder(context, tr('AI 그림 저장 폴더'), aiSaveDir(s.aiSaveDir)); if (d != null) await c.updateSettings((x) => x.aiSaveDir = d); }, child: Text(tr('바꾸기')), ), if (s.aiSaveDir.isNotEmpty) TextButton(onPressed: () => c.updateSettings((x) => x.aiSaveDir = ''), child: Text(tr('처음대로'))), ]), ), const Divider(), // 121: 해상도 올리기 (보기 화면 위쪽 막대의 [AI 해상도 올리기]) SettingTile( leading: const Icon(Icons.hd_outlined), title: Text(tr('해상도 올리기 모델')), subtitle: Text(tr('자동: 그래픽카드 (GPU) 가 있으면 사진용, CPU 뿐이면 가볍고 빠른 만화용. 원본은 덮어쓰지 않고 새 파일로 저장합니다')), trailing: DropdownButton( value: s.aiUpModel, items: [ DropdownMenuItem(value: 'auto', child: Text(tr('자동'))), DropdownMenuItem(value: 'photo', child: Text(tr('사진'))), DropdownMenuItem(value: 'anime', child: Text(tr('만화 · 그림'))), ], onChanged: (v) => c.updateSettings((x) => x.aiUpModel = v ?? 'auto'), ), ), // 176: Android CPU - 메모리를 많이 쓸수록 빠르다 (그래픽카드 · Windows 는 엔진이 정함) if (Platform.isAndroid) _upMemoryTile(), SettingTile( leading: const Icon(Icons.zoom_out_map), title: Text(tr('배율')), trailing: DropdownButton( value: s.aiUpScale, // 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), ), ), // 관리자 판단 (10/17): [품질 올리기] 한 단추 - 처음 값 끔 SettingTile( leading: const Icon(Icons.auto_fix_high), title: Text(tr('품질 올리기 단추 보이기')), subtitle: Text(tr('보기 화면에 [품질 올리기]: AI 그림 (그림 → 그림, 약하게) 으로 다듬은 뒤 해상도 올리기를 이어서. ' '새 파일 (이름_q) 로 저장하며 AI 그림 모델이 필요합니다')), trailing: Switch(value: s.aiQualityButton, onChanged: (v) => c.updateSettings((x) => x.aiQualityButton = v)), ), if (s.aiQualityButton) SettingTile( leading: const Icon(Icons.tune), title: Text(tr('품질 올리기 다듬기 정도')), subtitle: Text(tr('강할수록 많이 다듬지만 원본과 달라질 수 있습니다')), trailing: DropdownButton( value: s.aiQualityStrength, 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'), ), ), SettingTile( leading: const Icon(Icons.image_outlined), title: Text(tr('저장 형식')), subtitle: s.aiUpFormat == 'jpg' ? Text(trf('JPG 품질 {0}', [s.aiUpJpgQuality])) : Text(tr('WebP 등은 PNG 로 저장합니다')), trailing: DropdownButton( value: s.aiUpFormat, items: [ DropdownMenuItem(value: 'same', child: Text(tr('원본과 같은 형식'))), const DropdownMenuItem(value: 'png', child: Text('PNG')), const DropdownMenuItem(value: 'jpg', child: Text('JPG')), ], onChanged: (v) => c.updateSettings((x) => x.aiUpFormat = v ?? 'same'), ), ), if (s.aiUpFormat == 'jpg') Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Slider( value: s.aiUpJpgQuality.toDouble(), min: 50, max: 100, divisions: 50, label: '${s.aiUpJpgQuality}', onChanged: (v) => c.updateSettings((x) => x.aiUpJpgQuality = v.round()), ), ), SettingTile( leading: const Icon(Icons.drive_file_move_outline), title: Text(tr('저장 위치')), subtitle: Text(s.aiUpDir.isEmpty ? tr('원본 옆에 "이름_x2" (ZIP 은 ZIP 옆 "ZIP이름_AI" 폴더)') : s.aiUpDir), trailing: Wrap(spacing: 6, children: [ OutlinedButton( onPressed: () async { final d = await pickFolder(context, tr('해상도를 올린 그림 저장 폴더'), s.aiUpDir.isEmpty ? null : s.aiUpDir); if (d != null) await c.updateSettings((x) => x.aiUpDir = d); }, child: Text(tr('폴더 고르기')), ), if (s.aiUpDir.isNotEmpty) TextButton(onPressed: () => c.updateSettings((x) => x.aiUpDir = ''), child: Text(tr('원본 옆으로'))), ]), ), SettingTile( leading: const Icon(Icons.visibility_outlined), title: Text(tr('저장한 뒤')), trailing: DropdownButton( value: s.aiUpAfter, items: [ DropdownMenuItem(value: 'upscaled', child: Text(tr('올린 것으로 계속 보기'))), DropdownMenuItem(value: 'original', child: Text(tr('원본으로'))), ], onChanged: (v) => c.updateSettings((x) => x.aiUpAfter = v ?? 'upscaled'), ), ), const Divider(), SettingTile( leading: const Icon(Icons.public), title: Text(tr('서버 · 서비스 (기기 밖)')), subtitle: Text(tr('직접 운영하는 서버 (ComfyUI · Automatic1111, 예: Tailscale 주소) 나 인터넷 서비스 (OpenAI 이미지 API 형식). ' '넣지 않으면 기기 안에서만 그립니다. 쓸 때는 그림 · 프롬프트가 기기 밖으로 나간다고 늘 알립니다. API 키는 안전 저장소에 둡니다.')), trailing: TextButton.icon( onPressed: () => editAiService(context, c), icon: const Icon(Icons.add, size: 18), label: Text(tr('서비스 추가')), ), ), for (final x in s.aiServices) Padding( padding: const EdgeInsets.only(left: 16), child: SettingTile( dense: true, leading: const Icon(Icons.cloud_outlined), title: Text(x.label), subtitle: Text('${_kindName(x.kind)} · ${x.url}${x.apiKey.isNotEmpty ? ' · ${tr('키 있음')}' : ''}', maxLines: 1, overflow: TextOverflow.ellipsis), trailing: Row(mainAxisSize: MainAxisSize.min, children: [ IconButton( tooltip: tr('고치기'), icon: const Icon(Icons.edit_outlined, size: 18), onPressed: () => editAiService(context, c, old: x), ), IconButton( tooltip: tr('지우기'), icon: const Icon(Icons.delete_outline, size: 18), onPressed: () async { final ok = await confirmAction(context, title: trf('"{0}" 을(를) 지울까요?', [x.label]), body: tr('서비스 설정과 저장된 API 키를 지웁니다.'), ok: tr('지우기')); if (!ok) return; await c.updateSettings((y) { y.aiServices = [for (final z in y.aiServices) if (z.id != x.id) z]; if (y.aiEngine == x.id) y.aiEngine = 'local'; }); }, ), ]), ), ), ]); }, ); } Widget _fileRow(AiFile f) { final installed = store.isInstalled(f.id); final prog = store.progress[f.id]; final busy = store.progress.containsKey(f.id); final waiting = store.queued.contains(f.id); // 받다가 끊겨 조각이 남아 있으면 [이어 받기] final partial = !installed && !busy && store.neededSpace(f) < (f.kind == 'engine' ? f.size * 3 : f.size); return Padding( padding: const EdgeInsets.only(left: 16), child: SettingTile( dense: true, leading: Icon(installed ? Icons.check_circle : Icons.download_outlined, color: installed ? JjColors.success : JjColors.textDim, size: 20), title: Text(tr(f.name)), // 149-①②: 진행 막대 옆에 % · 받은 크기 / 전체 · 남은 시간, 기다리는 것은 "차례 기다림" subtitle: busy ? Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ LinearProgressIndicator(value: prog), const SizedBox(height: 2), Text(store.progressText(f.id) ?? '', style: const TextStyle(fontSize: 12)), ]) : Wrap(crossAxisAlignment: WrapCrossAlignment.center, children: [ Text([ AiStore.sizeText(f.size), tr(f.license), if (installed) tr('받음'), // 받을 때 SHA-256 을 공식 값과 맞춰 보았다 if (installed && store.isVerified(f)) tr('확인됨'), if (waiting) tr('차례 기다림'), ?store.failures[f.id], ].join(' · ')), // 175: 라이선스 원문 (저작권 줄 · 허가 문구 그대로) if (f.licenseAsset != null) TextButton( style: TextButton.styleFrom( visualDensity: VisualDensity.compact, padding: const EdgeInsets.symmetric(horizontal: 6)), onPressed: () => showLicenseDocument(context, trf('{0} 라이선스', [tr(f.license)]), f.licenseAsset!), child: Text(tr('원문')), ), ]), trailing: busy || waiting ? TextButton(onPressed: () => store.cancel(f.id), child: Text(tr('취소'))) : installed ? TextButton( // 151: 그리는 · 올리는 중에 쓰는 파일은 지울 수 없게 onPressed: AiJobs.instance.busy && AiJobs.instance.inUse.contains(f.id) ? null : () async { final ok = await confirmAction(context, title: trf('{0} 을(를) 지울까요?', [tr(f.name)]), body: tr('다시 쓰려면 다시 받아야 합니다.'), ok: tr('지우기')); if (ok) { await store.remove(f.id); await _refresh(); } }, child: Text(tr('지우기'))) : OutlinedButton( onPressed: () => _install([f]), child: Text(store.failures[f.id]?.contains(tr('파일이 깨졌습니다')) == true ? tr('다시 받기') : partial ? tr('이어 받기') : tr('받기'))), ), ); } } String _kindName(String kind) => switch (kind) { 'comfy' => 'ComfyUI', 'openai' => tr('OpenAI 이미지 API 형식'), _ => 'Automatic1111 · Forge', }; /// 서비스 추가 · 고치기. 저장된 API 키가 칸에 채워지므로 고칠 때는 먼저 마스터 비밀번호 (127 과 같음) Future editAiService(BuildContext context, AppController c, {AiService? old}) async { if (old != null && old.apiKey.isNotEmpty && !await SecretGate.pass(force: true)) return; if (!context.mounted) return; final name = TextEditingController(text: old?.name ?? ''); final url = TextEditingController(text: old?.url ?? 'http://'); final model = TextEditingController(text: old?.model ?? ''); final key = TextEditingController(text: old?.apiKey ?? ''); var kind = old?.kind ?? 'a1111'; var hide = true; final saved = await showDialog( context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, set) => AlertDialog( scrollable: true, title: Text(old == null ? tr('서비스 추가') : tr('서비스 고치기')), content: SizedBox( width: 480, child: Column(mainAxisSize: MainAxisSize.min, children: [ Container( padding: const EdgeInsets.all(8), color: JjColors.warning.withValues(alpha: 0.1), child: Text(tr('이 서비스로 그리면 그림 · 프롬프트가 기기 밖 (이 주소) 으로 나갑니다.'), style: const TextStyle(fontSize: 12)), ), TextField(controller: name, decoration: InputDecoration(labelText: tr('이름'))), DropdownButtonFormField( initialValue: kind, decoration: InputDecoration(labelText: tr('방식')), items: [for (final k in AiService.kinds) DropdownMenuItem(value: k, child: Text(_kindName(k)))], onChanged: (v) => set(() => kind = v ?? 'a1111'), ), TextField( controller: url, keyboardType: TextInputType.url, decoration: InputDecoration( labelText: tr('주소'), helperText: switch (kind) { 'comfy' => tr('예: http://100.x.x.x:8188 (ComfyUI)'), 'openai' => tr('예: https://api.openai.com (경로 /v1/images/generations 를 붙여 부름)'), _ => tr('예: http://100.x.x.x:7860 (--api 로 켠 Automatic1111 · Forge)'), }, // 관리자 지시 (10/21, 15 찾음 F2): 412 ja 에서 한 줄로 잘려 어떤 옵션으로 켜야 하는지가 안 보였다 - 다른 창의 도움 글처럼 3줄까지 helperMaxLines: 3, ), ), TextField( controller: model, decoration: InputDecoration( labelText: tr('모델 (선택)'), helperText: kind == 'comfy' ? tr('체크포인트 파일 이름 (예: sd_xl_turbo_1.0_fp16.safetensors)') : null, helperMaxLines: 3, ), ), TextField( controller: key, obscureText: hide, decoration: InputDecoration( // 관리자 지시 (10/21, 15 찾음 F1): 칸 이름은 짧게, 괄호 설명은 칸 아래 도움 글로 - 412 폭 en · ja 에서 👁 자리 때문에 이름이 "…" 로 잘렸다 labelText: kind == 'a1111' ? tr('API 키 또는 아이디:비밀번호') : tr('API 키'), helperText: tr('넣지 않아도 됩니다'), helperMaxLines: 3, suffixIcon: IconButton(icon: Icon(hide ? Icons.visibility : Icons.visibility_off), onPressed: () => set(() => hide = !hide)), ), ), ]), ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))), FilledButton( onPressed: () { final u = url.text.trim(); if (!u.startsWith('http://') && !u.startsWith('https://') || u.length < 10) return; Navigator.pop( ctx, AiService( id: old?.id ?? 'ai${DateTime.now().millisecondsSinceEpoch}', name: name.text.trim(), url: u, kind: kind, model: model.text.trim(), apiKey: key.text.trim(), ), ); }, child: Text(tr('저장')), ), ], ), ), ); for (final x in [name, url, model, key]) { x.dispose(); } if (saved == null) return; await c.updateSettings((x) => x.aiServices = [ for (final y in x.aiServices) if (y.id != saved.id) y, saved, ]); }