From e702864e5b41512430e7d5ef82cf9c77801ed6d5 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:58:39 +0900 Subject: [PATCH] =?UTF-8?q?WIP=20262=20MKV=20=EB=81=9D=EB=82=98=EB=8A=94?= =?UTF-8?q?=20=EC=88=9C=EA=B0=84=20=EC=A4=84=20=E2=96=A0:=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=EC=9E=90=EB=A6=AC=EC=97=90=20=EC=98=A4=EB=8A=94=20?= =?UTF-8?q?=E2=9F=B3=20=EB=8A=94=20=EB=AC=B6=EC=9D=8C=EC=9D=B4=20=EB=81=9D?= =?UTF-8?q?=EB=82=A0=20=EB=95=8C=EA=B9=8C=EC=A7=80=20=EC=95=88=20=EB=88=8C?= =?UTF-8?q?=EB=A6=BC,=20=EC=A7=91=EA=B3=84=EB=8A=94=20=EB=81=9D=EB=82=AC?= =?UTF-8?q?=EC=9D=84=20=EB=95=8C=EC=9D=98=20=EA=B2=B0=EA=B3=BC=EB=A1=9C=20?= =?UTF-8?q?=C2=B7=20=EC=8B=9C=EC=9E=91=20=EB=AA=BB=20=ED=95=9C=20=EA=B2=83?= =?UTF-8?q?=EB=A7=8C=20=EC=B7=A8=EC=86=8C=EB=90=A8=20(=EA=B2=80=ED=86=A0?= =?UTF-8?q?=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 1 + assets/l10n/ja.json | 1 + assets/l10n/zh-Hans.json | 1 + lib/app/app_controller.dart | 22 ++++++-- lib/ui/home_page.dart | 5 +- test/batch_test.dart | 108 ++++++++++++++++++++++++++++++++++++ 6 files changed, 131 insertions(+), 7 deletions(-) diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 93b7835..4ba1bc4 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -781,6 +781,7 @@ "다른 화면으로 가거나 앱이 뒤로 가면 Tor 화면의 페이지 · 연결은 바로 정리합니다. Tor 자체는 이 시간 동안 켜 두어 다시 들어오면 바로 쓸 수 있습니다.": "When you go to another screen or the app goes to the background, the Tor screen's pages and connections are cleared right away. Tor itself keeps running for this long so you can use it again immediately.", "다른 화면으로 가면 페이지 소리 멈춤": "Pause page sound when leaving the browser", "다시 만들기 대기로": "Back to waiting for remake", + "다시 만들기 대기로 (지금 묶음이 끝난 뒤)": "Queue again to rebuild (after the current batch finishes)", "다시 받기": "Download again", "다시 비교": "Compare again", "다시 설치": "Reinstall", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index d908835..6a83e43 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -781,6 +781,7 @@ "다른 화면으로 가거나 앱이 뒤로 가면 Tor 화면의 페이지 · 연결은 바로 정리합니다. Tor 자체는 이 시간 동안 켜 두어 다시 들어오면 바로 쓸 수 있습니다.": "別の画面に移るかアプリがバックグラウンドに回ると、Tor 画面のページ · 接続はすぐに片付けます。Tor 自体はこの時間だけ動かしておき、戻ったらすぐ使えます。", "다른 화면으로 가면 페이지 소리 멈춤": "他の画面に移るとページの音を止める", "다시 만들기 대기로": "作り直し待ちに戻す", + "다시 만들기 대기로 (지금 묶음이 끝난 뒤)": "再作成待ちに戻す (今のまとめ作業が終わった後)", "다시 받기": "再ダウンロード", "다시 비교": "もう一度比較", "다시 설치": "再インストール", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index d8436ff..3ede00b 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -781,6 +781,7 @@ "다른 화면으로 가거나 앱이 뒤로 가면 Tor 화면의 페이지 · 연결은 바로 정리합니다. Tor 자체는 이 시간 동안 켜 두어 다시 들어오면 바로 쓸 수 있습니다.": "切换到其他页面或应用转到后台时,会立即清理 Tor 页面的网页和连接。Tor 本身会在这段时间内保持运行,返回后可立即使用。", "다른 화면으로 가면 페이지 소리 멈춤": "离开浏览器时暂停网页声音", "다시 만들기 대기로": "改回等待重新制作", + "다시 만들기 대기로 (지금 묶음이 끝난 뒤)": "重新排队制作 (当前批次结束后)", "다시 받기": "重新下载", "다시 비교": "重新比较", "다시 설치": "重新安装", diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index 656b7cf..dd91ffc 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -1383,24 +1383,32 @@ class AppController extends ChangeNotifier { ..message = null; } final skipped = {}; + // 262: 동영상마다 끝났을 때의 결과 (집계는 이것으로 - 끝난 뒤 줄의 상태가 바뀌어도 기록과 같게) + final results = {}; Future worker() async { while (!_buildCancelled && _batchNext < queue.length) { final v = queue[_batchNext++]; - if (!await _build(v, existing)) skipped.add(v); + if (await _build(v, existing)) { + results[v] = v.status; + } else { + skipped.add(v); + } } } await Future.wait([for (var i = 0; i < limit.clamp(1, queue.length); i++) worker()]); - // P0-2: [남은 것 모두 취소] 로 시작하지 못한 것은 "취소됨" + // P0-2: [남은 것 모두 취소] 로 시작하지 못한 것만 "취소됨". + // 262: 상태가 "대기" 인 것으로 고르면, 끝난 뒤 다시 대기로 돌린 것까지 "취소됨" 이 되었다 for (final v in queue) { - if (v.status == JobStatus.ready && !skipped.contains(v)) { + if (!results.containsKey(v) && !skipped.contains(v)) { v ..status = JobStatus.cancelled ..message = tr('취소됨'); + results[v] = JobStatus.cancelled; } } // 이번에 만든 것 기준 (목록의 다른 동영상은 세지 않음). 187: 취소는 실패로 세지 않는다 - int count(JobStatus s) => queue.where((v) => v.status == s && !skipped.contains(v)).length; + 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]) @@ -1422,6 +1430,9 @@ class AppController extends ChangeNotifier { int get batchTotal => buildingMkv ? _batchQueue.length : 0; int get batchStarted => buildingMkv ? _batchNext.clamp(0, _batchQueue.length) : 0; + /// 262: [v] 가 지금 만드는 묶음에 들어 있는지 + bool inCurrentBatch(VideoItem v) => buildingMkv && _batchQueue.contains(v); + /// 186: 지금 묶음에서 아직 시작하지 않은 동영상 수 int get batchWaiting => buildingMkv ? (_batchQueue.length - _batchNext).clamp(0, _batchQueue.length) : 0; @@ -1587,7 +1598,8 @@ class AppController extends ChangeNotifier { /// 완료·실패 상태를 초기화해 다시 만들 수 있게 한다. void resetStatus(VideoItem v) { - if (v.status == JobStatus.running) return; + // 262: 지금 묶음의 것은 묶음이 끝난 뒤에 (끝나는 순간 ■ 자리에 ⟳ 가 나타나 ■ 를 누르려던 것이 ⟳ 로 들어갔다) + if (v.status == JobStatus.running || inCurrentBatch(v)) return; v ..status = JobStatus.ready ..progress = 0 diff --git a/lib/ui/home_page.dart b/lib/ui/home_page.dart index f92f9f8..666bfc0 100644 --- a/lib/ui/home_page.dart +++ b/lib/ui/home_page.dart @@ -934,10 +934,11 @@ class _VideoTile extends StatelessWidget { ), ), if (v.status == JobStatus.done || v.status == JobStatus.failed || v.status == JobStatus.cancelled) + // 262: 끝나는 순간 ■ 와 같은 자리에 나타난다 - 묶음이 끝날 때까지는 눌리지 않게 (자리는 그대로 두어 ✕ 가 밀려오지 않게) IconButton( - tooltip: tr('다시 만들기 대기로'), + tooltip: c.inCurrentBatch(v) ? tr('다시 만들기 대기로 (지금 묶음이 끝난 뒤)') : tr('다시 만들기 대기로'), iconSize: 16, - onPressed: () => c.resetStatus(v), + onPressed: c.inCurrentBatch(v) ? null : () => c.resetStatus(v), icon: const Icon(Icons.refresh, color: JjColors.textDim), ), // 240: 만드는 중인 동영상 하나만 취소 (다른 것 · 남은 것은 그대로) diff --git a/test/batch_test.dart b/test/batch_test.dart index 4353d7b..1aa0ed4 100644 --- a/test/batch_test.dart +++ b/test/batch_test.dart @@ -97,6 +97,20 @@ class _Backend implements DownloadBackend { Future shutdown() async {} } +/// 262: [_HoldTool] 과 같지만, 끝내면 (결과 이름으로 바꿀) 임시 출력 파일을 쓴다 +class _EndTool extends _HoldTool { + final args = >{}; + @override + Future runFfmpeg(List a, {Duration? duration, ProgressCallback? onProgress, Object? tag}) async { + args[tag] = a; + await super.runFfmpeg(a, duration: duration, onProgress: onProgress, tag: tag); + File(a.last).writeAsStringSync('NEW'); + } + + /// [tag] 의 FFmpeg 를 성공으로 끝낸다 + void finish(Object tag) => runs[tag]!.complete(); +} + AppController _plain() => AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); @@ -704,6 +718,100 @@ void main() { d.dispose(); }); + test('262: 동영상이 끝나는 바로 그때 줄 ■ (지금 것만 취소) 가 와도 성공 그대로 - 줄 · 집계 · 파일', () async { + final tool = _EndTool(); + final dir = Directory.systemTemp.createTempSync('jj_batch262_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final files = [for (final n in ['batch1', 'batch2', 'batch3']) (File(p.join(dir.path, '$n.mp4'))..writeAsBytesSync([0])).path]; + final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService())); + await c.init(); + c.settings + ..maxParallelJobs = 1 + ..mkvExisting = 'overwrite'; + await c.addVideos(files); + final [v1, v2, v3] = c.videos; + File(outputMkvPath(v1.path)) + ..createSync(recursive: true) + ..writeAsStringSync('OLD'); + final building = c.buildVideos([v1, v2, v3]); + Future until(bool Function() ok) async { + for (var i = 0; i < 300 && !ok(); i++) { + await Future.delayed(const Duration(milliseconds: 5)); + } + } + + await until(() => tool.runs.containsKey(v1)); + // FFmpeg 끝 (성공) 바로 뒤 · 결과 이름으로 바꾸기 전에 ■ + tool.finish(v1); + expect(v1.status, JobStatus.running); + c.cancelVideos([v1]); + // 끝난 바로 뒤 같은 자리의 ⟳ 를 눌러도 (손가락이 ■ 를 누르려던 것) 지금 묶음에서는 바뀌지 않는다 + await until(() => v1.status == JobStatus.done); + c.resetStatus(v1); + expect(v1.status, JobStatus.done); + for (final v in [v2, v3]) { + await until(() => tool.runs.containsKey(v)); + tool.finish(v); + } + await building; + expect([v1.status, v2.status, v3.status], [JobStatus.done, JobStatus.done, JobStatus.done], reason: '${v1.message}'); + expect(File(outputMkvPath(v1.path)).readAsStringSync(), 'NEW'); + final log = c.logs.join('\n'); + expect(log, contains('성공: batch1.mkv')); + expect(log, contains('성공 3 · 취소 0 · 실패 0 / 전체 3')); + // 묶음이 끝나면 ⟳ 로 다시 대기로 돌릴 수 있다 + c.resetStatus(v1); + expect(v1.status, JobStatus.ready); + }); + + testWidgets('262: 끝나는 순간 줄의 ■ 자리에 오는 ⟳ 는 지금 묶음이 끝날 때까지 눌리지 않는다 (✕ 도 그 자리로 밀려오지 않음)', (tester) async { + tester.view.physicalSize = const Size(1600, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final tool = _EndTool(); + final dir = Directory.systemTemp.createTempSync('jj_batch262w_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final files = [for (final n in ['a', 'b']) (File(p.join(dir.path, '$n.mp4'))..writeAsBytesSync([0])).path]; + final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService())); + late Future building; + await tester.runAsync(() async { + await c.init(); + c.settings.maxParallelJobs = 1; + await c.addVideos(files); + building = c.buildVideos([...c.videos]); + for (var i = 0; i < 200 && tool.runs.isEmpty; i++) { + await Future.delayed(const Duration(milliseconds: 10)); + } + }); + final [a, b] = c.videos; + await tester.pumpWidget(MaterialApp(home: HomePage(c: c))); + await tester.pump(); + final stopAt = tester.getCenter(find.byTooltip('이 동영상만 취소')); + // a 가 끝나고 b 가 시작 + await tester.runAsync(() async { + tool.finish(a); + for (var i = 0; i < 200 && !tool.runs.containsKey(b); i++) { + await Future.delayed(const Duration(milliseconds: 10)); + } + }); + await tester.pump(); + expect(a.status, JobStatus.done); + // ■ 를 누르려던 자리를 누른다 + await tester.tapAt(stopAt); + await tester.pump(); + expect(a.status, JobStatus.done, reason: '⟳ 로 대기가 되지 않음'); + expect(c.videos, contains(a), reason: '✕ 로 빠지지 않음'); + await tester.runAsync(() async { + tool.finish(b); + await building; + }); + await tester.pump(); + expect(c.logs.join('\n'), contains('성공 2 · 취소 0 · 실패 0 / 전체 2')); + // 누른 자리의 풍선 도움말 타이머 정리 + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 2)); + }); + testWidgets('240: 여럿이 동시에 돌 때 ■ 는 묻는다 ([그대로 두기] · [모두 취소 (n개)]) · 목록 줄의 ■ 는 그 동영상만', (tester) async { tester.view.physicalSize = const Size(1600, 900); tester.view.devicePixelRatio = 1;