231 lines
10 KiB
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');
|
|
}
|
|
}
|