WIP 259 번역 모델 (NLLB 912MB) 은 번역이 실제로 필요할 때만 받기 - 자동 감지 + 한 언어면 감지한 원어가 다를 때 (검토 대기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음6
RyuJongJin 2026-10-12 08:17:50 +09:00
parent 12db3d43b1
commit 99d446cea6
6 changed files with 95 additions and 16 deletions

View File

@ -542,6 +542,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}",
@ -1724,6 +1725,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)",

View File

@ -542,6 +542,7 @@
"가져올 즐겨찾기가 없습니다.": "取り込むブックマークがありません。",
"감지한 언어: {0} (자동 감지)": "検出した言語: {0} (自動検出)",
"감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "検出した言語: {0} - 違っていれば別の言語で作り直してください",
"감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "検出した言語が作成する言語と異なる場合のみダウンロード {0}",
"같은 시리즈 (번호만 다른 파일)": "同じシリーズ(番号だけ違うファイル)",
"같은 이름이 이미 있습니다": "同じ名前のものが既にあります",
"같은 이름이 이미 있습니다: {0}": "同じ名前が既にあります: {0}",
@ -1724,6 +1725,7 @@
"이미지 자막은 편집할 수 없습니다": "画像字幕は編集できません",
"이번 실행에서 넣은 비밀번호는 앱을 끄면 사라집니다 (설정 파일에 평문으로 쓰지 않습니다). [다시 시도] 를 눌러 보세요.": "今回入力したパスワードはアプリを終了すると消えます (設定ファイルに平文で書きません)。[再試行] を押してみてください。",
"이번 업데이트로 바뀐 기본 설정": "今回のアップデートで変わった既定の設定",
"이번에는 쓰지 않음 (번역 없음)": "今回は使用しません (翻訳なし)",
"이어 받기": "続きをダウンロード",
"이전": "前へ",
"이전 (P)": "前へ(P)",

View File

@ -542,6 +542,7 @@
"가져올 즐겨찾기가 없습니다.": "没有可导入的书签。",
"감지한 언어: {0} (자동 감지)": "检测到的语言:{0}(自动检测)",
"감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "检测到的语言:{0} - 如有误,请用其他语言重新生成",
"감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "仅当检测到的语言与要生成的语言不同时下载 {0}",
"같은 시리즈 (번호만 다른 파일)": "同一系列(仅编号不同的文件)",
"같은 이름이 이미 있습니다": "已存在同名项目",
"같은 이름이 이미 있습니다: {0}": "已存在同名项目: {0}",
@ -1724,6 +1725,7 @@
"이미지 자막은 편집할 수 없습니다": "图像字幕无法编辑",
"이번 실행에서 넣은 비밀번호는 앱을 끄면 사라집니다 (설정 파일에 평문으로 쓰지 않습니다). [다시 시도] 를 눌러 보세요.": "本次输入的密码在关闭应用后会丢失 (不会以明文写入设置文件)。请尝试按 [重试]。",
"이번 업데이트로 바뀐 기본 설정": "本次更新更改的默认设置",
"이번에는 쓰지 않음 (번역 없음)": "本次不使用 (无需翻译)",
"이어 받기": "继续下载",
"이전": "上一个",
"이전 (P)": "上一个(P)",

View File

@ -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<Language>? 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<void> _ensureAiModel(ModelSpec m, List<VideoItem> 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<void> _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));

View File

@ -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) ...[

View File

@ -54,6 +54,21 @@ class _FakeTranslator implements Translator {
Future<void> dispose() async => disposed = true;
}
/// 259: 받은 모델을 기록하는 가짜 모델 저장소 (인터넷 대신 빈 파일)
class _CountingModels extends ModelStore {
_CountingModels(super.dir);
final downloaded = <String>[];
@override
Future<void> 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));