596 lines
22 KiB
Dart
596 lines
22 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'dart:io';
|
||
|
||
import 'package:archive/archive_io.dart';
|
||
import 'package:crypto/crypto.dart';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter/services.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 'app_controller.dart';
|
||
import 'component_store.dart' show AccumulatorSink;
|
||
import 'copy_center.dart' show diskSpace;
|
||
|
||
/// 123 · 121: 기기 안 AI 파일 (모델 · 실행 파일) 받기 · 지우기. 앱 데이터 폴더의 ai/ 아래 (업데이트해도 남게).
|
||
class AiStore extends ChangeNotifier {
|
||
AiStore(this.dataDir, {HttpClient? http}) : _http = http ?? (HttpClient()..userAgent = 'JJMKVMaker');
|
||
final String dataDir;
|
||
final HttpClient _http;
|
||
|
||
static AiStore? instance;
|
||
|
||
String get root => p.join(dataDir, 'ai');
|
||
|
||
/// 받은 파일이 놓이는 곳. LoRA 는 sd-cli 의 <lora:이름:1> 과 맞게 이름을 바꿔 둔다.
|
||
String pathOf(AiFile f) => switch (f.kind) {
|
||
'engine' => p.join(root, f.id),
|
||
'lora' => p.join(root, 'lora', 'lcm-lora-sdv1-5.safetensors'),
|
||
_ => p.join(root, 'models', '${f.id}${p.extension(f.fileName)}'),
|
||
};
|
||
|
||
File _mark(AiFile f) => File(p.join(root, 'installed', '${f.id}.json'));
|
||
|
||
bool isInstalled(String id) => isFileInstalled(aiFile(id));
|
||
|
||
bool isFileInstalled(AiFile f) => _mark(f).existsSync() && FileSystemEntity.typeSync(pathOf(f)) != FileSystemEntityType.notFound;
|
||
|
||
/// 받는 중인 것 (id → 0~1, 모르면 null)
|
||
/// 받는 중인 것 (id → 0~1, 모르면 null)
|
||
final progress = <String, double?>{};
|
||
|
||
/// 받은 바이트 · 전체 · 이번에 받기 시작한 때와 그때 이미 있던 바이트 (속도 · 남은 시간)
|
||
final received = <String, int>{};
|
||
final totals = <String, int>{};
|
||
final _started = <String, (DateTime, int)>{};
|
||
|
||
/// [installAll] 로 차례를 기다리는 것 (149-②: "차례 기다림")
|
||
final queued = <String>{};
|
||
|
||
/// 연결이 끊겨 다시 잇는 중 (몇 번째)
|
||
final retrying = <String, int>{};
|
||
|
||
/// 마지막 실패 (id → 쉬운 말). 다시 받으면 지운다
|
||
final failures = <String, String>{};
|
||
final _cancel = <String>{};
|
||
|
||
void cancel(String id) {
|
||
_cancel.add(id);
|
||
queued.remove(id);
|
||
notifyListeners();
|
||
}
|
||
|
||
/// 받기 (SHA-256 확인). 저장 공간이 모자라면 먼저 알린다. zip (실행 파일) 은 푼다.
|
||
Future<void> install(String id) => installFile(aiFile(id));
|
||
|
||
/// 여러 개를 차례로 (기다리는 것은 [queued] 로 보인다). 하나가 실패하면 멈추고 알린다
|
||
Future<void> installAll(List<AiFile> files) async {
|
||
final todo = [for (final f in files) if (!isFileInstalled(f) && !progress.containsKey(f.id)) f];
|
||
queued.addAll(todo.map((f) => f.id));
|
||
notifyListeners();
|
||
try {
|
||
for (final f in todo) {
|
||
if (!queued.contains(f.id)) continue; // 기다리는 동안 취소
|
||
queued.remove(f.id);
|
||
await installFile(f);
|
||
}
|
||
} finally {
|
||
queued.removeAll(todo.map((f) => f.id));
|
||
notifyListeners();
|
||
}
|
||
}
|
||
|
||
/// 받을 때 필요한 공간 (zip 은 푸는 자리까지) - 이미 받아 둔 조각만큼은 빼고
|
||
int neededSpace(AiFile f) {
|
||
final part = _partOf(f);
|
||
final have = part.existsSync() ? part.lengthSync() : 0;
|
||
return (f.kind == 'engine' ? f.size * 3 : f.size) - have;
|
||
}
|
||
|
||
File _partOf(AiFile f) => File('${pathOf(f)}.part${f.kind == 'engine' ? '.zip' : ''}');
|
||
|
||
/// [install] 의 본체 (시험은 카탈로그 밖의 파일로).
|
||
/// 149: 끊기면 받은 곳부터 이어 받고 (조각 파일을 남겨 둠), 몇 번 저절로 다시 잇는다. 실패는 쉬운 말로 (주소는 보이지 않음).
|
||
Future<void> installFile(AiFile f, {int retries = 5, Duration retryWait = const Duration(seconds: 3)}) async {
|
||
final id = f.id;
|
||
if (progress.containsKey(id)) return;
|
||
_cancel.remove(id);
|
||
failures.remove(id);
|
||
await Directory(root).create(recursive: true);
|
||
final free = await diskSpace(root);
|
||
final need = neededSpace(f) + (200 << 20); // 여유 200MB
|
||
if (free != null && free.$1 < need) {
|
||
throw ImageAiException(trf('저장 공간이 모자랍니다: {0} 이 필요한데 {1} 남았습니다', [_gb(need), _gb(free.$1)]));
|
||
}
|
||
final part = _partOf(f);
|
||
progress[id] = 0;
|
||
notifyListeners();
|
||
try {
|
||
await part.parent.create(recursive: true);
|
||
for (var attempt = 0;; attempt++) {
|
||
try {
|
||
await _download(f, part);
|
||
break;
|
||
} on ImageAiException {
|
||
rethrow; // 취소 · 손상 (다시 받아도 같음)
|
||
} catch (e) {
|
||
if (_cancel.contains(id) || attempt >= retries) {
|
||
final msg = tr('연결이 끊겼습니다. [이어 받기] 를 누르면 받은 곳부터 이어 받습니다');
|
||
failures[id] = msg;
|
||
throw ImageAiException(msg);
|
||
}
|
||
retrying[id] = attempt + 1;
|
||
notifyListeners();
|
||
await Future<void>.delayed(retryWait * (attempt + 1));
|
||
}
|
||
}
|
||
retrying.remove(id);
|
||
if (f.kind == 'engine') {
|
||
final dir = Directory(pathOf(f));
|
||
if (await dir.exists()) await dir.delete(recursive: true);
|
||
await extractFileToDisk(part.path, dir.path);
|
||
await part.delete();
|
||
} else {
|
||
final dst = File(pathOf(f));
|
||
if (await dst.exists()) await dst.delete();
|
||
await part.rename(dst.path);
|
||
}
|
||
await _mark(f).parent.create(recursive: true);
|
||
await _mark(f).writeAsString(jsonEncode({'sha256': f.sha256, 'installed': DateTime.now().toIso8601String()}));
|
||
} finally {
|
||
progress.remove(id);
|
||
received.remove(id);
|
||
totals.remove(id);
|
||
_started.remove(id);
|
||
retrying.remove(id);
|
||
notifyListeners();
|
||
}
|
||
}
|
||
|
||
Future<void> remove(String id) async {
|
||
final f = aiFile(id);
|
||
try {
|
||
await _mark(f).delete();
|
||
} catch (_) {}
|
||
final t = FileSystemEntity.typeSync(pathOf(f));
|
||
if (t == FileSystemEntityType.directory) await Directory(pathOf(f)).delete(recursive: true);
|
||
if (t == FileSystemEntityType.file) await File(pathOf(f)).delete();
|
||
try {
|
||
final part = _partOf(f);
|
||
if (part.existsSync()) part.deleteSync();
|
||
} catch (_) {}
|
||
notifyListeners();
|
||
}
|
||
|
||
static String _gb(int b) => b >= 1 << 30 ? '${(b / (1 << 30)).toStringAsFixed(1)}GB' : '${(b / (1 << 20)).round()}MB';
|
||
static String sizeText(int b) => _gb(b);
|
||
|
||
/// 진행 한 줄: "58% · 2.3GB / 4.0GB · 남은 약 5분" (149-①)
|
||
String? progressText(String id) {
|
||
if (queued.contains(id)) return tr('차례 기다림');
|
||
if (!progress.containsKey(id)) return null;
|
||
final got = received[id] ?? 0, total = totals[id] ?? 0;
|
||
final retry = retrying[id];
|
||
final left = eta(id);
|
||
return [
|
||
if (total > 0) '${(got * 100 / total).floor()}%',
|
||
if (total > 0) '${_gb(got)} / ${_gb(total)}',
|
||
if (left != null) trf('남은 약 {0}', [AiJobs.durationText(left)]),
|
||
if (retry != null) trf('연결이 끊겨 다시 잇는 중 ({0}번째)', [retry]),
|
||
].join(' · ');
|
||
}
|
||
|
||
/// 남은 시간 (이번에 받은 속도로)
|
||
Duration? eta(String id) {
|
||
final s = _started[id];
|
||
final got = received[id], total = totals[id];
|
||
if (s == null || got == null || total == null || total <= 0) return null;
|
||
final secs = DateTime.now().difference(s.$1).inMilliseconds / 1000;
|
||
final rate = (got - s.$2) / (secs <= 0 ? 1 : secs);
|
||
if (secs < 3 || rate <= 0) return null;
|
||
return Duration(seconds: ((total - got) / rate).round());
|
||
}
|
||
|
||
/// 받는 중인 것을 한 줄로 (작업 알림 - 화면이 꺼져도 계속 받는 동안)
|
||
String? get statusLine {
|
||
if (progress.isEmpty) return null;
|
||
final id = progress.keys.first;
|
||
final name = aiCatalog.where((f) => f.id == id).firstOrNull?.name ?? id;
|
||
final more = queued.length;
|
||
return '${trf('AI 모델 받는 중: {0}', [tr(name)])} · ${progressText(id) ?? ''}'
|
||
'${more > 0 ? ' · ${trf('다음 {0}개 기다림', [more])}' : ''}';
|
||
}
|
||
|
||
/// 조각 파일 [out] 이 있으면 그 뒤부터 (Range) 받는다. 서버가 이어 주지 않으면 처음부터.
|
||
/// SHA-256 은 이미 받은 조각까지 합쳐 확인한다.
|
||
Future<void> _download(AiFile f, File out) async {
|
||
var have = out.existsSync() ? out.lengthSync() : 0;
|
||
if (have > f.size) {
|
||
out.deleteSync();
|
||
have = 0;
|
||
}
|
||
final req = await _http.getUrl(Uri.parse(f.url));
|
||
if (have > 0) req.headers.set(HttpHeaders.rangeHeader, 'bytes=$have-');
|
||
final res = await req.close();
|
||
final whole = res.statusCode == 416 && have == f.size; // 이미 다 받아 둠 - 확인만
|
||
if (whole) {
|
||
await res.drain<void>();
|
||
} else if (res.statusCode == 200) {
|
||
have = 0; // 이어 주지 않음 - 처음부터
|
||
} else if (res.statusCode != 206) {
|
||
await res.drain<void>();
|
||
throw HttpException('status ${res.statusCode}'); // 다시 시도 대상 (주소는 사용자에게 보이지 않음)
|
||
}
|
||
final total = f.size;
|
||
final hash = AccumulatorSink<Digest>();
|
||
final hasher = sha256.startChunkedConversion(hash);
|
||
if (have > 0) {
|
||
await for (final c in out.openRead(0, have)) {
|
||
hasher.add(c);
|
||
}
|
||
}
|
||
received[f.id] = have;
|
||
totals[f.id] = total;
|
||
_started[f.id] = (DateTime.now(), have);
|
||
notifyListeners();
|
||
final sink = out.openWrite(mode: have > 0 ? FileMode.append : FileMode.write);
|
||
var done = have;
|
||
var lastNotify = DateTime.now();
|
||
try {
|
||
if (!whole) {
|
||
await for (final chunk in res) {
|
||
if (_cancel.contains(f.id)) throw ImageAiException(tr('취소했습니다'));
|
||
sink.add(chunk);
|
||
hasher.add(chunk);
|
||
done += chunk.length;
|
||
received[f.id] = done;
|
||
progress[f.id] = total > 0 ? done / total : null;
|
||
if (DateTime.now().difference(lastNotify).inMilliseconds > 250) {
|
||
lastNotify = DateTime.now();
|
||
notifyListeners();
|
||
}
|
||
}
|
||
}
|
||
} finally {
|
||
await sink.flush();
|
||
await sink.close();
|
||
hasher.close();
|
||
}
|
||
if (done < total) throw HttpException('short read $done/$total'); // 끊김 - 이어 받기
|
||
if (hash.events.single.toString() != f.sha256) {
|
||
await out.delete();
|
||
throw ImageAiException(trf('받은 파일이 손상되었습니다 (SHA-256 다름): {0}', [f.name]));
|
||
}
|
||
}
|
||
|
||
// ───────── 실행 파일 ─────────
|
||
|
||
/// sd-cli: Android 는 앱에 들어 있는 것 (libsdcli.so - 앱 데이터 폴더의 파일은 실행할 수 없음), Windows 는 받은 것
|
||
Future<String?> sdCli({bool cuda = false}) async {
|
||
if (Platform.isAndroid) {
|
||
try {
|
||
final dir = await const MethodChannel('jj_mkvmaker/android').invokeMethod<String>('nativeLibDir');
|
||
final f = dir == null ? null : File(p.join(dir, 'libsdcli.so'));
|
||
return f != null && f.existsSync() ? f.path : null;
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
final id = cuda ? 'engine-cuda' : 'engine-vulkan';
|
||
if (!isInstalled(id)) return null;
|
||
final exe = _find(Directory(pathOf(aiFile(id))), 'sd-cli.exe');
|
||
return exe;
|
||
}
|
||
|
||
static String? _find(Directory dir, String name) {
|
||
if (!dir.existsSync()) return null;
|
||
for (final e in dir.listSync(recursive: true, followLinks: false)) {
|
||
if (e is File && p.basename(e.path).toLowerCase() == name) return e.path;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// CUDA 판은 런타임 (cudart) DLL 을 같은 폴더에 둔다
|
||
Future<void> placeCudaRuntime() async {
|
||
if (!isInstalled('engine-cuda') || !isInstalled('engine-cudart')) return;
|
||
final exe = await sdCli(cuda: true);
|
||
if (exe == null) return;
|
||
for (final e in Directory(pathOf(aiFile('engine-cudart'))).listSync(recursive: true)) {
|
||
if (e is File && e.path.toLowerCase().endsWith('.dll')) {
|
||
final dst = File(p.join(p.dirname(exe), p.basename(e.path)));
|
||
if (!dst.existsSync()) await e.copy(dst.path);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// NVIDIA 그래픽카드가 있는지 (CUDA 판을 받기 목록에 보일지)
|
||
static Future<bool> hasNvidia() async {
|
||
// 위젯 시험에서는 실제 프로그램을 부르지 않는다 (가짜 시간 안에서 끝나지 않음)
|
||
if (!Platform.isWindows || Platform.environment.containsKey('FLUTTER_TEST')) return false;
|
||
try {
|
||
final r = await Process.run('nvidia-smi', ['-L']);
|
||
return r.exitCode == 0 && '${r.stdout}'.contains('GPU');
|
||
} catch (_) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// 그림 만들기에 쓸 모델 파일 (없으면 null)
|
||
SdModelPaths? sd15Paths({required bool taesd}) {
|
||
if (!sd15LcmFiles.every(isInstalled)) return null;
|
||
final lora = pathOf(aiFile('lcm-lora-sd15'));
|
||
return SdModelPaths(
|
||
model: pathOf(aiFile('sd15')),
|
||
loraDir: p.dirname(lora),
|
||
loraName: p.basenameWithoutExtension(lora),
|
||
taesd: taesd && isInstalled('taesd') ? pathOf(aiFile('taesd')) : null,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 처리 장치 하나 (실행 파일 + 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<List<GeneratedImage>> 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 lines = await runSdCli(device.exe, args, onProgress: (prog) {
|
||
final step = switch (prog.phase) {
|
||
'load' => tr('모델 읽는 중'),
|
||
'sample' => trf('그리는 중 {0}/{1}', [prog.step, prog.steps]),
|
||
'decode' => tr('그림으로 바꾸는 중'),
|
||
_ => tr('저장하는 중'),
|
||
};
|
||
onProgress?.call(prog.overall, step);
|
||
}, onStart: (pr) => _proc = pr);
|
||
if (_cancelled) throw ImageAiException(tr('취소했습니다'));
|
||
final made = Directory(outDir)
|
||
.listSync()
|
||
.whereType<File>()
|
||
.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')]));
|
||
return [for (final (i, f) in made.indexed) GeneratedImage(f.path, seed + i)];
|
||
}
|
||
}
|
||
|
||
/// sd-cli 를 돌리고 진행을 알린다. 실패하면 마지막 출력 몇 줄로 [ImageAiException]
|
||
Future<List<String>> runSdCli(String exe, List<String> 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 = <String>[];
|
||
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;
|
||
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<void>();
|
||
final b = proc.stderr.transform(const Utf8Decoder(allowMalformed: true)).listen(feed).asFuture<void>();
|
||
final code = await proc.exitCode;
|
||
await Future.wait([a, b]);
|
||
// 그림을 다 저장한 뒤 끝내면서 0 이 아닌 코드로 끝나는 일이 있다 (이 판의 sd-cli) - 저장했으면 성공으로 본다
|
||
if (code != 0 && !saved) {
|
||
final err = tail.where((l) => l.contains('[E]') || l.toLowerCase().contains('error')).toList();
|
||
throw ImageAiException(trf('AI 엔진이 실패했습니다 ({0}): {1}', [code, (err.isEmpty ? tail : err).join('\n')]));
|
||
}
|
||
return tail;
|
||
}
|
||
|
||
/// 처리 장치 후보 (Windows: CUDA 판 · Vulkan 판의 GPU · CPU, Android: CPU)
|
||
Future<List<SdDevice>> sdDevices(AiStore store) async {
|
||
final out = <SdDevice>[];
|
||
final cuda = await store.sdCli(cuda: true);
|
||
if (cuda != null) {
|
||
await store.placeCudaRuntime();
|
||
out.add(SdDevice(cuda, 'cuda0', 'NVIDIA CUDA'));
|
||
}
|
||
final exe = await store.sdCli();
|
||
if (exe == null) return out;
|
||
if (Platform.isWindows) {
|
||
try {
|
||
final r = await Process.run(exe, ['--list-devices'], workingDirectory: p.dirname(exe));
|
||
final devs = parseSdDevices('${r.stdout}\n${r.stderr}');
|
||
// Vulkan 장치 중 첫째 (보통 따로 단 그래픽카드)
|
||
for (final (name, desc) in devs) {
|
||
if (name.startsWith('vulkan')) {
|
||
out.add(SdDevice(exe, name, 'Vulkan · $desc'));
|
||
break;
|
||
}
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
out.add(SdDevice(exe, 'cpu', 'CPU'));
|
||
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;
|
||
|
||
bool get busy => title != null;
|
||
|
||
void start(String t, ImageAiEngine engine, int count) {
|
||
title = t;
|
||
_engine = engine;
|
||
remaining = count;
|
||
progress = 0;
|
||
eta = null;
|
||
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();
|
||
}
|
||
|
||
void finish() {
|
||
title = null;
|
||
progress = null;
|
||
remaining = 0;
|
||
eta = null;
|
||
_engine = null;
|
||
notifyListeners();
|
||
}
|
||
|
||
void cancel() => _engine?.cancel();
|
||
|
||
/// 그리기 한 번 (한 번에 하나). 진행 · 남은 장 · 예상 시간을 알리고, 그림마다 옆에 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);
|
||
try {
|
||
final made = await engine.generate(r, outDir: outDir, baseName: 'ai_$stamp', onProgress: (prog, step) {
|
||
Duration? left;
|
||
if (prog != null && prog > 0.05) {
|
||
final spent = DateTime.now().difference(started);
|
||
left = spent * ((1 - prog) / prog);
|
||
}
|
||
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!)])}';
|
||
return '$t${remaining > 1 ? ' · ${trf('남은 {0}장', [remaining])}' : ''}$left';
|
||
}
|
||
|
||
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<SdDevice?> pickAiDevice(AppController c, AiStore store, {void Function(String step)? onStep}) async {
|
||
final devices = await sdDevices(store);
|
||
if (devices.isEmpty) return null;
|
||
final s = c.settings;
|
||
if (s.aiDevice != 'auto') return devices.where((d) => d.key == s.aiDevice).firstOrNull ?? devices.first;
|
||
if (devices.length == 1) return devices.first;
|
||
final signature = devices.map((d) => d.key).join(',');
|
||
if (s.aiAutoDeviceFor == signature) {
|
||
final known = devices.where((d) => d.key == s.aiAutoDevice).firstOrNull;
|
||
if (known != null) return known;
|
||
}
|
||
final models = store.sd15Paths(taesd: s.aiTaesd);
|
||
if (models == null) return devices.first;
|
||
final tmp = await Directory.systemTemp.createTemp('jj_ai_probe_');
|
||
SdDevice? best;
|
||
Duration? bestTime;
|
||
try {
|
||
for (final d in devices) {
|
||
onStep?.call(trf('가장 빠른 처리 장치를 찾는 중 (처음 한 번): {0}', [d.label]));
|
||
final sw = Stopwatch()..start();
|
||
try {
|
||
await runSdCli(d.exe, sdCliArgs(const ImageGenRequest(prompt: 'test', width: 256, height: 256, steps: 1, seed: 1), models,
|
||
out: p.join(tmp.path, 'probe.png'), backend: d.backend));
|
||
} catch (_) {
|
||
continue; // 이 장치는 안 됨 (메모리 부족 등)
|
||
}
|
||
sw.stop();
|
||
if (bestTime == null || sw.elapsed < bestTime) {
|
||
best = d;
|
||
bestTime = sw.elapsed;
|
||
}
|
||
}
|
||
} finally {
|
||
try {
|
||
await tmp.delete(recursive: true);
|
||
} catch (_) {}
|
||
onStep?.call('');
|
||
}
|
||
best ??= devices.last;
|
||
final chosen = best;
|
||
await c.updateSettings((x) => x
|
||
..aiAutoDevice = chosen.key
|
||
..aiAutoDeviceFor = signature);
|
||
return chosen;
|
||
}
|