From 17d5ff0be9a6ced864cf1513ce8297280166f702 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:17:50 +0900 Subject: [PATCH] =?UTF-8?q?WIP=20259=20=EB=B2=88=EC=97=AD=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20(NLLB=20912MB)=20=EC=9D=80=20=EB=B2=88=EC=97=AD?= =?UTF-8?q?=EC=9D=B4=20=EC=8B=A4=EC=A0=9C=EB=A1=9C=20=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=A0=20=EB=95=8C=EB=A7=8C=20=EB=B0=9B=EA=B8=B0=20-=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EA=B0=90=EC=A7=80=20+=20=ED=95=9C=20?= =?UTF-8?q?=EC=96=B8=EC=96=B4=EB=A9=B4=20=EA=B0=90=EC=A7=80=ED=95=9C=20?= =?UTF-8?q?=EC=9B=90=EC=96=B4=EA=B0=80=20=EB=8B=A4=EB=A5=BC=20=EB=95=8C=20?= =?UTF-8?q?(=EA=B2=80=ED=86=A0=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 2 ++ assets/l10n/ja.json | 2 ++ assets/l10n/zh-Hans.json | 2 ++ lib/app/app_controller.dart | 43 +++++++++++++++++++----------- lib/ui/ai_dialog.dart | 10 ++++++- test/ai_test.dart | 52 +++++++++++++++++++++++++++++++++++++ 6 files changed, 95 insertions(+), 16 deletions(-) diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 453c38c..cae5908 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -541,6 +541,7 @@ "가져올 즐겨찾기가 없습니다.": "No bookmarks to import.", "감지한 언어: {0} (자동 감지)": "Detected language: {0} (auto-detect)", "감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "Detected language: {0} - if it is wrong, make them again with another language", + "감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "Downloaded only if the detected language differs from the subtitle language {0}", "같은 시리즈 (번호만 다른 파일)": "Same series (files that differ only by number)", "같은 이름이 이미 있습니다": "An item with the same name already exists", "같은 이름이 이미 있습니다: {0}": "Already exists with the same name: {0}", @@ -1722,6 +1723,7 @@ "이미지 자막은 편집할 수 없습니다": "Image subtitles cannot be edited", "이번 실행에서 넣은 비밀번호는 앱을 끄면 사라집니다 (설정 파일에 평문으로 쓰지 않습니다). [다시 시도] 를 눌러 보세요.": "Passwords entered this session will be lost when the app closes (they are not written to the settings file in plain text). Try pressing [Try again].", "이번 업데이트로 바뀐 기본 설정": "Default settings changed by this update", + "이번에는 쓰지 않음 (번역 없음)": "Not used this time (no translation)", "이어 받기": "Resume", "이전": "Previous", "이전 (P)": "Previous (P)", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 52215e4..57ebcff 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -541,6 +541,7 @@ "가져올 즐겨찾기가 없습니다.": "取り込むブックマークがありません。", "감지한 언어: {0} (자동 감지)": "検出した言語: {0} (自動検出)", "감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "検出した言語: {0} - 違っていれば別の言語で作り直してください", + "감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "検出した言語が作成する言語と異なる場合のみダウンロード {0}", "같은 시리즈 (번호만 다른 파일)": "同じシリーズ(番号だけ違うファイル)", "같은 이름이 이미 있습니다": "同じ名前のものが既にあります", "같은 이름이 이미 있습니다: {0}": "同じ名前が既にあります: {0}", @@ -1722,6 +1723,7 @@ "이미지 자막은 편집할 수 없습니다": "画像字幕は編集できません", "이번 실행에서 넣은 비밀번호는 앱을 끄면 사라집니다 (설정 파일에 평문으로 쓰지 않습니다). [다시 시도] 를 눌러 보세요.": "今回入力したパスワードはアプリを終了すると消えます (設定ファイルに平文で書きません)。[再試行] を押してみてください。", "이번 업데이트로 바뀐 기본 설정": "今回のアップデートで変わった既定の設定", + "이번에는 쓰지 않음 (번역 없음)": "今回は使用しません (翻訳なし)", "이어 받기": "続きをダウンロード", "이전": "前へ", "이전 (P)": "前へ(P)", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 7625acc..c076cc6 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -541,6 +541,7 @@ "가져올 즐겨찾기가 없습니다.": "没有可导入的书签。", "감지한 언어: {0} (자동 감지)": "检测到的语言:{0}(自动检测)", "감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "检测到的语言:{0} - 如有误,请用其他语言重新生成", + "감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "仅当检测到的语言与要生成的语言不同时下载 {0}", "같은 시리즈 (번호만 다른 파일)": "同一系列(仅编号不同的文件)", "같은 이름이 이미 있습니다": "已存在同名项目", "같은 이름이 이미 있습니다: {0}": "已存在同名项目: {0}", @@ -1722,6 +1723,7 @@ "이미지 자막은 편집할 수 없습니다": "图像字幕无法编辑", "이번 실행에서 넣은 비밀번호는 앱을 끄면 사라집니다 (설정 파일에 평문으로 쓰지 않습니다). [다시 시도] 를 눌러 보세요.": "本次输入的密码在关闭应用后会丢失 (不会以明文写入设置文件)。请尝试按 [重试]。", "이번 업데이트로 바뀐 기본 설정": "本次更新更改的默认设置", + "이번에는 쓰지 않음 (번역 없음)": "本次不使用 (无需翻译)", "이어 받기": "继续下载", "이전": "上一个", "이전 (P)": "上一个(P)", diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index 9e27582..23cec26 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -57,6 +57,11 @@ class AiOptions { bool get needsTranslation => source == undetermined || targets.any((t) => t.code != source.code); + /// 259: 시작 전에 이미 번역이 필요한 것이 확실한지 - 원어를 골랐으면 다른 언어가 있을 때, + /// 자동 감지면 만들 언어가 둘 이상일 때 (하나뿐이면 감지한 원어가 그 언어일 수 있어 감지한 뒤에 정한다) + bool get surelyNeedsTranslation => + source == undetermined ? targets.length > 1 : targets.any((t) => t.code != source.code); + AiOptions copyWith({Language? source, Set? targets, ModelSpec? whisper}) => AiOptions( source: source ?? this.source, targets: targets ?? this.targets, whisper: whisper ?? this.whisper); } @@ -863,21 +868,9 @@ class AppController extends ChangeNotifier { _aiCancelled = false; try { // 1. 모델 준비 (없으면 내려받기) - for (final m in [opts.whisper, nllbModel]) { - if (m == nllbModel && !opts.needsTranslation) continue; - if (await services.models.isInstalled(m)) continue; - _log(trf('모델 내려받는 중: {0} ({1})', [m.label, m.sizeLabel])); - await services.models.download(m, - isCancelled: () => _aiCancelled, - onProgress: (x) { - for (final v in targets) { - v - ..phase = trf('모델 내려받는 중 {0}%', [(x * 100).round()]) - ..progress = x - ..status = JobStatus.running; - } - notifyListeners(); - }); + // 259: 번역 모델 (NLLB 912MB) 은 번역이 확실히 필요할 때만 먼저 받는다 - 아니면 감지한 원어가 만들 언어와 다를 때 받음 + for (final m in [opts.whisper, if (opts.surelyNeedsTranslation) nllbModel]) { + await _ensureAiModel(m, targets); } for (final v in targets) { if (_aiCancelled) break; @@ -898,6 +891,23 @@ class AppController extends ChangeNotifier { } } + /// AI 자막 모델이 없으면 내려받기 (진행은 [targets] 의 줄에) + Future _ensureAiModel(ModelSpec m, List targets) async { + if (await services.models.isInstalled(m)) return; + _log(trf('모델 내려받는 중: {0} ({1})', [m.label, m.sizeLabel])); + await services.models.download(m, + isCancelled: () => _aiCancelled, + onProgress: (x) { + for (final v in targets) { + v + ..phase = trf('모델 내려받는 중 {0}%', [(x * 100).round()]) + ..progress = x + ..status = JobStatus.running; + } + notifyListeners(); + }); + } + Future _generateOne(VideoItem v, AiOptions opts) async { void phase(String text, double p) { v @@ -962,6 +972,9 @@ class AppController extends ChangeNotifier { out = [for (final c in cues) c.copy()]; } else { if (_translator == null) { + // 259: 감지한 원어가 만들 언어와 달라 이제 번역이 필요 - 모델이 없으면 여기서 받는다 + await _ensureAiModel(nllbModel, [v]); + if (_aiCancelled) throw const AiCancelled(); phase(tr('번역 모델 불러오는 중'), base); _translator = services.createTranslator!(); await _translator!.load(await services.models.folderOf(nllbModel)); diff --git a/lib/ui/ai_dialog.dart b/lib/ui/ai_dialog.dart index 931b6e8..ca28a19 100644 --- a/lib/ui/ai_dialog.dart +++ b/lib/ui/ai_dialog.dart @@ -218,6 +218,14 @@ class _AiDialogState extends State<_AiDialog> { style: TextStyle(fontSize: 11, color: ok ? JjColors.success : JjColors.textDim)); } + /// 259: 번역이 없으면 받지 않는다 · 감지한 뒤에야 알 수 있으면 그때 받는다 + Widget _nllbStatus() { + if (_installed[nllbModel.id] != false || _o.surelyNeedsTranslation) return _status(nllbModel); + const style = TextStyle(fontSize: 11, color: JjColors.textDim); + if (!_o.needsTranslation) return Text(tr('이번에는 쓰지 않음 (번역 없음)'), style: style); + return Text(trf('감지한 원어가 만들 언어와 다를 때만 내려받기 {0}', [nllbModel.sizeLabel]), style: style); + } + @override Widget build(BuildContext context) { final extra = languages.where((l) => !_o.targets.contains(l)).toList(); @@ -290,7 +298,7 @@ class _AiDialogState extends State<_AiDialog> { ), Row(children: [ Text(tr('번역 모델: NLLB-200 (로컬) '), style: TextStyle(fontSize: 13)), - _status(nllbModel), + Flexible(child: _nllbStatus()), ]), const NllbLicenseNote(), if (widget.videoCount > 1) ...[ diff --git a/test/ai_test.dart b/test/ai_test.dart index 6592b1b..dfffeef 100644 --- a/test/ai_test.dart +++ b/test/ai_test.dart @@ -54,6 +54,21 @@ class _FakeTranslator implements Translator { Future dispose() async => disposed = true; } +/// 259: 받은 모델을 기록하는 가짜 모델 저장소 (인터넷 대신 빈 파일) +class _CountingModels extends ModelStore { + _CountingModels(super.dir); + final downloaded = []; + @override + Future download(ModelSpec m, {void Function(double)? onProgress, bool Function()? isCancelled}) async { + downloaded.add(m.id); + for (var i = 0; i < m.files.length; i++) { + File(await pathOf(m, i)) + ..createSync(recursive: true) + ..writeAsStringSync('x'); + } + } +} + void main() { group('원어 판별', () { test('문자 체계', () { @@ -177,6 +192,43 @@ void main() { expect(v.subtitles.where((s) => s.kind == SubtitleKind.external), hasLength(3)); }); + test('259: 번역이 확실할 때만 번역 모델을 먼저 받는다', () { + final en = languageOf('en'), ko = languageOf('ko'); + final w = whisperModels.first; + expect(AiOptions(targets: {en}, whisper: w).surelyNeedsTranslation, isFalse, reason: '자동 감지 + 한 언어: 감지한 뒤에'); + expect(AiOptions(targets: {en, ko}, whisper: w).surelyNeedsTranslation, isTrue); + expect(AiOptions(source: en, targets: {en}, whisper: w).surelyNeedsTranslation, isFalse); + expect(AiOptions(source: en, targets: {ko}, whisper: w).surelyNeedsTranslation, isTrue); + }); + + for (final (target, wantDownload) in [('en', false), ('ko', true)]) { + test('259: 자동 감지 + 만들 언어 $target 하나 - 감지한 원어 (영어) 와 ${wantDownload ? '달라 그때' : '같아'} 번역 모델을 ${wantDownload ? '받는다' : '받지 않는다'}', () async { + final tool = ProcessMediaTool('ffmpeg', 'ffprobe'); + if (await tool.version() == null) return markTestSkipped('FFmpeg 없음'); + final dir = Directory.systemTemp.createTempSync('jj_ai259_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final video = p.join(dir.path, '강의.mp4'); + await tool.runFfmpeg(['-y', '-f', 'lavfi', '-i', 'testsrc=size=160x120:rate=10', + '-f', 'lavfi', '-i', 'sine', '-t', '2', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac', video]); + final models = _CountingModels(p.join(dir.path, 'models')); + final tr = _FakeTranslator(); + final c = AppController(PlatformServices( + mediaTool: tool, + storage: DesktopStorageService(), + createRecognizer: () => _FakeRecognizer(), + createTranslator: () => tr, + models: models, + )); + await c.init(); + await c.addVideos([video]); + final v = c.videos.single; + await c.generateAiSubtitles([v], AiOptions(targets: {languageOf(target)}, whisper: whisperModels.first)); + expect(v.status, JobStatus.ready, reason: v.message); + expect(models.downloaded, [whisperModels.first.id, if (wantDownload) nllbModel.id]); + expect(tr.calls, [if (wantDownload) 'eng_Latn>kor_Hang']); + }); + } + test('있는 자막 번역: 영어 SRT → _ko.srt (MKV 목록에 추가, 덮어쓰지 않음) + 작업 대기열', () async { final dir = Directory.systemTemp.createTempSync('jj_tr_'); addTearDown(() => dir.deleteSync(recursive: true));