diff --git a/lib/ui/ai_upscale/upscale_compare.dart b/lib/ui/ai_upscale/upscale_compare.dart new file mode 100644 index 0000000..eff0899 --- /dev/null +++ b/lib/ui/ai_upscale/upscale_compare.dart @@ -0,0 +1,134 @@ +/// 약속: 해상도 올린 결과를 원본과 나란히 보여 주고 (밀어서 비교), 고른 것을 저장한다. +library; + +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; + +import '../../app/ai_upscale.dart'; +import '../../app/app_controller.dart'; +import '../../l10n/tr.dart'; +import '../theme.dart'; +import 'upscale_prep.dart'; +import '../ai_upscale_ui.dart'; + +/// 4배 결과 [four] 를 [원본 / 올린 것] 비교 → [저장] (새 파일, 원본을 덮지 않음). 끝나면 임시 결과를 지운다 +Future compareAndSaveUpscale(NavigatorState nav, AppController c, String four, + {required String input, required String nameFrom, required String outDir}) async { + final messenger = ScaffoldMessenger.maybeOf(nav.context); + try { + final save = await nav.push(MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => UpscaleCompareView(original: input, upscaled: four, title: p.basename(nameFrom)), + )); + if (save != true) return null; + final saved = await saveUpscaled(four, source: nameFrom, original: input, o: upscaleOptionsOf(c, outDir: outDir)); + messenger?.showSnackBar(SnackBar(content: Text(trf('저장했습니다: {0}', [saved])))); + return saved; + } catch (e) { + messenger?.showSnackBar(SnackBar(content: Text(trf('저장하지 못했습니다: {0}', [e])))); + return null; + } finally { + try { + await Directory(p.dirname(four)).delete(recursive: true); + } catch (_) {} + } +} + +class UpscaleCompareView extends StatefulWidget { + const UpscaleCompareView({super.key, required this.original, required this.upscaled, required this.title}); + final String original; + final String upscaled; + final String title; + + @override + State createState() => UpscaleCompareViewState(); +} + +class UpscaleCompareViewState extends State { + bool _up = true; + final _zoom = TransformationController(); + + @override + void dispose() { + _zoom.dispose(); + super.dispose(); + } + + /// 160: 저장하지 않고 닫으면 올린 그림이 지워지므로 묻는다 (닫기 버튼 · 뒤로 키 · 뒤로 몸짓) + Future _close() async { + final r = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(tr('저장하지 않고 닫을까요?')), + content: Text(tr('올린 그림은 지워집니다. 다시 보려면 해상도 올리기를 다시 해야 합니다.')), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))), + TextButton(onPressed: () => Navigator.pop(ctx, 'discard'), child: Text(tr('저장하지 않고 닫기'))), + FilledButton.icon( + onPressed: () => Navigator.pop(ctx, 'save'), + icon: const Icon(Icons.save_outlined, size: 18), + label: Text(tr('저장')), + ), + ], + ), + ); + if (!mounted || r == null) return; + Navigator.pop(context, r == 'save'); + } + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _close(); + }, + child: _page(context), + ); + } + + Widget _page(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + leading: IconButton(tooltip: tr('닫기'), icon: const Icon(Icons.close), onPressed: _close), + title: Text(widget.title, maxLines: 1, overflow: TextOverflow.ellipsis), + actions: [ + SegmentedButton( + segments: [ + ButtonSegment(value: false, label: Text(tr('원본'))), + ButtonSegment(value: true, label: Text(tr('올린 것'))), + ], + selected: {_up}, + onSelectionChanged: (v) => setState(() => _up = v.first), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.save_outlined, size: 18), + label: Text(tr('저장')), + ), + const SizedBox(width: 8), + ], + ), + body: InteractiveViewer( + transformationController: _zoom, + maxScale: 8, + child: Center( + // 같은 크기로 보여 확대해 비교한다 (원본은 부드럽게 늘리지 않고 픽셀 그대로) + child: Image.file(File(_up ? widget.upscaled : widget.original), + key: ValueKey(_up), fit: BoxFit.contain, filterQuality: _up ? FilterQuality.medium : FilterQuality.none, + width: double.infinity, height: double.infinity), + ), + ), + bottomNavigationBar: Container( + color: JjColors.panel, + padding: const EdgeInsets.all(8), + child: Text(_up ? tr('올린 것 (AI)') : tr('원본'), textAlign: TextAlign.center), + ), + ); + } +} diff --git a/lib/ui/ai_upscale/upscale_prep.dart b/lib/ui/ai_upscale/upscale_prep.dart new file mode 100644 index 0000000..176b38c --- /dev/null +++ b/lib/ui/ai_upscale/upscale_prep.dart @@ -0,0 +1,178 @@ +/// 약속: 해상도 올리기 준비 - 설정에서 옵션 · 보기 화면의 장을 파일로 · 엔진 준비 (모델 받기 확인) · 걸린 시간 기록과 예상 시간. +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 등은 아직 +} + +/// 시험용: 받기 · 엔진 고르기 없이 쓸 업스케일러 (가짜 엔진) +@visibleForTesting +AiUpscaler Function()? debugUpscalerForTest; + +/// 준비: 모델 · 엔진이 없으면 그 자리에서 무엇을 받을지 고르고 받는다 (156 - 설정 화면으로 보내지 않음). 못 하면 null +Future readyUpscalerFor(BuildContext context, AppController c) async { + if (debugUpscalerForTest 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; + } +} diff --git a/lib/ui/ai_upscale_ui.dart b/lib/ui/ai_upscale_ui.dart index 6574cdd..05a0cde 100644 --- a/lib/ui/ai_upscale_ui.dart +++ b/lib/ui/ai_upscale_ui.dart @@ -11,175 +11,19 @@ import '../app/ai_local.dart'; import '../app/ai_local_engine.dart'; import '../app/ai_upscale.dart'; import '../app/app_controller.dart'; -import '../core/ai_catalog.dart'; import '../core/reader_sources.dart'; import '../core/sd_cli.dart' show ImageGenRequest; -import '../core/vfs.dart'; import '../l10n/tr.dart'; import '../services/image_ai.dart' show ImageAiException; -import 'ai_image_page.dart' show aiSaveDir; import 'confirm.dart'; import 'explorer_page.dart' show ExplorerPage; import 'theme.dart'; import 'notice.dart'; +import 'ai_upscale/upscale_prep.dart'; +import 'ai_upscale/upscale_compare.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 -/// 시험용: 받기 · 엔진 고르기 없이 쓸 업스케일러 (가짜 엔진) -@visibleForTesting -AiUpscaler Function()? debugUpscalerForTest; - -Future _prepare(BuildContext context, AppController c) async { - if (debugUpscalerForTest 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 _download(context, store, files); - if (!ok || !context.mounted) return null; - return ready(); -} - -/// 받는 동안 진행 창 (취소). 다 받았으면 true -Future _download(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 _recordSpeed(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 _estimateText(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; - } -} +export 'ai_upscale/upscale_compare.dart' show UpscaleCompareView, UpscaleCompareViewState; +export 'ai_upscale/upscale_prep.dart' show debugUpscalerForTest, readerPageFile, upscaleOptionsOf; /// 121: 한 장 올리기 → 진행 (취소) → [원본 / 올린 것] 비교 → [저장] (새 파일, 원본을 덮지 않음). 저장한 경로 (안 하면 null) Future upscaleOne(BuildContext context, AppController c, {required String input, required String nameFrom, required String outDir}) async { @@ -188,7 +32,7 @@ Future upscaleOne(BuildContext context, AppController c, {required Stri ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(tr('이미 작업 중입니다')))); return null; } - final up = await _prepare(context, c); + final up = await readyUpscalerFor(context, c); if (up == null || !context.mounted) return null; final progress = ValueNotifier(null); // 167: 남은 시간 (타일 한 장에 걸린 초로) - 모르면 null @@ -200,7 +44,7 @@ Future upscaleOne(BuildContext context, AppController c, {required Stri var dialogOpen = true; // 관리자: CPU 뿐이라 자동이 만화용을 고르면 사진은 질감이 뭉개질 수 있다 - 알리고 바로 바꿀 수 있게 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 upscaleEstimateText(c, up, input); if (!context.mounted) return null; final nav = Navigator.of(context); unawaited(showDialog( @@ -278,7 +122,7 @@ Future upscaleOne(BuildContext context, AppController c, {required Stri cancelled = true; up.cancel(); }, uses: const {'esrgan-x4plus', 'esrgan-anime6b', 'engine-vulkan', 'engine-cuda', 'engine-cudart'}); - if (four != null) await _recordSpeed(c, up, input, watch.elapsed); + if (four != null) await recordUpscaleSpeed(c, up, input, watch.elapsed); } catch (e) { error = e; } @@ -300,12 +144,12 @@ Future upscaleOne(BuildContext context, AppController c, {required Stri return null; } // 176: 화면에 남아 있으면 바로 비교, 떠났으면 (뒤에서 계속 · 화면 닫음) 결과를 남겨 두고 [보기] - if (!background && context.mounted) return _compareAndSave(Navigator.of(context), c, result, input: input, nameFrom: nameFrom, outDir: outDir); + if (!background && context.mounted) return compareAndSaveUpscale(Navigator.of(context), c, result, input: input, nameFrom: nameFrom, outDir: outDir); final text = trf('{0} 올림 - [보기] 로 비교하고 저장', [nameShort]); AiJobs.instance.done(text, view: () async { AiJobs.instance.clearDoneView(); - final saved = await _compareAndSave(nav, c, result, input: input, nameFrom: nameFrom, outDir: outDir); + final saved = await compareAndSaveUpscale(nav, c, result, input: input, nameFrom: nameFrom, outDir: outDir); // 작업 현황 줄도 결과로 (어디에 저장했는지 + [열기]) if (saved != null) { AiJobs.instance.done(trf('저장했습니다: {0}', [saved]), dir: p.dirname(saved)); @@ -327,29 +171,6 @@ Future upscaleOne(BuildContext context, AppController c, {required Stri return null; } -/// 4배 결과 [four] 를 [원본 / 올린 것] 비교 → [저장] (새 파일, 원본을 덮지 않음). 끝나면 임시 결과를 지운다 -Future _compareAndSave(NavigatorState nav, AppController c, String four, - {required String input, required String nameFrom, required String outDir}) async { - final messenger = ScaffoldMessenger.maybeOf(nav.context); - try { - final save = await nav.push(MaterialPageRoute( - fullscreenDialog: true, - builder: (_) => UpscaleCompareView(original: input, upscaled: four, title: p.basename(nameFrom)), - )); - if (save != true) return null; - final saved = await saveUpscaled(four, source: nameFrom, original: input, o: upscaleOptionsOf(c, outDir: outDir)); - messenger?.showSnackBar(SnackBar(content: Text(trf('저장했습니다: {0}', [saved])))); - return saved; - } catch (e) { - messenger?.showSnackBar(SnackBar(content: Text(trf('저장하지 못했습니다: {0}', [e])))); - return null; - } finally { - try { - await Directory(p.dirname(four)).delete(recursive: true); - } catch (_) {} - } -} - /// 157 · 162: 저장한 폴더 열기 - Android 는 이 앱의 파일 탐색기로 (삼성 파일 앱 대신), Windows 는 탐색기로 Future openFolderInApp(NavigatorState nav, AppController c, String dir) async { if (Platform.isAndroid) { @@ -362,7 +183,7 @@ Future openFolderInApp(NavigatorState nav, AppController c, String dir) as /// 121: 폴더 · ZIP 의 모든 장을 올려 저장 (백그라운드 - 작업 알림 · 작업 현황에 남은 장 · 예상 시간) Future upscaleAll(BuildContext context, AppController c, ReaderSource src) async { final n = src.length; - final up = await _prepare(context, c); + final up = await readyUpscalerFor(context, c); if (up == null || !context.mounted) return; // 156-2: 첫 장 크기와 지난번 걸린 시간으로 한 장 · 모두의 예상 시간 String? estimate; @@ -399,7 +220,7 @@ Future upscaleAll(BuildContext context, AppController c, ReaderSource src) try { final watch = Stopwatch()..start(); final saved = await up.upscaleAndSave(file.$1, upscaleOptionsOf(c, outDir: file.$3), nameFrom: file.$2, onProgress: progress); - await _recordSpeed(c, up, file.$1, watch.elapsed); + await recordUpscaleSpeed(c, up, file.$1, watch.elapsed); lastDir = p.dirname(saved); } catch (e) { failed.add('${src.pageName(i)}: $e'); @@ -446,7 +267,7 @@ Future qualityUp(BuildContext context, AppController c, {required Strin return null; } // 해상도 올리기 모델 · 엔진 (없으면 받는 창) - final up = await _prepare(context, c); + final up = await readyUpscalerFor(context, c); if (up == null || !context.mounted) return null; final device = await pickAiDevice(c, store); if (!context.mounted) return null; @@ -561,99 +382,3 @@ Future qualityUp(BuildContext context, AppController c, {required Strin c.note('${tr('품질 올리기')}: $saved'); return saved; } - -class UpscaleCompareView extends StatefulWidget { - const UpscaleCompareView({super.key, required this.original, required this.upscaled, required this.title}); - final String original; - final String upscaled; - final String title; - - @override - State createState() => UpscaleCompareViewState(); -} - -class UpscaleCompareViewState extends State { - bool _up = true; - final _zoom = TransformationController(); - - @override - void dispose() { - _zoom.dispose(); - super.dispose(); - } - - /// 160: 저장하지 않고 닫으면 올린 그림이 지워지므로 묻는다 (닫기 버튼 · 뒤로 키 · 뒤로 몸짓) - Future _close() async { - final r = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(tr('저장하지 않고 닫을까요?')), - content: Text(tr('올린 그림은 지워집니다. 다시 보려면 해상도 올리기를 다시 해야 합니다.')), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))), - TextButton(onPressed: () => Navigator.pop(ctx, 'discard'), child: Text(tr('저장하지 않고 닫기'))), - FilledButton.icon( - onPressed: () => Navigator.pop(ctx, 'save'), - icon: const Icon(Icons.save_outlined, size: 18), - label: Text(tr('저장')), - ), - ], - ), - ); - if (!mounted || r == null) return; - Navigator.pop(context, r == 'save'); - } - - @override - Widget build(BuildContext context) { - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, _) { - if (!didPop) _close(); - }, - child: _page(context), - ); - } - - Widget _page(BuildContext context) { - return Scaffold( - backgroundColor: Colors.black, - appBar: AppBar( - leading: IconButton(tooltip: tr('닫기'), icon: const Icon(Icons.close), onPressed: _close), - title: Text(widget.title, maxLines: 1, overflow: TextOverflow.ellipsis), - actions: [ - SegmentedButton( - segments: [ - ButtonSegment(value: false, label: Text(tr('원본'))), - ButtonSegment(value: true, label: Text(tr('올린 것'))), - ], - selected: {_up}, - onSelectionChanged: (v) => setState(() => _up = v.first), - ), - const SizedBox(width: 8), - FilledButton.icon( - onPressed: () => Navigator.pop(context, true), - icon: const Icon(Icons.save_outlined, size: 18), - label: Text(tr('저장')), - ), - const SizedBox(width: 8), - ], - ), - body: InteractiveViewer( - transformationController: _zoom, - maxScale: 8, - child: Center( - // 같은 크기로 보여 확대해 비교한다 (원본은 부드럽게 늘리지 않고 픽셀 그대로) - child: Image.file(File(_up ? widget.upscaled : widget.original), - key: ValueKey(_up), fit: BoxFit.contain, filterQuality: _up ? FilterQuality.medium : FilterQuality.none, - width: double.infinity, height: double.infinity), - ), - ), - bottomNavigationBar: Container( - color: JjColors.panel, - padding: const EdgeInsets.all(8), - child: Text(_up ? tr('올린 것 (AI)') : tr('원본'), textAlign: TextAlign.center), - ), - ); - } -} diff --git a/test/upscale_modules_contract_test.dart b/test/upscale_modules_contract_test.dart new file mode 100644 index 0000000..ac369ff --- /dev/null +++ b/test/upscale_modules_contract_test.dart @@ -0,0 +1,47 @@ +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/core/reader_sources.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/ai_upscale/upscale_prep.dart'; +import 'package:path/path.dart' as p; + +void main() { + AppController controller() => AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + + test('약속 upscaleOptionsOf: 환경 설정 > AI 그림의 해상도 올리기 값을 그대로, 저장 폴더는 준 것이 먼저', () { + final c = controller(); + c.settings.aiImage + ..upScale = 2 + ..upFormat = 'jpg' + ..upJpgQuality = 80 + ..upDir = r'D:\up'; + final o = upscaleOptionsOf(c); + expect([o.model, o.scale, o.format, o.jpgQuality, o.outDir], [c.settings.aiImage.upModel, 2, 'jpg', 80, r'D:\up']); + expect(upscaleOptionsOf(c, outDir: r'E:\here').outDir, r'E:\here'); + }); + + test('약속 readerPageFile: 이 기기의 그림은 그대로, ZIP 안의 그림은 꺼내 "ZIP이름_AI" 폴더 기준 (원본은 그대로)', () async { + final dir = await Directory.systemTemp.createTemp('jj_up_'); + addTearDown(() => dir.delete(recursive: true)); + final c = controller()..settings.aiImage.upDir = ''; + final a = p.join(dir.path, 'a.png'); + File(a).writeAsBytesSync([1, 2, 3]); + final files = ImageFilesSource([a], tempDir: dir.path, title: 't'); + expect(await readerPageFile(c, files, 0), (a, a, '')); + + final zip = p.join(dir.path, 'book.zip'); + File(zip).writeAsBytesSync(ZipEncoder().encode(Archive()..addFile(ArchiveFile('p1.png', 3, [4, 5, 6])))); + final src = await ZipImagesSource.open(zip, tempDir: dir.path, imageExts: const ['png']); + final r = (await readerPageFile(c, src, 0))!; + expect(File(r.$1).readAsBytesSync(), [4, 5, 6], reason: '꺼낸 임시 파일'); + expect([r.$2, r.$3], [p.join(dir.path, 'book_AI', 'p1.png'), p.join(dir.path, 'book_AI')]); + expect(File(zip).existsSync(), isTrue); + await src.dispose(); + await Directory(p.dirname(r.$1)).delete(recursive: true); + }); +}