ai_upscale_ui 나누기 (659 → 387): 준비 (upscale_prep) · 비교 보기 (upscale_compare) 를 모듈로
- 약속 시험: upscaleOptionsOf · readerPageFile (그림 파일 · ZIP 안의 그림) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>modular
parent
7d724ee9ca
commit
8eaa83b69d
|
|
@ -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<String?> 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<bool>(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<UpscaleCompareView> createState() => UpscaleCompareViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class UpscaleCompareViewState extends State<UpscaleCompareView> {
|
||||||
|
bool _up = true;
|
||||||
|
final _zoom = TransformationController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_zoom.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 160: 저장하지 않고 닫으면 올린 그림이 지워지므로 묻는다 (닫기 버튼 · 뒤로 키 · 뒤로 몸짓)
|
||||||
|
Future<void> _close() async {
|
||||||
|
final r = await showDialog<String>(
|
||||||
|
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<bool>(
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<AiUpscaler?> readyUpscalerFor(BuildContext context, AppController c) async {
|
||||||
|
if (debugUpscalerForTest case final f?) return f();
|
||||||
|
final store = AiStore.instance ??= AiStore(Directory.systemTemp.path);
|
||||||
|
Future<AiUpscaler?> 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)) ? <AiFile>[] : choices;
|
||||||
|
String label(AiFile f) =>
|
||||||
|
'${f.id == anime.id ? tr('만화 · 그림용') : tr('사진용')} (${AiStore.sizeText(f.size + (engine?.size ?? 0))})';
|
||||||
|
final picked = await showDialog<AiFile?>(
|
||||||
|
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<bool> downloadAiModelFiles(BuildContext context, AiStore store, List<AiFile> files) async {
|
||||||
|
final nav = Navigator.of(context);
|
||||||
|
var cancelled = false;
|
||||||
|
unawaited(showDialog<void>(
|
||||||
|
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<void> 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<String?> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,175 +11,19 @@ import '../app/ai_local.dart';
|
||||||
import '../app/ai_local_engine.dart';
|
import '../app/ai_local_engine.dart';
|
||||||
import '../app/ai_upscale.dart';
|
import '../app/ai_upscale.dart';
|
||||||
import '../app/app_controller.dart';
|
import '../app/app_controller.dart';
|
||||||
import '../core/ai_catalog.dart';
|
|
||||||
import '../core/reader_sources.dart';
|
import '../core/reader_sources.dart';
|
||||||
import '../core/sd_cli.dart' show ImageGenRequest;
|
import '../core/sd_cli.dart' show ImageGenRequest;
|
||||||
import '../core/vfs.dart';
|
|
||||||
import '../l10n/tr.dart';
|
import '../l10n/tr.dart';
|
||||||
import '../services/image_ai.dart' show ImageAiException;
|
import '../services/image_ai.dart' show ImageAiException;
|
||||||
import 'ai_image_page.dart' show aiSaveDir;
|
|
||||||
import 'confirm.dart';
|
import 'confirm.dart';
|
||||||
import 'explorer_page.dart' show ExplorerPage;
|
import 'explorer_page.dart' show ExplorerPage;
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
import 'notice.dart';
|
import 'notice.dart';
|
||||||
|
import 'ai_upscale/upscale_prep.dart';
|
||||||
|
import 'ai_upscale/upscale_compare.dart';
|
||||||
|
|
||||||
UpscaleOptions upscaleOptionsOf(AppController c, {String? outDir}) {
|
export 'ai_upscale/upscale_compare.dart' show UpscaleCompareView, UpscaleCompareViewState;
|
||||||
final s = c.settings;
|
export 'ai_upscale/upscale_prep.dart' show debugUpscalerForTest, readerPageFile, upscaleOptionsOf;
|
||||||
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<AiUpscaler?> _prepare(BuildContext context, AppController c) async {
|
|
||||||
if (debugUpscalerForTest case final f?) return f();
|
|
||||||
final store = AiStore.instance ??= AiStore(Directory.systemTemp.path);
|
|
||||||
Future<AiUpscaler?> 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)) ? <AiFile>[] : choices;
|
|
||||||
String label(AiFile f) =>
|
|
||||||
'${f.id == anime.id ? tr('만화 · 그림용') : tr('사진용')} (${AiStore.sizeText(f.size + (engine?.size ?? 0))})';
|
|
||||||
final picked = await showDialog<AiFile?>(
|
|
||||||
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<bool> _download(BuildContext context, AiStore store, List<AiFile> files) async {
|
|
||||||
final nav = Navigator.of(context);
|
|
||||||
var cancelled = false;
|
|
||||||
unawaited(showDialog<void>(
|
|
||||||
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<void> _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<String?> _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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 121: 한 장 올리기 → 진행 (취소) → [원본 / 올린 것] 비교 → [저장] (새 파일, 원본을 덮지 않음). 저장한 경로 (안 하면 null)
|
/// 121: 한 장 올리기 → 진행 (취소) → [원본 / 올린 것] 비교 → [저장] (새 파일, 원본을 덮지 않음). 저장한 경로 (안 하면 null)
|
||||||
Future<String?> upscaleOne(BuildContext context, AppController c, {required String input, required String nameFrom, required String outDir}) async {
|
Future<String?> upscaleOne(BuildContext context, AppController c, {required String input, required String nameFrom, required String outDir}) async {
|
||||||
|
|
@ -188,7 +32,7 @@ Future<String?> upscaleOne(BuildContext context, AppController c, {required Stri
|
||||||
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(tr('이미 작업 중입니다'))));
|
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(tr('이미 작업 중입니다'))));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
final up = await _prepare(context, c);
|
final up = await readyUpscalerFor(context, c);
|
||||||
if (up == null || !context.mounted) return null;
|
if (up == null || !context.mounted) return null;
|
||||||
final progress = ValueNotifier<double?>(null);
|
final progress = ValueNotifier<double?>(null);
|
||||||
// 167: 남은 시간 (타일 한 장에 걸린 초로) - 모르면 null
|
// 167: 남은 시간 (타일 한 장에 걸린 초로) - 모르면 null
|
||||||
|
|
@ -200,7 +44,7 @@ Future<String?> upscaleOne(BuildContext context, AppController c, {required Stri
|
||||||
var dialogOpen = true;
|
var dialogOpen = true;
|
||||||
// 관리자: CPU 뿐이라 자동이 만화용을 고르면 사진은 질감이 뭉개질 수 있다 - 알리고 바로 바꿀 수 있게
|
// 관리자: CPU 뿐이라 자동이 만화용을 고르면 사진은 질감이 뭉개질 수 있다 - 알리고 바로 바꿀 수 있게
|
||||||
final cpuAnime = c.settings.aiImage.upModel == '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 upscaleEstimateText(c, up, input);
|
||||||
if (!context.mounted) return null;
|
if (!context.mounted) return null;
|
||||||
final nav = Navigator.of(context);
|
final nav = Navigator.of(context);
|
||||||
unawaited(showDialog<void>(
|
unawaited(showDialog<void>(
|
||||||
|
|
@ -278,7 +122,7 @@ Future<String?> upscaleOne(BuildContext context, AppController c, {required Stri
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
up.cancel();
|
up.cancel();
|
||||||
}, uses: const {'esrgan-x4plus', 'esrgan-anime6b', 'engine-vulkan', 'engine-cuda', 'engine-cudart'});
|
}, 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) {
|
} catch (e) {
|
||||||
error = e;
|
error = e;
|
||||||
}
|
}
|
||||||
|
|
@ -300,12 +144,12 @@ Future<String?> upscaleOne(BuildContext context, AppController c, {required Stri
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// 176: 화면에 남아 있으면 바로 비교, 떠났으면 (뒤에서 계속 · 화면 닫음) 결과를 남겨 두고 [보기]
|
// 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]);
|
final text = trf('{0} 올림 - [보기] 로 비교하고 저장', [nameShort]);
|
||||||
AiJobs.instance.done(text,
|
AiJobs.instance.done(text,
|
||||||
view: () async {
|
view: () async {
|
||||||
AiJobs.instance.clearDoneView();
|
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) {
|
if (saved != null) {
|
||||||
AiJobs.instance.done(trf('저장했습니다: {0}', [saved]), dir: p.dirname(saved));
|
AiJobs.instance.done(trf('저장했습니다: {0}', [saved]), dir: p.dirname(saved));
|
||||||
|
|
@ -327,29 +171,6 @@ Future<String?> upscaleOne(BuildContext context, AppController c, {required Stri
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 4배 결과 [four] 를 [원본 / 올린 것] 비교 → [저장] (새 파일, 원본을 덮지 않음). 끝나면 임시 결과를 지운다
|
|
||||||
Future<String?> _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<bool>(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 는 탐색기로
|
/// 157 · 162: 저장한 폴더 열기 - Android 는 이 앱의 파일 탐색기로 (삼성 파일 앱 대신), Windows 는 탐색기로
|
||||||
Future<void> openFolderInApp(NavigatorState nav, AppController c, String dir) async {
|
Future<void> openFolderInApp(NavigatorState nav, AppController c, String dir) async {
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
|
|
@ -362,7 +183,7 @@ Future<void> openFolderInApp(NavigatorState nav, AppController c, String dir) as
|
||||||
/// 121: 폴더 · ZIP 의 모든 장을 올려 저장 (백그라운드 - 작업 알림 · 작업 현황에 남은 장 · 예상 시간)
|
/// 121: 폴더 · ZIP 의 모든 장을 올려 저장 (백그라운드 - 작업 알림 · 작업 현황에 남은 장 · 예상 시간)
|
||||||
Future<void> upscaleAll(BuildContext context, AppController c, ReaderSource src) async {
|
Future<void> upscaleAll(BuildContext context, AppController c, ReaderSource src) async {
|
||||||
final n = src.length;
|
final n = src.length;
|
||||||
final up = await _prepare(context, c);
|
final up = await readyUpscalerFor(context, c);
|
||||||
if (up == null || !context.mounted) return;
|
if (up == null || !context.mounted) return;
|
||||||
// 156-2: 첫 장 크기와 지난번 걸린 시간으로 한 장 · 모두의 예상 시간
|
// 156-2: 첫 장 크기와 지난번 걸린 시간으로 한 장 · 모두의 예상 시간
|
||||||
String? estimate;
|
String? estimate;
|
||||||
|
|
@ -399,7 +220,7 @@ Future<void> upscaleAll(BuildContext context, AppController c, ReaderSource src)
|
||||||
try {
|
try {
|
||||||
final watch = Stopwatch()..start();
|
final watch = Stopwatch()..start();
|
||||||
final saved = await up.upscaleAndSave(file.$1, upscaleOptionsOf(c, outDir: file.$3), nameFrom: file.$2, onProgress: progress);
|
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);
|
lastDir = p.dirname(saved);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
failed.add('${src.pageName(i)}: $e');
|
failed.add('${src.pageName(i)}: $e');
|
||||||
|
|
@ -446,7 +267,7 @@ Future<String?> qualityUp(BuildContext context, AppController c, {required Strin
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// 해상도 올리기 모델 · 엔진 (없으면 받는 창)
|
// 해상도 올리기 모델 · 엔진 (없으면 받는 창)
|
||||||
final up = await _prepare(context, c);
|
final up = await readyUpscalerFor(context, c);
|
||||||
if (up == null || !context.mounted) return null;
|
if (up == null || !context.mounted) return null;
|
||||||
final device = await pickAiDevice(c, store);
|
final device = await pickAiDevice(c, store);
|
||||||
if (!context.mounted) return null;
|
if (!context.mounted) return null;
|
||||||
|
|
@ -561,99 +382,3 @@ Future<String?> qualityUp(BuildContext context, AppController c, {required Strin
|
||||||
c.note('${tr('품질 올리기')}: $saved');
|
c.note('${tr('품질 올리기')}: $saved');
|
||||||
return 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<UpscaleCompareView> createState() => UpscaleCompareViewState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class UpscaleCompareViewState extends State<UpscaleCompareView> {
|
|
||||||
bool _up = true;
|
|
||||||
final _zoom = TransformationController();
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_zoom.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 160: 저장하지 않고 닫으면 올린 그림이 지워지므로 묻는다 (닫기 버튼 · 뒤로 키 · 뒤로 몸짓)
|
|
||||||
Future<void> _close() async {
|
|
||||||
final r = await showDialog<String>(
|
|
||||||
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<bool>(
|
|
||||||
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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue