// 기기 안 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> 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 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() .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> Function(String exe, List args, {void Function(SdProgress)? onProgress, void Function(Process)? onStart}); /// sd-cli 를 돌리고 진행을 알린다. 실패하면 마지막 출력 몇 줄로 [ImageAiException] Future> runSdCli(String exe, List 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 = []; final fatal = []; 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(); final b = proc.stderr.transform(const Utf8Decoder(allowMalformed: true)).listen(feed).asFuture(); 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]), };