JJ_MKVMaker/lib/app/ai_jobs.dart

196 lines
7.1 KiB
Dart

// 진행 중인 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<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;
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<String> 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<int> runTask(String title, int count, Future<void> Function(int i, void Function(double? p) progress) body,
{required void Function() onCancel, Set<String> 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<List<GeneratedImage>> 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]);
}