JJ_MKVMaker/lib/app/ai_local.dart

492 lines
20 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// 기기 안 AI 파일 (모델 · 실행 파일) 받기 · 지우기 저장소 [AiStore] 와 처리 장치 고르기 ([sdDevices] · [pickAiDevice]).
// 엔진 실행은 ai_local_engine.dart, 진행 중인 작업은 ai_jobs.dart (이 파일에서 나눔).
// 약속:
// - 입력: 앱 데이터 폴더 · 카탈로그 파일 [AiFile] (받기 · 지우기), 설정 (aiDevice · aiAutoDevice · aiAutoDeviceFor · aiTaesd).
// - 출력: ai/ 아래에 받은 파일 · 표시 파일 (installed/<id>.json, sha256), 진행 (progress · received · totals · queued),
// 쓸 수 있는 처리 장치 목록 · 고른 장치 (auto 면 짧게 재서 고르고 설정에 기억).
// - 오류: 공간 부족 · 손상 (SHA-256 다름) · 취소 · 엔진이 이 기기에서 못 돎 → [ImageAiException] (쉬운 말),
// 연결이 끊기면 몇 번 다시 잇고 그래도 안 되면 failures 에 남기고 [ImageAiException].
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 'ai_jobs.dart';
import 'ai_local_engine.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));
/// 받을 때 SHA-256 이 공식 값과 같았는지 (표시 파일에 남김) - 화면에 "확인됨"
bool isVerified(AiFile f) {
try {
return (jsonDecode(_mark(f).readAsStringSync()) as Map)['sha256'] == f.sha256;
} catch (_) {
return false;
}
}
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 (실행 파일) 은 푼다.
/// 195: 받기 전 엔진 검사를 거치게 [installAll] 로 (하나만 받을 때도)
Future<void> install(String id) => installAll([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];
// 195: 큰 모델을 받기 전에 이 기기에서 엔진이 도는지 (안 되면 받지 않고 이유를 알린다)
if (todo.any((f) => f.kind != 'engine')) {
final problem = await sdEngineProblem();
if (problem != null) throw ImageAiException(problem);
}
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 == 416) {
// 조각이 서버 파일과 맞지 않음 (크기가 다름) - 조각을 버리고 처음부터 (다시 시도만 반복하다 막히지 않게)
await res.drain<void>();
if (out.existsSync()) out.deleteSync();
throw HttpException('range not satisfiable - restart');
} else if (res.statusCode == 200) {
have = 0; // 이어 주지 않음 - 처음부터
} else if (res.statusCode == 206 && contentRangeStart(res.headers.value(HttpHeaders.contentRangeHeader)) != have) {
// CDN 이 다른 범위를 줌 - 섞이지 않게 조각을 버리고 처음부터
await res.drain<void>();
if (out.existsSync()) out.deleteSync();
throw HttpException('unexpected range - restart');
} 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();
failures[f.id] = tr('파일이 깨졌습니다 · [다시 받기] 를 눌러 주세요');
throw ImageAiException(trf('받은 파일이 손상되었습니다 (SHA-256 다름): {0}', [f.name]));
}
}
// ───────── 실행 파일 ─────────
/// sd-cli: Android 는 앱에 들어 있는 것 (libsdcli.so - 앱 데이터 폴더의 파일은 실행할 수 없음), Windows 는 받은 것
Future<String?> sdCli({bool cuda = false}) async {
if (Platform.isAndroid) {
// 156: Android 에는 CUDA 판이 없다 (같은 libsdcli.so 를 CUDA 로 잘못 세면 GPU 가 있는 줄 알고 사진용 모델을 찾았다)
if (cuda) return null;
try {
final dir = await const MethodChannel('jj_mkvmaker/android').invokeMethod<String>('nativeLibDir');
// 195: dotprod · fp16 이 없는 CPU (ARMv8.0) 는 그 명령을 쓰지 않는 판으로 (v8.2 판은 SIGILL 로 죽는다)
final f = dir == null ? null : File(p.join(dir, sdCliLibName(await _cpuInfo())));
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 Future<String> _cpuInfo() async {
try {
return await File('/proc/cpuinfo').readAsString();
} catch (_) {
return ''; // 모르면 어느 CPU 에서나 도는 판 ([sdCliLibName])
}
}
/// 195: 큰 모델을 받기 전에 엔진을 짧게 실행해 본다 (도움말). 이 기기에서 못 돌면 쉬운 말 한 줄, 되면 null. 한 번만 본다
Future<String?> sdEngineProblem() => _engineProblem ??= _checkEngine();
Future<String?>? _engineProblem;
Future<String?> _checkEngine() async {
if (!Platform.isAndroid) return null;
final lib = sdCliLibName(await _cpuInfo());
final exe = await sdCli();
String? problem;
String result;
if (exe == null) {
problem = tr('이 기기에서는 AI 엔진을 찾지 못했습니다. 앱을 다시 설치해 보세요');
result = problem;
} else {
try {
final r = await Process.run(exe, ['--help'], workingDirectory: p.dirname(exe)).timeout(const Duration(seconds: 20));
// 신호로 죽었을 때만 (도움말은 0 이 아닌 코드로 끝날 수 있다)
problem = r.exitCode < 0 ? sdExitMessage(r.exitCode) : null;
result = problem ?? tr('통과');
} catch (e) {
result = trf('실행해 보지 못함 ({0})', [e.runtimeType]); // 받기는 막지 않는다
}
}
// 관리자 지시: 기기별 문제를 볼 때 쓰게 작업 기록에 한 줄 (이 기기에서 고른 판 · 결과)
log?.call(trf('AI 엔진 확인: {0} · {1}', [lib, result]));
return problem;
}
/// 작업 기록에 한 줄 (앱이 켜질 때 AppController.note 로 이어 둔다)
static void Function(String line)? log;
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,
);
}
}
/// Content-Range ("bytes 120000-299999/300000") 의 시작 (모르면 null)
int? contentRangeStart(String? header) {
final m = RegExp(r'bytes\s+(\d+)-').firstMatch(header ?? '');
return m == null ? null : int.parse(m.group(1)!);
}
/// 처리 장치 후보 (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;
}
/// 그릴 처리 장치. '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.aiImage.device != 'auto') return devices.where((d) => d.key == s.aiImage.device).firstOrNull ?? devices.first;
if (devices.length == 1) return devices.first;
final signature = devices.map((d) => d.key).join(',');
if (s.aiImage.autoDeviceFor == signature) {
final known = devices.where((d) => d.key == s.aiImage.autoDevice).firstOrNull;
if (known != null) return known;
}
final models = store.sd15Paths(taesd: s.aiImage.taesd);
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
..aiImage.autoDevice = chosen.key
..aiImage.autoDeviceFor = signature);
return chosen;
}