/// 약속: 해상도 올리기 준비 - 설정에서 옵션 · 보기 화면의 장을 파일로 · 엔진 준비 (모델 받기 확인) · 걸린 시간 기록과 예상 시간. library; import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:path/path.dart' as p; import '../../app/ai_jobs.dart'; import '../../app/ai_local.dart'; import '../../app/ai_upscale.dart'; import '../../app/app_controller.dart'; import '../../core/ai_catalog.dart'; import '../../core/reader_sources.dart'; import '../../core/vfs.dart'; import '../../l10n/tr.dart'; import '../../services/image_ai.dart' show ImageAiException; import '../ai_image_page.dart' show aiSaveDir; import '../theme.dart'; UpscaleOptions upscaleOptionsOf(AppController c, {String? outDir}) { final s = c.settings; return UpscaleOptions( model: s.aiImage.upModel, scale: s.aiImage.upScale, format: s.aiImage.upFormat, jpgQuality: s.aiImage.upJpgQuality, outDir: outDir ?? s.aiImage.upDir, ); } /// 보기 화면의 i 번째 장을 이 기기의 파일로 (WebDAV 는 받아 두고, ZIP 은 꺼내 임시 파일로) 과 /// 저장할 이름 기준 (ZIP 은 ZIP 옆 "ZIP이름_AI" 폴더, WebDAV 는 AI 그림 저장 폴더 - 원본 · ZIP 은 바꾸지 않음) Future<(String input, String nameFrom, String outDir)?> readerPageFile(AppController c, ReaderSource src, int i) async { final s = c.settings; if (src is ImageFilesSource) { final path = src.paths[i]; if (!isDav(path)) return (path, path, s.aiImage.upDir); final local = await vLocalCopy(path, src.tempDir); 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]; final tmp = await Directory.systemTemp.createTemp('jj_zip_up_'); final name = p.basename(e.name); final f = File(p.join(tmp.path, name))..writeAsBytesSync(e.readBytes() ?? const []); final beside = isDav(src.path) ? aiSaveDir(s.aiImage.saveDir) : p.join(p.dirname(src.path), '${p.basenameWithoutExtension(src.path)}_AI'); final out = s.aiImage.upDir.isEmpty ? beside : s.aiImage.upDir; return (f.path, p.join(out, name), out); } return null; // PDF 등은 아직 } /// 준비: 모델 · 엔진이 없으면 그 자리에서 무엇을 받을지 고르고 받는다 (156 - 설정 화면으로 보내지 않음). 못 하면 null Future readyUpscalerFor(BuildContext context, AppController c) async { if (c.services.createUpscaler case final f?) return f(); final store = AiStore.instance ??= AiStore(Directory.systemTemp.path); Future ready() => prepareUpscaler(store, 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.aiImage.device) == null && Platform.isWindows ? aiFile('engine-vulkan') : null; if (!context.mounted) return null; final setting = c.settings.aiImage.upModel; final anime = aiFile('esrgan-anime6b'), photo = aiFile('esrgan-x4plus'); final choices = setting == 'photo' ? [photo] : setting == 'anime' ? [anime] : [anime, photo]; final models = choices.any((f) => store.isFileInstalled(f)) ? [] : choices; String label(AiFile f) => '${f.id == anime.id ? tr('만화 · 그림용') : tr('사진용')} (${AiStore.sizeText(f.size + (engine?.size ?? 0))})'; final picked = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(tr('AI 해상도 올리기')), content: Text([ if (models.isNotEmpty) tr('해상도 올리기 모델을 받아야 합니다. 어느 것을 받을까요?'), if (models.length > 1) tr('만화 · 그림용은 가볍고 빠르며, 사진용은 사진의 질감을 더 잘 살리지만 그래픽카드가 없으면 느립니다.'), if (engine != null) trf('AI 엔진 ({0}) 도 함께 받습니다.', [engine.name]), ].join('\n\n')), actions: [ TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))), if (models.isEmpty && engine != null) FilledButton(onPressed: () => Navigator.pop(ctx, engine), child: Text(trf('받기 ({0})', [AiStore.sizeText(engine.size)]))), for (final f in models) FilledButton(onPressed: () => Navigator.pop(ctx, f), child: Text(label(f))), ], ), ); if (picked == null || !context.mounted) return null; final files = [?engine, if (picked != engine) picked]; final ok = await downloadAiModelFiles(context, store, files); if (!ok || !context.mounted) return null; return ready(); } /// 받는 동안 진행 창 (취소). 다 받았으면 true Future downloadAiModelFiles(BuildContext context, AiStore store, List files) async { final nav = Navigator.of(context); var cancelled = false; unawaited(showDialog( context: context, barrierDismissible: false, builder: (ctx) => AlertDialog( title: Text(tr('받는 중')), content: SizedBox( width: 420, child: ListenableBuilder( listenable: store, builder: (_, _) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final f in files) ...[ Text(f.name, style: const TextStyle(fontSize: 13)), Text(store.isFileInstalled(f) ? tr('받음') : store.progressText(f.id) ?? tr('준비하는 중'), style: TextStyle(fontSize: 12, color: JjColors.textDim)), const SizedBox(height: 8), ], ]), ), ), actions: [ TextButton( onPressed: () { cancelled = true; for (final f in files) { store.cancel(f.id); } }, child: Text(tr('취소')), ), ], ), )); String? error; try { await store.installAll(files); } on ImageAiException catch (e) { error = e.message; } catch (_) { error = tr('받지 못했습니다. 잠시 뒤 다시 해 주세요'); } nav.pop(); final ok = files.every(store.isFileInstalled); if (!ok && !cancelled && context.mounted) { ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(error ?? tr('받지 못했습니다. 잠시 뒤 다시 해 주세요')))); } return ok; } /// 156-2: 걸린 시간을 남긴다 (다음에 "한 장 약 n초" 로) Future recordUpscaleSpeed(AppController c, AiUpscaler up, String input, Duration took) async { try { final mp = megapixels(await File(input).readAsBytes()); if (mp == null || mp <= 0) return; final v = took.inMilliseconds / 1000 / mp; await c.updateSettings((x) => x.aiImage.upSecPerMp = {...x.aiImage.upSecPerMp, upscaleSpeedKey(up): v}); } catch (_) {} } /// "이 기기에서 한 장 약 n초 (지난번 걸린 시간 기준)" (처음이면 null) Future upscaleEstimateText(AppController c, AiUpscaler up, String input) async { try { final mp = megapixels(await File(input).readAsBytes()); final d = mp == null ? null : upscaleEstimate(c.settings.aiImage.upSecPerMp, up, mp); return d == null ? null : trf('이 기기에서 한 장 약 {0} (지난번 걸린 시간 기준)', [AiJobs.durationText(d)]); } catch (_) { return null; } }