JJ_MKVMaker/lib/app/ai_local_engine.dart

187 lines
8.3 KiB
Dart
Raw 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 엔진 (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<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 List<String> 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<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')]));
// 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<List<String>> Function(String exe, List<String> args,
{void Function(SdProgress)? onProgress, void Function(Process)? onStart});
/// 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>[];
final fatal = <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;
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<void>();
final b = proc.stderr.transform(const Utf8Decoder(allowMalformed: true)).listen(feed).asFuture<void>();
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]),
};