WIP 259 번역 모델 (NLLB 912MB) 은 번역이 실제로 필요할 때만 받기 - 자동 감지 + 한 언어면 감지한 원어가 다를 때 (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>묶음6
parent
12db3d43b1
commit
99d446cea6
|
|
@ -542,6 +542,7 @@
|
||||||
"가져올 즐겨찾기가 없습니다.": "No bookmarks to import.",
|
"가져올 즐겨찾기가 없습니다.": "No bookmarks to import.",
|
||||||
"감지한 언어: {0} (자동 감지)": "Detected language: {0} (auto-detect)",
|
"감지한 언어: {0} (자동 감지)": "Detected language: {0} (auto-detect)",
|
||||||
"감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "Detected language: {0} - if it is wrong, make them again with another language",
|
"감지한 언어: {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)",
|
"같은 시리즈 (번호만 다른 파일)": "Same series (files that differ only by number)",
|
||||||
"같은 이름이 이미 있습니다": "An item with the same name already exists",
|
"같은 이름이 이미 있습니다": "An item with the same name already exists",
|
||||||
"같은 이름이 이미 있습니다: {0}": "Already exists with the same name: {0}",
|
"같은 이름이 이미 있습니다: {0}": "Already exists with the same name: {0}",
|
||||||
|
|
@ -1724,6 +1725,7 @@
|
||||||
"이미지 자막은 편집할 수 없습니다": "Image subtitles cannot be edited",
|
"이미지 자막은 편집할 수 없습니다": "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].",
|
"이번 실행에서 넣은 비밀번호는 앱을 끄면 사라집니다 (설정 파일에 평문으로 쓰지 않습니다). [다시 시도] 를 눌러 보세요.": "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",
|
"이번 업데이트로 바뀐 기본 설정": "Default settings changed by this update",
|
||||||
|
"이번에는 쓰지 않음 (번역 없음)": "Not used this time (no translation)",
|
||||||
"이어 받기": "Resume",
|
"이어 받기": "Resume",
|
||||||
"이전": "Previous",
|
"이전": "Previous",
|
||||||
"이전 (P)": "Previous (P)",
|
"이전 (P)": "Previous (P)",
|
||||||
|
|
|
||||||
|
|
@ -542,6 +542,7 @@
|
||||||
"가져올 즐겨찾기가 없습니다.": "取り込むブックマークがありません。",
|
"가져올 즐겨찾기가 없습니다.": "取り込むブックマークがありません。",
|
||||||
"감지한 언어: {0} (자동 감지)": "検出した言語: {0} (自動検出)",
|
"감지한 언어: {0} (자동 감지)": "検出した言語: {0} (自動検出)",
|
||||||
"감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "検出した言語: {0} - 違っていれば別の言語で作り直してください",
|
"감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "検出した言語: {0} - 違っていれば別の言語で作り直してください",
|
||||||
|
"감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "検出した言語が作成する言語と異なる場合のみダウンロード {0}",
|
||||||
"같은 시리즈 (번호만 다른 파일)": "同じシリーズ(番号だけ違うファイル)",
|
"같은 시리즈 (번호만 다른 파일)": "同じシリーズ(番号だけ違うファイル)",
|
||||||
"같은 이름이 이미 있습니다": "同じ名前のものが既にあります",
|
"같은 이름이 이미 있습니다": "同じ名前のものが既にあります",
|
||||||
"같은 이름이 이미 있습니다: {0}": "同じ名前が既にあります: {0}",
|
"같은 이름이 이미 있습니다: {0}": "同じ名前が既にあります: {0}",
|
||||||
|
|
@ -1724,6 +1725,7 @@
|
||||||
"이미지 자막은 편집할 수 없습니다": "画像字幕は編集できません",
|
"이미지 자막은 편집할 수 없습니다": "画像字幕は編集できません",
|
||||||
"이번 실행에서 넣은 비밀번호는 앱을 끄면 사라집니다 (설정 파일에 평문으로 쓰지 않습니다). [다시 시도] 를 눌러 보세요.": "今回入力したパスワードはアプリを終了すると消えます (設定ファイルに平文で書きません)。[再試行] を押してみてください。",
|
"이번 실행에서 넣은 비밀번호는 앱을 끄면 사라집니다 (설정 파일에 평문으로 쓰지 않습니다). [다시 시도] 를 눌러 보세요.": "今回入力したパスワードはアプリを終了すると消えます (設定ファイルに平文で書きません)。[再試行] を押してみてください。",
|
||||||
"이번 업데이트로 바뀐 기본 설정": "今回のアップデートで変わった既定の設定",
|
"이번 업데이트로 바뀐 기본 설정": "今回のアップデートで変わった既定の設定",
|
||||||
|
"이번에는 쓰지 않음 (번역 없음)": "今回は使用しません (翻訳なし)",
|
||||||
"이어 받기": "続きをダウンロード",
|
"이어 받기": "続きをダウンロード",
|
||||||
"이전": "前へ",
|
"이전": "前へ",
|
||||||
"이전 (P)": "前へ(P)",
|
"이전 (P)": "前へ(P)",
|
||||||
|
|
|
||||||
|
|
@ -542,6 +542,7 @@
|
||||||
"가져올 즐겨찾기가 없습니다.": "没有可导入的书签。",
|
"가져올 즐겨찾기가 없습니다.": "没有可导入的书签。",
|
||||||
"감지한 언어: {0} (자동 감지)": "检测到的语言:{0}(自动检测)",
|
"감지한 언어: {0} (자동 감지)": "检测到的语言:{0}(自动检测)",
|
||||||
"감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "检测到的语言:{0} - 如有误,请用其他语言重新生成",
|
"감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "检测到的语言:{0} - 如有误,请用其他语言重新生成",
|
||||||
|
"감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "仅当检测到的语言与要生成的语言不同时下载 {0}",
|
||||||
"같은 시리즈 (번호만 다른 파일)": "同一系列(仅编号不同的文件)",
|
"같은 시리즈 (번호만 다른 파일)": "同一系列(仅编号不同的文件)",
|
||||||
"같은 이름이 이미 있습니다": "已存在同名项目",
|
"같은 이름이 이미 있습니다": "已存在同名项目",
|
||||||
"같은 이름이 이미 있습니다: {0}": "已存在同名项目: {0}",
|
"같은 이름이 이미 있습니다: {0}": "已存在同名项目: {0}",
|
||||||
|
|
@ -1724,6 +1725,7 @@
|
||||||
"이미지 자막은 편집할 수 없습니다": "图像字幕无法编辑",
|
"이미지 자막은 편집할 수 없습니다": "图像字幕无法编辑",
|
||||||
"이번 실행에서 넣은 비밀번호는 앱을 끄면 사라집니다 (설정 파일에 평문으로 쓰지 않습니다). [다시 시도] 를 눌러 보세요.": "本次输入的密码在关闭应用后会丢失 (不会以明文写入设置文件)。请尝试按 [重试]。",
|
"이번 실행에서 넣은 비밀번호는 앱을 끄면 사라집니다 (설정 파일에 평문으로 쓰지 않습니다). [다시 시도] 를 눌러 보세요.": "本次输入的密码在关闭应用后会丢失 (不会以明文写入设置文件)。请尝试按 [重试]。",
|
||||||
"이번 업데이트로 바뀐 기본 설정": "本次更新更改的默认设置",
|
"이번 업데이트로 바뀐 기본 설정": "本次更新更改的默认设置",
|
||||||
|
"이번에는 쓰지 않음 (번역 없음)": "本次不使用 (无需翻译)",
|
||||||
"이어 받기": "继续下载",
|
"이어 받기": "继续下载",
|
||||||
"이전": "上一个",
|
"이전": "上一个",
|
||||||
"이전 (P)": "上一个(P)",
|
"이전 (P)": "上一个(P)",
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,11 @@ class AiOptions {
|
||||||
bool get needsTranslation =>
|
bool get needsTranslation =>
|
||||||
source == undetermined || targets.any((t) => t.code != source.code);
|
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(
|
AiOptions copyWith({Language? source, Set<Language>? targets, ModelSpec? whisper}) => AiOptions(
|
||||||
source: source ?? this.source, targets: targets ?? this.targets, whisper: whisper ?? this.whisper);
|
source: source ?? this.source, targets: targets ?? this.targets, whisper: whisper ?? this.whisper);
|
||||||
}
|
}
|
||||||
|
|
@ -863,21 +868,9 @@ class AppController extends ChangeNotifier {
|
||||||
_aiCancelled = false;
|
_aiCancelled = false;
|
||||||
try {
|
try {
|
||||||
// 1. 모델 준비 (없으면 내려받기)
|
// 1. 모델 준비 (없으면 내려받기)
|
||||||
for (final m in [opts.whisper, nllbModel]) {
|
// 259: 번역 모델 (NLLB 912MB) 은 번역이 확실히 필요할 때만 먼저 받는다 - 아니면 감지한 원어가 만들 언어와 다를 때 받음
|
||||||
if (m == nllbModel && !opts.needsTranslation) continue;
|
for (final m in [opts.whisper, if (opts.surelyNeedsTranslation) nllbModel]) {
|
||||||
if (await services.models.isInstalled(m)) continue;
|
await _ensureAiModel(m, targets);
|
||||||
_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();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
for (final v in targets) {
|
for (final v in targets) {
|
||||||
if (_aiCancelled) break;
|
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 {
|
Future<void> _generateOne(VideoItem v, AiOptions opts) async {
|
||||||
void phase(String text, double p) {
|
void phase(String text, double p) {
|
||||||
v
|
v
|
||||||
|
|
@ -962,6 +972,9 @@ class AppController extends ChangeNotifier {
|
||||||
out = [for (final c in cues) c.copy()];
|
out = [for (final c in cues) c.copy()];
|
||||||
} else {
|
} else {
|
||||||
if (_translator == null) {
|
if (_translator == null) {
|
||||||
|
// 259: 감지한 원어가 만들 언어와 달라 이제 번역이 필요 - 모델이 없으면 여기서 받는다
|
||||||
|
await _ensureAiModel(nllbModel, [v]);
|
||||||
|
if (_aiCancelled) throw const AiCancelled();
|
||||||
phase(tr('번역 모델 불러오는 중'), base);
|
phase(tr('번역 모델 불러오는 중'), base);
|
||||||
_translator = services.createTranslator!();
|
_translator = services.createTranslator!();
|
||||||
await _translator!.load(await services.models.folderOf(nllbModel));
|
await _translator!.load(await services.models.folderOf(nllbModel));
|
||||||
|
|
|
||||||
|
|
@ -218,6 +218,14 @@ class _AiDialogState extends State<_AiDialog> {
|
||||||
style: TextStyle(fontSize: 11, color: ok ? JjColors.success : JjColors.textDim));
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final extra = languages.where((l) => !_o.targets.contains(l)).toList();
|
final extra = languages.where((l) => !_o.targets.contains(l)).toList();
|
||||||
|
|
@ -290,7 +298,7 @@ class _AiDialogState extends State<_AiDialog> {
|
||||||
),
|
),
|
||||||
Row(children: [
|
Row(children: [
|
||||||
Text(tr('번역 모델: NLLB-200 (로컬) '), style: TextStyle(fontSize: 13)),
|
Text(tr('번역 모델: NLLB-200 (로컬) '), style: TextStyle(fontSize: 13)),
|
||||||
_status(nllbModel),
|
Flexible(child: _nllbStatus()),
|
||||||
]),
|
]),
|
||||||
const NllbLicenseNote(),
|
const NllbLicenseNote(),
|
||||||
if (widget.videoCount > 1) ...[
|
if (widget.videoCount > 1) ...[
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,21 @@ class _FakeTranslator implements Translator {
|
||||||
Future<void> dispose() async => disposed = true;
|
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() {
|
void main() {
|
||||||
group('원어 판별', () {
|
group('원어 판별', () {
|
||||||
test('문자 체계', () {
|
test('문자 체계', () {
|
||||||
|
|
@ -177,6 +192,43 @@ void main() {
|
||||||
expect(v.subtitles.where((s) => s.kind == SubtitleKind.external), hasLength(3));
|
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 {
|
test('있는 자막 번역: 영어 SRT → _ko.srt (MKV 목록에 추가, 덮어쓰지 않음) + 작업 대기열', () async {
|
||||||
final dir = Directory.systemTemp.createTempSync('jj_tr_');
|
final dir = Directory.systemTemp.createTempSync('jj_tr_');
|
||||||
addTearDown(() => dir.deleteSync(recursive: true));
|
addTearDown(() => dir.deleteSync(recursive: true));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue