JJ_MKVMaker/lib/app/controller/mkv_batch.dart

231 lines
10 KiB
Dart

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<VideoItem> _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 = <VideoItem>{};
/// 지금 묶음을 멈춘다 (만들던 것은 "취소됨", 시작하지 않은 것도 "취소됨")
void cancelAll() {
_cancelGen++;
_cancelled = true;
}
/// 186 · 239: [targets] 동영상만 취소하고 묶음의 다음 것부터 이어서. 돌려주는 것: 이미 끝나 (돌고 있지 않아) 취소하지 못한 것
List<VideoItem> cancelVideos(List<VideoItem> 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<void> run(List<VideoItem> queue,
{required String existing, required EncodeSettings encode, required Set<String> 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 = <VideoItem>{};
// 262: 동영상마다 끝났을 때의 결과 (집계는 이것으로 - 끝난 뒤 줄의 상태가 바뀌어도 기록과 같게)
final results = <VideoItem, JobStatus>{};
Future<void> 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<bool> _build(VideoItem v, String existing, EncodeSettings encode, Set<String> 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 = <SubtitleEntry, String>{};
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<List<VideoItem>> withExistingOutput(List<VideoItem> targets) async => [
for (final v in targets)
if (await storage.exists(outputMkvPath(v.path))) v,
];
/// "이름 (2).mkv" 처럼 비어 있는 이름 (P0-1 [다른 이름])
Future<String> _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');
}
}