MKV 취소 창은 창을 연 때의 동영상만 취소 · 여럿이 돌면 묻기 · 줄마다 ■ (239 · 240)

- 239 (버그, 기기 확인): 창을 보는 사이 다음 동영상이 시작하면 [지금 것만 취소] 가 그 다음 것을 취소했다 - FFmpeg 실행에 꼬리표 (MediaTool tag · cancelTagged), 이미 끝났으면 알리고 다시 묻기
- 240 (사용자 편의, 기기 확인): 여럿이 동시에 돌면 [그대로 두기] · [모두 취소 (n개)], 목록 줄의 ■ 로 하나만

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix_mkv
RyuJongJin 2026-10-12 00:29:18 +09:00
parent 976f4633aa
commit 54c4d6f97f
12 changed files with 165 additions and 17 deletions

View File

@ -323,6 +323,7 @@
"{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2} (change at the top of the main screen)",
"{0} · {1}배 · {2}": "{0} · {1}× · {2}",
"{0} → {1} 번역 {2}%": "{0} → {1} translation {2}%",
"{0} 는 이미 끝났습니다": "{0} has already finished",
"{0} 라이선스": "{0} license",
"{0} 로 번역 중 {1}%": "Translating to {0} {1}%",
"{0} 로 표현할 수 없는 글자 {1}개가 \"?\" 로 바뀝니다.\n계속 저장할까요? (UTF-8 을 권장합니다)": "{1} characters that cannot be represented in {0} will become \"?\".\nSave anyway? (UTF-8 is recommended)",
@ -857,6 +858,7 @@
"모두 종료": "Quit all",
"모두 지우기": "Clear all",
"모두 초기화": "Reset all",
"모두 취소 ({0}개)": "Cancel all ({0})",
"모두 취소 (대기 {0}개 포함)": "Cancel all (including {0} waiting)",
"모두 풀기": "Extract all",
"모든 작업 취소": "Cancel all jobs",
@ -1454,6 +1456,7 @@
"이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "Couldn't save the password because this device's secure storage is unavailable",
"이 기기의 폴더": "Folder on this device",
"이 단축키를 등록할 수 없습니다 (다른 프로그램이 사용 중일 수 있음)": "Cannot register this hotkey (another program may be using it)",
"이 동영상만 취소": "Cancel only this video",
"이 두 폴더는 지금 rsync 하는 중입니다. 끝난 뒤에 하세요.": "These two folders are being synced with rsync right now. Try again when it finishes.",
"이 버전 건너뛰기": "Skip this version",
"이 버전에는 Android 용 APK 가 없습니다.": "This version has no APK for Android.",
@ -2010,6 +2013,7 @@
"필요한 것 모두 받기": "Download everything needed",
"필요한 공간 {0}": "Space needed {0}",
"필요한 공간 {0} · 남은 공간 {1}": "Space needed {0} · free {1}",
"하나씩 멈추려면 목록 줄의 ■ 를 누르세요.": "To stop one at a time, press ■ on its row in the list.",
"하위 폴더가 없습니다": "No subfolders",
"한 번 더": "Once more",
"한 번: 바로 실행": "Single: open right away",

View File

@ -323,6 +323,7 @@
"{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2}(メイン画面の上部で変更)",
"{0} · {1}배 · {2}": "{0}・{1} 倍・{2}",
"{0} → {1} 번역 {2}%": "{0} → {1} 翻訳 {2}%",
"{0} 는 이미 끝났습니다": "{0} はすでに終わりました",
"{0} 라이선스": "{0} ライセンス",
"{0} 로 번역 중 {1}%": "{0} に翻訳中 {1}%",
"{0} 로 표현할 수 없는 글자 {1}개가 \"?\" 로 바뀝니다.\n계속 저장할까요? (UTF-8 을 권장합니다)": "{0} で表現できない文字 {1}個が「?」に変わります。\n保存を続けますか?(UTF-8 を推奨します)",
@ -857,6 +858,7 @@
"모두 종료": "すべて終了",
"모두 지우기": "すべて削除",
"모두 초기화": "すべてリセット",
"모두 취소 ({0}개)": "すべてキャンセル ({0} 件)",
"모두 취소 (대기 {0}개 포함)": "すべてキャンセル (待機 {0} 件を含む)",
"모두 풀기": "すべて展開",
"모든 작업 취소": "すべての作業をキャンセル",
@ -1454,6 +1456,7 @@
"이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "この端末の安全な保存領域を使えないため、パスワードを保存できませんでした",
"이 기기의 폴더": "この端末のフォルダー",
"이 단축키를 등록할 수 없습니다 (다른 프로그램이 사용 중일 수 있음)": "このショートカットを登録できません(他のプログラムが使用中の可能性があります)",
"이 동영상만 취소": "この動画だけキャンセル",
"이 두 폴더는 지금 rsync 하는 중입니다. 끝난 뒤에 하세요.": "この 2 つのフォルダーは今 rsync 中です。終わってから行ってください。",
"이 버전 건너뛰기": "このバージョンをスキップ",
"이 버전에는 Android 용 APK 가 없습니다.": "このバージョンには Android 用 APK がありません。",
@ -2010,6 +2013,7 @@
"필요한 것 모두 받기": "必要なものをすべてダウンロード",
"필요한 공간 {0}": "必要な容量 {0}",
"필요한 공간 {0} · 남은 공간 {1}": "必要な容量 {0}・空き容量 {1}",
"하나씩 멈추려면 목록 줄의 ■ 를 누르세요.": "1 つずつ止めるには、一覧の行の ■ を押してください。",
"하위 폴더가 없습니다": "サブフォルダーがありません",
"한 번 더": "もう一度",
"한 번: 바로 실행": "1 回: すぐに開く",

