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
RyuJongJin 2026-10-10 05:54:22 +09:00
parent e46c73a317
commit 36e724afc1
8 changed files with 252 additions and 22 deletions

View File

@ -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.",

View File

@ -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 画面で表示 (左右)",
"뒤로": "戻る",
"뒤로 보내고 계속": "裏で続ける",
"뒤에서 계속": "バックグラウンドで続ける",
"드라이브의 맨 위 폴더는 지울 수 없습니다": "ドライブの最上位フォルダーは削除できません",
"등록": "登録",
"등록 중 일부가 실패했습니다.": "登録の一部に失敗しました。",

View File

@ -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 @@
"두 창으로 보기 (좌우)": "以双窗格显示 (左右)",
"뒤로": "后退",
"뒤로 보내고 계속": "转到后台继续",
"뒤에서 계속": "在后台继续",
"드라이브의 맨 위 폴더는 지울 수 없습니다": "无法删除驱动器的根文件夹",
"등록": "注册",
"등록 중 일부가 실패했습니다.": "部分注册失败。",

View File

@ -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;

View File

@ -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);

View File

@ -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 {
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 Navigator.of(context).push<bool>(MaterialPageRoute(
final save = await nav.push<bool>(MaterialPageRoute(
fullscreenDialog: true,
builder: (_) => UpscaleCompareView(original: input, upscaled: four!, title: p.basename(nameFrom)),
builder: (_) => UpscaleCompareView(original: input, upscaled: four, title: p.basename(nameFrom)),
));
if (save != true || !context.mounted) return null;
if (save != true) 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]))));
}
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 {

View File

@ -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('열기'))),
]),

108
test/upscale_job_test.dart Normal file
View File

@ -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();
});
}