나누기: ai_local (827) 을 받기 저장소 · 엔진 실행 · 작업 모듈로 (→ 482 · 186 · 195)

- ai_local.dart: AiStore · contentRangeStart · sdDevices · pickAiDevice (설정 읽는 줄 그대로)
- ai_local_engine.dart (새): SdDevice · LocalSdEngine · runSdCli · SdRunner · isSdFatalLine · sizeMatches · sdCliLibName · sdExitMessage
- ai_jobs.dart (새): AiJobs
- 부르는 쪽 import 만 고침 (이름 · 동작 그대로), 약속 시험 2개 (실제 cmd.exe 프로세스 · 시험 폴더)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
split_ui
RyuJongJin 2026-10-22 16:26:12 +09:00
parent d537d1c3e1
commit afba0dfede
16 changed files with 522 additions and 351 deletions

195
lib/app/ai_jobs.dart Normal file
View File

@ -0,0 +1,195 @@
// 진행 중인 AI 작업 하나 (그리기 · 여러 장 해상도 올리기) 를 맡는 모듈 (ai_local.dart 에서 나눔).
// 약속:
// - 입력: 엔진 [ImageAiEngine] · 요청 [ImageGenRequest] · 저장 폴더 ([AiJobs.run]), 장 수 · 장마다 할 일 ([AiJobs.runTask]).
// - 출력: 만든 그림 목록 (그림마다 옆에 같은 이름의 .json) · 끝낸 장 수. 진행 · 남은 장 · 예상 시간은
// 필드 ([title] · [progress] · [remaining] · [eta]) 와 [statusLine] 으로, 바뀔 때마다 notifyListeners.
// - 오류: 이미 작업 중이면 [ImageAiException]. 엔진 · 할 일의 오류는 그대로 올리고, 끝나면 늘 [finish] 로 비운다.
// 화면 상태에 기대지 않는다 (작업 알림 · 모니터링 · 화면이 [AiJobs.instance] 를 함께 본다).
import 'dart:io';
import 'package:flutter/foundation.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_local_engine.dart';
/// 123: 진행 중인 AI 작업 (작업 알림 · 모니터링 · 화면이 함께 본다)
class AiJobs extends ChangeNotifier {
static final instance = AiJobs();
/// 지금 하는 일 (없으면 null) · 진행 (0~1) · 남은 장 수 · 예상 남은 시간
String? title;
double? progress;
int remaining = 0;
Duration? eta;
ImageAiEngine? _engine;
void Function()? _cancelTask;
bool get busy => title != null;
/// 157: 마지막으로 끝난 일 (작업 현황에 "✓ 4장 저장: 폴더 [열기]") - 다음 일을 시작하면 지운다
String? lastDone;
String? lastDoneDir;
/// 176: 끝난 일을 다시 볼 것 (화면을 떠나 끝난 한 장 해상도 올리기 → 비교 · 저장). 작업 현황의 [보기]
Future<void> Function()? lastDoneView;
void Function()? _lastDoneDispose;
/// [dispose]: 다음 일을 시작하거나 다른 끝난 일로 바뀔 때 (남겨 둔 임시 결과 지우기 등)
void done(String text, {String? dir, Future<void> Function()? view, void Function()? dispose}) {
_clearDone();
lastDone = text;
lastDoneDir = dir;
lastDoneView = view;
_lastDoneDispose = dispose;
notifyListeners();
}
/// [보기] 를 한 번 쓰면 (비교 화면에서 저장 · 버림) 지운다
void clearDoneView() {
lastDoneView = null;
_lastDoneDispose = null;
notifyListeners();
}
void _clearDone() {
_lastDoneDispose?.call();
_lastDoneDispose = null;
lastDoneView = null;
lastDone = null;
lastDoneDir = null;
}
void start(String t, ImageAiEngine engine, int count) {
title = t;
_engine = engine;
total = count;
remaining = count;
progress = 0;
eta = null;
_clearDone();
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();
}
/// 151: 지금 쓰는 받은 파일 (그동안은 [지우기] 를 끈다)
Set<String> inUse = const {};
void finish() {
inUse = const {};
title = null;
progress = null;
total = 0;
remaining = 0;
eta = null;
_engine = null;
notifyListeners();
}
void cancel() {
_engine?.cancel();
_cancelTask?.call();
}
/// 121: 여러 장 작업 (폴더 · ZIP 전체 해상도 올리기 등) 을 한 번에 하나로. [body] 는 i 번째 장을 하고 진행 (0~1) 을 알린다.
/// 남은 장 · 예상 시간을 알림 · 작업 현황에 보이고, [onCancel] 로 지금 장을 멈춘다. 끝낸 장 수를 돌려준다.
Future<int> runTask(String title, int count, Future<void> Function(int i, void Function(double? p) progress) body,
{required void Function() onCancel, Set<String> uses = const {}}) async {
if (busy) throw ImageAiException(tr('이미 작업 중입니다'));
inUse = uses;
_clearDone();
var stop = false;
this.title = title;
total = count;
remaining = count;
progress = 0;
eta = null;
_cancelTask = () {
stop = true;
onCancel();
};
notifyListeners();
final started = DateTime.now();
var done = 0;
try {
for (var i = 0; i < count && !stop; i++) {
await body(i, (pp) {
final overall = (i + (pp ?? 0)) / count;
final spent = DateTime.now().difference(started);
update(
progress: overall,
remaining: count - i,
eta: overall > 0.02 ? spent * ((1 - overall) / overall) : null,
);
});
done++;
}
} finally {
_cancelTask = null;
finish();
}
return done;
}
/// 그리기 한 번 (한 번에 하나). 진행 · 남은 장 · 예상 시간을 알리고, 그림마다 옆에 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);
if (engine is LocalSdEngine) inUse = {...sd15LcmFiles, 'taesd', 'engine-vulkan', 'engine-cuda', 'engine-cudart'};
try {
final made = await engine.generate(r, outDir: outDir, baseName: 'ai_$stamp', onProgress: (prog, step) {
Duration? left;
// 152: 모델을 읽는 동안 (0.1 까지) 은 남은 시간을 모른다
if (prog != null && prog > 0.12) {
final spent = DateTime.now().difference(started);
left = spent * ((1 - prog) / prog);
}
// update 는 null 을 무시하므로 직접 지운다 (읽는 동안 지난 "남은 약 0초" 가 남아 있었다)
eta = left;
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!)])}';
// 161: 여러 장이면 "10장 중 3장째" (알림 · 작업 현황 · 그림 화면)
final at = total > 1 ? ' · ${trf('{0}장 중 {1}장째', [total, (total - remaining + 1).clamp(1, total)])}' : '';
return '$t$at$left';
}
/// 161: 이번 작업의 장 수 (남은 장 [remaining] 과 함께 "n장 중 m장째")
int total = 0;
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]);
}

