From afba0dfede6adaca0a51657a333d16f557de21e8 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:26:12 +0900 Subject: [PATCH] =?UTF-8?q?=EB=82=98=EB=88=84=EA=B8=B0:=20ai=5Flocal=20(82?= =?UTF-8?q?7)=20=EC=9D=84=20=EB=B0=9B=EA=B8=B0=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EC=86=8C=20=C2=B7=20=EC=97=94=EC=A7=84=20=EC=8B=A4=ED=96=89=20?= =?UTF-8?q?=C2=B7=20=EC=9E=91=EC=97=85=20=EB=AA=A8=EB=93=88=EB=A1=9C=20(?= =?UTF-8?q?=E2=86=92=20482=20=C2=B7=20186=20=C2=B7=20195)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ai_local.dart: AiStore · contentRangeStart · sdDevices · pickAiDevice (설정 읽는 줄 그대로) - ai_local_engine.dart (새): SdDevice · LocalSdEngine · runSdCli · SdRunner · isSdFatalLine · sizeMatches · sdCliLibName · sdExitMessage - ai_jobs.dart (새): AiJobs - 부르는 쪽 import 만 고침 (이름 · 동작 그대로), 약속 시험 2개 (실제 cmd.exe 프로세스 · 시험 폴더) Co-Authored-By: Claude Opus 5.5 --- lib/app/ai_jobs.dart | 195 ++++++++++ lib/app/ai_local.dart | 356 +------------------ lib/app/ai_local_engine.dart | 186 ++++++++++ lib/app/ai_upscale.dart | 1 + lib/platform/android/android_keep_alive.dart | 1 + lib/ui/ai_image_page.dart | 2 + lib/ui/ai_settings.dart | 2 + lib/ui/ai_upscale_ui.dart | 2 + lib/ui/work_panel.dart | 2 +- test/ai_image_test.dart | 2 + test/ai_jobs_contract_test.dart | 61 ++++ test/ai_local_engine_contract_test.dart | 53 +++ test/ai_real_test.dart | 2 +- test/ai_upscale_test.dart | 3 +- test/sd_engine_select_test.dart | 2 +- test/upscale_job_test.dart | 3 +- 16 files changed, 522 insertions(+), 351 deletions(-) create mode 100644 lib/app/ai_jobs.dart create mode 100644 lib/app/ai_local_engine.dart create mode 100644 test/ai_jobs_contract_test.dart create mode 100644 test/ai_local_engine_contract_test.dart diff --git a/lib/app/ai_jobs.dart b/lib/app/ai_jobs.dart new file mode 100644 index 0000000..7054e67 --- /dev/null +++ b/lib/app/ai_jobs.dart @@ -0,0 +1,195 @@ +// 진행 중인 AI 작업 하나 (그리기 · 여러 장 해상도 올리기) 를 맡는 모듈 (ai_local.dart 에서 나눔). +// 약속: +// - 입력: 엔진 [ImageAiEngine] · 요청 [ImageGenRequest] · 저장 폴더 ([AiJobs.run]), 장 수 · 장마다 할 일 ([AiJobs.runTask]). +// - 출력: 만든 그림 목록 (그림마다 옆에 같은 이름의 .json) · 끝낸 장 수. 진행 · 남은 장 · 예상 시간은 +// 필드 ([title] · [progress] · [remaining] · [eta]) 와 [statusLine] 으로, 바뀔 때마다 notifyListeners. +// - 오류: 이미 작업 중이면 [ImageAiException]. 엔진 · 할 일의 오류는 그대로 올리고, 끝나면 늘 [finish] 로 비운다. +// 화면 상태에 기대지 않는다 (작업 알림 · 모니터링 · 화면이 [AiJobs.instance] 를 함께 본다). +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; + +import '../core/ai_catalog.dart'; +import '../core/sd_cli.dart'; +import '../l10n/tr.dart'; +import '../services/image_ai.dart'; +import 'ai_local_engine.dart'; + +/// 123: 진행 중인 AI 작업 (작업 알림 · 모니터링 · 화면이 함께 본다) +class AiJobs extends ChangeNotifier { + static final instance = AiJobs(); + + /// 지금 하는 일 (없으면 null) · 진행 (0~1) · 남은 장 수 · 예상 남은 시간 + String? title; + double? progress; + int remaining = 0; + Duration? eta; + ImageAiEngine? _engine; + void Function()? _cancelTask; + + bool get busy => title != null; + + /// 157: 마지막으로 끝난 일 (작업 현황에 "✓ 4장 저장: 폴더 [열기]") - 다음 일을 시작하면 지운다 + String? lastDone; + String? lastDoneDir; + + /// 176: 끝난 일을 다시 볼 것 (화면을 떠나 끝난 한 장 해상도 올리기 → 비교 · 저장). 작업 현황의 [보기] + Future Function()? lastDoneView; + void Function()? _lastDoneDispose; + + /// [dispose]: 다음 일을 시작하거나 다른 끝난 일로 바뀔 때 (남겨 둔 임시 결과 지우기 등) + void done(String text, {String? dir, Future 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; + total = count; + remaining = count; + progress = 0; + eta = null; + _clearDone(); + notifyListeners(); + } + + void update({double? progress, int? remaining, Duration? eta, String? title}) { + this.progress = progress ?? this.progress; + this.remaining = remaining ?? this.remaining; + this.eta = eta ?? this.eta; + this.title = title ?? this.title; + notifyListeners(); + } + + /// 151: 지금 쓰는 받은 파일 (그동안은 [지우기] 를 끈다) + Set inUse = const {}; + + void finish() { + inUse = const {}; + title = null; + progress = null; + total = 0; + remaining = 0; + eta = null; + _engine = null; + notifyListeners(); + } + + void cancel() { + _engine?.cancel(); + _cancelTask?.call(); + } + + /// 121: 여러 장 작업 (폴더 · ZIP 전체 해상도 올리기 등) 을 한 번에 하나로. [body] 는 i 번째 장을 하고 진행 (0~1) 을 알린다. + /// 남은 장 · 예상 시간을 알림 · 작업 현황에 보이고, [onCancel] 로 지금 장을 멈춘다. 끝낸 장 수를 돌려준다. + Future runTask(String title, int count, Future Function(int i, void Function(double? p) progress) body, + {required void Function() onCancel, Set uses = const {}}) async { + if (busy) throw ImageAiException(tr('이미 작업 중입니다')); + inUse = uses; + _clearDone(); + var stop = false; + this.title = title; + total = count; + remaining = count; + progress = 0; + eta = null; + _cancelTask = () { + stop = true; + onCancel(); + }; + notifyListeners(); + final started = DateTime.now(); + var done = 0; + try { + for (var i = 0; i < count && !stop; i++) { + await body(i, (pp) { + final overall = (i + (pp ?? 0)) / count; + final spent = DateTime.now().difference(started); + update( + progress: overall, + remaining: count - i, + eta: overall > 0.02 ? spent * ((1 - overall) / overall) : null, + ); + }); + done++; + } + } finally { + _cancelTask = null; + finish(); + } + return done; + } + + /// 그리기 한 번 (한 번에 하나). 진행 · 남은 장 · 예상 시간을 알리고, 그림마다 옆에 json (프롬프트 · 시드 · 모델) 을 남긴다. + Future> run(ImageAiEngine engine, ImageGenRequest r, {required String outDir}) async { + if (busy) throw ImageAiException(tr('이미 그리는 중입니다')); + final started = DateTime.now(); + final stamp = started.toIso8601String().replaceAll(RegExp(r'[-:T]'), '').split('.').first; + start(trf('AI 그림 ({0})', [engine.label]), engine, r.count); + if (engine is LocalSdEngine) inUse = {...sd15LcmFiles, 'taesd', 'engine-vulkan', 'engine-cuda', 'engine-cudart'}; + try { + final made = await engine.generate(r, outDir: outDir, baseName: 'ai_$stamp', onProgress: (prog, step) { + Duration? left; + // 152: 모델을 읽는 동안 (0.1 까지) 은 남은 시간을 모른다 + if (prog != null && prog > 0.12) { + final spent = DateTime.now().difference(started); + left = spent * ((1 - prog) / prog); + } + // update 는 null 을 무시하므로 직접 지운다 (읽는 동안 지난 "남은 약 0초" 가 남아 있었다) + eta = left; + update( + progress: prog, + title: trf('AI 그림: {0}', [step]), + remaining: prog == null ? null : (r.count - (prog * r.count).floor()).clamp(1, r.count), + eta: left, + ); + }); + for (final g in made) { + try { + await File('${p.withoutExtension(g.path)}.json').writeAsString(r.encode(engine: engine.label, actualSeed: g.seed)); + } catch (_) {} + } + return made; + } finally { + finish(); + } + } + + /// 알림 · 모니터링에 보일 한 줄 + String? get statusLine { + final t = title; + if (t == null) return null; + final left = eta == null ? '' : ' · ${trf('남은 약 {0}', [_dur(eta!)])}'; + // 161: 여러 장이면 "10장 중 3장째" (알림 · 작업 현황 · 그림 화면) + final at = total > 1 ? ' · ${trf('{0}장 중 {1}장째', [total, (total - remaining + 1).clamp(1, total)])}' : ''; + return '$t$at$left'; + } + + /// 161: 이번 작업의 장 수 (남은 장 [remaining] 과 함께 "n장 중 m장째") + int total = 0; + + static String durationText(Duration d) => _dur(d); + + static String _dur(Duration d) => d.inMinutes >= 1 ? trf('{0}분', [d.inMinutes + (d.inSeconds % 60 >= 30 ? 1 : 0)]) : trf('{0}초', [d.inSeconds]); +} diff --git a/lib/app/ai_local.dart b/lib/app/ai_local.dart index 67d5af0..afb86ae 100644 --- a/lib/app/ai_local.dart +++ b/lib/app/ai_local.dart @@ -1,3 +1,11 @@ +// 기기 안 AI 파일 (모델 · 실행 파일) 받기 · 지우기 저장소 [AiStore] 와 처리 장치 고르기 ([sdDevices] · [pickAiDevice]). +// 엔진 실행은 ai_local_engine.dart, 진행 중인 작업은 ai_jobs.dart (이 파일에서 나눔). +// 약속: +// - 입력: 앱 데이터 폴더 · 카탈로그 파일 [AiFile] (받기 · 지우기), 설정 (aiDevice · aiAutoDevice · aiAutoDeviceFor · aiTaesd). +// - 출력: ai/ 아래에 받은 파일 · 표시 파일 (installed/.json, sha256), 진행 (progress · received · totals · queued), +// 쓸 수 있는 처리 장치 목록 · 고른 장치 (auto 면 짧게 재서 고르고 설정에 기억). +// - 오류: 공간 부족 · 손상 (SHA-256 다름) · 취소 · 엔진이 이 기기에서 못 돎 → [ImageAiException] (쉬운 말), +// 연결이 끊기면 몇 번 다시 잇고 그래도 안 되면 failures 에 남기고 [ImageAiException]. import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -6,13 +14,14 @@ import 'package:archive/archive_io.dart'; import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; -import 'package:image/image.dart' as img; import 'package:path/path.dart' as p; import '../core/ai_catalog.dart'; import '../core/sd_cli.dart'; import '../l10n/tr.dart'; import '../services/image_ai.dart'; +import 'ai_jobs.dart'; +import 'ai_local_engine.dart'; import 'app_controller.dart'; import 'component_store.dart' show AccumulatorSink; import 'copy_center.dart' show diskSpace; @@ -407,173 +416,6 @@ int? contentRangeStart(String? header) { return m == null ? null : int.parse(m.group(1)!); } -/// 처리 장치 하나 (실행 파일 + sd-cli --backend 값) -class SdDevice { - final String exe; - final String backend; - final String label; - const SdDevice(this.exe, this.backend, this.label); - String get key => '${p.basename(p.dirname(exe))}:$backend'; -} - -/// 기기 안 엔진 (stable-diffusion.cpp 의 sd-cli 를 따로 띄움 - 메모리가 모자라 꺼져도 앱은 살아 있고, 취소는 프로세스 끝내기) -class LocalSdEngine implements ImageAiEngine { - LocalSdEngine({required this.device, required this.models, this.threads = 0}); - final SdDevice device; - final SdModelPaths models; - final int threads; - Process? _proc; - bool _cancelled = false; - - @override - String get label => trf('이 기기 ({0})', [device.label]); - @override - bool get leavesDevice => false; - - @override - void cancel() { - _cancelled = true; - _proc?.kill(); - } - - @override - Future> generate(ImageGenRequest r, {required String outDir, required String baseName, AiProgress? onProgress}) async { - _cancelled = false; - await Directory(outDir).create(recursive: true); - final seed = resolveSeed(r.seed); - final req = ImageGenRequest( - prompt: r.prompt, - negative: r.negative, - width: r.width, - height: r.height, - steps: r.steps, - cfg: r.cfg, - seed: seed, - count: r.count, - initImage: r.initImage, - strength: r.strength, - model: r.model, - ); - final out = p.join(outDir, '${baseName}_%d.png'); - final args = sdCliArgs(req, models, out: out, backend: device.backend, threads: threads); - final List lines; - try { - lines = await runSdCli(device.exe, args, onProgress: (prog) { - final step = switch (prog.phase) { - // 152: 읽는 동안은 남은 시간을 모른다 - 'load' => tr('모델 읽는 중 …'), - 'sample' => trf('그리는 중 {0}/{1}', [prog.step, prog.steps]), - 'decode' => tr('그림으로 바꾸는 중'), - _ => tr('저장하는 중'), - }; - onProgress?.call(prog.overall, step); - }, onStart: (pr) => _proc = pr); - } catch (_) { - // 150: 취소로 엔진을 끝낸 것은 실패가 아니다 - if (_cancelled) throw ImageAiException(tr('취소했습니다'), cancelled: true); - rethrow; - } - if (_cancelled) throw ImageAiException(tr('취소했습니다'), cancelled: true); - final made = Directory(outDir) - .listSync() - .whereType() - .where((f) => RegExp('^${RegExp.escape(baseName)}_\\d+\\.png\$').hasMatch(p.basename(f.path))) - .toList() - ..sort((a, b) => a.path.compareTo(b.path)); - if (made.isEmpty) throw ImageAiException(trf('그림을 만들지 못했습니다: {0}', [lines.join('\n')])); - // 156-2: 정한 크기가 아니면 (바탕 그림을 그대로 돌려준 것 등) 엔진이 그리지 못한 것 - for (final f in made) { - if (!sizeMatches(f.readAsBytesSync(), r.width, r.height)) { - for (final x in made) { - try { - x.deleteSync(); - } catch (_) {} - } - throw ImageAiException(tr('AI 엔진이 그림을 그리지 못했습니다 (처리 장치를 바꿔 보세요)'), detail: lines.join('\n')); - } - } - return [for (final (i, f) in made.indexed) GeneratedImage(f.path, seed + i)]; - } -} - -/// 그림 [bytes] 가 [w]×[h] 인지 (머리만 읽음) -bool sizeMatches(Uint8List bytes, int w, int h) { - final info = img.findDecoderForData(bytes)?.startDecode(bytes); - return info != null && info.width == w && info.height == h; -} - -/// sd-cli 기록 중 결과를 믿을 수 없게 만드는 실패 (업스케일러 · 장치 · 모델을 만들지 못함) -bool isSdFatalLine(String line) { - final l = line.toLowerCase(); - return const [ - 'new_upscaler_ctx failed', - 'upscale failed', - 'backend config failed', - 'failed to initialize', - 'new_sd_ctx_t failed', - ].any(l.contains); -} - -/// sd-cli 실행기 (시험에서 가짜 엔진으로 바꿀 수 있게) -typedef SdRunner = Future> Function(String exe, List args, - {void Function(SdProgress)? onProgress, void Function(Process)? onStart}); - -/// sd-cli 를 돌리고 진행을 알린다. 실패하면 마지막 출력 몇 줄로 [ImageAiException] -Future> runSdCli(String exe, List args, - {void Function(SdProgress)? onProgress, void Function(Process)? onStart}) async { - final proc = await Process.start(exe, args, workingDirectory: p.dirname(exe)); - onStart?.call(proc); - final tail = []; - final fatal = []; - var saved = false; - void feed(String chunk) { - if (chunk.contains('images saved')) saved = true; - for (final piece in chunk.split(RegExp(r'[\r\n]+'))) { - if (piece.trim().isEmpty) continue; - if (isSdFatalLine(piece)) fatal.add(piece.trim()); - final prog = parseSdProgress(piece); - if (prog != null) { - onProgress?.call(prog); - } else { - tail.add(piece.trim()); - if (tail.length > 8) tail.removeAt(0); - } - } - } - - final a = proc.stdout.transform(const Utf8Decoder(allowMalformed: true)).listen(feed).asFuture(); - final b = proc.stderr.transform(const Utf8Decoder(allowMalformed: true)).listen(feed).asFuture(); - final code = await proc.exitCode; - await Future.wait([a, b]); - // 156-2: 업스케일러 · 장치를 만들지 못해도 sd-cli 는 원본을 저장하고 0 으로 끝난다 - 기록으로 실패를 알아챈다 - if (fatal.isNotEmpty) { - throw ImageAiException(tr('AI 엔진이 해상도를 올리거나 그리지 못했습니다 (처리 장치를 바꿔 보세요)'), detail: fatal.join('\n')); - } - // 그림을 다 저장한 뒤 끝내면서 0 이 아닌 코드로 끝나는 일이 있다 (이 판의 sd-cli) - 저장했으면 성공으로 본다 - if (code != 0 && !saved) { - final err = tail.where((l) => l.contains('[E]') || l.toLowerCase().contains('error')).toList(); - // 150: 쉬운 말 한 줄, 엔진 원문은 [자세히] 에 - throw ImageAiException(sdExitMessage(code), detail: (err.isEmpty ? tail : err).join('\n')); - } - return tail; -} - -/// 195: Android sd-cli 판 고르기 - 모든 코어의 Features 에 asimddp (dotprod) · fphp (fp16) 가 있으면 빠른 v8.2 판, -/// 아니면 (모름 포함) 어느 arm64 에서나 도는 v8.0 판 (tool/build_sdcpp_android.sh) -String sdCliLibName(String cpuinfo) { - final lines = [for (final l in cpuinfo.split('\n')) if (l.trimLeft().toLowerCase().startsWith('features')) l]; - bool has(String line, String f) => line.split(RegExp(r'[\s:]+')).contains(f); - final v82 = lines.isNotEmpty && lines.every((l) => has(l, 'asimddp') && has(l, 'fphp')); - return v82 ? 'libsdcli.so' : 'libsdcli_v80.so'; -} - -/// 195: 엔진이 끝난 코드 → 쉬운 말 (음수 = 그 신호로 죽음). 메모리 · 파일 탓은 실제 그 원인일 때만 말한다 -String sdExitMessage(int code) => switch (code) { - -4 => tr('이 기기의 CPU 가 이 AI 엔진을 지원하지 않습니다'), // SIGILL: CPU 에 없는 명령 - -9 => tr('메모리가 모자라 Android 가 AI 엔진을 끝냈습니다. 다른 앱을 닫거나 환경 설정의 해상도 올리기 메모리를 "적게" 로 해 보세요'), - _ => trf('AI 엔진이 멈췄습니다 (코드 {0}). 엔진 기록은 [자세히] 에 있습니다', [code]), - }; - /// 처리 장치 후보 (Windows: CUDA 판 · Vulkan 판의 GPU · CPU, Android: CPU) Future> sdDevices(AiStore store) async { final out = []; @@ -601,184 +443,6 @@ Future> sdDevices(AiStore store) async { return out; } -/// 123: 진행 중인 AI 작업 (작업 알림 · 모니터링 · 화면이 함께 본다) -class AiJobs extends ChangeNotifier { - static final instance = AiJobs(); - - /// 지금 하는 일 (없으면 null) · 진행 (0~1) · 남은 장 수 · 예상 남은 시간 - String? title; - double? progress; - int remaining = 0; - Duration? eta; - ImageAiEngine? _engine; - void Function()? _cancelTask; - - bool get busy => title != null; - - /// 157: 마지막으로 끝난 일 (작업 현황에 "✓ 4장 저장: 폴더 [열기]") - 다음 일을 시작하면 지운다 - String? lastDone; - String? lastDoneDir; - - /// 176: 끝난 일을 다시 볼 것 (화면을 떠나 끝난 한 장 해상도 올리기 → 비교 · 저장). 작업 현황의 [보기] - Future Function()? lastDoneView; - void Function()? _lastDoneDispose; - - /// [dispose]: 다음 일을 시작하거나 다른 끝난 일로 바뀔 때 (남겨 둔 임시 결과 지우기 등) - void done(String text, {String? dir, Future 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; - total = count; - remaining = count; - progress = 0; - eta = null; - _clearDone(); - notifyListeners(); - } - - void update({double? progress, int? remaining, Duration? eta, String? title}) { - this.progress = progress ?? this.progress; - this.remaining = remaining ?? this.remaining; - this.eta = eta ?? this.eta; - this.title = title ?? this.title; - notifyListeners(); - } - - /// 151: 지금 쓰는 받은 파일 (그동안은 [지우기] 를 끈다) - Set inUse = const {}; - - void finish() { - inUse = const {}; - title = null; - progress = null; - total = 0; - remaining = 0; - eta = null; - _engine = null; - notifyListeners(); - } - - void cancel() { - _engine?.cancel(); - _cancelTask?.call(); - } - - /// 121: 여러 장 작업 (폴더 · ZIP 전체 해상도 올리기 등) 을 한 번에 하나로. [body] 는 i 번째 장을 하고 진행 (0~1) 을 알린다. - /// 남은 장 · 예상 시간을 알림 · 작업 현황에 보이고, [onCancel] 로 지금 장을 멈춘다. 끝낸 장 수를 돌려준다. - Future runTask(String title, int count, Future Function(int i, void Function(double? p) progress) body, - {required void Function() onCancel, Set uses = const {}}) async { - if (busy) throw ImageAiException(tr('이미 작업 중입니다')); - inUse = uses; - _clearDone(); - var stop = false; - this.title = title; - total = count; - remaining = count; - progress = 0; - eta = null; - _cancelTask = () { - stop = true; - onCancel(); - }; - notifyListeners(); - final started = DateTime.now(); - var done = 0; - try { - for (var i = 0; i < count && !stop; i++) { - await body(i, (pp) { - final overall = (i + (pp ?? 0)) / count; - final spent = DateTime.now().difference(started); - update( - progress: overall, - remaining: count - i, - eta: overall > 0.02 ? spent * ((1 - overall) / overall) : null, - ); - }); - done++; - } - } finally { - _cancelTask = null; - finish(); - } - return done; - } - - /// 그리기 한 번 (한 번에 하나). 진행 · 남은 장 · 예상 시간을 알리고, 그림마다 옆에 json (프롬프트 · 시드 · 모델) 을 남긴다. - Future> run(ImageAiEngine engine, ImageGenRequest r, {required String outDir}) async { - if (busy) throw ImageAiException(tr('이미 그리는 중입니다')); - final started = DateTime.now(); - final stamp = started.toIso8601String().replaceAll(RegExp(r'[-:T]'), '').split('.').first; - start(trf('AI 그림 ({0})', [engine.label]), engine, r.count); - if (engine is LocalSdEngine) inUse = {...sd15LcmFiles, 'taesd', 'engine-vulkan', 'engine-cuda', 'engine-cudart'}; - try { - final made = await engine.generate(r, outDir: outDir, baseName: 'ai_$stamp', onProgress: (prog, step) { - Duration? left; - // 152: 모델을 읽는 동안 (0.1 까지) 은 남은 시간을 모른다 - if (prog != null && prog > 0.12) { - final spent = DateTime.now().difference(started); - left = spent * ((1 - prog) / prog); - } - // update 는 null 을 무시하므로 직접 지운다 (읽는 동안 지난 "남은 약 0초" 가 남아 있었다) - eta = left; - update( - progress: prog, - title: trf('AI 그림: {0}', [step]), - remaining: prog == null ? null : (r.count - (prog * r.count).floor()).clamp(1, r.count), - eta: left, - ); - }); - for (final g in made) { - try { - await File('${p.withoutExtension(g.path)}.json').writeAsString(r.encode(engine: engine.label, actualSeed: g.seed)); - } catch (_) {} - } - return made; - } finally { - finish(); - } - } - - /// 알림 · 모니터링에 보일 한 줄 - String? get statusLine { - final t = title; - if (t == null) return null; - final left = eta == null ? '' : ' · ${trf('남은 약 {0}', [_dur(eta!)])}'; - // 161: 여러 장이면 "10장 중 3장째" (알림 · 작업 현황 · 그림 화면) - final at = total > 1 ? ' · ${trf('{0}장 중 {1}장째', [total, (total - remaining + 1).clamp(1, total)])}' : ''; - return '$t$at$left'; - } - - /// 161: 이번 작업의 장 수 (남은 장 [remaining] 과 함께 "n장 중 m장째") - int total = 0; - - static String durationText(Duration d) => _dur(d); - - static String _dur(Duration d) => d.inMinutes >= 1 ? trf('{0}분', [d.inMinutes + (d.inSeconds % 60 >= 30 ? 1 : 0)]) : trf('{0}초', [d.inSeconds]); -} - /// 그릴 처리 장치. 'auto' 면 한 번 짧게 재서 (256×256 · 1단계) 가장 빠른 것을 기억한다 (장치 목록이 바뀌면 다시 잰다). Future pickAiDevice(AppController c, AiStore store, {void Function(String step)? onStep}) async { final devices = await sdDevices(store); diff --git a/lib/app/ai_local_engine.dart b/lib/app/ai_local_engine.dart new file mode 100644 index 0000000..54b68d0 --- /dev/null +++ b/lib/app/ai_local_engine.dart @@ -0,0 +1,186 @@ +// 기기 안 AI 엔진 (stable-diffusion.cpp 의 sd-cli) 를 실행하는 모듈 (ai_local.dart 에서 나눔). +// 약속: +// - 입력: sd-cli 실행 파일 경로 · 인자 ([runSdCli]), 처리 장치 [SdDevice] · 모델 경로 [SdModelPaths] ([LocalSdEngine]), +// /proc/cpuinfo 글 ([sdCliLibName]), 끝난 코드 ([sdExitMessage]), 그림 바이트 ([sizeMatches]). +// - 출력: 엔진 기록 마지막 줄들 · 만든 그림 [GeneratedImage] 목록 · 고를 .so 이름 · 쉬운 말 한 줄. +// - 오류: 엔진이 실패하거나 (기록에 치명 줄 · 저장 없이 0 이 아닌 코드) 그림 크기가 다르면 [ImageAiException], +// 취소로 끝내면 cancelled: true 인 [ImageAiException]. +// 화면 · 받기 저장소 (AiStore) 상태에 기대지 않는다. +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:image/image.dart' as img; +import 'package:path/path.dart' as p; + +import '../core/sd_cli.dart'; +import '../l10n/tr.dart'; +import '../services/image_ai.dart'; + +/// 처리 장치 하나 (실행 파일 + sd-cli --backend 값) +class SdDevice { + final String exe; + final String backend; + final String label; + const SdDevice(this.exe, this.backend, this.label); + String get key => '${p.basename(p.dirname(exe))}:$backend'; +} + +/// 기기 안 엔진 (stable-diffusion.cpp 의 sd-cli 를 따로 띄움 - 메모리가 모자라 꺼져도 앱은 살아 있고, 취소는 프로세스 끝내기) +class LocalSdEngine implements ImageAiEngine { + LocalSdEngine({required this.device, required this.models, this.threads = 0}); + final SdDevice device; + final SdModelPaths models; + final int threads; + Process? _proc; + bool _cancelled = false; + + @override + String get label => trf('이 기기 ({0})', [device.label]); + @override + bool get leavesDevice => false; + + @override + void cancel() { + _cancelled = true; + _proc?.kill(); + } + + @override + Future> generate(ImageGenRequest r, {required String outDir, required String baseName, AiProgress? onProgress}) async { + _cancelled = false; + await Directory(outDir).create(recursive: true); + final seed = resolveSeed(r.seed); + final req = ImageGenRequest( + prompt: r.prompt, + negative: r.negative, + width: r.width, + height: r.height, + steps: r.steps, + cfg: r.cfg, + seed: seed, + count: r.count, + initImage: r.initImage, + strength: r.strength, + model: r.model, + ); + final out = p.join(outDir, '${baseName}_%d.png'); + final args = sdCliArgs(req, models, out: out, backend: device.backend, threads: threads); + final List lines; + try { + lines = await runSdCli(device.exe, args, onProgress: (prog) { + final step = switch (prog.phase) { + // 152: 읽는 동안은 남은 시간을 모른다 + 'load' => tr('모델 읽는 중 …'), + 'sample' => trf('그리는 중 {0}/{1}', [prog.step, prog.steps]), + 'decode' => tr('그림으로 바꾸는 중'), + _ => tr('저장하는 중'), + }; + onProgress?.call(prog.overall, step); + }, onStart: (pr) => _proc = pr); + } catch (_) { + // 150: 취소로 엔진을 끝낸 것은 실패가 아니다 + if (_cancelled) throw ImageAiException(tr('취소했습니다'), cancelled: true); + rethrow; + } + if (_cancelled) throw ImageAiException(tr('취소했습니다'), cancelled: true); + final made = Directory(outDir) + .listSync() + .whereType() + .where((f) => RegExp('^${RegExp.escape(baseName)}_\\d+\\.png\$').hasMatch(p.basename(f.path))) + .toList() + ..sort((a, b) => a.path.compareTo(b.path)); + if (made.isEmpty) throw ImageAiException(trf('그림을 만들지 못했습니다: {0}', [lines.join('\n')])); + // 156-2: 정한 크기가 아니면 (바탕 그림을 그대로 돌려준 것 등) 엔진이 그리지 못한 것 + for (final f in made) { + if (!sizeMatches(f.readAsBytesSync(), r.width, r.height)) { + for (final x in made) { + try { + x.deleteSync(); + } catch (_) {} + } + throw ImageAiException(tr('AI 엔진이 그림을 그리지 못했습니다 (처리 장치를 바꿔 보세요)'), detail: lines.join('\n')); + } + } + return [for (final (i, f) in made.indexed) GeneratedImage(f.path, seed + i)]; + } +} + +/// 그림 [bytes] 가 [w]×[h] 인지 (머리만 읽음) +bool sizeMatches(Uint8List bytes, int w, int h) { + final info = img.findDecoderForData(bytes)?.startDecode(bytes); + return info != null && info.width == w && info.height == h; +} + +/// sd-cli 기록 중 결과를 믿을 수 없게 만드는 실패 (업스케일러 · 장치 · 모델을 만들지 못함) +bool isSdFatalLine(String line) { + final l = line.toLowerCase(); + return const [ + 'new_upscaler_ctx failed', + 'upscale failed', + 'backend config failed', + 'failed to initialize', + 'new_sd_ctx_t failed', + ].any(l.contains); +} + +/// sd-cli 실행기 (시험에서 가짜 엔진으로 바꿀 수 있게) +typedef SdRunner = Future> Function(String exe, List args, + {void Function(SdProgress)? onProgress, void Function(Process)? onStart}); + +/// sd-cli 를 돌리고 진행을 알린다. 실패하면 마지막 출력 몇 줄로 [ImageAiException] +Future> runSdCli(String exe, List args, + {void Function(SdProgress)? onProgress, void Function(Process)? onStart}) async { + final proc = await Process.start(exe, args, workingDirectory: p.dirname(exe)); + onStart?.call(proc); + final tail = []; + final fatal = []; + var saved = false; + void feed(String chunk) { + if (chunk.contains('images saved')) saved = true; + for (final piece in chunk.split(RegExp(r'[\r\n]+'))) { + if (piece.trim().isEmpty) continue; + if (isSdFatalLine(piece)) fatal.add(piece.trim()); + final prog = parseSdProgress(piece); + if (prog != null) { + onProgress?.call(prog); + } else { + tail.add(piece.trim()); + if (tail.length > 8) tail.removeAt(0); + } + } + } + + final a = proc.stdout.transform(const Utf8Decoder(allowMalformed: true)).listen(feed).asFuture(); + final b = proc.stderr.transform(const Utf8Decoder(allowMalformed: true)).listen(feed).asFuture(); + final code = await proc.exitCode; + await Future.wait([a, b]); + // 156-2: 업스케일러 · 장치를 만들지 못해도 sd-cli 는 원본을 저장하고 0 으로 끝난다 - 기록으로 실패를 알아챈다 + if (fatal.isNotEmpty) { + throw ImageAiException(tr('AI 엔진이 해상도를 올리거나 그리지 못했습니다 (처리 장치를 바꿔 보세요)'), detail: fatal.join('\n')); + } + // 그림을 다 저장한 뒤 끝내면서 0 이 아닌 코드로 끝나는 일이 있다 (이 판의 sd-cli) - 저장했으면 성공으로 본다 + if (code != 0 && !saved) { + final err = tail.where((l) => l.contains('[E]') || l.toLowerCase().contains('error')).toList(); + // 150: 쉬운 말 한 줄, 엔진 원문은 [자세히] 에 + throw ImageAiException(sdExitMessage(code), detail: (err.isEmpty ? tail : err).join('\n')); + } + return tail; +} + +/// 195: Android sd-cli 판 고르기 - 모든 코어의 Features 에 asimddp (dotprod) · fphp (fp16) 가 있으면 빠른 v8.2 판, +/// 아니면 (모름 포함) 어느 arm64 에서나 도는 v8.0 판 (tool/build_sdcpp_android.sh) +String sdCliLibName(String cpuinfo) { + final lines = [for (final l in cpuinfo.split('\n')) if (l.trimLeft().toLowerCase().startsWith('features')) l]; + bool has(String line, String f) => line.split(RegExp(r'[\s:]+')).contains(f); + final v82 = lines.isNotEmpty && lines.every((l) => has(l, 'asimddp') && has(l, 'fphp')); + return v82 ? 'libsdcli.so' : 'libsdcli_v80.so'; +} + +/// 195: 엔진이 끝난 코드 → 쉬운 말 (음수 = 그 신호로 죽음). 메모리 · 파일 탓은 실제 그 원인일 때만 말한다 +String sdExitMessage(int code) => switch (code) { + -4 => tr('이 기기의 CPU 가 이 AI 엔진을 지원하지 않습니다'), // SIGILL: CPU 에 없는 명령 + -9 => tr('메모리가 모자라 Android 가 AI 엔진을 끝냈습니다. 다른 앱을 닫거나 환경 설정의 해상도 올리기 메모리를 "적게" 로 해 보세요'), + _ => trf('AI 엔진이 멈췄습니다 (코드 {0}). 엔진 기록은 [자세히] 에 있습니다', [code]), + }; diff --git a/lib/app/ai_upscale.dart b/lib/app/ai_upscale.dart index c570b82..91c506c 100644 --- a/lib/app/ai_upscale.dart +++ b/lib/app/ai_upscale.dart @@ -10,6 +10,7 @@ import '../core/sd_cli.dart'; import '../l10n/tr.dart'; import '../services/image_ai.dart'; import 'ai_local.dart'; +import 'ai_local_engine.dart'; /// 121: AI 해상도 올리기 (Real-ESRGAN, stable-diffusion.cpp 의 sd-cli -M upscale). /// 모델은 늘 4배로 그리고, 2 · 3배는 그 결과를 줄여 만든다. 원본은 절대 덮어쓰지 않는다. diff --git a/lib/platform/android/android_keep_alive.dart b/lib/platform/android/android_keep_alive.dart index e14d2ea..27c5020 100644 --- a/lib/platform/android/android_keep_alive.dart +++ b/lib/platform/android/android_keep_alive.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import '../../app/app_controller.dart'; import '../../app/i18n_controller.dart'; +import '../../app/ai_jobs.dart'; import '../../app/ai_local.dart'; import '../../app/download_manager.dart'; import '../../app/copy_center.dart'; diff --git a/lib/ui/ai_image_page.dart b/lib/ui/ai_image_page.dart index b8c7525..e276bf3 100644 --- a/lib/ui/ai_image_page.dart +++ b/lib/ui/ai_image_page.dart @@ -5,7 +5,9 @@ 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_local_engine.dart'; import '../app/app_controller.dart'; import '../core/reader_sources.dart'; import '../core/sd_cli.dart'; diff --git a/lib/ui/ai_settings.dart b/lib/ui/ai_settings.dart index 0a4a095..b8efea0 100644 --- a/lib/ui/ai_settings.dart +++ b/lib/ui/ai_settings.dart @@ -2,7 +2,9 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import '../app/ai_jobs.dart'; import '../app/ai_local.dart'; +import '../app/ai_local_engine.dart'; import '../app/app_controller.dart'; import '../app/copy_center.dart' show diskSpace; import '../core/ai_catalog.dart'; diff --git a/lib/ui/ai_upscale_ui.dart b/lib/ui/ai_upscale_ui.dart index 60ceaae..2ed72dd 100644 --- a/lib/ui/ai_upscale_ui.dart +++ b/lib/ui/ai_upscale_ui.dart @@ -6,7 +6,9 @@ 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/ai_catalog.dart'; diff --git a/lib/ui/work_panel.dart b/lib/ui/work_panel.dart index 37d905d..f3d0ee1 100644 --- a/lib/ui/work_panel.dart +++ b/lib/ui/work_panel.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import '../app/app_controller.dart'; -import '../app/ai_local.dart'; +import '../app/ai_jobs.dart'; import '../app/download_manager.dart'; import '../core/models.dart'; import '../services/downloader.dart'; diff --git a/test/ai_image_test.dart b/test/ai_image_test.dart index df33ffd..62a0980 100644 --- a/test/ai_image_test.dart +++ b/test/ai_image_test.dart @@ -5,7 +5,9 @@ import 'package:archive/archive_io.dart'; import 'package:crypto/crypto.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:image/image.dart' as img; +import 'package:jj_mkvmaker/app/ai_jobs.dart'; import 'package:jj_mkvmaker/app/ai_local.dart'; +import 'package:jj_mkvmaker/app/ai_local_engine.dart'; import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/core/ai_catalog.dart'; import 'package:jj_mkvmaker/core/sd_cli.dart'; diff --git a/test/ai_jobs_contract_test.dart b/test/ai_jobs_contract_test.dart new file mode 100644 index 0000000..a2b72ba --- /dev/null +++ b/test/ai_jobs_contract_test.dart @@ -0,0 +1,61 @@ +// ai_jobs.dart 약속 시험: 실제 시험 폴더에 파일을 쓰는 할 일로 runTask 의 진행 · 끝 · 오류 · 취소를 본다. +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/ai_jobs.dart'; +import 'package:jj_mkvmaker/services/image_ai.dart'; +import 'package:path/path.dart' as p; + +void main() { + late Directory dir; + setUp(() => dir = Directory.systemTemp.createTempSync('jj_ai_jobs_')); + tearDown(() => dir.deleteSync(recursive: true)); + + test('runTask: 장마다 할 일 · 진행 · "n장 중 m장째" · 끝나면 비움', () async { + final jobs = AiJobs(); + final lines = []; + final done = await jobs.runTask('일', 3, (i, progress) async { + File(p.join(dir.path, '$i.txt')).writeAsStringSync('$i'); + progress(0.5); + lines.add(jobs.statusLine); + }, onCancel: () {}, uses: {'sd15'}); + expect(done, 3); + expect(dir.listSync().length, 3); + expect(lines.first, contains('3장 중 1장째')); + expect(lines.last, contains('3장 중 3장째')); + expect(jobs.busy, isFalse); + expect(jobs.inUse, isEmpty); + expect(jobs.statusLine, isNull); + }); + + test('runTask: 작업 중에 또 부르면 오류 · 취소하면 그 뒤 장은 하지 않음', () async { + final jobs = AiJobs(); + var cancelled = false; + final run = jobs.runTask('일', 5, (i, _) async { + await expectLater(jobs.runTask('또', 1, (_, _) async {}, onCancel: () {}), throwsA(isA())); + if (i == 1) jobs.cancel(); + }, onCancel: () => cancelled = true); + expect(await run, 2); + expect(cancelled, isTrue); + expect(jobs.busy, isFalse); + }); + + test('runTask: 할 일의 오류는 그대로 · 끝나면 비움', () async { + final jobs = AiJobs(); + await expectLater(jobs.runTask('일', 2, (_, _) async => throw const FileSystemException('x'), onCancel: () {}), + throwsA(isA())); + expect(jobs.busy, isFalse); + }); + + test('done · clearDoneView: 다음 일을 시작하면 남긴 것의 dispose 를 부른다', () async { + final jobs = AiJobs(); + var disposed = 0; + jobs.done('끝', dir: dir.path, view: () async {}, dispose: () => disposed++); + expect((jobs.lastDone, jobs.lastDoneDir, jobs.lastDoneView != null), ('끝', dir.path, true)); + await jobs.runTask('일', 1, (_, _) async {}, onCancel: () {}); + expect(disposed, 1); + expect(jobs.lastDone, isNull); + expect(AiJobs.durationText(const Duration(seconds: 90)), '2분'); + expect(AiJobs.durationText(const Duration(seconds: 20)), '20초'); + }); +} diff --git a/test/ai_local_engine_contract_test.dart b/test/ai_local_engine_contract_test.dart new file mode 100644 index 0000000..3b8c065 --- /dev/null +++ b/test/ai_local_engine_contract_test.dart @@ -0,0 +1,53 @@ +// ai_local_engine.dart 약속 시험: 실제 프로세스 (Windows cmd.exe) 와 실제 PNG 로 입력 · 출력 · 오류를 본다. +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'package:jj_mkvmaker/app/ai_local_engine.dart'; +import 'package:jj_mkvmaker/services/image_ai.dart'; + +void main() { + final cmd = Platform.environment['ComSpec'] ?? r'C:\Windows\System32\cmd.exe'; + + test('runSdCli: 저장했다고 하면 0 이 아닌 코드여도 성공 · 마지막 줄들을 돌려준다', () async { + if (!Platform.isWindows) return markTestSkipped('Windows 전용 (cmd.exe)'); + // "images saved" 줄은 진행 (저장 단계) 으로 읽혀 마지막 줄들에는 들지 않는다 + final tail = await runSdCli(cmd, ['/c', 'echo first&& echo 2 images saved&& exit 1']); + expect(tail, contains('first')); + }); + + test('runSdCli: 저장 없이 0 이 아닌 코드 → 쉬운 말 · 원문은 detail', () async { + if (!Platform.isWindows) return markTestSkipped('Windows 전용 (cmd.exe)'); + await expectLater( + runSdCli(cmd, ['/c', 'echo [E] boom&& exit 3']), + throwsA(isA() + .having((e) => e.message, 'message', sdExitMessage(3)) + .having((e) => e.detail, 'detail', contains('[E] boom')))); + }); + + test('runSdCli: 치명 줄이 있으면 0 으로 끝나도 실패 · onStart 로 프로세스를 넘긴다', () async { + if (!Platform.isWindows) return markTestSkipped('Windows 전용 (cmd.exe)'); + Process? started; + await expectLater(runSdCli(cmd, ['/c', 'echo new_sd_ctx_t failed'], onStart: (p) => started = p), + throwsA(isA().having((e) => e.detail, 'detail', contains('new_sd_ctx_t failed')))); + expect(started, isNotNull); + }); + + test('sizeMatches · isSdFatalLine · sdCliLibName · sdExitMessage', () { + final png = img.encodePng(img.Image(width: 7, height: 5)); + expect(sizeMatches(png, 7, 5), isTrue); + expect(sizeMatches(png, 5, 7), isFalse); + expect(isSdFatalLine('[E] Upscale Failed'), isTrue); + expect(isSdFatalLine('[I] sampling completed'), isFalse); + expect(sdCliLibName('Features : fp asimd fphp asimddp\nFeatures : fp asimd fphp asimddp'), 'libsdcli.so'); + expect(sdCliLibName('Features : fp asimd fphp asimddp\nFeatures : fp asimd'), 'libsdcli_v80.so'); + expect(sdCliLibName(''), 'libsdcli_v80.so'); + expect(sdExitMessage(-4), contains('CPU')); + expect(sdExitMessage(7), contains('7')); + }); + + test('SdDevice.key = 실행 파일 폴더 이름:backend', () { + expect(SdDevice(['', 'ai', 'engine-vulkan', 'sd-cli.exe'].join(Platform.pathSeparator), 'vulkan0', 'GPU').key, + 'engine-vulkan:vulkan0'); + }); +} diff --git a/test/ai_real_test.dart b/test/ai_real_test.dart index 185ee5c..ac3f695 100644 --- a/test/ai_real_test.dart +++ b/test/ai_real_test.dart @@ -1,7 +1,7 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; -import 'package:jj_mkvmaker/app/ai_local.dart'; +import 'package:jj_mkvmaker/app/ai_local_engine.dart'; import 'package:jj_mkvmaker/core/sd_cli.dart'; import 'package:path/path.dart' as p; diff --git a/test/ai_upscale_test.dart b/test/ai_upscale_test.dart index 74a0ca5..a3560e0 100644 --- a/test/ai_upscale_test.dart +++ b/test/ai_upscale_test.dart @@ -2,7 +2,8 @@ import 'dart:io'; 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_jobs.dart'; +import 'package:jj_mkvmaker/app/ai_local_engine.dart'; import 'package:jj_mkvmaker/app/ai_upscale.dart'; import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/core/ai_catalog.dart'; diff --git a/test/sd_engine_select_test.dart b/test/sd_engine_select_test.dart index 8b779e5..a6fc36c 100644 --- a/test/sd_engine_select_test.dart +++ b/test/sd_engine_select_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:jj_mkvmaker/app/ai_local.dart'; +import 'package:jj_mkvmaker/app/ai_local_engine.dart'; /// 195: Android 의 AI 엔진 (sd-cli) 판 고르기 · 엔진이 죽은 신호별 문구 void main() { diff --git a/test/upscale_job_test.dart b/test/upscale_job_test.dart index 36b3818..7d190d6 100644 --- a/test/upscale_job_test.dart +++ b/test/upscale_job_test.dart @@ -4,7 +4,8 @@ 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_jobs.dart'; +import 'package:jj_mkvmaker/app/ai_local_engine.dart'; import 'package:jj_mkvmaker/app/ai_upscale.dart'; import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/app/settings.dart';