385 lines
17 KiB
Dart
385 lines
17 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart' show MethodChannel;
|
|
import 'package:image/image.dart' as img;
|
|
import 'package:path/path.dart' as p;
|
|
|
|
import '../app/ai_jobs.dart';
|
|
import '../app/ai_local.dart';
|
|
import '../app/ai_local_engine.dart';
|
|
import '../app/ai_upscale.dart';
|
|
import '../app/app_controller.dart';
|
|
import '../core/reader_sources.dart';
|
|
import '../core/sd_cli.dart' show ImageGenRequest;
|
|
import '../l10n/tr.dart';
|
|
import '../services/image_ai.dart' show ImageAiException;
|
|
import 'confirm_dialog.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';
|
|
|
|
export 'ai_upscale/upscale_compare.dart' show UpscaleCompareView, UpscaleCompareViewState;
|
|
export 'ai_upscale/upscale_prep.dart' show readerPageFile, upscaleOptionsOf;
|
|
|
|
/// 121: 한 장 올리기 → 진행 (취소) → [원본 / 올린 것] 비교 → [저장] (새 파일, 원본을 덮지 않음). 저장한 경로 (안 하면 null)
|
|
Future<String?> upscaleOne(BuildContext context, AppController c, {required String input, required String nameFrom, required String outDir}) async {
|
|
// 176: 한 장도 작업 (AiJobs) 으로 - 하나씩만
|
|
if (AiJobs.instance.busy) {
|
|
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(tr('이미 작업 중입니다'))));
|
|
return null;
|
|
}
|
|
final up = await readyUpscalerFor(context, c);
|
|
if (up == null || !context.mounted) return null;
|
|
final progress = ValueNotifier<double?>(null);
|
|
// 167: 남은 시간 (타일 한 장에 걸린 초로) - 모르면 null
|
|
final left = ValueNotifier<Duration?>(null);
|
|
var cancelled = false;
|
|
var toPhoto = false;
|
|
// 176: [뒤에서 계속] - 진행 창만 닫고 작업은 계속 (작업 알림 · 작업 현황에서 보이고, 끝나면 [보기])
|
|
var background = false;
|
|
var dialogOpen = true;
|
|
// 관리자: CPU 뿐이라 자동이 만화용을 고르면 사진은 질감이 뭉개질 수 있다 - 알리고 바로 바꿀 수 있게
|
|
final cpuAnime = c.settings.aiImage.upModel == 'auto' && up.device.backend == 'cpu' && up.modelId == 'esrgan-anime6b';
|
|
final estimate = await upscaleEstimateText(c, up, input);
|
|
if (!context.mounted) return null;
|
|
final nav = Navigator.of(context);
|
|
unawaited(showDialog<void>(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(tr('AI 해상도 올리기')),
|
|
content: SizedBox(
|
|
width: 420,
|
|
child: ValueListenableBuilder<double?>(
|
|
valueListenable: progress,
|
|
builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
Text(trf('{0} · {1}배 · {2}', [p.basename(nameFrom), c.settings.aiImage.upScale, up.device.label])),
|
|
const SizedBox(height: 10),
|
|
LinearProgressIndicator(value: v),
|
|
const SizedBox(height: 4),
|
|
// 167: 모델을 읽는 동안은 숫자 없이 움직이는 막대 (예전엔 읽기 막대가 100% 로 보였다)
|
|
ValueListenableBuilder<Duration?>(
|
|
valueListenable: left,
|
|
builder: (_, d, _) => Text(
|
|
v == null
|
|
? tr('준비하는 중')
|
|
: d == null
|
|
? '${(v * 100).round()}%'
|
|
: '${(v * 100).round()}% · ${trf('약 {0} 남음', [AiJobs.durationText(d)])}',
|
|
style: const TextStyle(fontSize: 12)),
|
|
),
|
|
if (estimate != null) Text(estimate, style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
|
if (cpuAnime) ...[
|
|
const SizedBox(height: 10),
|
|
Text(tr("사진에는 '사진' 모델이 더 낫지만, 이 기기 (그래픽카드 없음) 에서는 한 장에 약 20분 걸립니다."),
|
|
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
|
TextButton(
|
|
onPressed: () {
|
|
toPhoto = true;
|
|
cancelled = true;
|
|
up.cancel();
|
|
},
|
|
child: Text(tr("'사진' 모델로 바꿔 다시")),
|
|
),
|
|
],
|
|
]),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () {
|
|
cancelled = true;
|
|
up.cancel();
|
|
},
|
|
child: Text(tr('취소')),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
background = true;
|
|
Navigator.pop(ctx);
|
|
},
|
|
child: Text(tr('뒤에서 계속')),
|
|
),
|
|
],
|
|
),
|
|
).whenComplete(() => dialogOpen = false));
|
|
String? four;
|
|
Object? error;
|
|
final watch = Stopwatch()..start();
|
|
final nameShort = p.basename(nameFrom);
|
|
try {
|
|
// 176: 작업 (AiJobs) 으로 - 작업 알림 · 앞 서비스 (Android) · 작업 현황에 나오고, 화면을 떠나도 계속한다
|
|
await AiJobs.instance.runTask(trf('AI 해상도 올리기: {0}', [nameShort]), 1, (_, prog) async {
|
|
four = await up.upscale4x(input, onProgress: (v) {
|
|
progress.value = v;
|
|
prog(v);
|
|
}, onEta: (d) => left.value = d);
|
|
}, onCancel: () {
|
|
cancelled = true;
|
|
up.cancel();
|
|
}, uses: const {'esrgan-x4plus', 'esrgan-anime6b', 'engine-vulkan', 'engine-cuda', 'engine-cudart'});
|
|
if (four != null) await recordUpscaleSpeed(c, up, input, watch.elapsed);
|
|
} catch (e) {
|
|
error = e;
|
|
}
|
|
if (dialogOpen) nav.pop(); // 진행 창
|
|
if (toPhoto && context.mounted) {
|
|
await c.updateSettings((x) => x.aiImage.upModel = 'photo');
|
|
if (!context.mounted) return null;
|
|
return upscaleOne(context, c, input: input, nameFrom: nameFrom, outDir: outDir);
|
|
}
|
|
final result = four;
|
|
if (result == null) {
|
|
final text = cancelled ? null : '$error';
|
|
if (text == null) return null;
|
|
if (!background && context.mounted) {
|
|
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(text)));
|
|
} else {
|
|
AiJobs.instance.done(trf('{0}: {1}', [nameShort, text]));
|
|
}
|
|
return null;
|
|
}
|
|
// 176: 화면에 남아 있으면 바로 비교, 떠났으면 (뒤에서 계속 · 화면 닫음) 결과를 남겨 두고 [보기]
|
|
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 compareAndSaveUpscale(nav, c, result, input: input, nameFrom: nameFrom, outDir: outDir);
|
|
// 작업 현황 줄도 결과로 (어디에 저장했는지 + [열기])
|
|
if (saved != null) {
|
|
AiJobs.instance.done(trf('저장했습니다: {0}', [saved]), dir: p.dirname(saved));
|
|
} else {
|
|
AiJobs.instance.done(trf('{0}: 저장하지 않았습니다', [nameShort]));
|
|
}
|
|
},
|
|
dispose: () {
|
|
try {
|
|
Directory(p.dirname(result)).deleteSync(recursive: true);
|
|
} catch (_) {}
|
|
});
|
|
c.note('${tr('AI 해상도 올리기')}: $text');
|
|
if (Platform.isAndroid) {
|
|
try {
|
|
await const MethodChannel('jj_mkvmaker/android').invokeMethod<void>('notifyDone', {'title': tr('AI 해상도 올리기'), 'text': text});
|
|
} catch (_) {}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// 157 · 162: 저장한 폴더 열기 - Android 는 이 앱의 파일 탐색기로 (삼성 파일 앱 대신), Windows 는 탐색기로
|
|
Future<void> openFolderInApp(NavigatorState nav, AppController c, String dir) async {
|
|
if (Platform.isAndroid) {
|
|
await ExplorerPage.openAt(nav, c: c, dir: dir);
|
|
} else {
|
|
await c.services.shell.revealFile(dir);
|
|
}
|
|
}
|
|
|
|
/// 121: 폴더 · ZIP 의 모든 장을 올려 저장 (백그라운드 - 작업 알림 · 작업 현황에 남은 장 · 예상 시간)
|
|
Future<void> upscaleAll(BuildContext context, AppController c, ReaderSource src) async {
|
|
final n = src.length;
|
|
final up = await readyUpscalerFor(context, c);
|
|
if (up == null || !context.mounted) return;
|
|
// 156-2: 첫 장 크기와 지난번 걸린 시간으로 한 장 · 모두의 예상 시간
|
|
String? estimate;
|
|
try {
|
|
final first = n == 0 ? null : await readerPageFile(c, src, 0);
|
|
final mp = first == null ? null : megapixels(await File(first.$1).readAsBytes());
|
|
final d = mp == null ? null : upscaleEstimate(c.settings.aiImage.upSecPerMp, up, mp);
|
|
if (d != null) {
|
|
estimate = trf('이 기기에서 한 장 약 {0} · 모두 약 {1} (지난번 걸린 시간 기준)',
|
|
[AiJobs.durationText(d), AiJobs.durationText(d * n)]);
|
|
}
|
|
} catch (_) {}
|
|
if (!context.mounted) return;
|
|
final ok = await confirmAction(
|
|
context,
|
|
title: trf('{0}장 모두 해상도 올리기', [n]),
|
|
body: [
|
|
c.settings.aiImage.upScale == 1
|
|
? tr('크기는 그대로 두고 선명하게 해 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.')
|
|
: trf('{0}배로 올려 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.', [c.settings.aiImage.upScale]),
|
|
?estimate,
|
|
].join('\n\n'),
|
|
ok: tr('시작'),
|
|
danger: false,
|
|
);
|
|
if (!ok || !context.mounted) return;
|
|
final messenger = ScaffoldMessenger.maybeOf(context);
|
|
final nav = Navigator.of(context);
|
|
final failed = <String>[];
|
|
String? lastDir;
|
|
final done = await AiJobs.instance.runTask(tr('AI 해상도 올리기'), n, (i, progress) async {
|
|
final file = await readerPageFile(c, src, i);
|
|
if (file == null) return;
|
|
try {
|
|
final watch = Stopwatch()..start();
|
|
final saved = await up.upscaleAndSave(file.$1, upscaleOptionsOf(c, outDir: file.$3), nameFrom: file.$2, onProgress: progress);
|
|
await recordUpscaleSpeed(c, up, file.$1, watch.elapsed);
|
|
lastDir = p.dirname(saved);
|
|
} catch (e) {
|
|
failed.add('${src.pageName(i)}: $e');
|
|
}
|
|
}, onCancel: up.cancel, uses: {'esrgan-x4plus', 'esrgan-anime6b', 'engine-vulkan', 'engine-cuda', 'engine-cudart'});
|
|
// 157: 끝나면 화면 · 작업 현황 · 알림 (Android) · 작업 기록에 "n장 저장: 폴더" 와 [열기]
|
|
final saved = done - failed.length;
|
|
final text = [
|
|
if (done < n) trf('{0}장 중 {1}장에서 멈췄습니다', [n, done]),
|
|
trf('{0}장 저장: {1}', [saved, lastDir ?? '-']),
|
|
if (failed.isNotEmpty) trf('{0}장은 실패했습니다', [failed.length]),
|
|
].join(' · ');
|
|
AiJobs.instance.done(text, dir: lastDir);
|
|
c.note('${tr('AI 해상도 올리기')}: $text');
|
|
if (Platform.isAndroid) {
|
|
try {
|
|
await const MethodChannel('jj_mkvmaker/android').invokeMethod<void>('notifyDone', {'title': tr('AI 해상도 올리기'), 'text': text});
|
|
} catch (_) {}
|
|
}
|
|
final dir = lastDir;
|
|
showNotice(messenger, text: text, action: dir == null ? null : (tr('열기'), () => openFolderInApp(nav, c, dir)));
|
|
}
|
|
|
|
/// 원본 / 올린 것을 바꿔 보며 비교 (확대해서 볼 수 있음). [저장] 이면 true, 저장하지 않고 닫으면 false
|
|
/// 관리자 판단 (10/17, 사용자 질문에 답이 없어 넣되 단추는 처음에 숨김): [품질 올리기] -
|
|
/// AI 그림 (그림 → 그림, 설정의 다듬기 정도 - 처음 0.25) 으로 잡음 · 뭉개짐을 다듬은 뒤 이어서 해상도 올리기 (설정의 배율).
|
|
/// 원본은 덮지 않고 새 파일 "이름_q" 로 저장한다. AI 그림 모델이 없으면 받으라는 안내.
|
|
Future<String?> qualityUp(BuildContext context, AppController c, {required String input, required String nameFrom, required String outDir}) async {
|
|
if (AiJobs.instance.busy) {
|
|
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(tr('이미 작업 중입니다'))));
|
|
return null;
|
|
}
|
|
final store = AiStore.instance ??= AiStore(Directory.systemTemp.path);
|
|
final models = store.sd15Paths(taesd: c.settings.aiImage.taesd);
|
|
if (models == null) {
|
|
await showDialog<void>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(tr('품질 올리기')),
|
|
content: Text(tr('품질 올리기는 AI 그림 모델이 필요합니다. 환경 설정 > AI 그림에서 모델을 받은 뒤 다시 눌러 주세요.')),
|
|
actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기')))],
|
|
),
|
|
);
|
|
return null;
|
|
}
|
|
// 해상도 올리기 모델 · 엔진 (없으면 받는 창)
|
|
final up = await readyUpscalerFor(context, c);
|
|
if (up == null || !context.mounted) return null;
|
|
final device = await pickAiDevice(c, store);
|
|
if (!context.mounted) return null;
|
|
if (device == null) {
|
|
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(tr('AI 엔진이 없습니다. 환경 설정 > AI 그림에서 받아 주세요.'))));
|
|
return null;
|
|
}
|
|
final orig = img.decodeImage(await File(input).readAsBytes());
|
|
final (w, h) = qualitySize(orig?.width ?? 0, orig?.height ?? 0);
|
|
final tmp = await Directory.systemTemp.createTemp('jj_quality_');
|
|
final engine = LocalSdEngine(device: device, models: models);
|
|
final step = ValueNotifier<String>(tr('준비하는 중'));
|
|
final progress = ValueNotifier<double?>(null);
|
|
var cancelled = false;
|
|
if (!context.mounted) return null;
|
|
final nav = Navigator.of(context);
|
|
var dialogOpen = true;
|
|
unawaited(showDialog<void>(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(tr('품질 올리기')),
|
|
content: SizedBox(
|
|
width: 420,
|
|
child: ValueListenableBuilder<double?>(
|
|
valueListenable: progress,
|
|
builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
Text('${p.basename(nameFrom)} · ${device.label}'),
|
|
const SizedBox(height: 10),
|
|
LinearProgressIndicator(value: v),
|
|
const SizedBox(height: 4),
|
|
ValueListenableBuilder<String>(valueListenable: step, builder: (_, t, _) => Text(t, style: const TextStyle(fontSize: 12))),
|
|
]),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () {
|
|
cancelled = true;
|
|
engine.cancel();
|
|
up.cancel();
|
|
},
|
|
child: Text(tr('취소')),
|
|
),
|
|
],
|
|
),
|
|
).whenComplete(() => dialogOpen = false));
|
|
String? saved;
|
|
Object? error;
|
|
try {
|
|
await AiJobs.instance.runTask(trf('품질 올리기: {0}', [p.basename(nameFrom)]), 1, (_, prog) async {
|
|
// 1) 그림 → 그림 (약하게): 전체의 앞 40%
|
|
step.value = tr('1/2 · AI 그림으로 다듬는 중');
|
|
final made = await engine.generate(
|
|
ImageGenRequest(
|
|
prompt: 'high quality, detailed, sharp focus, clean',
|
|
negative: 'blurry, lowres, noise, jpeg artifacts, deformed',
|
|
width: w,
|
|
height: h,
|
|
steps: c.settings.aiImage.steps,
|
|
cfg: c.settings.aiImage.cfg,
|
|
initImage: input,
|
|
strength: qualityStrength(c.settings.aiImage.qualityStrength),
|
|
),
|
|
outDir: tmp.path,
|
|
baseName: 'q',
|
|
onProgress: (v, _) {
|
|
final x = v == null ? null : v * 0.4;
|
|
progress.value = x;
|
|
prog(x);
|
|
},
|
|
);
|
|
if (cancelled) return;
|
|
// 2) 해상도 올리기: 뒤 60%
|
|
step.value = tr('2/2 · 해상도 올리는 중');
|
|
final four = await up.upscale4x(made.first.path, onProgress: (v) {
|
|
final x = v == null ? null : 0.4 + v * 0.6;
|
|
progress.value = x;
|
|
prog(x);
|
|
});
|
|
try {
|
|
if (cancelled) return;
|
|
// 크기는 원본 기준 (설정의 배율, 1배면 원본 크기 그대로) · 이름_q
|
|
final o = upscaleOptionsOf(c, outDir: outDir);
|
|
saved = await saveUpscaled(four, source: nameFrom, original: input, o: o, suffix: '_q');
|
|
} finally {
|
|
// 4배 결과의 임시 폴더 (jj_up_) - 저장했든 못 했든 지운다
|
|
try {
|
|
await Directory(p.dirname(four)).delete(recursive: true);
|
|
} catch (_) {}
|
|
}
|
|
}, onCancel: () {
|
|
cancelled = true;
|
|
engine.cancel();
|
|
up.cancel();
|
|
}, uses: const {'esrgan-x4plus', 'esrgan-anime6b', 'engine-vulkan', 'engine-cuda', 'engine-cudart'});
|
|
} catch (e) {
|
|
error = e is ImageAiException ? e.message : e;
|
|
} finally {
|
|
try {
|
|
await tmp.delete(recursive: true);
|
|
} catch (_) {}
|
|
}
|
|
if (dialogOpen) nav.pop();
|
|
final messenger = context.mounted ? ScaffoldMessenger.maybeOf(context) : null;
|
|
if (cancelled) return null;
|
|
if (saved == null) {
|
|
messenger?.showSnackBar(SnackBar(content: Text(trf('품질 올리기를 끝내지 못했습니다: {0}', [error ?? '']))));
|
|
return null;
|
|
}
|
|
messenger?.showSnackBar(SnackBar(content: Text(trf('저장했습니다: {0}', [saved]))));
|
|
c.note('${tr('품질 올리기')}: $saved');
|
|
return saved;
|
|
}
|