AI 자막: 말 없는 곳의 지어낸 되풀이 (ん · ん · ん) 정리, 짧은 말 (はい · えー · そうそう · 네 · yes …) 은 정해진 번역으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
RyuJongJin 2026-10-04 17:17:26 +09:00
parent 31a4672971
commit 61da57aea1
4 changed files with 232 additions and 11 deletions

View File

@ -0,0 +1,58 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/core/languages.dart';
import 'package:jj_mkvmaker/core/models.dart';
import 'package:jj_mkvmaker/core/output_paths.dart';
import 'package:jj_mkvmaker/services/model_store.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
/// 긴 실제 영상 (JJ_LONG_VIDEO) 으로 AI 자막: 원어 자동 → [JJ_LONG_TARGETS] (기본 ja,en).
/// 번역이 제대로 됐는지 줄 단위로 센다 (영어 자막에 일본어가 남은 줄 · 빈 줄 · 같은 말 되풀이).
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('긴 영상 AI 자막 → 번역 확인', (tester) async {
final video = Platform.environment['JJ_LONG_VIDEO'];
if (video == null || !File(video).existsSync()) return markTestSkipped('JJ_LONG_VIDEO 없음');
final targets = (Platform.environment['JJ_LONG_TARGETS'] ?? 'ja,en').split(',');
final c = AppController(PlatformServices.create());
final sw = Stopwatch()..start();
await tester.runAsync(() async {
await c.init();
await c.addVideos([video]);
await c.generateAiSubtitles(
[c.videos.single],
AiOptions(targets: {for (final t in targets) languageOf(t)}, whisper: whisperModels.first),
);
});
final v = c.videos.single;
// ignore: avoid_print
print('RESULT 걸린 시간 ${sw.elapsed.inSeconds}초\n${c.logs.join('\n')}');
expect(v.status, isNot(JobStatus.failed), reason: v.message);
final jp = RegExp(r'[぀-ヿ一-鿿]');
List<String> texts(String path) {
final blocks = File(path).readAsStringSync().replaceAll('\r', '').trim().split(RegExp(r'\n\n+'));
return [for (final b in blocks) b.split('\n').skip(2).join(' ').trim()];
}
for (final t in targets) {
final path = languageSubtitlePath(video, languageOf(t));
final lines = texts(path);
final empty = lines.where((l) => l.isEmpty).length;
final withJp = lines.where(jp.hasMatch).length;
var repeats = 0;
for (var i = 1; i < lines.length; i++) {
if (lines[i].isNotEmpty && lines[i] == lines[i - 1]) repeats++;
}
// ignore: avoid_print
print('RESULT [$t] 줄 ${lines.length} · 빈 줄 $empty · 일본어 글자 있는 줄 $withJp · 바로 앞과 같은 줄 $repeats\n'
' 처음: ${lines.take(5).join(' / ')}\n'
' 중간: ${lines.skip(lines.length ~/ 2).take(5).join(' / ')}\n'
' 끝: ${lines.skip(lines.length - 5).join(' / ')}');
}
}, timeout: const Timeout(Duration(minutes: 90)));
}

View File

