From f331672e3a18f7d0f88b47917a58314f21af2e1f Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:12:46 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A7=84=ED=96=89=20=ED=91=9C=EC=8B=9C:=20?= =?UTF-8?q?=ED=95=B4=EC=83=81=EB=8F=84=20=EC=98=AC=EB=A6=AC=EA=B8=B0?= =?UTF-8?q?=EB=8A=94=20=EB=AA=A8=EB=8D=B8=EC=9D=84=20=EC=9D=BD=EB=8A=94=20?= =?UTF-8?q?=EB=8F=99=EC=95=88=20=EC=88=AB=EC=9E=90=20=EC=97=86=EC=9D=B4=20?= =?UTF-8?q?(167)=20=C2=B7=20=ED=81=B0=20=ED=8C=8C=EC=9D=BC=20=ED=95=98?= =?UTF-8?q?=EB=82=98=EB=8F=84=20=EB=B3=B4=EB=82=B8=20=EB=B0=94=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=EB=A1=9C=20=EC=98=A4=EB=A6=84=20=C2=B7=20=EB=81=9D?= =?UTF-8?q?=EB=82=9C=20=EC=A7=84=ED=96=89=20=EC=B0=BD=EC=9D=98=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20(171)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 167: sd-cli 의 모델 읽기 막대를 올리기 진행으로 읽어 처음부터 100% 였다. 읽는 동안은 움직이는 막대, 올리기는 타일 n/m 으로 % 와 "약 n초 남음" (upscaleProgressOf) - 앱이 직접 복사할 때 항목마다 바이트 크기와 보낸 바이트로 진행률 (이름 바꾸기로 옮기면 파일 수로), 보내는 동안 0.2초마다 알림 - 큰 파일 하나가 끝날 때까지 0% 에 머물던 것 - 171: 끝난 진행 창의 " 안의 파일" 에 마지막 항목 이름 - 시험: upscaleProgressOf, 256KB 를 256KB/s 로 복사하는 도중 20~100% 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/ai_upscale.dart | 21 +++++++++++++++++++-- lib/app/transfer_job.dart | 29 +++++++++++++++++++++++++++-- lib/ui/ai_upscale_ui.dart | 16 ++++++++++++++-- test/ai_upscale_test.dart | 9 +++++++++ test/sync_tools_test.dart | 15 +++++++++++++++ 8 files changed, 87 insertions(+), 6 deletions(-) diff --git a/assets/l10n/en.json b/assets/l10n/en.json index f0c565d..bb4c8bd 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -1138,6 +1138,7 @@ "앱을 새로 설치해 설정이 없습니다. 저장해 둔 v{0} 의 설정 (동영상 목록 · 즐겨찾기 포함) 을 되살릴까요?\n(자막 API 키 · 비밀번호는 저장하지 않았으니 다시 넣어 주세요)": "The app was newly installed and has no settings. Restore the saved settings of v{0} (including the video list and bookmarks)?\n(Subtitle API keys and passwords were not saved - please enter them again)", "앱을 지우면 다음은 되살릴 수 없습니다:": "Removing the app loses the following for good:", "앱이 켜져 있는 동안 늘 동기화합니다.": "Always syncs while the app is running.", + "약 {0} 남음": "about {0} left", "양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "Both ways: newer files on the receiving side are not overwritten (-u).", "어둡게": "Darker", "어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "Press anywhere to bring up the JJ_MKVMaker window", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 9842b47..994d8e4 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -1138,6 +1138,7 @@ "앱을 새로 설치해 설정이 없습니다. 저장해 둔 v{0} 의 설정 (동영상 목록 · 즐겨찾기 포함) 을 되살릴까요?\n(자막 API 키 · 비밀번호는 저장하지 않았으니 다시 넣어 주세요)": "アプリを新しくインストールしたため設定がありません。保存した v{0} の設定 (動画リスト · ブックマークを含む) を復元しますか?\n(字幕 API キー · パスワードは保存していないので再入力してください)", "앱을 지우면 다음은 되살릴 수 없습니다:": "アプリを削除すると次のものは復元できません:", "앱이 켜져 있는 동안 늘 동기화합니다.": "アプリの起動中は常に同期します。", + "약 {0} 남음": "残り約 {0}", "양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "双方向: 受け取る側の方が新しいファイルは上書きしません (-u)。", "어둡게": "暗く", "어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "どこからでも押すと JJ_MKVMaker ウィンドウが表示されます", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 8d45170..81a0a53 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -1138,6 +1138,7 @@ "앱을 새로 설치해 설정이 없습니다. 저장해 둔 v{0} 의 설정 (동영상 목록 · 즐겨찾기 포함) 을 되살릴까요?\n(자막 API 키 · 비밀번호는 저장하지 않았으니 다시 넣어 주세요)": "应用为全新安装,没有设置。要恢复保存的 v{0} 设置 (包括视频列表 · 书签) 吗?\n(字幕 API 密钥 · 密码未保存,请重新输入)", "앱을 지우면 다음은 되살릴 수 없습니다:": "删除应用后以下内容无法恢复:", "앱이 켜져 있는 동안 늘 동기화합니다.": "应用运行期间始终同步。", + "약 {0} 남음": "约剩 {0}", "양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "双向: 接收端较新的文件不会被覆盖 (-u)。", "어둡게": "调暗", "어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "在任何地方按下即可显示 JJ_MKVMaker 窗口", diff --git a/lib/app/ai_upscale.dart b/lib/app/ai_upscale.dart index 2f80acf..26f9a44 100644 --- a/lib/app/ai_upscale.dart +++ b/lib/app/ai_upscale.dart @@ -89,6 +89,19 @@ Duration? upscaleEstimate(Map secPerMp, AiUpscaler up, double mp return s == null ? null : Duration(seconds: (s * mp).round().clamp(1, 1 << 20)); } +/// 167: sd-cli 진행 → (진행 0~1, 남은 시간). 모델을 읽는 막대 ("192/192 - 84MB/s") 는 올리기 진행이 아니다 - +/// 그때는 모름 (null, 움직이는 막대). 올리기는 타일 (n/m) 과 타일 한 장에 걸린 초로 남은 시간을 셈 +(double?, Duration?) upscaleProgressOf(SdProgress prog) => switch (prog.phase) { + 'done' => (1.0, Duration.zero), + 'sample' when prog.steps > 0 => ( + prog.step / prog.steps, + prog.secondsPerStep == null || prog.step == 0 + ? null + : Duration(milliseconds: ((prog.steps - prog.step) * prog.secondsPerStep! * 1000).round()), + ), + _ => (null, null), + }; + /// 156-2: [result] 가 [input] 의 정확히 4배 크기인지 (그림 머리만 읽음) bool isFourTimes(Uint8List input, Uint8List result) { final a = img.findDecoderForData(input)?.startDecode(input); @@ -118,13 +131,17 @@ class AiUpscaler { } /// 4배 결과를 임시 파일로 만든다 (진행: 0~1) - Future upscale4x(String input, {void Function(double? p)? onProgress}) async { + Future upscale4x(String input, {void Function(double? p)? onProgress, void Function(Duration? left)? onEta}) async { _cancelled = false; final tmp = await Directory.systemTemp.createTemp('jj_up_'); final out = p.join(tmp.path, 'up.png'); try { await runner(device.exe, sdUpscaleArgs(model: modelPath, input: input, out: out, backend: device.backend, threads: upscaleThreads(device), tileSize: upscaleTileSize(device)), - onProgress: (prog) => onProgress?.call(prog.phase == 'done' ? 1 : (prog.steps == 0 ? null : prog.step / prog.steps)), + onProgress: (prog) { + final (v, left) = upscaleProgressOf(prog); + onProgress?.call(v); + onEta?.call(left); + }, onStart: (pr) => _proc = pr); } catch (_) { if (_cancelled) throw ImageAiException(tr('취소했습니다'), cancelled: true); diff --git a/lib/app/transfer_job.dart b/lib/app/transfer_job.dart index b83b5d8..6d1564a 100644 --- a/lib/app/transfer_job.dart +++ b/lib/app/transfer_job.dart @@ -73,8 +73,15 @@ class TransferJob extends ChangeNotifier { final ms = now.difference(_samples.first.$1).inMilliseconds; if (ms > 0) speed = (_bytes - _samples.first.$2) * 1000 / ms; } + // 큰 파일 하나를 보내는 동안에도 진행 막대가 오르게 (파일이 끝날 때만 알리면 0% 에 머문다) - 0.2초마다 + if (now.difference(_lastTick).inMilliseconds >= 200) { + _lastTick = now; + _tick(); + } } + DateTime _lastTick = DateTime.fromMillisecondsSinceEpoch(0); + /// 원본 정리 중 · 지운 빈 폴더 수 bool pruning = false; int pruned = 0; @@ -82,10 +89,18 @@ class TransferJob extends ChangeNotifier { // ── 진행 상태 ── int index = 0; // 지금 항목 int get total => sources.length; - String get currentName => index < sources.length ? p.basename(sources[index]) : ''; + // 171: 끝난 뒤에도 마지막 항목 이름 (빈 이름으로 " 안의 파일" 이 되던 것) + String get currentName => sources.isEmpty ? '' : p.basename(sources[index < sources.length ? index : sources.length - 1]); late final List filesTotal = List.filled(sources.length, 0); late final List filesDone = List.filled(sources.length, 0); + /// 진행 표시 (한 파일 0% 에 머물던 것): 앱이 직접 복사할 때 항목마다 바이트 크기와 보낸 바이트로 진행률을 센다. + /// 같은 드라이브에서 이름 바꾸기로 옮기면 보낸 바이트가 없으므로 파일 수로 + late final List itemBytes = List.filled(sources.length, 0); + int _itemStart = 0; + int get _bytesTotal => itemBytes.fold(0, (a, b) => a + b); + bool get _byBytes => _bytesTotal > 0 && _bytes > 0; + /// 지금 파일의 진행률 (rsync -P · robocopy 가 알려 줄 때) double? filePercent; bool counting = true; @@ -114,6 +129,7 @@ class TransferJob extends ChangeNotifier { double get overall { if (finished) return 1; if (total == 0) return 0; + if (_byBytes) return (_bytes / _bytesTotal).clamp(0.0, 1.0); final cur = index < total && filesTotal[index] > 0 ? (filesDone[index] / filesTotal[index]).clamp(0.0, 1.0) : 0.0; return ((index + cur) / total).clamp(0.0, 1.0); } @@ -121,7 +137,9 @@ class TransferJob extends ChangeNotifier { /// 아래쪽 진행률: 지금 항목의 파일 double get current { if (finished) return 1; - if (index >= total || filesTotal[index] == 0) return 0; + if (index >= total) return 0; + if (_byBytes && itemBytes[index] > 0) return ((_bytes - _itemStart) / itemBytes[index]).clamp(0.0, 1.0); + if (filesTotal[index] == 0) return 0; return (filesDone[index] / filesTotal[index]).clamp(0.0, 1.0); } @@ -169,6 +187,12 @@ class TransferJob extends ChangeNotifier { throw FileSystemException('폴더를 자기 안으로 복사 · 이동할 수 없습니다', s); } filesTotal[i] = await countFiles(s); + // 앱이 직접 복사할 때만 (robocopy · rsync 는 그 프로그램이 진행을 알려 줌) + if (viaWebDav || method == CopyMethod.builtin) { + try { + itemBytes[i] = await FileOps.totalSize([s]); + } catch (_) {} + } } counting = false; _tick(); @@ -203,6 +227,7 @@ class TransferJob extends ChangeNotifier { for (index = 0; index < sources.length; index++) { if (cancelled) return; final i = index; + _itemStart = _bytes; _ops = FileOps( bandwidthKBps: bandwidthKBps, onFileDone: (src) { diff --git a/lib/ui/ai_upscale_ui.dart b/lib/ui/ai_upscale_ui.dart index f29a126..6d67268 100644 --- a/lib/ui/ai_upscale_ui.dart +++ b/lib/ui/ai_upscale_ui.dart @@ -175,6 +175,8 @@ Future upscaleOne(BuildContext context, AppController c, {required Stri final up = await _prepare(context, c); if (up == null || !context.mounted) return null; final progress = ValueNotifier(null); + // 167: 남은 시간 (타일 한 장에 걸린 초로) - 모르면 null + final left = ValueNotifier(null); var cancelled = false; var toPhoto = false; // 관리자: CPU 뿐이라 자동이 만화용을 고르면 사진은 질감이 뭉개질 수 있다 - 알리고 바로 바꿀 수 있게 @@ -196,7 +198,17 @@ Future upscaleOne(BuildContext context, AppController c, {required Stri const SizedBox(height: 10), LinearProgressIndicator(value: v), const SizedBox(height: 4), - Text(v == null ? tr('준비하는 중') : '${(v * 100).round()}%', style: const TextStyle(fontSize: 12)), + // 167: 모델을 읽는 동안은 숫자 없이 움직이는 막대 (예전엔 읽기 막대가 100% 로 보였다) + ValueListenableBuilder( + valueListenable: left, + builder: (_, d, _) => Text( + v == null + ? tr('준비하는 중') + : d == null + ? '${(v * 100).round()}%' + : '${(v * 100).round()}% · ${trf('약 {0} 남음', [AiJobs.durationText(d)])}', + style: const TextStyle(fontSize: 12)), + ), if (estimate != null) Text(estimate, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), if (cpuAnime) ...[ const SizedBox(height: 10), @@ -229,7 +241,7 @@ Future upscaleOne(BuildContext context, AppController c, {required Stri Object? error; final watch = Stopwatch()..start(); try { - four = await up.upscale4x(input, onProgress: (v) => progress.value = v); + four = await up.upscale4x(input, onProgress: (v) => progress.value = v, onEta: (d) => left.value = d); await _recordSpeed(c, up, input, watch.elapsed); } catch (e) { error = e; diff --git a/test/ai_upscale_test.dart b/test/ai_upscale_test.dart index a824cde..107d5d4 100644 --- a/test/ai_upscale_test.dart +++ b/test/ai_upscale_test.dart @@ -6,6 +6,7 @@ import 'package:jj_mkvmaker/app/ai_local.dart'; import 'package:jj_mkvmaker/app/ai_upscale.dart'; import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/core/ai_catalog.dart'; +import 'package:jj_mkvmaker/core/sd_cli.dart'; import 'package:jj_mkvmaker/services/image_ai.dart'; import 'package:path/path.dart' as p; @@ -182,4 +183,12 @@ void main() { expect(b.aiUpSecPerMp, {'cpu|esrgan-anime6b': 442.5}); }); }); + + test('167: 진행 - 모델 읽기 막대는 올리기 진행이 아님 (예전엔 100% 로 보임) · 타일 n/m 과 한 장 초로 남은 시간', () { + expect(upscaleProgressOf(parseSdProgress(' |##################################################| 192/192 - 84.37MB/s')!), (null, null)); + final (v, left) = upscaleProgressOf(parseSdProgress(' |==========> | 5/20 - 4.00s/it')!); + expect(v, 0.25); + expect(left, const Duration(seconds: 60)); + expect(upscaleProgressOf(parseSdProgress('[I] 1/1 images saved --- main.cpp:573')!), (1.0, Duration.zero)); + }); } diff --git a/test/sync_tools_test.dart b/test/sync_tools_test.dart index bd60995..f39fe11 100644 --- a/test/sync_tools_test.dart +++ b/test/sync_tools_test.dart @@ -155,6 +155,21 @@ void main() { expect(File(p.join(dst, 'single.txt')).existsSync(), isFalse); }); + test('진행 표시: 큰 파일 하나도 보낸 바이트로 0% 에서 오른다 · 끝난 뒤에도 항목 이름 (171)', () async { + File(p.join(src, 'one.bin')).writeAsBytesSync(List.filled(256 * 1024, 7)); + final job = TransferJob(sources: [p.join(src, 'one.bin')], dest: dst, move: false, bandwidthKBps: 256); + final mid = []; + job.addListener(() { + if (!job.finished && !job.counting) mid.add(job.overall); + }); + await job.run(); + expect(job.error, isNull); + // 예전에는 파일 수 (0/1) 로 세어 끝날 때까지 0% 였다 + expect(mid.any((v) => v > 0.2 && v < 1), isTrue, reason: '중간 진행: $mid'); + expect(job.current, 1); + expect(job.currentName, 'one.bin', reason: '끝난 진행 창에 " 안의 파일" 대신 이름'); + }); + test('현재 방식 속도 제한: 64KB 를 32KB/s 로 → 1.5초 넘게', () async { File(p.join(src, 'big.bin')).writeAsBytesSync(List.filled(64 * 1024, 7)); final sw = Stopwatch()..start();