View File

@ -1,3 +1,11 @@
// 기기 안 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';
@ -6,13 +14,14 @@ import 'package:archive/archive_io.dart';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:image/image.dart' as img;
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;
@ -407,173 +416,6 @@ int? contentRangeStart(String? header) {
return m == null ? null : int.parse(m.group(1)!);
}
/// 처리 장치 하나 (실행 파일 + 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]),
};
/// 처리 장치 후보 (Windows: CUDA 판 · Vulkan 판의 GPU · CPU, Android: CPU)
Future<List<SdDevice>> sdDevices(AiStore store) async {
final out = <SdDevice>[];
@ -601,184 +443,6 @@ Future<List<SdDevice>> sdDevices(AiStore store) async {
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;
void Function()? _cancelTask;
bool get busy => title != null;
/// 157: 마지막으로 끝난 일 (작업 현황에 "✓ 4장 저장: 폴더 [열기]") - 다음 일을 시작하면 지운다
String? lastDone;
String? lastDoneDir;
/// 176: 끝난 일을 다시 볼 것 (화면을 떠나 끝난 한 장 해상도 올리기 → 비교 · 저장). 작업 현황의 [보기]
Future<void> Function()? lastDoneView;
void Function()? _lastDoneDispose;
/// [dispose]: 다음 일을 시작하거나 다른 끝난 일로 바뀔 때 (남겨 둔 임시 결과 지우기 등)
void done(String text, {String? dir, Future<void> Function()? view, void Function()? dispose}) {
_clearDone();
lastDone = text;
lastDoneDir = dir;
lastDoneView = view;
_lastDoneDispose = dispose;
notifyListeners();
}
/// [보기] 를 한 번 쓰면 (비교 화면에서 저장 · 버림) 지운다
void clearDoneView() {
lastDoneView = null;
_lastDoneDispose = null;
notifyListeners();
}
void _clearDone() {
_lastDoneDispose?.call();
_lastDoneDispose = null;
lastDoneView = null;
lastDone = null;
lastDoneDir = null;
}
void start(String t, ImageAiEngine engine, int count) {
title = t;
_engine = engine;
total = count;
remaining = count;
progress = 0;
eta = null;
_clearDone();
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();
}
/// 151: 지금 쓰는 받은 파일 (그동안은 [지우기] 를 끈다)
Set<String> inUse = const {};
void finish() {
inUse = const {};
title = null;
progress = null;
total = 0;
remaining = 0;
eta = null;
_engine = null;
notifyListeners();
}
void cancel() {
_engine?.cancel();
_cancelTask?.call();
}
/// 121: 여러 장 작업 (폴더 · ZIP 전체 해상도 올리기 등) 을 한 번에 하나로. [body] 는 i 번째 장을 하고 진행 (0~1) 을 알린다.
/// 남은 장 · 예상 시간을 알림 · 작업 현황에 보이고, [onCancel] 로 지금 장을 멈춘다. 끝낸 장 수를 돌려준다.
Future<int> runTask(String title, int count, Future<void> Function(int i, void Function(double? p) progress) body,
{required void Function() onCancel, Set<String> uses = const {}}) async {
if (busy) throw ImageAiException(tr('이미 작업 중입니다'));
inUse = uses;
_clearDone();
var stop = false;
this.title = title;
total = count;
remaining = count;
progress = 0;
eta = null;
_cancelTask = () {
stop = true;
onCancel();
};
notifyListeners();
final started = DateTime.now();
var done = 0;
try {
for (var i = 0; i < count && !stop; i++) {
await body(i, (pp) {
final overall = (i + (pp ?? 0)) / count;
final spent = DateTime.now().difference(started);
update(
progress: overall,
remaining: count - i,
eta: overall > 0.02 ? spent * ((1 - overall) / overall) : null,
);
});
done++;
}
} finally {
_cancelTask = null;
finish();
}
return done;
}
/// 그리기 한 번 (한 번에 하나). 진행 · 남은 장 · 예상 시간을 알리고, 그림마다 옆에 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);
if (engine is LocalSdEngine) inUse = {...sd15LcmFiles, 'taesd', 'engine-vulkan', 'engine-cuda', 'engine-cudart'};
try {
final made = await engine.generate(r, outDir: outDir, baseName: 'ai_$stamp', onProgress: (prog, step) {
Duration? left;
// 152: 모델을 읽는 동안 (0.1 까지) 은 남은 시간을 모른다
if (prog != null && prog > 0.12) {
final spent = DateTime.now().difference(started);
left = spent * ((1 - prog) / prog);
}
// update 는 null 을 무시하므로 직접 지운다 (읽는 동안 지난 "남은 약 0초" 가 남아 있었다)
eta = left;
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!)])}';
// 161: 여러 장이면 "10장 중 3장째" (알림 · 작업 현황 · 그림 화면)
final at = total > 1 ? ' · ${trf('{0}장 중 {1}장째', [total, (total - remaining + 1).clamp(1, total)])}' : '';
return '$t$at$left';
}
/// 161: 이번 작업의 장 수 (남은 장 [remaining] 과 함께 "n장 중 m장째")
int total = 0;
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);

