JJ_MKVMaker/lib/app/controller/ai_subtitle_runner.dart

303 lines
12 KiB
Dart

import 'dart:io';
import 'package:path/path.dart' as p;
import '../../core/ai_subtitle.dart';
import '../../core/languages.dart';
import '../../core/models.dart';
import '../../core/output_paths.dart';
import '../../core/srt.dart';
import '../../core/subtitle_search.dart';
import '../../core/text_codec.dart';
import '../../l10n/tr.dart';
import '../../services/ai_services.dart';
import '../../services/media_tool.dart';
import '../../services/model_store.dart';
import '../../services/platform_services.dart';
import 'ai_options.dart';
import 'subtitle_files.dart';
import 'video_files.dart';
/// AI 자막 만들기 (음성 추출 → 음성인식 → 언어별 번역) 와 있는 자막 번역. 이 기기 안에서 (Whisper · NLLB).
/// 약속: 동영상 줄 ([VideoItem]) 의 상태 · 단계 · 진행률과 자막 목록만 바꾸고 바뀔 때마다 [onChanged].
/// 결과는 jj_mkv\파일명_AI.srt (원본) · 파일명_언어코드.srt. 모델이 없으면 받는다. [cancel] 하면 지금 단계에서 멈춘다.
/// 대기열 (하나씩) 은 부르는 쪽이 맡는다.
class AiSubtitleRunner {
final PlatformServices services;
final SubtitleFiles files;
final void Function() onChanged;
final void Function(String msg) log;
AiSubtitleRunner({required this.services, required this.files, required this.onChanged, required this.log});
MediaTool get _tool => services.mediaTool;
Translator? _translator;
/// 지금 음성인식 (취소하면 멈춘다)
SpeechRecognizer? _recognizer;
/// 지금 작업이 취소되었는지 (다음 작업을 시작하면 다시 꺼진다)
bool cancelled = false;
/// 260: AI 자막 · 자막 번역이 모델을 쓰는 중 (그동안 환경 설정에서 그 모델을 지우지 못하게)
bool get modelsInUse => _modelUsers > 0;
int _modelUsers = 0;
Future<void> usingModels(Future<void> Function() job) async {
_modelUsers++;
onChanged();
try {
await job();
} finally {
_modelUsers--;
onChanged();
}
}
void cancel() {
cancelled = true;
_recognizer?.cancel();
_translator?.cancel();
}
/// [targets] 를 하나씩 (취소하면 거기서 멈춤)
Future<void> run(List<VideoItem> targets, AiOptions opts) async {
cancelled = false;
try {
// 1. 모델 준비 (없으면 내려받기)
// 259: 번역 모델 (NLLB 912MB) 은 번역이 확실히 필요할 때만 먼저 받는다 - 아니면 감지한 원어가 만들 언어와 다를 때 받음
for (final m in [opts.whisper, if (opts.surelyNeedsTranslation) nllbModel]) {
await _ensureModel(m, targets);
}
for (final v in targets) {
if (cancelled) break;
await _generateOne(v, opts);
}
} catch (e) {
log(trf('AI 자막 중단: {0}', [e]));
for (final v in targets.where((v) => v.status == JobStatus.running)) {
v
..status = JobStatus.failed
..message = '$e'
..phase = null;
}
} finally {
await _translator?.dispose();
_translator = null;
onChanged();
}
}
/// AI 자막 모델이 없으면 내려받기 (진행은 [targets] 의 줄에)
Future<void> _ensureModel(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: () => cancelled,
onProgress: (x) {
for (final v in targets) {
v
..phase = trf('모델 내려받는 중 {0}%', [(x * 100).round()])
..progress = x
..status = JobStatus.running;
}
onChanged();
});
}
Future<void> _generateOne(VideoItem v, AiOptions opts) async {
void phase(String text, double p) {
v
..status = JobStatus.running
..phase = text
..progress = p;
onChanged();
}
final storage = services.storage;
final tmp = p.join(await storage.tempDirectory(), 'ai_${DateTime.now().microsecondsSinceEpoch}');
await storage.ensureDirectory(tmp);
final wav = p.join(tmp, 'audio.wav');
log(trf('AI 자막 시작: {0}', [v.fileName]));
try {
if (!await storage.exists(v.path)) throw MediaToolException(VideoFiles.missingMessage);
// 2. 음성 추출 (전체의 5%)
if (v.info?.ofType('audio').isEmpty ?? false) {
throw MediaToolException(tr('음성 트랙이 없습니다.'));
}
phase(tr('음성 추출 중'), 0);
await _tool.runFfmpeg(buildExtractAudioArgs(v.path, wav),
duration: v.info?.duration, onProgress: (x) => phase(tr('음성 추출 중'), x * 0.05));
if (cancelled) throw const AiCancelled();
// 3. 음성인식 (5% ~ 50%)
phase(tr('음성인식 중'), 0.05);
final recognizer = _recognizer = services.createRecognizer!();
if (cancelled) throw const AiCancelled();
final raw = await recognizer.transcribe(
wav,
modelPath: await services.models.pathOf(opts.whisper),
language: opts.source == undetermined ? 'auto' : whisperCode(opts.source),
onProgress: (x) => phase(trf('음성인식 중 {0}%', [(x * 100).round()]), 0.05 + x * 0.45),
);
final cues = cleanRecognized(raw);
if (cues.isEmpty) throw MediaToolException(tr('인식된 말이 없습니다.'));
if (cancelled) throw const AiCancelled();
// 4. 원어
final src = opts.source != undetermined ? opts.source : detectLanguage(cues.map((c) => c.text).join(' '));
log(trf('음성인식 완료: {0}줄, 원어 {1}({2})', [cues.length, src.name, src.code]));
// 198: 자동 감지가 틀릴 수 있다 (영어를 한국어로 잡아 영어 발음을 한글로 적음) - 끝난 뒤 알리고 다른 언어로 다시 할 수 있게
v.aiDetected = opts.source == undetermined ? src.code : null;
// 5. 파일명_AI.srt (원본)
await storage.ensureDirectory(outputDirFor(v.path));
await storage.writeBytes(aiSubtitlePath(v.path), encodeText(formatSrt(cues), 'UTF-8').bytes);
// 6. 언어별 파일명_코드.srt (원어도 따로 만듦) → MKV 에 추가
final langs = opts.targets.toList();
for (var i = 0; i < langs.length; i++) {
final tgt = langs[i];
final base = 0.5 + 0.5 * i / langs.length;
final span = 0.5 / langs.length;
List<Cue> out;
if (tgt.code == src.code || src == undetermined) {
out = [for (final c in cues) c.copy()];
} else {
if (_translator == null) {
// 259: 감지한 원어가 만들 언어와 달라 이제 번역이 필요 - 모델이 없으면 여기서 받는다
await _ensureModel(nllbModel, [v]);
if (cancelled) throw const AiCancelled();
phase(tr('번역 모델 불러오는 중'), base);
_translator = services.createTranslator!();
await _translator!.load(await services.models.folderOf(nllbModel));
}
phase(trf('{0} 번역 중', [tgt.name]), base);
out = await _translated(cues, _translator!, src, tgt,
(x) => phase(trf('{0} 번역 중 {1}%', [tgt.name, (x * 100).round()]), base + span * x));
}
final path = languageSubtitlePath(v.path, tgt);
await storage.writeBytes(path, encodeText(formatSrt(out), 'UTF-8').bytes);
SubtitleFiles.addOrReplace(v, path, tgt, tgt.code == src.code ? tr('AI 인식') : tr('AI 번역'));
log(trf('저장: {0}', [p.basename(path)]));
}
v
..status = JobStatus.ready
..phase = null
..progress = 0;
// 196: 경로는 기기의 구분자로 (Android 에서도 "jj_mkv\이름" 으로 보였다)
log(trf('AI 자막 완료: {0} → {1} 외 {2}개', [v.fileName, p.join(outputFolderName, '${v.baseName}_AI.srt'), langs.length]));
} on AiCancelled {
v
..status = JobStatus.ready
..phase = null
..progress = 0;
cancelled = true;
log(trf('AI 자막 취소: {0}', [v.fileName]));
} catch (e) {
v
..status = JobStatus.failed
..phase = null
..message = e is MediaToolException ? e.message : '$e';
log(trf('AI 자막 실패: {0}\n{1}', [v.fileName, v.message]));
} finally {
// 작업 폴더째 지운다 (음성인식이 만드는 변환 사본 audio.wav.wav 도 함께)
try {
await Directory(tmp).delete(recursive: true);
} catch (_) {
await storage.delete(wav);
}
onChanged();
}
}
/// 줄마다 번역 (같은 시각, 번역한 글도 인식 결과처럼 다듬는다)
static Future<List<Cue>> _translated(
List<Cue> cues, Translator t, Language src, Language tgt, void Function(double) onProgress) async {
final texts = await translateKeepingFillers(
[for (final c in cues) c.text.replaceAll('\n', ' ')],
(rest) => t.translate(rest, source: src.nllb, target: tgt.nllb, onProgress: onProgress),
src: src.code,
tgt: tgt.code,
);
return [
for (var k = 0; k < cues.length; k++)
Cue(cues[k].start, cues[k].end, cleanRecognized([Cue(cues[k].start, cues[k].end, texts[k])]).firstOrNull?.text ?? texts[k]),
];
}
/// 자막 [s] 를 [targets] 언어로 번역해 jj_mkv\파일명_언어코드.srt 로 저장하고 MKV 자막에 추가.
/// 원어는 자막의 언어 태그, 없으면 글자로 추정
Future<void> translate(VideoItem v, SubtitleEntry s, Set<Language> targets) async {
cancelled = false;
void phase(String t, double p) {
v
..status = JobStatus.running
..phase = t
..progress = p;
onChanged();
}
Translator? translator;
try {
phase(tr('자막 불러오는 중'), 0);
final cues = await files.loadCues(v, s);
if (cues.isEmpty) throw MediaToolException(tr('자막이 비어 있습니다.'));
final src = s.language != undetermined ? s.language : detectLanguage(cues.take(200).map((c) => c.text).join(' '));
if (src == undetermined) throw MediaToolException(tr('자막 언어를 알 수 없습니다. 자막 줄에서 언어를 먼저 고르세요.'));
final langs = targets.where((t) => t.code != src.code).toList();
if (langs.isEmpty) throw MediaToolException(trf('원어({0})와 같은 언어로는 번역하지 않습니다.', [src.name]));
if (!await services.models.isInstalled(nllbModel)) {
log(trf('번역 모델 내려받는 중: {0}', [nllbModel.sizeLabel]));
await services.models.download(nllbModel,
isCancelled: () => cancelled, onProgress: (x) => phase(trf('번역 모델 내려받는 중 {0}%', [(x * 100).round()]), x * 0.2));
}
phase(tr('번역 모델 불러오는 중'), 0.2);
translator = _translator = services.createTranslator!();
await translator.load(await services.models.folderOf(nllbModel));
final dir = outputDirFor(v.path);
await services.storage.ensureDirectory(dir);
final used = {
for (final f in await services.storage.listFiles(dir)) p.basename(f).toLowerCase(),
for (final e in v.subtitles)
if (e.path != null) p.basename(e.path!).toLowerCase(),
};
for (var i = 0; i < langs.length; i++) {
if (cancelled) throw const AiCancelled();
final tgt = langs[i];
final base = 0.25 + 0.75 * i / langs.length;
final span = 0.75 / langs.length;
final out = await _translated(cues, translator, src, tgt,
(x) => phase(trf('{0} → {1} 번역 {2}%', [src.name, tgt.name, (x * 100).round()]), base + span * x));
final name = nextFreeName('${v.baseName}_${tgt.code}.srt', used);
used.add(name.toLowerCase());
final path = p.join(dir, name);
await services.storage.writeBytes(path, encodeText(formatSrt(out), 'UTF-8').bytes);
v.subtitles.add(SubtitleEntry.external(
path: path, language: tgt, charset: 'UTF-8', title: trf('{0} (AI 번역 ← {1})', [tgt.name, src.code])));
log(trf('자막 번역 저장: {0} ({1} → {2}, {3}줄)', [name, src.name, tgt.name, cues.length]));
}
v
..status = JobStatus.ready
..phase = null
..progress = 0;
} on AiCancelled {
v
..status = JobStatus.ready
..phase = null;
log(trf('자막 번역 취소: {0}', [v.fileName]));
} catch (e) {
v
..status = JobStatus.failed
..phase = null
..message = e is MediaToolException ? e.message : '$e';
log(trf('자막 번역 실패: {0}\n{1}', [v.fileName, v.message]));
} finally {
await translator?.dispose();
_translator = null;
onChanged();
}
}
}