WIP 260 환경 설정 > 자막: 받은 AI 자막 모델 (Whisper · NLLB) 크기 · [지우기], 작업 중에는 못 지움 (검토 대기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
backup_묶음6_d46e089
RyuJongJin 2026-10-12 08:22:35 +09:00
parent 17d5ff0be9
commit 5646c976a8
8 changed files with 238 additions and 2 deletions

View File

@ -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",
@ -1061,10 +1062,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)",
@ -1075,10 +1078,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",

View File

@ -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 字幕を作成",
@ -1061,10 +1062,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 エンジンのインストールで残った一時ファイル",
"받은 자막을 한국어로 번역해 함께 추가 (한국어 자막이 없을 때)": "受け取った字幕を韓国語に翻訳して一緒に追加(韓国語字幕がないとき)",
@ -1075,10 +1078,12 @@
"받을 수 없습니다 ({0}): {1}": "ダウンロードできません ({0}): {1}",
"받음": "ダウンロード済み",
"받음 {0}": "受信 {0}",
"받음 · {0}": "ダウンロード済み · {0}",
"받지 못했습니다": "ダウンロードできませんでした",
"받지 못했습니다. 잠시 뒤 [이어 받기] 를 눌러 주세요": "ダウンロードできませんでした。少し待ってから [続きをダウンロード] を押してください",
"받지 못했습니다. 잠시 뒤 다시 해 주세요": "ダウンロードできませんでした。しばらくしてからもう一度お試しください",
"받지 못했습니다: {0}": "ダウンロードできませんでした: {0}",
"받지 않음 (처음 쓸 때 내려받기 {0})": "未ダウンロード (初回使用時にダウンロード {0})",
"밝게": "明るく",
"밝기": "明るさ",
"밤 0~6시": "夜 0~6 時",

View File

@ -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 字幕",
@ -1061,10 +1062,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 引擎后残留的临时文件",
"받은 자막을 한국어로 번역해 함께 추가 (한국어 자막이 없을 때)": "将收到的字幕翻译成韩语并一并添加(没有韩语字幕时)",
@ -1075,10 +1078,12 @@
"받을 수 없습니다 ({0}): {1}": "无法下载 ({0}): {1}",
"받음": "已下载",
"받음 {0}": "已接收 {0}",
"받음 · {0}": "已下载 · {0}",
"받지 못했습니다": "无法下载",
"받지 못했습니다. 잠시 뒤 [이어 받기] 를 눌러 주세요": "下载失败。请稍后点按 [继续下载]",
"받지 못했습니다. 잠시 뒤 다시 해 주세요": "下载失败。请稍后重试",
"받지 못했습니다: {0}": "无法下载: {0}",
"받지 않음 (처음 쓸 때 내려받기 {0})": "未下载 (首次使用时下载 {0})",
"밝게": "调亮",
"밝기": "亮度",
"밤 0~6시": "夜间 0~6 点",

View File

@ -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<void> _usingAiModels(Future<void> Function() job) async {
_aiModelUsers++;
notifyListeners();
try {
await job();
} finally {
_aiModelUsers--;
notifyListeners();
}
}
Future<void> _runAi(List<VideoItem> 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<void> _runTranslate(VideoItem v, SubtitleEntry s, Set<Language> targets) async {

View File

@ -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<List<File>> 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<int> usedBytes(ModelSpec m) async {
var n = 0;
for (final f in await filesOf(m)) {
try {
n += f.lengthSync();
} catch (_) {}
}
return n;
}
/// 260: 모델 지우기 (다시 쓰면 그때 다시 받는다). 혼자 쓰는 빈 폴더도 지운다
Future<void> 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<File>().isEmpty) {
await dir.delete(recursive: true);
}
}
/// 없는 파일만 내려받는다. [onProgress] 0.0~1.0, [isCancelled] 가 true 면 중단.
Future<void> download(
ModelSpec m, {

View File

@ -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<AiSubtitleModels> createState() => _AiSubtitleModelsState();
}
class _AiSubtitleModelsState extends State<AiSubtitleModels> {
/// 모델 id → 차지한 바이트 (0 이면 받지 않음)
Map<String, int>? _used;
/// 다 받은 모델 (나머지 중 크기가 있으면 받다 만 조각)
final _installed = <String>{};
String? _error;
ModelStore get _store => widget.c.services.models;
@override
void initState() {
super.initState();
_refresh();
}
Future<void> _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<void> _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)),
),
]);
},
);
}
}

View File

@ -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<SettingsPage> {
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'),

View File

@ -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<void>.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<void>.delayed(const Duration(milliseconds: 20)));
await tester.pump();
}
expect(nllbDir.existsSync(), isFalse);
expect(find.text('지우기'), findsNWidgets(2));
expect(find.textContaining('받지 않음'), findsOneWidget);
});
}