AI 자막: 말 없는 곳의 지어낸 되풀이 (ん · ん · ん) 정리, 짧은 말 (はい · えー · そうそう · 네 · yes …) 은 정해진 번역으로
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>main
parent
31a4672971
commit
61da57aea1
|
|
@ -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)));
|
||||
}
|
||||
|
|
@ -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++)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 없음');
|
||||
|
|
|
|||
Loading…
Reference in New Issue