303 lines
12 KiB
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();
|
|
}
|
|
}
|
|
}
|