// 진행 중인 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]); }