View File

@ -0,0 +1,186 @@
// 기기 안 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]),
};

View File

@ -10,6 +10,7 @@ import '../core/sd_cli.dart';
import '../l10n/tr.dart';
import '../services/image_ai.dart';
import 'ai_local.dart';
import 'ai_local_engine.dart';
/// 121: AI 해상도 올리기 (Real-ESRGAN, stable-diffusion.cpp 의 sd-cli -M upscale).
/// 모델은 늘 4배로 그리고, 2 · 3배는 그 결과를 줄여 만든다. 원본은 절대 덮어쓰지 않는다.

View File

@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
import '../../app/app_controller.dart';
import '../../app/i18n_controller.dart';
import '../../app/ai_jobs.dart';
import '../../app/ai_local.dart';
import '../../app/download_manager.dart';
import '../../app/copy_center.dart';

View File

@ -5,7 +5,9 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import '../app/ai_jobs.dart';
import '../app/ai_local.dart';
import '../app/ai_local_engine.dart';
import '../app/app_controller.dart';
import '../core/reader_sources.dart';
import '../core/sd_cli.dart';

View File

@ -2,7 +2,9 @@ import 'dart:io';
import 'package:flutter/material.dart';
import '../app/ai_jobs.dart';
import '../app/ai_local.dart';
import '../app/ai_local_engine.dart';
import '../app/app_controller.dart';
import '../app/copy_center.dart' show diskSpace;
import '../core/ai_catalog.dart';

View File

@ -6,7 +6,9 @@ import 'package:flutter/services.dart' show MethodChannel;
import 'package:image/image.dart' as img;
import 'package:path/path.dart' as p;
import '../app/ai_jobs.dart';
import '../app/ai_local.dart';
import '../app/ai_local_engine.dart';
import '../app/ai_upscale.dart';
import '../app/app_controller.dart';
import '../core/ai_catalog.dart';

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import '../app/app_controller.dart';
import '../app/ai_local.dart';
import '../app/ai_jobs.dart';
import '../app/download_manager.dart';
import '../core/models.dart';
import '../services/downloader.dart';

View File

@ -5,7 +5,9 @@ import 'package:archive/archive_io.dart';
import 'package:crypto/crypto.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
import 'package:jj_mkvmaker/app/ai_jobs.dart';
import 'package:jj_mkvmaker/app/ai_local.dart';
import 'package:jj_mkvmaker/app/ai_local_engine.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/core/ai_catalog.dart';
import 'package:jj_mkvmaker/core/sd_cli.dart';

View File

@ -0,0 +1,61 @@
// ai_jobs.dart 약속 시험: 실제 시험 폴더에 파일을 쓰는 할 일로 runTask 의 진행 · 끝 · 오류 · 취소를 본다.
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/ai_jobs.dart';
import 'package:jj_mkvmaker/services/image_ai.dart';
import 'package:path/path.dart' as p;
void main() {
late Directory dir;
setUp(() => dir = Directory.systemTemp.createTempSync('jj_ai_jobs_'));
tearDown(() => dir.deleteSync(recursive: true));
test('runTask: 장마다 할 일 · 진행 · "n장 중 m장째" · 끝나면 비움', () async {
final jobs = AiJobs();
final lines = <String?>[];
final done = await jobs.runTask('일', 3, (i, progress) async {
File(p.join(dir.path, '$i.txt')).writeAsStringSync('$i');
progress(0.5);
lines.add(jobs.statusLine);
}, onCancel: () {}, uses: {'sd15'});
expect(done, 3);
expect(dir.listSync().length, 3);
expect(lines.first, contains('3장 중 1장째'));
expect(lines.last, contains('3장 중 3장째'));
expect(jobs.busy, isFalse);
expect(jobs.inUse, isEmpty);
expect(jobs.statusLine, isNull);
});
test('runTask: 작업 중에 또 부르면 오류 · 취소하면 그 뒤 장은 하지 않음', () async {
final jobs = AiJobs();
var cancelled = false;
final run = jobs.runTask('일', 5, (i, _) async {
await expectLater(jobs.runTask('또', 1, (_, _) async {}, onCancel: () {}), throwsA(isA<ImageAiException>()));
if (i == 1) jobs.cancel();
}, onCancel: () => cancelled = true);
expect(await run, 2);
expect(cancelled, isTrue);
expect(jobs.busy, isFalse);
});
test('runTask: 할 일의 오류는 그대로 · 끝나면 비움', () async {
final jobs = AiJobs();
await expectLater(jobs.runTask('일', 2, (_, _) async => throw const FileSystemException('x'), onCancel: () {}),
throwsA(isA<FileSystemException>()));
expect(jobs.busy, isFalse);
});
test('done · clearDoneView: 다음 일을 시작하면 남긴 것의 dispose 를 부른다', () async {
final jobs = AiJobs();
var disposed = 0;
jobs.done('끝', dir: dir.path, view: () async {}, dispose: () => disposed++);
expect((jobs.lastDone, jobs.lastDoneDir, jobs.lastDoneView != null), ('끝', dir.path, true));
await jobs.runTask('일', 1, (_, _) async {}, onCancel: () {});
expect(disposed, 1);
expect(jobs.lastDone, isNull);
expect(AiJobs.durationText(const Duration(seconds: 90)), '2분');
expect(AiJobs.durationText(const Duration(seconds: 20)), '20초');
});
}

View File

@ -0,0 +1,53 @@
// ai_local_engine.dart 약속 시험: 실제 프로세스 (Windows cmd.exe) 와 실제 PNG 로 입력 · 출력 · 오류를 본다.
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
import 'package:jj_mkvmaker/app/ai_local_engine.dart';
import 'package:jj_mkvmaker/services/image_ai.dart';
void main() {
final cmd = Platform.environment['ComSpec'] ?? r'C:\Windows\System32\cmd.exe';
test('runSdCli: 저장했다고 하면 0 이 아닌 코드여도 성공 · 마지막 줄들을 돌려준다', () async {
if (!Platform.isWindows) return markTestSkipped('Windows 전용 (cmd.exe)');
// "images saved" 줄은 진행 (저장 단계) 으로 읽혀 마지막 줄들에는 들지 않는다
final tail = await runSdCli(cmd, ['/c', 'echo first&& echo 2 images saved&& exit 1']);
expect(tail, contains('first'));
});
test('runSdCli: 저장 없이 0 이 아닌 코드 → 쉬운 말 · 원문은 detail', () async {
if (!Platform.isWindows) return markTestSkipped('Windows 전용 (cmd.exe)');
await expectLater(
runSdCli(cmd, ['/c', 'echo [E] boom&& exit 3']),
throwsA(isA<ImageAiException>()
.having((e) => e.message, 'message', sdExitMessage(3))
.having((e) => e.detail, 'detail', contains('[E] boom'))));
});
test('runSdCli: 치명 줄이 있으면 0 으로 끝나도 실패 · onStart 로 프로세스를 넘긴다', () async {
if (!Platform.isWindows) return markTestSkipped('Windows 전용 (cmd.exe)');
Process? started;
await expectLater(runSdCli(cmd, ['/c', 'echo new_sd_ctx_t failed'], onStart: (p) => started = p),
throwsA(isA<ImageAiException>().having((e) => e.detail, 'detail', contains('new_sd_ctx_t failed'))));
expect(started, isNotNull);
});
test('sizeMatches · isSdFatalLine · sdCliLibName · sdExitMessage', () {
final png = img.encodePng(img.Image(width: 7, height: 5));
expect(sizeMatches(png, 7, 5), isTrue);
expect(sizeMatches(png, 5, 7), isFalse);
expect(isSdFatalLine('[E] Upscale Failed'), isTrue);
expect(isSdFatalLine('[I] sampling completed'), isFalse);
expect(sdCliLibName('Features : fp asimd fphp asimddp\nFeatures : fp asimd fphp asimddp'), 'libsdcli.so');
expect(sdCliLibName('Features : fp asimd fphp asimddp\nFeatures : fp asimd'), 'libsdcli_v80.so');
expect(sdCliLibName(''), 'libsdcli_v80.so');
expect(sdExitMessage(-4), contains('CPU'));
expect(sdExitMessage(7), contains('7'));
});
test('SdDevice.key = 실행 파일 폴더 이름:backend', () {
expect(SdDevice(['', 'ai', 'engine-vulkan', 'sd-cli.exe'].join(Platform.pathSeparator), 'vulkan0', 'GPU').key,
'engine-vulkan:vulkan0');
});
}

View File

@ -1,7 +1,7 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/ai_local.dart';
import 'package:jj_mkvmaker/app/ai_local_engine.dart';
import 'package:jj_mkvmaker/core/sd_cli.dart';
import 'package:path/path.dart' as p;

View File

@ -2,7 +2,8 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
import 'package:jj_mkvmaker/app/ai_local.dart';
import 'package:jj_mkvmaker/app/ai_jobs.dart';
import 'package:jj_mkvmaker/app/ai_local_engine.dart';
import 'package:jj_mkvmaker/app/ai_upscale.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/core/ai_catalog.dart';

View File

@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/ai_local.dart';
import 'package:jj_mkvmaker/app/ai_local_engine.dart';
/// 195: Android 의 AI 엔진 (sd-cli) 판 고르기 · 엔진이 죽은 신호별 문구
void main() {

View File

@ -4,7 +4,8 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
import 'package:jj_mkvmaker/app/ai_local.dart';
import 'package:jj_mkvmaker/app/ai_jobs.dart';
import 'package:jj_mkvmaker/app/ai_local_engine.dart';
import 'package:jj_mkvmaker/app/ai_upscale.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/settings.dart';