@ -845,11 +845,16 @@ class AppController extends ChangeNotifier {
await _translator!.load(await services.models.folderOf(nllbModel));
}
phase(trf('{0} 번역 중', [tgt.name]), base);
final texts = await _translator!.translate(
final texts = await translateKeepingFillers(
[for (final c in cues) c.text.replaceAll('\n', ' ')],
source: src.nllb,
target: tgt.nllb,
onProgress: (x) => phase(trf('{0} 번역 중 {1}%', [tgt.name, (x * 100).round()]), base + span * x),
(rest) => _translator!.translate(
rest,
source: src.nllb,
target: tgt.nllb,
onProgress: (x) => phase(trf('{0} 번역 중 {1}%', [tgt.name, (x * 100).round()]), base + span * x),
),
src: src.code,
tgt: tgt.code,
);
out = [
for (var k = 0; k < cues.length; k++)
@ -1035,11 +1040,16 @@ class AppController extends ChangeNotifier {
final tgt = langs[i];
final base = 0.25 + 0.75 * i / langs.length;
final span = 0.75 / langs.length;
final texts = await translator.translate(
final texts = await translateKeepingFillers(
[for (final c in cues) c.text.replaceAll('\n', ' ')],
source: src.nllb,
target: tgt.nllb,
onProgress: (x) => phase(trf('{0} → {1} 번역 {2}%', [src.name, tgt.name, (x * 100).round()]), base + span * x),
(rest) => translator!.translate(
rest,
source: src.nllb,
target: tgt.nllb,
onProgress: (x) => phase(trf('{0} → {1} 번역 {2}%', [src.name, tgt.name, (x * 100).round()]), base + span * x),
),
src: src.code,
tgt: tgt.code,
);
final out = [
for (var k = 0; k < cues.length; k++)

View File

@ -72,16 +72,136 @@ Language detectLanguage(String text) {
final _nonSpeech = RegExp(r'^\s*[\[\((【♪*].*[\]\))】♪*]\s*$');
/// 자주 나오는 짧은 말 (맞장구 · 감탄) 의 번역: 원어 → (정리한 말 → 대상 언어 → 번역).
/// 번역 모델은 짧은 말을 엉뚱하게 옮기는 일이 많아서 (はい → "I know.", はーい → "What?") 이 표를 먼저 쓴다.
const _fillers = <String, Map<String, Map<String, String>>>{
'ja': {
'はい': {'en': 'Yes.', 'ko': '네.'},
'はーい': {'en': 'Okay!', 'ko': '네~'},
'はいはい': {'en': 'Yes, yes.', 'ko': '네, 네.'},
'うん': {'en': 'Yeah.', 'ko': '응.'},
'うんうん': {'en': 'Yeah, yeah.', 'ko': '응응.'},
'ええ': {'en': 'Yes.', 'ko': '네.'},
'えー': {'en': 'Um...', 'ko': '음...'},
'えっと': {'en': 'Um...', 'ko': '음...'},
'えーと': {'en': 'Um...', 'ko': '음...'},
'あー': {'en': 'Ah...', 'ko': '아...'},
'あ': {'en': 'Oh.', 'ko': '아.'},
'あっ': {'en': 'Oh!', 'ko': '앗.'},
'うーん': {'en': 'Hmm...', 'ko': '음...'},
'そう': {'en': 'Right.', 'ko': '맞아.'},
'そうそう': {'en': 'Right, right.', 'ko': '맞아 맞아.'},
'そうそうそう': {'en': 'Right, right.', 'ko': '맞아 맞아.'},
'そうですね': {'en': "That's right.", 'ko': '그렇네요.'},
'そうなんです': {'en': "That's right.", 'ko': '그렇거든요.'},
'そうなんだ': {'en': 'I see.', 'ko': '그렇구나.'},
'なるほど': {'en': 'I see.', 'ko': '그렇구나.'},
'ね': {'en': 'Right?', 'ko': '그치?'},
'ねえ': {'en': 'Hey.', 'ko': '있잖아.'},
'へえ': {'en': 'Huh.', 'ko': '오~'},
'へー': {'en': 'Huh.', 'ko': '오~'},
'ほんとに': {'en': 'Really?', 'ko': '정말?'},
'ありがとうございます': {'en': 'Thank you.', 'ko': '감사합니다.'},
'ありがとう': {'en': 'Thanks.', 'ko': '고마워.'},
'さようなら': {'en': 'Goodbye.', 'ko': '안녕히 계세요.'},
'バイバイ': {'en': 'Bye-bye.', 'ko': '바이바이.'},
'おはようございます': {'en': 'Good morning.', 'ko': '안녕하세요.'},
'こんにちは': {'en': 'Hello.', 'ko': '안녕하세요.'},
},
'ko': {
'네': {'en': 'Yes.', 'ja': 'はい。'},
'예': {'en': 'Yes.', 'ja': 'はい。'},
'응': {'en': 'Yeah.', 'ja': 'うん。'},
'음': {'en': 'Hmm.', 'ja': 'うーん。'},
'어': {'en': 'Uh.', 'ja': 'あ。'},
'아': {'en': 'Oh.', 'ja': 'あ。'},
'그래': {'en': 'Okay.', 'ja': 'そう。'},
'맞아': {'en': 'Right.', 'ja': 'そうそう。'},
'감사합니다': {'en': 'Thank you.', 'ja': 'ありがとうございます。'},
'안녕하세요': {'en': 'Hello.', 'ja': 'こんにちは。'},
},
'en': {
'yes': {'ko': '네.', 'ja': 'はい。'},
'yeah': {'ko': '응.', 'ja': 'うん。'},
'okay': {'ko': '좋아.', 'ja': 'オーケー。'},
'ok': {'ko': '좋아.', 'ja': 'オーケー。'},
'um': {'ko': '음...', 'ja': 'えーと。'},
'uh': {'ko': '어...', 'ja': 'えー。'},
'oh': {'ko': '아.', 'ja': 'あ。'},
'right': {'ko': '맞아.', 'ja': 'そうそう。'},
'thank you': {'ko': '감사합니다.', 'ja': 'ありがとうございます。'},
},
};
/// 짧은 말 표에 있으면 그 번역 ([src] · [tgt] 는 언어 코드 ja · ko · en). 없으면 null.
String? fillerTranslation(String text, String src, String tgt) {
final table = _fillers[src];
if (table == null) return null;
var key = text
.toLowerCase()
.replaceAll(RegExp(r'[\s\p{P}~〜!!??]', unicode: true), ' ')
.trim()
.replaceAll(RegExp(r' +'), src == 'en' ? ' ' : '')
.replaceAll(RegExp(r'ー+'), 'ー');
return table[key]?[tgt];
}
/// 여러 줄 번역: 짧은 말 표에 있는 줄은 표의 번역을 쓰고, 나머지만 [translate] (번역 모델) 에 넘긴다.
Future<List<String>> translateKeepingFillers(
List<String> lines,
Future<List<String>> Function(List<String> rest) translate, {
required String src,
required String tgt,
}) async {
final out = List<String?>.filled(lines.length, null);
final rest = <int>[];
for (var i = 0; i < lines.length; i++) {
final f = fillerTranslation(lines[i], src, tgt);
if (f != null) {
out[i] = f;
} else {
rest.add(i);
}
}
if (rest.isNotEmpty) {
final got = await translate([for (final i in rest) lines[i]]);
for (var k = 0; k < rest.length && k < got.length; k++) {
out[rest[k]] = got[k];
}
}
return [for (var i = 0; i < lines.length; i++) out[i] ?? lines[i]];
}
/// 음성인식 구간 → 자막 줄 정리
/// - 빈 줄, "[BLANK_AUDIO]" · "(음악)" 같은 비음성 표시 제거
/// - 말이 없는 곳 (끝 음악 등) 에서 음성인식이 지어낸 같은 말 되풀이 정리:
/// 같은 줄이 [repeatRun] 번 넘게 이어지면 첫 줄만 남기고, 아주 짧은 말 ("ん" · "う" 등) 이면 모두 뺀다
/// - 긴 줄은 두 줄로 나눔 (한 줄 약 [maxLine] 글자)
List<Cue> cleanRecognized(List<Cue> raw, {int maxLine = 42}) {
final out = <Cue>[];
List<Cue> cleanRecognized(List<Cue> raw, {int maxLine = 42, int repeatRun = 3}) {
final kept = <Cue>[];
for (final c in raw) {
final t = c.text.replaceAll(RegExp(r'\s+'), ' ').trim();
if (t.isEmpty || _nonSpeech.hasMatch(t)) continue;
if (c.end <= c.start) continue;
out.add(Cue(c.start, c.end, _wrap(t, maxLine)));
kept.add(Cue(c.start, c.end, t));
}
final out = <Cue>[];
for (var i = 0; i < kept.length;) {
var j = i + 1;
while (j < kept.length && kept[j].text == kept[i].text) {
j++;
}
final run = j - i;
if (run > repeatRun) {
// 되풀이: 짧은 소리 ("ん" 등) 면 모두 빼고, 아니면 첫 줄만
final core = kept[i].text.replaceAll(RegExp(r'[\s\p{P}]', unicode: true), '');
if (core.length > 2) out.add(Cue(kept[i].start, kept[i].end, _wrap(kept[i].text, maxLine)));
} else {
for (var k = i; k < j; k++) {
out.add(Cue(kept[k].start, kept[k].end, _wrap(kept[k].text, maxLine)));
}
}
i = j;
}
return out;
}

View File

@ -83,6 +83,39 @@ void main() {
'This sentence is definitely much longer than forty two characters in total');
});
test('인식 결과 정리: 말 없는 곳에서 지어낸 되풀이 (ん · ん · ん …) 는 빼고, 진짜 대화의 짧은 반복은 둔다', () {
Cue cue(int s, String t) => Cue(Duration(seconds: s), Duration(seconds: s + 1), t);
final out = cleanRecognized([
cue(0, 'はい'),
cue(1, 'はい'),
cue(2, 'それでは'),
for (var i = 0; i < 5; i++) cue(10 + i, 'ん'),
for (var i = 0; i < 6; i++) cue(20 + i, 'ご視聴ありがとうございました'),
]);
expect([for (final c in out) c.text], ['はい', 'はい', 'それでは', 'ご視聴ありがとうございました']);
});
test('짧은 말 (はい · えー · そうそう …) 은 표의 번역, 나머지만 번역 모델로', () async {
expect(fillerTranslation('はい。', 'ja', 'en'), 'Yes.');
expect(fillerTranslation('はーーい!', 'ja', 'ko'), '네~');
expect(fillerTranslation('えー、', 'ja', 'en'), 'Um...');
expect(fillerTranslation('Thank you!', 'en', 'ja'), 'ありがとうございます。');
expect(fillerTranslation('はい、皆さんこんにちは', 'ja', 'en'), isNull);
expect(fillerTranslation('はい', 'ja', 'fr'), isNull); // 표에 없는 언어는 모델로
final sent = <String>[];
final out = await translateKeepingFillers(
['はい。', '大学はどこに行けばいいですかね', 'そうそう', '頭がいい人が好きなんだ'],
(rest) async {
sent.addAll(rest);
return [for (final r in rest) 'EN($r)'];
},
src: 'ja',
tgt: 'en',
);
expect(sent, ['大学はどこに行けばいいですかね', '頭がいい人が好きなんだ']);
expect(out, ['Yes.', 'EN(大学はどこに行けばいいですかね)', 'Right, right.', 'EN(頭がいい人が好きなんだ)']);
});
test('흐름: 영어 영상 → _AI.srt + _ko/_en/_ja.srt, MKV 목록에 추가', () async {
final tool = ProcessMediaTool('ffmpeg', 'ffprobe');
if (await tool.version() == null) return markTestSkipped('FFmpeg 없음');