View File

@ -323,6 +323,7 @@
"{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2}(在主界面顶部更改)",
"{0} · {1}배 · {2}": "{0} · {1} 倍 · {2}",
"{0} → {1} 번역 {2}%": "{0} → {1} 翻译 {2}%",
"{0} 는 이미 끝났습니다": "{0} 已经完成",
"{0} 라이선스": "{0} 许可证",
"{0} 로 번역 중 {1}%": "正在翻译为 {0} {1}%",
"{0} 로 표현할 수 없는 글자 {1}개가 \"?\" 로 바뀝니다.\n계속 저장할까요? (UTF-8 을 권장합니다)": "{1} 个无法用 {0} 表示的字符将变为“?”。\n仍要保存吗?(推荐使用 UTF-8)",
@ -857,6 +858,7 @@
"모두 종료": "全部退出",
"모두 지우기": "全部清除",
"모두 초기화": "全部重置",
"모두 취소 ({0}개)": "全部取消({0} 个)",
"모두 취소 (대기 {0}개 포함)": "全部取消 (包括等待中的 {0} 个)",
"모두 풀기": "全部解压",
"모든 작업 취소": "取消所有任务",
@ -1454,6 +1456,7 @@
"이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "本设备的安全存储不可用,无法保存密码",
"이 기기의 폴더": "本设备上的文件夹",
"이 단축키를 등록할 수 없습니다 (다른 프로그램이 사용 중일 수 있음)": "无法注册此快捷键(可能已被其他程序使用)",
"이 동영상만 취소": "仅取消此视频",
"이 두 폴더는 지금 rsync 하는 중입니다. 끝난 뒤에 하세요.": "这两个文件夹正在进行 rsync。请等完成后再操作。",
"이 버전 건너뛰기": "跳过此版本",
"이 버전에는 Android 용 APK 가 없습니다.": "此版本没有 Android 版 APK。",
@ -2010,6 +2013,7 @@
"필요한 것 모두 받기": "下载全部所需文件",
"필요한 공간 {0}": "所需空间 {0}",
"필요한 공간 {0} · 남은 공간 {1}": "所需空间 {0} · 剩余空间 {1}",
"하나씩 멈추려면 목록 줄의 ■ 를 누르세요.": "要逐个停止,请按列表行上的 ■。",
"하위 폴더가 없습니다": "没有子文件夹",
"한 번 더": "再输入一次",
"한 번: 바로 실행": "单击: 立即打开",

View File

@ -1356,18 +1356,28 @@ class AppController extends ChangeNotifier {
/// 186: 지금 묶음에서 아직 시작하지 않은 동영상 수
int get batchWaiting => buildingMkv ? (_batchQueue.length - _batchNext).clamp(0, _batchQueue.length) : 0;
/// 186: 지금 만드는 동영상 이름 (취소 창에 보일 것)
List<String> get runningVideoNames => [for (final v in videos) if (v.status == JobStatus.running) v.fileName];
/// 186: 지금 만드는 동영상 (취소 창에 보일 것)
List<VideoItem> get runningVideos => [for (final v in videos) if (v.status == JobStatus.running) v];
/// 186 · 187: 취소 요청 차례 - 만들던 중에 바뀌었으면 그 동영상은 "취소됨"
int _cancelGen = 0;
/// 186: 지금 만드는 동영상만 취소하고 묶음의 다음 것부터 이어서 (대기열의 다른 작업도 그대로)
void cancelRunningVideos() {
if (!buildingMkv) return cancelCurrent();
_log(trf('지금 것만 취소: {0}', [runningVideoNames.join(', ')]));
_cancelGen++;
_tool.cancel();
/// 239 · 240: 사용자가 취소하라고 고른 동영상 (만들던 것이 끝나면 "취소됨")
final _cancelledVideos = <VideoItem>{};
/// 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> _build(VideoItem v) async {
@ -1393,9 +1403,12 @@ class AppController extends ChangeNotifier {
final c = await _utf8Copy(s);
if (c != null) copies[s] = c;
}
// 239: FFmpeg 가 시작하기 전에 취소됐으면 (자막 준비 중) 시작하지 않는다
if (_cancelledVideos.contains(v)) throw MediaToolException(tr('사용자가 취소했습니다.'));
started = true;
await _tool.runFfmpeg(
buildMuxArgs(v, out, encode: encode, encoders: encoders, utf8Copies: copies),
tag: v,
duration: v.info?.duration,
onProgress: (x) {
v.progress = x;
@ -1413,7 +1426,7 @@ class AppController extends ChangeNotifier {
? '${e.message}'
: '$e';
// 187: 사용자가 취소한 것은 실패가 아니라 "취소됨"
final cancelled = gen != _cancelGen || _buildCancelled;
final cancelled = gen != _cancelGen || _buildCancelled || _cancelledVideos.contains(v);
v
..status = cancelled ? JobStatus.cancelled : JobStatus.failed
..message = cancelled ? tr('취소됨') : msg;
@ -1425,6 +1438,7 @@ class AppController extends ChangeNotifier {
} catch (_) {}
}
} finally {
_cancelledVideos.remove(v);
for (final c in copies.values) {
try {
await services.storage.delete(c);

View File

@ -59,7 +59,7 @@ class FfmpegKitMediaTool implements MediaTool {
}
@override
Future<void> runFfmpeg(List<String> args, {Duration? duration, ProgressCallback? onProgress}) async {
Future<void> runFfmpeg(List<String> args, {Duration? duration, ProgressCallback? onProgress, Object? tag}) async {
// 표준 출력으로 진행률을 쓰는 인수는 빼고 통계 콜백을 쓴다
final a = <String>[];
for (var i = 0; i < args.length; i++) {
@ -78,6 +78,7 @@ class FfmpegKitMediaTool implements MediaTool {
(s) async {
final id = s.getSessionId();
_running.remove(id);
_tags.remove(id);
final rc = await s.getReturnCode();
if (_cancelled.remove(id) || ReturnCode.isCancel(rc)) {
done.completeError(MediaToolException(tr('사용자가 취소했습니다.')));
@ -104,7 +105,10 @@ class FfmpegKitMediaTool implements MediaTool {
},
);
final id = session.getSessionId();
if (id != null && !done.isCompleted) _running.add(id);
if (id != null && !done.isCompleted) {
_running.add(id);
if (tag != null) _tags[id] = tag;
}
await done.future;
onProgress?.call(1.0);
}
@ -116,4 +120,15 @@ class FfmpegKitMediaTool implements MediaTool {
unawaited(FFmpegKit.cancel(id));
}
}
final _tags = <int, Object>{};
@override
void cancelTagged(Object tag) {
for (final id in _running.toList()) {
if (_tags[id] != tag) continue;
_cancelled.add(id);
unawaited(FFmpegKit.cancel(id));
}
}
}

View File

@ -108,6 +108,7 @@ class ProcessMediaTool implements MediaTool {
List<String> args, {
Duration? duration,
ProgressCallback? onProgress,
Object? tag,
}) async {
final Process proc;
try {
@ -116,6 +117,7 @@ class ProcessMediaTool implements MediaTool {
throw MediaToolException(trf('ffmpeg 를 실행할 수 없습니다: {0}', [e.message]));
}
_running.add(proc);
if (tag != null) _tags[proc] = tag;
final totalUs = duration?.inMicroseconds ?? 0;
final stdoutDone = proc.stdout
@ -142,6 +144,7 @@ class ProcessMediaTool implements MediaTool {
final code = await proc.exitCode;
await Future.wait([stdoutDone, stderrDone]);
_running.remove(proc);
_tags.remove(proc);
if (_killed.remove(proc)) throw MediaToolException(tr('사용자가 취소했습니다.'));
if (code != 0) {
@ -159,6 +162,17 @@ class ProcessMediaTool implements MediaTool {
proc.kill();
}
}
final _tags = <Process, Object>{};
@override
void cancelTagged(Object tag) {
for (final proc in _running.toList()) {
if (_tags[proc] != tag) continue;
_killed.add(proc);
proc.kill();
}
}
}
/// `ffmpeg -encoders` 출력 → 영상 인코더 이름

View File

@ -24,12 +24,17 @@ abstract class MediaTool {
Future<Set<String>> encoders();
/// FFmpeg 실행. [duration] 이 있으면 진행률을 계산해 [onProgress] 로 알린다.
/// [tag] (239): 이 실행만 골라 취소할 때 ([cancelTagged]) 쓰는 표 (예: 만드는 동영상)
Future<void> runFfmpeg(
List<String> args, {
Duration? duration,
ProgressCallback? onProgress,
Object? tag,
});
/// 실행 중인 작업 취소
void cancel();
/// 239: [tag] 를 붙여 실행한 것만 취소 (동시에 도는 다른 것은 그대로)
void cancelTagged(Object tag);
}

View File

@ -140,12 +140,40 @@ class _TopBar extends StatelessWidget {
Future<void> _cancelJobs(BuildContext context) async {
final inBatch = c.batchWaiting;
final waiting = c.pendingJobs.length + inBatch;
// 239: 창을 연 때 돌고 있던 것 - [지금 것만 취소] 는 이것만 (그사이 끝나고 다음 것이 시작해도 다음 것은 취소하지 않는다)
final targets = c.buildingMkv ? c.runningVideos : const <VideoItem>[];
if (waiting == 0) {
// 240: 여럿이 동시에 돌고 있으면 묻는다 (하나씩은 목록 줄의 ■)
if (targets.length > 1) {
final all = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
actionsOverflowDirection: VerticalDirection.down,
title: Text(tr('작업 취소')),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(trf('지금: {0}', [targets.map((v) => v.fileName).join(', ')])),
const SizedBox(height: 4),
Text(tr('하나씩 멈추려면 목록 줄의 ■ 를 누르세요.'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('그대로 두기'))),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: JjColors.danger),
onPressed: () => Navigator.pop(ctx, true),
child: Text(trf('모두 취소 ({0}개)', [targets.length])),
),
],
),
);
if (all == true) c.cancel();
return;
}
c.cancel();
return;
}
if (inBatch > 0) {
final now = c.runningVideoNames.join(', ');
final now = targets.map((v) => v.fileName).join(', ');
final pick = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
@ -169,7 +197,15 @@ class _TopBar extends StatelessWidget {
],
),
);
if (pick == 'current') c.cancelRunningVideos();
if (pick == 'current') {
final gone = c.cancelVideos(targets);
// 239: 창을 보는 사이 이미 끝났으면 알리고 지금 상태로 다시 묻는다
if (gone.isNotEmpty && context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(trf('{0} 는 이미 끝났습니다', [gone.map((v) => v.fileName).join(', ')]))));
if (c.busy) await _cancelJobs(context);
}
}
if (pick == 'all') c.cancel();
return;
}
@ -851,6 +887,14 @@ class _VideoTile extends StatelessWidget {
onPressed: () => c.resetStatus(v),
icon: const Icon(Icons.refresh, color: JjColors.textDim),
),
// 240: 만드는 중인 동영상 하나만 취소 (다른 것 · 남은 것은 그대로)
if (v.status == JobStatus.running && c.buildingMkv)
IconButton(
tooltip: tr('이 동영상만 취소'),
iconSize: 16,
onPressed: () => c.cancelVideos([v]),
icon: const Icon(Icons.stop, color: JjColors.danger),
),
IconButton(
tooltip: tr('목록에서 제거'),
iconSize: 16,

View File

@ -213,7 +213,7 @@ void main() {
}
expect(v1.status, JobStatus.running);
expect(c.batchWaiting, 2, reason: '한 묶음 안의 기다리는 동영상도 대기로 센다');
c.cancelRunningVideos();
expect(c.cancelVideos(c.runningVideos), isEmpty);
await building;
expect([v1.status, v2.status, v3.status], [JobStatus.cancelled, JobStatus.done, JobStatus.done],
reason: '${v1.message} ${v2.message} ${v3.message}');
@ -222,6 +222,44 @@ void main() {
expect(c.logs.join('\n'), contains('성공 2 · 취소 1 · 실패 0 / 전체 3'));
});
test('239: 취소 창을 연 뒤 그 동영상이 끝나고 다음 것이 시작했으면 [지금 것만 취소] 는 다음 것을 취소하지 않는다', () async {
final tool = ProcessMediaTool('ffmpeg', 'ffprobe');
if (await tool.version() == null) return markTestSkipped('FFmpeg 없음');
final dir = Directory.systemTemp.createTempSync('jj_batch239_');
addTearDown(() => dir.deleteSync(recursive: true));
// v1 은 짧게 (창을 보는 사이 끝남), v2 는 길게
final files = [for (final n in ['v1', 'v2', 'v3']) p.join(dir.path, '$n.mp4')];
for (final (i, f) in files.indexed) {
await tool.runFfmpeg(['-y', '-f', 'lavfi', '-i', 'testsrc=size=1280x720:rate=30', '-t', i == 1 ? '60' : '1',
'-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', f]);
}
final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService()));
await c.init();
c.settings.maxParallelJobs = 1;
c.encode = const EncodeSettings(codec: VideoCodecChoice.h264, quality: QualityChoice.high);
await c.addVideos(files);
final [v1, v2, v3] = c.videos;
final building = c.buildVideos([v1, v2, v3]);
for (var i = 0; i < 400 && v1.status != JobStatus.running; i++) {
await Future<void>.delayed(const Duration(milliseconds: 10));
}
final shown = c.runningVideos; // 창을 연 때: v1
expect(shown, [v1]);
// 창을 보는 사이 v1 이 끝나고 v2 가 시작
for (var i = 0; i < 800 && !(v2.status == JobStatus.running && v2.progress > 0.01); i++) {
await Future<void>.delayed(const Duration(milliseconds: 25));
}
expect([v1.status, v2.status], [JobStatus.done, JobStatus.running]);
expect(c.cancelVideos(shown), [v1], reason: '이미 끝남 - 알리고 다시 묻는다');
await Future<void>.delayed(const Duration(milliseconds: 500));
expect(v2.status, JobStatus.running, reason: '창이 보여 준 것이 아닌 v2 는 취소하지 않음');
// 다시 묻는 창에서 v2 를 취소 → v3 은 이어서
expect(c.cancelVideos(c.runningVideos), isEmpty);
await building;
expect([v1.status, v2.status, v3.status], [JobStatus.done, JobStatus.cancelled, JobStatus.done],
reason: '${v1.message} ${v2.message} ${v3.message}');
});
testWidgets('MKV 화면: 체크 → "자막 만들기 (2)" · "MKV 만들기 (2)" 글, 전체 선택', (tester) async {
tester.view.physicalSize = const Size(1600, 900); // 위쪽 버튼 글이 다 보이는 너비
tester.view.devicePixelRatio = 1;

View File

@ -64,7 +64,7 @@ class _Tool implements MediaTool {
@override
Future<MediaInfo> probe(String path) async => const MediaInfo(duration: Duration(seconds: 1));
@override
Future<void> runFfmpeg(List<String> args, {Duration? duration, ProgressCallback? onProgress}) async {
Future<void> runFfmpeg(List<String> args, {Duration? duration, ProgressCallback? onProgress, Object? tag}) async {
now++;
if (now > peak) peak = now;
await Future<void>.delayed(const Duration(milliseconds: 30));
@ -73,6 +73,8 @@ class _Tool implements MediaTool {
@override
void cancel() {}
@override
void cancelTagged(Object tag) {}
}
void main() {

View File

@ -33,9 +33,11 @@ class _Tool implements MediaTool {
}
@override
Future<void> runFfmpeg(List<String> args, {Duration? duration, ProgressCallback? onProgress}) async {}
Future<void> runFfmpeg(List<String> args, {Duration? duration, ProgressCallback? onProgress, Object? tag}) async {}
@override
void cancel() {}
@override
void cancelTagged(Object tag) {}
}
void main() {

View File

@ -31,9 +31,11 @@ class _FakeTool implements MediaTool {
);
@override
Future<void> runFfmpeg(List<String> args,
{Duration? duration, ProgressCallback? onProgress}) async {}
{Duration? duration, ProgressCallback? onProgress, Object? tag}) async {}
@override
void cancel() {}
@override
void cancelTagged(Object tag) {}
}
class _FakeStorage implements StorageService {