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 usingModels(Future Function() job) async { _modelUsers++; onChanged(); try { await job(); } finally { _modelUsers--; onChanged(); } } void cancel() { cancelled = true; _recognizer?.cancel(); _translator?.cancel(); } /// [targets] 를 하나씩 (취소하면 거기서 멈춤) Future run(List 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 _ensureModel(ModelSpec m, List 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 _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 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> _translated( List 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 translate(VideoItem v, SubtitleEntry s, Set 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(); } } }