import 'dart:io'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import '../platform/common/onnx_external.dart'; import '../l10n/tr.dart'; /// 내려받을 AI 모델 class ModelSpec { final String id; /// 한국어 이름 (번역 사전의 열쇠) final String koLabel; /// 화면에 보일 이름 (화면 언어로) String get label => tr(koLabel); /// 모델 폴더 아래 상대 경로의 폴더 final String folder; final List files; const ModelSpec(this.id, this.koLabel, this.folder, this.files); int get totalBytes => files.fold(0, (a, f) => a + f.bytes); String get sizeLabel => '${(totalBytes / 1e6).round()}MB'; } class ModelFile { final String name; final String url; /// 대략적인 크기 (진행률 표시용) final int bytes; const ModelFile(this.name, this.url, this.bytes); } const _whisperBase = 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main'; const _nllbBase = 'https://huggingface.co/Xenova/nllb-200-distilled-600M/resolve/main'; /// 음성인식 모델 final whisperModels = [ ModelSpec('whisper-base', 'Whisper base (빠름)', 'whisper', [ModelFile('ggml-base.bin', '$_whisperBase/ggml-base.bin', 147951465)]), ModelSpec('whisper-small', 'Whisper small (정확)', 'whisper', [ModelFile('ggml-small.bin', '$_whisperBase/ggml-small.bin', 487601967)]), ]; /// 번역 모델 (NLLB-200 600M, int8) final nllbModel = ModelSpec('nllb-600m', 'NLLB-200 번역 (600M)', 'nllb-200-distilled-600M', [ ModelFile('tokenizer.json', '$_nllbBase/tokenizer.json', 17331176), ModelFile('encoder_model_quantized.onnx', '$_nllbBase/onnx/encoder_model_quantized.onnx', 419100000), ModelFile('decoder_model_merged_quantized.onnx', '$_nllbBase/onnx/decoder_model_merged_quantized.onnx', 475500000), ]); /// 모델 저장소 (처음 사용할 때 인터넷에서 내려받음 - 다운로드만, 업로드 없음) /// /// 위치: 환경 변수 JJ_MKVMAKER_MODELS → 프로그램 폴더\models (휴대용, 쓸 수 있을 때) /// → 앱 데이터 폴더\models class ModelStore { String? _dir; ModelStore([this._dir]); Future directory() async { if (_dir != null) return _dir!; // 이전 이름(JJ_CAPCUT_MODELS)도 인식 final env = Platform.environment['JJ_MKVMAKER_MODELS'] ?? Platform.environment['JJ_CAPCUT_MODELS']; if (env != null && env.isNotEmpty) return _dir = env; final portable = p.join(p.dirname(Platform.resolvedExecutable), 'models'); try { await Directory(portable).create(recursive: true); final probe = File(p.join(portable, '.jj_write_test')); await probe.writeAsString('x'); await probe.delete(); return _dir = portable; } catch (_) { return _dir = p.join((await getApplicationSupportDirectory()).path, 'models'); } } Future folderOf(ModelSpec m) async => p.join(await directory(), m.folder); Future pathOf(ModelSpec m, [int file = 0]) async => p.join(await folderOf(m), m.files[file].name); Future isInstalled(ModelSpec m) async { final dir = await folderOf(m); for (final f in m.files) { if (!_present(p.join(dir, f.name))) return false; } return true; } /// 파일이 있거나, 외부 데이터 형식으로 바꿔 둔 것이 있으면 (Android 는 바꾼 뒤 원래 파일을 지운다) static bool _present(String path) => File(path).existsSync() || OnnxExternal.isReady(path); /// 260: 이 모델이 차지한 파일 - 모델 파일 · 받다 만 조각 (.part) · Android 에서 바꿔 둔 형식 (_jj2.onnx · .data). /// 혼자 쓰는 폴더면 폴더 안 전부 Future> filesOf(ModelSpec m) async { final dir = Directory(await folderOf(m)); if (!dir.existsSync()) return []; final shared = [...whisperModels, nllbModel].any((o) => o.id != m.id && o.folder == m.folder); if (!shared) return [for (final e in dir.listSync(recursive: true)) if (e is File) e]; return [ for (final f in m.files) for (final path in {p.join(dir.path, f.name), '${p.join(dir.path, f.name)}.part'}) if (File(path).existsSync()) File(path), ]; } /// 260: 차지한 크기 (바이트, 없으면 0) Future usedBytes(ModelSpec m) async { var n = 0; for (final f in await filesOf(m)) { try { n += f.lengthSync(); } catch (_) {} } return n; } /// 260: 모델 지우기 (다시 쓰면 그때 다시 받는다). 혼자 쓰는 빈 폴더도 지운다 Future remove(ModelSpec m) async { for (final f in await filesOf(m)) { await f.delete(); } final dir = Directory(await folderOf(m)); if (dir.existsSync() && dir.listSync(recursive: true).whereType().isEmpty) { await dir.delete(recursive: true); } } /// 없는 파일만 내려받는다. [onProgress] 0.0~1.0, [isCancelled] 가 true 면 중단. Future download( ModelSpec m, { void Function(double)? onProgress, bool Function()? isCancelled, }) async { final dir = await folderOf(m); await Directory(dir).create(recursive: true); final total = m.totalBytes; var done = 0; final client = HttpClient(); try { for (final f in m.files) { final target = File(p.join(dir, f.name)); if (_present(target.path)) { done += f.bytes; continue; } final part = File('${target.path}.part'); final req = await client.getUrl(Uri.parse(f.url)); final res = await req.close(); if (res.statusCode != 200) { throw HttpException(trf('모델을 내려받을 수 없습니다 ({0}): {1}', [res.statusCode, f.url])); } final sink = part.openWrite(); var got = 0; try { await for (final chunk in res) { if (isCancelled?.call() ?? false) throw const _Cancelled(); sink.add(chunk); got += chunk.length; onProgress?.call(((done + got) / total).clamp(0.0, 1.0)); } } finally { await sink.close(); } await part.rename(target.path); done += f.bytes; } onProgress?.call(1.0); } finally { client.close(force: true); } } } class _Cancelled implements Exception { const _Cancelled(); @override String toString() => tr('사용자가 취소했습니다.'); }