AI 해상도 올리기 [이 장] 도 작업으로 · 큰 그림은 메모리에 맞는 타일 (176)
한 장 올리기가 작업 알림 · 앞 서비스 없이 돌아, 앱이 뒤에 있으면 Android 가 꺼 결과가 사라질 수 있었다 (S10: 엔진 6.6GB · 약 4분). - AiJobs.runTask (1장) 로 - 작업 알림 · 앞 서비스 · 작업 현황. 다른 AI 일이 돌면 막는다. - 진행 창 [뒤에서 계속]: 창만 닫고 계속. 화면에 남아 있으면 끝난 뒤 비교 화면, 떠났으면 작업 현황 끝 줄 [보기] (비교 → 저장, 한 번만) 와 Android 끝 알림. 다음 일을 시작하면 남겨 둔 임시 결과를 지운다. - Android CPU 타일: 기기 메모리 (/proc/meminfo) 의 20% 안에 드는 가장 큰 타일 (512 · 384 · 320 · 256 · 192), 모르면 큰 그림만 256. - upscale_job_test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>묶음4
parent
e46c73a317
commit
36e724afc1
|
|
@ -106,6 +106,7 @@
|
|||
"AI 해상도 올리기 (모든 장)": "AI upscaling (all pages)",
|
||||
"AI 해상도 올리기 (이 장)": "AI upscaling (this page)",
|
||||
"AI 해상도 올리기 등 오래 걸린 작업이 끝났을 때": "When a long task such as AI upscaling finishes",
|
||||
"AI 해상도 올리기: {0}": "AI upscaling: {0}",
|
||||
"API 키": "API key",
|
||||
"API 키 (선택)": "API key (optional)",
|
||||
"API 키 (필수, 무료)": "API key (required, free)",
|
||||
|
|
@ -327,6 +328,7 @@
|
|||
"{0} 에 연결할 수 없습니다. 네트워크 · NAS 가 켜져 있는지 확인하세요.": "Cannot connect to {0}. Check that the network and NAS are on.",
|
||||
"{0} 에서 그리는 중 ({1}초)": "Drawing on {0} ({1} s)",
|
||||
"{0} 에서 받을 파일을 찾지 못했습니다": "Could not find a file to download from {0}",
|
||||
"{0} 올림 - [보기] 로 비교하고 저장": "{0} upscaled - compare and save with [View]",
|
||||
"{0} 외 {1}개": "{0} and {1} more",
|
||||
"{0} 은(는) 이미 이 폴더에 있어 옮길 것이 없습니다.\n{1}": "{0} is already in this folder, so there is nothing to move.\n{1}",
|
||||
"{0} 을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "Picked up {0}. Open the target folder and tap [Paste] ({1}).",
|
||||
|
|
@ -746,6 +748,7 @@
|
|||
"두 창으로 보기 (좌우)": "Show two panes (side by side)",
|
||||
"뒤로": "Back",
|
||||
"뒤로 보내고 계속": "Keep running in background",
|
||||
"뒤에서 계속": "Continue in background",
|
||||
"드라이브의 맨 위 폴더는 지울 수 없습니다": "The top folder of a drive can't be deleted",
|
||||
"등록": "Register",
|
||||
"등록 중 일부가 실패했습니다.": "Some registrations failed.",
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@
|
|||
"AI 해상도 올리기 (모든 장)": "AI 高解像度化 (全ページ)",
|
||||
"AI 해상도 올리기 (이 장)": "AI 高解像度化 (このページ)",
|
||||
"AI 해상도 올리기 등 오래 걸린 작업이 끝났을 때": "AI 高解像度化など時間のかかる作業が終わったとき",
|
||||
"AI 해상도 올리기: {0}": "AI 高解像度化: {0}",
|
||||
"API 키": "API キー",
|
||||
"API 키 (선택)": "API キー (任意)",
|
||||
"API 키 (필수, 무료)": "API キー(必須・無料)",
|
||||
|
|
@ -327,6 +328,7 @@
|
|||
"{0} 에 연결할 수 없습니다. 네트워크 · NAS 가 켜져 있는지 확인하세요.": "{0} に接続できません。ネットワーク・NAS がオンか確認してください。",
|
||||
"{0} 에서 그리는 중 ({1}초)": "{0} で描画中 ({1} 秒)",
|
||||
"{0} 에서 받을 파일을 찾지 못했습니다": "{0} からダウンロードするファイルが見つかりませんでした",
|
||||
"{0} 올림 - [보기] 로 비교하고 저장": "{0} を高解像度化 - [表示] で比較して保存",
|
||||
"{0} 외 {1}개": "{0} ほか {1} 件",
|
||||
"{0} 은(는) 이미 이 폴더에 있어 옮길 것이 없습니다.\n{1}": "{0} は既にこのフォルダーにあるため移動するものがありません。\n{1}",
|
||||
"{0} 을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "{0} を保持しました。入れるフォルダーで [貼り付け] を押してください ({1})。",
|
||||
|
|
@ -746,6 +748,7 @@
|
|||
"두 창으로 보기 (좌우)": "2 画面で表示 (左右)",
|
||||
"뒤로": "戻る",
|
||||
"뒤로 보내고 계속": "裏で続ける",
|
||||
"뒤에서 계속": "バックグラウンドで続ける",
|
||||
"드라이브의 맨 위 폴더는 지울 수 없습니다": "ドライブの最上位フォルダーは削除できません",
|
||||
"등록": "登録",
|
||||
"등록 중 일부가 실패했습니다.": "登録の一部に失敗しました。",
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@
|
|||
"AI 해상도 올리기 (모든 장)": "AI 提高分辨率 (全部页面)",
|
||||
"AI 해상도 올리기 (이 장)": "AI 提高分辨率 (本页)",
|
||||
"AI 해상도 올리기 등 오래 걸린 작업이 끝났을 때": "AI 提升分辨率等耗时任务完成时",
|
||||
"AI 해상도 올리기: {0}": "AI 提升分辨率: {0}",
|
||||
"API 키": "API 密钥",
|
||||
"API 키 (선택)": "API 密钥 (可选)",
|
||||
"API 키 (필수, 무료)": "API 密钥(必填,免费)",
|
||||
|
|
@ -327,6 +328,7 @@
|
|||
"{0} 에 연결할 수 없습니다. 네트워크 · NAS 가 켜져 있는지 확인하세요.": "无法连接到 {0}。请确认网络和 NAS 已开启。",
|
||||
"{0} 에서 그리는 중 ({1}초)": "正在 {0} 上绘制 ({1} 秒)",
|
||||
"{0} 에서 받을 파일을 찾지 못했습니다": "未在 {0} 找到要下载的文件",
|
||||
"{0} 올림 - [보기] 로 비교하고 저장": "{0} 已提升 - 用 [查看] 比较并保存",
|
||||
"{0} 외 {1}개": "{0} 等 {1} 个",
|
||||
"{0} 은(는) 이미 이 폴더에 있어 옮길 것이 없습니다.\n{1}": "{0} 已在此文件夹中,无需移动。\n{1}",
|
||||
"{0} 을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "已暂存 {0}。请在目标文件夹中点按 [粘贴] ({1})。",
|
||||
|
|
@ -746,6 +748,7 @@
|
|||
"두 창으로 보기 (좌우)": "以双窗格显示 (左右)",
|
||||
"뒤로": "后退",
|
||||
"뒤로 보내고 계속": "转到后台继续",
|
||||
"뒤에서 계속": "在后台继续",
|
||||
"드라이브의 맨 위 폴더는 지울 수 없습니다": "无法删除驱动器的根文件夹",
|
||||
"등록": "注册",
|
||||
"등록 중 일부가 실패했습니다.": "部分注册失败。",
|
||||
|
|
|
|||
|
|
@ -558,12 +558,35 @@ class AiJobs extends ChangeNotifier {
|
|||
String? lastDone;
|
||||
String? lastDoneDir;
|
||||
|
||||
void done(String text, {String? dir}) {
|
||||
/// 176: 끝난 일을 다시 볼 것 (화면을 떠나 끝난 한 장 해상도 올리기 → 비교 · 저장). 작업 현황의 [보기]
|
||||
Future<void> Function()? lastDoneView;
|
||||
void Function()? _lastDoneDispose;
|
||||
|
||||
/// [dispose]: 다음 일을 시작하거나 다른 끝난 일로 바뀔 때 (남겨 둔 임시 결과 지우기 등)
|
||||
void done(String text, {String? dir, Future<void> Function()? view, void Function()? dispose}) {
|
||||
_clearDone();
|
||||
lastDone = text;
|
||||
lastDoneDir = dir;
|
||||
lastDoneView = view;
|
||||
_lastDoneDispose = dispose;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// [보기] 를 한 번 쓰면 (비교 화면에서 저장 · 버림) 지운다
|
||||
void clearDoneView() {
|
||||
lastDoneView = null;
|
||||
_lastDoneDispose = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _clearDone() {
|
||||
_lastDoneDispose?.call();
|
||||
_lastDoneDispose = null;
|
||||
lastDoneView = null;
|
||||
lastDone = null;
|
||||
lastDoneDir = null;
|
||||
}
|
||||
|
||||
void start(String t, ImageAiEngine engine, int count) {
|
||||
title = t;
|
||||
_engine = engine;
|
||||
|
|
@ -571,8 +594,7 @@ class AiJobs extends ChangeNotifier {
|
|||
remaining = count;
|
||||
progress = 0;
|
||||
eta = null;
|
||||
lastDone = null;
|
||||
lastDoneDir = null;
|
||||
_clearDone();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
|
@ -609,8 +631,7 @@ class AiJobs extends ChangeNotifier {
|
|||
{required void Function() onCancel, Set<String> uses = const {}}) async {
|
||||
if (busy) throw ImageAiException(tr('이미 작업 중입니다'));
|
||||
inUse = uses;
|
||||
lastDone = null;
|
||||
lastDoneDir = null;
|
||||
_clearDone();
|
||||
var stop = false;
|
||||
this.title = title;
|
||||
total = count;
|
||||
|
|
|
|||
|
|
@ -71,7 +71,29 @@ Uint8List _finish((Uint8List, int, int, int, String, int) a) {
|
|||
/// 그 밖 (Windows · GPU) 은 엔진 기본값
|
||||
bool _androidCpu(SdDevice d) => Platform.isAndroid && d.backend == 'cpu';
|
||||
int upscaleThreads(SdDevice d) => _androidCpu(d) ? Platform.numberOfProcessors : 0;
|
||||
int upscaleTileSize(SdDevice d) => _androidCpu(d) ? 512 : 0;
|
||||
int upscaleTileSize(SdDevice d, {int? memTotalMb, int? longSide}) =>
|
||||
_androidCpu(d) ? androidUpscaleTile(memTotalMb: memTotalMb, longSide: longSide) : 0;
|
||||
|
||||
/// 176: Android CPU 타일 크기 - 메모리는 타일 넓이에 비례한다 (S10 실측: 타일 512 에서 엔진 6.6GB, 기기의 45%).
|
||||
/// 기기 전체 메모리의 20% 안에 드는 가장 큰 타일 (512 · 384 · 320 · 256 · 192). 메모리를 모르면 큰 그림 (긴 변 1000 이상) 은 256.
|
||||
int androidUpscaleTile({int? memTotalMb, int? longSide}) {
|
||||
const at512Mb = 6600;
|
||||
if (memTotalMb == null || memTotalMb <= 0) return (longSide ?? 0) >= 1000 ? 256 : 512;
|
||||
for (final t in const [512, 384, 320, 256]) {
|
||||
if (at512Mb * (t / 512) * (t / 512) <= memTotalMb * 0.20) return t;
|
||||
}
|
||||
return 192;
|
||||
}
|
||||
|
||||
/// 기기 전체 메모리 (MB, /proc/meminfo - Android · Linux). 모르면 null
|
||||
int? deviceMemTotalMb() {
|
||||
try {
|
||||
final m = RegExp(r'MemTotal:\s+(\d+)\s*kB').firstMatch(File('/proc/meminfo').readAsStringSync());
|
||||
return m == null ? null : int.parse(m[1]!) ~/ 1024;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 156-2: 걸린 시간을 기억할 열쇠 (처리 장치 · 모델이 같아야 비교가 된다)
|
||||
String upscaleSpeedKey(AiUpscaler up) => '${up.device.backend}|${up.modelId}';
|
||||
|
|
@ -136,7 +158,15 @@ class AiUpscaler {
|
|||
final tmp = await Directory.systemTemp.createTemp('jj_up_');
|
||||
final out = p.join(tmp.path, 'up.png');
|
||||
try {
|
||||
await runner(device.exe, sdUpscaleArgs(model: modelPath, input: input, out: out, backend: device.backend, threads: upscaleThreads(device), tileSize: upscaleTileSize(device)),
|
||||
// 176: 큰 그림 · 메모리가 적은 기기는 작은 타일 (메모리를 덜 쓴다)
|
||||
int? longSide;
|
||||
try {
|
||||
final bytes = await File(input).openRead(0, 1 << 16).fold<List<int>>([], (a, b) => a..addAll(b));
|
||||
final info = img.findDecoderForData(Uint8List.fromList(bytes))?.startDecode(Uint8List.fromList(bytes));
|
||||
if (info != null) longSide = info.width > info.height ? info.width : info.height;
|
||||
} catch (_) {}
|
||||
final tile = upscaleTileSize(device, memTotalMb: Platform.isAndroid ? deviceMemTotalMb() : null, longSide: longSide);
|
||||
await runner(device.exe, sdUpscaleArgs(model: modelPath, input: input, out: out, backend: device.backend, threads: upscaleThreads(device), tileSize: tile),
|
||||
onProgress: (prog) {
|
||||
final (v, left) = upscaleProgressOf(prog);
|
||||
onProgress?.call(v);
|
||||
|
|
|
|||
|
|
@ -54,7 +54,12 @@ Future<(String input, String nameFrom, String outDir)?> readerPageFile(AppContro
|
|||
}
|
||||
|
||||
/// 준비: 모델 · 엔진이 없으면 그 자리에서 무엇을 받을지 고르고 받는다 (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.aiUpModel, device: c.settings.aiDevice, autoDevice: c.settings.aiAutoDevice);
|
||||
|
|
@ -172,6 +177,11 @@ Future<String?> _estimateText(AppController c, AiUpscaler up, String input) asyn
|
|||
|
||||
/// 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 _prepare(context, c);
|
||||
if (up == null || !context.mounted) return null;
|
||||
final progress = ValueNotifier<double?>(null);
|
||||
|
|
@ -179,6 +189,9 @@ Future<String?> upscaleOne(BuildContext context, AppController c, {required Stri
|
|||
final left = ValueNotifier<Duration?>(null);
|
||||
var cancelled = false;
|
||||
var toPhoto = false;
|
||||
// 176: [뒤에서 계속] - 진행 창만 닫고 작업은 계속 (작업 알림 · 작업 현황에서 보이고, 끝나면 [보기])
|
||||
var background = false;
|
||||
var dialogOpen = true;
|
||||
// 관리자: CPU 뿐이라 자동이 만화용을 고르면 사진은 질감이 뭉개질 수 있다 - 알리고 바로 바꿀 수 있게
|
||||
final cpuAnime = c.settings.aiUpModel == 'auto' && up.device.backend == 'cpu' && up.modelId == 'esrgan-anime6b';
|
||||
final estimate = await _estimateText(c, up, input);
|
||||
|
|
@ -234,42 +247,89 @@ Future<String?> upscaleOne(BuildContext context, AppController c, {required Stri
|
|||
},
|
||||
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 {
|
||||
four = await up.upscale4x(input, onProgress: (v) => progress.value = v, onEta: (d) => left.value = d);
|
||||
await _recordSpeed(c, up, input, watch.elapsed);
|
||||
// 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 _recordSpeed(c, up, input, watch.elapsed);
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
nav.pop(); // 진행 창
|
||||
if (!context.mounted) return null;
|
||||
if (toPhoto) {
|
||||
if (dialogOpen) nav.pop(); // 진행 창
|
||||
if (toPhoto && context.mounted) {
|
||||
await c.updateSettings((x) => x.aiUpModel = 'photo');
|
||||
if (!context.mounted) return null;
|
||||
return upscaleOne(context, c, input: input, nameFrom: nameFrom, outDir: outDir);
|
||||
}
|
||||
if (four == null) {
|
||||
if (!cancelled) ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text('$error')));
|
||||
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 _compareAndSave(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();
|
||||
await _compareAndSave(nav, c, result, input: input, nameFrom: nameFrom, outDir: outDir);
|
||||
},
|
||||
dispose: () {
|
||||
try {
|
||||
final save = await Navigator.of(context).push<bool>(MaterialPageRoute(
|
||||
fullscreenDialog: true,
|
||||
builder: (_) => UpscaleCompareView(original: input, upscaled: four!, title: p.basename(nameFrom)),
|
||||
));
|
||||
if (save != true || !context.mounted) return null;
|
||||
final saved = await saveUpscaled(four, source: nameFrom, original: input, o: upscaleOptionsOf(c, outDir: outDir));
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(trf('저장했습니다: {0}', [saved]))));
|
||||
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;
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
if (context.mounted) ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(trf('저장하지 못했습니다: {0}', [e]))));
|
||||
messenger?.showSnackBar(SnackBar(content: Text(trf('저장하지 못했습니다: {0}', [e]))));
|
||||
return null;
|
||||
} finally {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -196,6 +196,8 @@ class WorkPanel extends StatelessWidget {
|
|||
if (!AiJobs.instance.busy && AiJobs.instance.lastDone != null)
|
||||
Row(children: [
|
||||
Expanded(child: Text('✓ ${AiJobs.instance.lastDone}', style: const TextStyle(fontSize: 12))),
|
||||
// 176: 화면을 떠나 끝난 한 장 해상도 올리기 - 비교해 보고 저장
|
||||
if (AiJobs.instance.lastDoneView case final view?) TextButton(onPressed: view, child: Text(tr('보기'))),
|
||||
if (AiJobs.instance.lastDoneDir case final dir?)
|
||||
TextButton(onPressed: () => openFolderInApp(Navigator.of(context), c, dir), child: Text(tr('열기'))),
|
||||
]),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:jj_mkvmaker/app/ai_local.dart';
|
||||
import 'package:jj_mkvmaker/app/ai_upscale.dart';
|
||||
import 'package:jj_mkvmaker/app/app_controller.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_ui.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 176: 한 장 해상도 올리기도 작업 (AiJobs) 으로 - 작업 알림 · 앞 서비스 · 작업 현황에 나오고 화면을 떠나도 끝난다
|
||||
void main() {
|
||||
late Directory tmp;
|
||||
late String input;
|
||||
late Completer<void> engine;
|
||||
setUp(() {
|
||||
tmp = Directory.systemTemp.createTempSync('jj_up_job_');
|
||||
input = p.join(tmp.path, 'page.png');
|
||||
File(input).writeAsBytesSync(img.encodePng(img.Image(width: 8, height: 6)));
|
||||
engine = Completer<void>();
|
||||
debugUpscalerForTest = () => AiUpscaler(
|
||||
device: const SdDevice('sd-cli', 'cpu', 'CPU'),
|
||||
modelPath: 'm.pth',
|
||||
modelId: 'esrgan-x4plus',
|
||||
runner: (exe, args, {onProgress, onStart}) async {
|
||||
await engine.future; // 엔진이 도는 동안
|
||||
File(args[args.indexOf('-o') + 1]).writeAsBytesSync(img.encodePng(img.Image(width: 32, height: 24)));
|
||||
return const [];
|
||||
},
|
||||
);
|
||||
});
|
||||
tearDown(() {
|
||||
debugUpscalerForTest = null;
|
||||
AiJobs.instance.done(''); // 남은 [보기] 정리
|
||||
tmp.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
test('타일 크기: 기기 메모리의 20% 안 (S10 16GB → 320), 메모리를 모르면 큰 그림만 256', () {
|
||||
expect(androidUpscaleTile(memTotalMb: 15000), 320);
|
||||
expect(androidUpscaleTile(memTotalMb: 40000), 512);
|
||||
expect(androidUpscaleTile(memTotalMb: 6000), 192);
|
||||
expect(androidUpscaleTile(longSide: 1024), 256);
|
||||
expect(androidUpscaleTile(longSide: 800), 512);
|
||||
});
|
||||
|
||||
testWidgets('[이 장] 은 작업 목록에 오르고, [뒤에서 계속] 으로 화면을 떠나도 끝나며 [보기] 로 비교 · 저장', (tester) async {
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
var finished = false;
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (ctx) => TextButton(
|
||||
onPressed: () => upscaleOne(ctx, c, input: input, nameFrom: input, outDir: tmp.path).whenComplete(() => finished = true),
|
||||
child: const Text('올리기'),
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
await tester.tap(find.text('올리기'));
|
||||
for (var i = 0; i < 20 && !AiJobs.instance.busy; i++) {
|
||||
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 20)));
|
||||
await tester.pump();
|
||||
}
|
||||
// 작업 목록 (알림 · 작업 현황) 에 오른다
|
||||
expect(AiJobs.instance.busy, isTrue);
|
||||
expect(AiJobs.instance.statusLine, contains('page.png'));
|
||||
// 화면을 떠난다 (진행 창만 닫힘, 작업은 계속)
|
||||
await tester.tap(find.text('뒤에서 계속'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('AI 해상도 올리기'), findsNothing);
|
||||
expect(AiJobs.instance.busy, isTrue);
|
||||
// 엔진이 끝나면 비교 화면을 띄우지 않고 [보기] 를 남긴다
|
||||
engine.complete();
|
||||
// 시험의 가짜 시간 안에서 시작한 일이라 기다리지 말고 끝날 때까지 돌린다
|
||||
for (var i = 0; i < 200 && !finished; i++) {
|
||||
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 20)));
|
||||
await tester.pump();
|
||||
}
|
||||
expect(finished, isTrue);
|
||||
await tester.pumpAndSettle();
|
||||
expect(AiJobs.instance.busy, isFalse);
|
||||
expect(AiJobs.instance.lastDone, contains('page.png'));
|
||||
expect(find.byType(UpscaleCompareView), findsNothing);
|
||||
final view = AiJobs.instance.lastDoneView;
|
||||
expect(view, isNotNull);
|
||||
// [보기] → 비교 화면 → [저장]
|
||||
var viewed = false;
|
||||
view!().whenComplete(() => viewed = true);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(UpscaleCompareView), findsOneWidget);
|
||||
await tester.runAsync(() => tester.tap(find.widgetWithText(FilledButton, '저장')));
|
||||
for (var i = 0; i < 100 && !viewed; i++) {
|
||||
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 20)));
|
||||
await tester.pump();
|
||||
}
|
||||
expect(viewed, isTrue);
|
||||
final saved = [for (final f in tmp.listSync(recursive: true).whereType<File>()) if (!p.equals(f.path, input)) f.path];
|
||||
expect(saved, isNotEmpty, reason: '저장됨 (원본 옆, 새 파일)');
|
||||
expect(AiJobs.instance.lastDoneView, isNull, reason: '[보기] 는 한 번');
|
||||
ScaffoldMessenger.of(tester.element(find.text('올리기'))).clearSnackBars();
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue