diff --git a/assets/l10n/en.json b/assets/l10n/en.json index f162ccc..94b2f10 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -96,6 +96,7 @@ "AI 자막": "AI subtitles", "AI 자막 ({0}) 을 시작합니다": "Starting AI subtitles ({0})", "AI 자막 {0}개": "AI subtitles for {0}", + "AI 자막 · 번역 작업이 끝난 뒤 지울 수 있습니다": "Can be deleted after the AI subtitle / translation job finishes", "AI 자막 → MKV ({0}) 을 시작합니다": "Starting AI subtitles → MKV ({0})", "AI 자막 대기 중": "Waiting for AI subtitles", "AI 자막 만들기": "Make AI subtitles", @@ -1062,10 +1063,12 @@ "받는 크기: 약 {0}MB · 받은 파일 · AI 모델 · 설정은 그대로 둡니다": "Download size: about {0}MB · downloaded files · AI models · settings are kept", "받다 만 AI 모델": "Unfinished AI models", "받다 만 다운로드": "Unfinished downloads", + "받다 만 조각 · {0}": "Partial download · {0}", "받아서 열기": "Download and open", "받은 AI 모델 ({0}) - Download/JJ_MKVMaker/AI 모델 보관": "Downloaded AI models ({0}) - Download/JJ_MKVMaker/AI 모델 보관", "받은 AI 모델 ({0}) 을 Download/JJ_MKVMaker 에 복사해 두었다가 다시 설치한 뒤 되살리기 (잠시 {0} 더 필요)": "Copy downloaded AI models ({0}) to Download/JJ_MKVMaker and restore them after reinstalling (needs {0} more space for a while)", "받은 AI 모델 (그림 · 자막) - 다시 받아야 합니다 (약 {0})": "Downloaded AI models (images · subtitles) - must be downloaded again (about {0})", + "받은 AI 자막 모델": "Downloaded AI subtitle models", "받은 동영상을 편집 목록에 추가: {0}": "Added the received video to the edit list: {0}", "받은 업데이트 · 필수 프로그램 · Chrome 엔진을 설치하고 남은 임시 파일": "Temporary files left from installing updates · required programs · the Chrome engine", "받은 자막을 한국어로 번역해 함께 추가 (한국어 자막이 없을 때)": "Translate received subtitles to Korean and add them too (when there are no Korean subtitles)", @@ -1076,10 +1079,12 @@ "받을 수 없습니다 ({0}): {1}": "Can't download ({0}): {1}", "받음": "Downloaded", "받음 {0}": "Received {0}", + "받음 · {0}": "Downloaded · {0}", "받지 못했습니다": "Could not download", "받지 못했습니다. 잠시 뒤 [이어 받기] 를 눌러 주세요": "Couldn't download. Tap [Resume] in a moment", "받지 못했습니다. 잠시 뒤 다시 해 주세요": "Download failed. Please try again shortly", "받지 못했습니다: {0}": "Could not download: {0}", + "받지 않음 (처음 쓸 때 내려받기 {0})": "Not downloaded (downloads on first use, {0})", "밝게": "Brighter", "밝기": "Brightness", "밤 0~6시": "Night 0-6", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 35435ff..dd034dc 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -96,6 +96,7 @@ "AI 자막": "AI 字幕", "AI 자막 ({0}) 을 시작합니다": "AI 字幕 ({0}) を開始します", "AI 자막 {0}개": "AI 字幕 {0}個", + "AI 자막 · 번역 작업이 끝난 뒤 지울 수 있습니다": "AI 字幕・翻訳の作業が終わってから削除できます", "AI 자막 → MKV ({0}) 을 시작합니다": "AI 字幕 → MKV ({0}) を開始します", "AI 자막 대기 중": "AI 字幕待機中", "AI 자막 만들기": "AI 字幕を作成", @@ -1062,10 +1063,12 @@ "받는 크기: 약 {0}MB · 받은 파일 · AI 모델 · 설정은 그대로 둡니다": "ダウンロードサイズ: 約 {0}MB・ダウンロードしたファイル・AI モデル・設定はそのまま", "받다 만 AI 모델": "ダウンロード途中の AI モデル", "받다 만 다운로드": "ダウンロード途中のファイル", + "받다 만 조각 · {0}": "途中までのダウンロード · {0}", "받아서 열기": "ダウンロードして開く", "받은 AI 모델 ({0}) - Download/JJ_MKVMaker/AI 모델 보관": "ダウンロードした AI モデル ({0}) - Download/JJ_MKVMaker/AI 모델 보관", "받은 AI 모델 ({0}) 을 Download/JJ_MKVMaker 에 복사해 두었다가 다시 설치한 뒤 되살리기 (잠시 {0} 더 필요)": "ダウンロードした AI モデル ({0}) を Download/JJ_MKVMaker にコピーし、再インストール後に復元する (一時的に {0} 余分に必要)", "받은 AI 모델 (그림 · 자막) - 다시 받아야 합니다 (약 {0})": "ダウンロードした AI モデル (画像・字幕) - 再ダウンロードが必要です (約 {0})", + "받은 AI 자막 모델": "ダウンロード済みの AI 字幕モデル", "받은 동영상을 편집 목록에 추가: {0}": "受け取った動画を編集リストに追加: {0}", "받은 업데이트 · 필수 프로그램 · Chrome 엔진을 설치하고 남은 임시 파일": "更新・必須プログラム・Chrome エンジンのインストールで残った一時ファイル", "받은 자막을 한국어로 번역해 함께 추가 (한국어 자막이 없을 때)": "受け取った字幕を韓国語に翻訳して一緒に追加(韓国語字幕がないとき)", @@ -1076,10 +1079,12 @@ "받을 수 없습니다 ({0}): {1}": "ダウンロードできません ({0}): {1}", "받음": "ダウンロード済み", "받음 {0}": "受信 {0}", + "받음 · {0}": "ダウンロード済み · {0}", "받지 못했습니다": "ダウンロードできませんでした", "받지 못했습니다. 잠시 뒤 [이어 받기] 를 눌러 주세요": "ダウンロードできませんでした。少し待ってから [続きをダウンロード] を押してください", "받지 못했습니다. 잠시 뒤 다시 해 주세요": "ダウンロードできませんでした。しばらくしてからもう一度お試しください", "받지 못했습니다: {0}": "ダウンロードできませんでした: {0}", + "받지 않음 (처음 쓸 때 내려받기 {0})": "未ダウンロード (初回使用時にダウンロード {0})", "밝게": "明るく", "밝기": "明るさ", "밤 0~6시": "夜 0~6 時", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 01ef4ae..7448ba0 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -96,6 +96,7 @@ "AI 자막": "AI 字幕", "AI 자막 ({0}) 을 시작합니다": "开始 AI 字幕({0})", "AI 자막 {0}개": "AI 字幕 {0} 个", + "AI 자막 · 번역 작업이 끝난 뒤 지울 수 있습니다": "AI 字幕 · 翻译任务结束后才能删除", "AI 자막 → MKV ({0}) 을 시작합니다": "开始 AI 字幕 → MKV({0})", "AI 자막 대기 중": "等待 AI 字幕", "AI 자막 만들기": "生成 AI 字幕", @@ -1062,10 +1063,12 @@ "받는 크기: 약 {0}MB · 받은 파일 · AI 모델 · 설정은 그대로 둡니다": "下载大小: 约 {0}MB · 已下载的文件 · AI 模型 · 设置均保留", "받다 만 AI 모델": "未下载完的 AI 模型", "받다 만 다운로드": "未完成的下载", + "받다 만 조각 · {0}": "未完成的下载 · {0}", "받아서 열기": "下载后打开", "받은 AI 모델 ({0}) - Download/JJ_MKVMaker/AI 모델 보관": "已下载的 AI 模型 ({0}) - Download/JJ_MKVMaker/AI 모델 보관", "받은 AI 모델 ({0}) 을 Download/JJ_MKVMaker 에 복사해 두었다가 다시 설치한 뒤 되살리기 (잠시 {0} 더 필요)": "将已下载的 AI 模型 ({0}) 复制到 Download/JJ_MKVMaker,重新安装后恢复 (暂时需要额外 {0})", "받은 AI 모델 (그림 · 자막) - 다시 받아야 합니다 (약 {0})": "已下载的 AI 模型 (图片 · 字幕) - 需要重新下载 (约 {0})", + "받은 AI 자막 모델": "已下载的 AI 字幕模型", "받은 동영상을 편집 목록에 추가: {0}": "已将收到的视频加入编辑列表: {0}", "받은 업데이트 · 필수 프로그램 · Chrome 엔진을 설치하고 남은 임시 파일": "安装更新 · 必需程序 · Chrome 引擎后残留的临时文件", "받은 자막을 한국어로 번역해 함께 추가 (한국어 자막이 없을 때)": "将收到的字幕翻译成韩语并一并添加(没有韩语字幕时)", @@ -1076,10 +1079,12 @@ "받을 수 없습니다 ({0}): {1}": "无法下载 ({0}): {1}", "받음": "已下载", "받음 {0}": "已接收 {0}", + "받음 · {0}": "已下载 · {0}", "받지 못했습니다": "无法下载", "받지 못했습니다. 잠시 뒤 [이어 받기] 를 눌러 주세요": "下载失败。请稍后点按 [继续下载]", "받지 못했습니다. 잠시 뒤 다시 해 주세요": "下载失败。请稍后重试", "받지 못했습니다: {0}": "无法下载: {0}", + "받지 않음 (처음 쓸 때 내려받기 {0})": "未下载 (首次使用时下载 {0})", "밝게": "调亮", "밝기": "亮度", "밤 0~6시": "夜间 0~6 点", diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index 23cec26..656b7cf 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -861,7 +861,22 @@ class AppController extends ChangeNotifier { } } final label = targets.length == 1 ? trf('AI 자막: {0}', [targets.first.fileName]) : trf('AI 자막 {0}개', [targets.length]); - return _enqueue(label, () => _runAi(targets, opts)); + return _enqueue(label, () => _usingAiModels(() => _runAi(targets, opts))); + } + + /// 260: AI 자막 · 자막 번역이 모델을 쓰는 중 (그동안 환경 설정에서 그 모델을 지우지 못하게) + bool get aiModelsInUse => _aiModelUsers > 0; + int _aiModelUsers = 0; + + Future _usingAiModels(Future Function() job) async { + _aiModelUsers++; + notifyListeners(); + try { + await job(); + } finally { + _aiModelUsers--; + notifyListeners(); + } } Future _runAi(List targets, AiOptions opts) async { @@ -1135,7 +1150,7 @@ class AppController extends ChangeNotifier { if (busy) v.phase = tr('자막 번역 대기 중'); notifyListeners(); return _enqueue(trf('자막 번역: {0} → {1}', [v.fileName, targets.map((t) => t.code).join('/')]), - () => _runTranslate(v, s, targets)); + () => _usingAiModels(() => _runTranslate(v, s, targets))); } Future _runTranslate(VideoItem v, SubtitleEntry s, Set targets) async { diff --git a/lib/services/model_store.dart b/lib/services/model_store.dart index d7b13f8..0c97ed5 100644 --- a/lib/services/model_store.dart +++ b/lib/services/model_store.dart @@ -97,6 +97,42 @@ class ModelStore { /// 파일이 있거나, 외부 데이터 형식으로 바꿔 둔 것이 있으면 (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, { diff --git a/lib/ui/ai_subtitle_models.dart b/lib/ui/ai_subtitle_models.dart new file mode 100644 index 0000000..7e9a6ed --- /dev/null +++ b/lib/ui/ai_subtitle_models.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; + +import '../app/ai_local.dart' show AiStore; +import '../app/app_controller.dart'; +import '../l10n/tr.dart'; +import '../services/model_store.dart'; +import 'confirm.dart'; +import 'setting_tile.dart'; +import 'theme.dart'; + +/// 260: 받은 AI 자막 모델 (Whisper · NLLB) - 차지한 크기 · [지우기]. 다시 쓰면 그때 다시 받는다 +class AiSubtitleModels extends StatefulWidget { + final AppController c; + const AiSubtitleModels({super.key, required this.c}); + + @override + State createState() => _AiSubtitleModelsState(); +} + +class _AiSubtitleModelsState extends State { + /// 모델 id → 차지한 바이트 (0 이면 받지 않음) + Map? _used; + + /// 다 받은 모델 (나머지 중 크기가 있으면 받다 만 조각) + final _installed = {}; + String? _error; + + ModelStore get _store => widget.c.services.models; + + @override + void initState() { + super.initState(); + _refresh(); + } + + Future _refresh() async { + final used = {for (final m in [...whisperModels, nllbModel]) m.id: await _store.usedBytes(m)}; + _installed + ..clear() + ..addAll([for (final m in [...whisperModels, nllbModel]) if (await _store.isInstalled(m)) m.id]); + if (mounted) setState(() => _used = used); + } + + Future _remove(ModelSpec m) async { + final ok = await confirmAction(context, + title: trf('{0} 을(를) 지울까요?', [m.label]), body: tr('다시 쓰려면 다시 받아야 합니다.'), ok: tr('지우기')); + if (!ok) return; + try { + await _store.remove(m); + _error = null; + } catch (e) { + _error = trf('지우지 못했습니다: {0}', [e]); + } + await _refresh(); + } + + @override + Widget build(BuildContext context) { + final used = _used; + if (used == null) return const SizedBox(); + return ListenableBuilder( + listenable: widget.c, + builder: (context, _) { + final inUse = widget.c.aiModelsInUse; + return Column(mainAxisSize: MainAxisSize.min, children: [ + for (final m in [...whisperModels, nllbModel]) + SettingTile( + dense: true, + leading: Icon(used[m.id]! > 0 ? Icons.check_circle : Icons.cloud_download_outlined, + color: used[m.id]! > 0 ? JjColors.success : JjColors.textDim, size: 20), + title: Text(m.label), + subtitle: Text(used[m.id]! > 0 + ? trf(_installed.contains(m.id) ? '받음 · {0}' : '받다 만 조각 · {0}', [AiStore.sizeText(used[m.id]!)]) + : trf('받지 않음 (처음 쓸 때 내려받기 {0})', [m.sizeLabel])), + trailing: used[m.id]! > 0 + ? inUse + ? Tooltip( + message: tr('AI 자막 · 번역 작업이 끝난 뒤 지울 수 있습니다'), + child: TextButton(onPressed: null, child: Text(tr('지우기'))), + ) + : TextButton(onPressed: () => _remove(m), child: Text(tr('지우기'))) + : null, + ), + if (_error != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 0), + child: Text(_error!, style: const TextStyle(fontSize: 12, color: JjColors.danger)), + ), + ]); + }, + ); + } +} diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index 83b8577..03eacd8 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -36,6 +36,7 @@ import 'browser_page.dart' show deleteExportedCookies; import 'security_settings.dart'; import 'network_settings.dart'; import 'ai_settings.dart'; +import 'ai_subtitle_models.dart'; import '../app/master_lock.dart'; import 'toast_status.dart'; import 'license_texts.dart'; @@ -541,6 +542,9 @@ class _SettingsPageState extends State { title: Text(tr('시작할 때마다 설정 창 보기')), subtitle: Text(trf('끄면 마지막 설정으로 바로 시작: 원어 {0}, ' '언어 {1}, {2}', [c.aiOptions.source.name, c.aiOptions.targets.map((t) => t.code).join('/'), c.aiOptions.whisper.label])), ), + // 260: 받은 자막 모델 크기 · 지우기 + _subTitle(tr('받은 AI 자막 모델')), + AiSubtitleModels(c: c), _subTitle(tr('인터넷 자막 (OpenSubtitles.com)')), _textTile(tr('API 키 (필수, 무료)'), s.openSubtitlesKey, tr('가입 → 프로필 → API consumers → New consumer'), diff --git a/test/ai_subtitle_models_test.dart b/test/ai_subtitle_models_test.dart new file mode 100644 index 0000000..2694abc --- /dev/null +++ b/test/ai_subtitle_models_test.dart @@ -0,0 +1,73 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/model_store.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/ai_subtitle_models.dart'; +import 'package:path/path.dart' as p; + +/// 260: 받은 AI 자막 모델 (Whisper · NLLB) 을 앱 안에서 크기 보고 지우기 +void main() { + late Directory tmp; + late ModelStore models; + setUp(() async { + tmp = Directory.systemTemp.createTempSync('jj_m260_'); + models = ModelStore(tmp.path); + // whisper base 만 받음 (small 은 받다 만 조각), NLLB 는 Android 가 바꿔 둔 파일까지 + File(await models.pathOf(whisperModels[0])) + ..createSync(recursive: true) + ..writeAsBytesSync(List.filled(3000, 1)); + File('${await models.pathOf(whisperModels[1])}.part').writeAsBytesSync(List.filled(500, 1)); + final nllb = await models.folderOf(nllbModel); + for (final n in ['tokenizer.json', 'encoder_model_quantized_jj2.onnx', 'encoder_model_quantized_jj2.onnx.data', + 'decoder_model_merged_quantized_jj2.onnx', 'decoder_model_merged_quantized_jj2.onnx.data']) { + File(p.join(nllb, n)) + ..createSync(recursive: true) + ..writeAsBytesSync(List.filled(1000, 1)); + } + }); + tearDown(() => tmp.deleteSync(recursive: true)); + + test('크기 · 지우기: 같은 폴더의 다른 Whisper 는 남기고, NLLB 는 폴더째', () async { + expect(await models.usedBytes(whisperModels[0]), 3000); + expect(await models.usedBytes(whisperModels[1]), 500, reason: '받다 만 조각도 셈'); + expect(await models.usedBytes(nllbModel), 5000); + await models.remove(whisperModels[0]); + expect(File(await models.pathOf(whisperModels[0])).existsSync(), isFalse); + expect(File('${await models.pathOf(whisperModels[1])}.part').existsSync(), isTrue, reason: '다른 모델 것은 남김'); + await models.remove(nllbModel); + expect(Directory(await models.folderOf(nllbModel)).existsSync(), isFalse); + expect(await models.usedBytes(nllbModel), 0); + }); + + testWidgets('환경 설정 목록: 받은 것은 크기 · [지우기] → 확인 뒤 지움', (tester) async { + final c = AppController(PlatformServices( + mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), models: models)); + await tester.runAsync(() async { + await tester.pumpWidget(MaterialApp(home: Scaffold(body: ListView(children: [AiSubtitleModels(c: c)])))); + await Future.delayed(const Duration(milliseconds: 300)); + }); + await tester.pump(); + expect(find.text(whisperModels[0].label), findsOneWidget); + expect(find.textContaining('받음'), findsNWidgets(2)); + expect(find.textContaining('받다 만 조각'), findsOneWidget); + expect(find.text('지우기'), findsNWidgets(3)); + await tester.tap(find.text('지우기').last); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, '지우기')); + await tester.pumpAndSettle(); + // 지우기는 실제 파일을 하나씩 지운다 - 다 지워질 때까지 실제 시간을 조금씩 준다 + final nllbDir = Directory((await tester.runAsync(() => models.folderOf(nllbModel)))!); + for (var i = 0; i < 50 && find.textContaining('받지 않음').evaluate().isEmpty; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); + await tester.pump(); + } + expect(nllbDir.existsSync(), isFalse); + expect(find.text('지우기'), findsNWidgets(2)); + expect(find.textContaining('받지 않음'), findsOneWidget); + }); +}