From 61da57aea13c85603646fba2ba1baf8751b7f899 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:17:26 +0900 Subject: [PATCH] =?UTF-8?q?AI=20=EC=9E=90=EB=A7=89:=20=EB=A7=90=20?= =?UTF-8?q?=EC=97=86=EB=8A=94=20=EA=B3=B3=EC=9D=98=20=EC=A7=80=EC=96=B4?= =?UTF-8?q?=EB=82=B8=20=EB=90=98=ED=92=80=EC=9D=B4=20(=E3=82=93=20=C2=B7?= =?UTF-8?q?=20=E3=82=93=20=C2=B7=20=E3=82=93)=20=EC=A0=95=EB=A6=AC,=20?= =?UTF-8?q?=EC=A7=A7=EC=9D=80=20=EB=A7=90=20(=E3=81=AF=E3=81=84=20=C2=B7?= =?UTF-8?q?=20=E3=81=88=E3=83=BC=20=C2=B7=20=E3=81=9D=E3=81=86=E3=81=9D?= =?UTF-8?q?=E3=81=86=20=C2=B7=20=EB=84=A4=20=C2=B7=20yes=20=E2=80=A6)=20?= =?UTF-8?q?=EC=9D=80=20=EC=A0=95=ED=95=B4=EC=A7=84=20=EB=B2=88=EC=97=AD?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- integration_test/ai_long_test.dart | 58 +++++++++++++ lib/app/app_controller.dart | 26 ++++-- lib/core/ai_subtitle.dart | 126 ++++++++++++++++++++++++++++- test/ai_test.dart | 33 ++++++++ 4 files changed, 232 insertions(+), 11 deletions(-) create mode 100644 integration_test/ai_long_test.dart diff --git a/integration_test/ai_long_test.dart b/integration_test/ai_long_test.dart new file mode 100644 index 0000000..2c16f45 --- /dev/null +++ b/integration_test/ai_long_test.dart @@ -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 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))); +} diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index bb94141..91b90bb 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -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++) diff --git a/lib/core/ai_subtitle.dart b/lib/core/ai_subtitle.dart index aa993f4..0180dc7 100644 --- a/lib/core/ai_subtitle.dart +++ b/lib/core/ai_subtitle.dart @@ -72,16 +72,136 @@ Language detectLanguage(String text) { final _nonSpeech = RegExp(r'^\s*[\[\((【♪*].*[\]\))】♪*]\s*$'); +/// 자주 나오는 짧은 말 (맞장구 · 감탄) 의 번역: 원어 → (정리한 말 → 대상 언어 → 번역). +/// 번역 모델은 짧은 말을 엉뚱하게 옮기는 일이 많아서 (はい → "I know.", はーい → "What?") 이 표를 먼저 쓴다. +const _fillers = >>{ + '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> translateKeepingFillers( + List lines, + Future> Function(List rest) translate, { + required String src, + required String tgt, +}) async { + final out = List.filled(lines.length, null); + final rest = []; + 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 cleanRecognized(List raw, {int maxLine = 42}) { - final out = []; +List cleanRecognized(List raw, {int maxLine = 42, int repeatRun = 3}) { + final kept = []; 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 = []; + 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; } diff --git a/test/ai_test.dart b/test/ai_test.dart index 5533974..041fc68 100644 --- a/test/ai_test.dart +++ b/test/ai_test.dart @@ -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 = []; + 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 없음');