import 'dart:io'; import 'package:path/path.dart' as p; import '../../core/encode_options.dart'; import '../../core/mkv_command_builder.dart'; import '../../core/models.dart'; import '../../core/output_paths.dart'; import '../../l10n/tr.dart'; import '../../services/media_tool.dart'; import '../../services/storage_service.dart'; import 'subtitle_files.dart'; import 'video_files.dart'; /// MKV 만들기 묶음. 약속: [run] 은 받은 동영상들을 동시에 [limit] 개씩 (0 = 모두) 만들고 끝나면 집계를 기록한다. /// 결과 MKV 가 이미 있으면 [run] 의 existing 에 따라 'overwrite' · 'skip' · 'rename' ("이름 (2).mkv"). /// 임시 이름 (….mkv.jjpart) 에 만들고 다 만들었을 때만 결과 이름으로 바꿔 넣는다 (취소 · 실패면 예전 MKV 는 그대로). /// 동영상 줄의 상태 · 진행률만 바꾸고 바뀔 때마다 [onChanged]. 대기열 (하나씩) 은 부르는 쪽이 맡는다. class MkvBatch { final StorageService storage; final MediaTool tool; final SubtitleFiles files; final void Function() onChanged; final void Function(String msg) log; MkvBatch({required this.storage, required this.tool, required this.files, required this.onChanged, required this.log}); /// 지금 MKV 를 만드는 중인지 (32: AI 자막 · 번역만 돌 때는 인코딩 설정을 막지 않는다. 화면 시험은 직접 정하기도 한다) bool building = false; /// 186: 지금 MKV 만들기 묶음 (차례를 기다리는 동영상도 "대기" 로 센다) List _queue = const []; int _next = 0; /// P0-4: 지금 묶음의 전체 수 · 시작한 수 (진행 글 "3개 중 2번째") int get total => building ? _queue.length : 0; int get started => building ? _next.clamp(0, _queue.length) : 0; /// 186: 지금 묶음에서 아직 시작하지 않은 동영상 수 int get waiting => building ? (_queue.length - _next).clamp(0, _queue.length) : 0; /// 262: [v] 가 지금 만드는 묶음에 들어 있는지 bool contains(VideoItem v) => building && _queue.contains(v); /// 186 · 187: 취소 요청 차례 - 만들던 중에 바뀌었으면 그 동영상은 "취소됨" int _cancelGen = 0; bool _cancelled = false; /// 239 · 240: 사용자가 취소하라고 고른 동영상 (만들던 것이 끝나면 "취소됨") final _cancelledVideos = {}; /// 지금 묶음을 멈춘다 (만들던 것은 "취소됨", 시작하지 않은 것도 "취소됨") void cancelAll() { _cancelGen++; _cancelled = true; } /// 186 · 239: [targets] 동영상만 취소하고 묶음의 다음 것부터 이어서. 돌려주는 것: 이미 끝나 (돌고 있지 않아) 취소하지 못한 것 List cancelVideos(List targets) { final live = [for (final v in targets) if (v.status == JobStatus.running) v]; if (live.isNotEmpty) { log(trf('지금 것만 취소: {0}', [live.map((v) => v.fileName).join(', ')])); for (final v in live) { _cancelledVideos.add(v); tool.cancelTagged(v); } } return [for (final v in targets) if (!live.contains(v)) v]; } Future run(List queue, {required String existing, required EncodeSettings encode, required Set encoders, required int limit}) async { _cancelled = false; building = true; onChanged(); try { if (queue.isEmpty) return; final n = limit <= 0 ? queue.length : limit; if (queue.length > 1) log(trf('MKV 만들기: {0}개, 동시에 {1}개씩', [queue.length, n.clamp(1, queue.length)])); _queue = queue; _next = 0; // P0-2: 앞 실행의 ✓ · 실패가 이번 집계에 섞이지 않게 - 이번 묶음은 모두 "아직" 에서 시작 for (final v in queue) { v ..status = JobStatus.ready ..progress = 0 ..message = null; } final skipped = {}; // 262: 동영상마다 끝났을 때의 결과 (집계는 이것으로 - 끝난 뒤 줄의 상태가 바뀌어도 기록과 같게) final results = {}; Future worker() async { while (!_cancelled && _next < queue.length) { final v = queue[_next++]; if (await _build(v, existing, encode, encoders)) { results[v] = v.status; } else { skipped.add(v); } } } await Future.wait([for (var i = 0; i < n.clamp(1, queue.length); i++) worker()]); // P0-2: [남은 것 모두 취소] 로 시작하지 못한 것만 "취소됨". // 262: 상태가 "대기" 인 것으로 고르면, 끝난 뒤 다시 대기로 돌린 것까지 "취소됨" 이 되었다 for (final v in queue) { if (!results.containsKey(v) && !skipped.contains(v)) { v ..status = JobStatus.cancelled ..message = tr('취소됨'); results[v] = JobStatus.cancelled; } } // 이번에 만든 것 기준 (목록의 다른 동영상은 세지 않음). 187: 취소는 실패로 세지 않는다 int count(JobStatus s) => results.values.where((x) => x == s).length; log(skipped.isEmpty ? trf('완료: 성공 {0} · 취소 {1} · 실패 {2} / 전체 {3}', [count(JobStatus.done), count(JobStatus.cancelled), count(JobStatus.failed), queue.length]) : trf('완료: 성공 {0} · 건너뜀 {1} · 취소 {2} · 실패 {3} / 전체 {4}', [count(JobStatus.done), skipped.length, count(JobStatus.cancelled), count(JobStatus.failed), queue.length])); } finally { building = false; _queue = const []; _next = 0; onChanged(); } } /// 만들었으면 (또는 실패 · 취소) true, 결과가 이미 있어 건너뛰었으면 false. /// P0-1: 임시 이름 (….mkv.jjpart) 에 만들고 끝났을 때만 결과 이름으로 바꿔 넣는다 - 취소 · 실패면 임시 파일만 지우고 /// 예전에 완성한 MKV 는 그대로 (예전에는 FFmpeg -y 가 처음부터 결과 파일을 덮어쓰고, 실패 · 취소면 그것을 지웠다) Future _build(VideoItem v, String existing, EncodeSettings encode, Set encoders) async { var out = outputMkvPath(v.path); if (await storage.exists(out)) { if (existing == 'skip') { v ..status = JobStatus.done ..outputPath = out ..message = tr('이미 있어 건너뜀'); log(trf('건너뜀 (이미 있음): {0}', [p.basename(out)])); onChanged(); return false; } if (existing == 'rename') out = await _freeName(out); } final part = '$out.jjpart'; final gen = _cancelGen; v ..status = JobStatus.running ..progress = 0 ..message = null; onChanged(); log(trf('시작: {0} → {1}{2}', [v.fileName, p.join(outputFolderName, p.basename(out)), encode.reencode ? ' [${encode.codec.label} · ${encode.resolution.label} · ${encode.quality.label}${encode.adjusts ? ' · ${encode.adjustSummary}' : ''}]' : ''])); final copies = {}; var started = false; // FFmpeg 가 출력 파일을 쓰기 시작했는지 try { if (!await storage.exists(v.path)) throw MediaToolException(VideoFiles.missingMessage); // Android 의 FFmpeg (ffmpeg-kit) 에는 AV1 디코더가 없어 다시 인코딩할 수 없다 (원본 유지는 된다) final vcodec = v.info?.ofType('video').firstOrNull?.codec; if (Platform.isAndroid && encode.reencode && vcodec == 'av1') { throw MediaToolException(tr('AV1 영상은 이 기기에서 다시 인코딩할 수 없습니다. ' '코덱을 "원본 유지" 로 바꿔 MKV 를 만드세요. (PC 판은 됩니다)')); } await storage.ensureDirectory(outputDirFor(v.path)); for (final s in v.subtitles.where((s) => s.enabled)) { final c = await files.utf8Copy(s); if (c != null) copies[s] = c; } // 239: FFmpeg 가 시작하기 전에 취소됐으면 (자막 준비 중) 시작하지 않는다 if (_cancelledVideos.contains(v)) throw MediaToolException(tr('사용자가 취소했습니다.')); started = true; await tool.runFfmpeg( buildMuxArgs(v, part, encode: encode, encoders: encoders, utf8Copies: copies), tag: v, duration: v.info?.duration, onProgress: (x) { v.progress = x; onChanged(); }, ); // 다 만들었을 때만 결과 이름으로 (덮어쓰기면 예전 것은 이때 바뀐다) await storage.replaceFile(part, out); v ..status = JobStatus.done ..outputPath = out; log(trf('성공: {0}', [p.basename(out)])); } catch (e) { final msg = e is MediaToolException ? e.message : e is ArgumentError ? '${e.message}' : '$e'; // 187: 사용자가 취소한 것은 실패가 아니라 "취소됨" final cancelled = gen != _cancelGen || _cancelled || _cancelledVideos.contains(v); v ..status = cancelled ? JobStatus.cancelled : JobStatus.failed ..message = cancelled ? tr('취소됨') : msg; log(cancelled ? trf('취소됨: {0}', [v.fileName]) : trf('실패: {0}\n{1}', [v.fileName, msg])); // 만들다 만 임시 파일만 지운다 - 결과 이름의 예전 MKV 는 건드리지 않는다 (P0-1) if (started) { try { await storage.delete(part); } catch (_) {} } } finally { _cancelledVideos.remove(v); for (final c in copies.values) { try { await storage.delete(c); } catch (_) {} } } onChanged(); return true; } /// P0: 결과 MKV 가 이미 있는 동영상 (만들기 전에 덮어쓸지 묻는 데) Future> withExistingOutput(List targets) async => [ for (final v in targets) if (await storage.exists(outputMkvPath(v.path))) v, ]; /// "이름 (2).mkv" 처럼 비어 있는 이름 (P0-1 [다른 이름]) Future _freeName(String path) async { final dir = p.dirname(path), base = p.basenameWithoutExtension(path), ext = p.extension(path); for (var i = 2; i < 1000; i++) { final c = p.join(dir, '$base ($i)$ext'); if (!await storage.exists(c)) return c; } return p.join(dir, '$base (${DateTime.now().millisecondsSinceEpoch})$ext'); } }