187 lines
8.3 KiB
Dart
187 lines
8.3 KiB
Dart
// 기기 안 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]),
|
||
};
|