진행 표시: 해상도 올리기는 모델을 읽는 동안 숫자 없이 (167) · 큰 파일 하나도 보낸 바이트로 오름 · 끝난 진행 창의 이름 (171)

- 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 <noreply@anthropic.com>
묶음4
RyuJongJin 2026-10-09 23:12:46 +09:00
parent 794f484c19
commit f331672e3a
8 changed files with 87 additions and 6 deletions

View File

@ -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",

View File

@ -1138,6 +1138,7 @@
"앱을 새로 설치해 설정이 없습니다. 저장해 둔 v{0} 의 설정 (동영상 목록 · 즐겨찾기 포함) 을 되살릴까요?\n(자막 API 키 · 비밀번호는 저장하지 않았으니 다시 넣어 주세요)": "アプリを新しくインストールしたため設定がありません。保存した v{0} の設定 (動画リスト · ブックマークを含む) を復元しますか?\n(字幕 API キー · パスワードは保存していないので再入力してください)",
"앱을 지우면 다음은 되살릴 수 없습니다:": "アプリを削除すると次のものは復元できません:",
"앱이 켜져 있는 동안 늘 동기화합니다.": "アプリの起動中は常に同期します。",
"약 {0} 남음": "残り約 {0}",
"양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "双方向: 受け取る側の方が新しいファイルは上書きしません (-u)。",
"어둡게": "暗く",
"어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "どこからでも押すと JJ_MKVMaker ウィンドウが表示されます",

View File

@ -1138,6 +1138,7 @@
"앱을 새로 설치해 설정이 없습니다. 저장해 둔 v{0} 의 설정 (동영상 목록 · 즐겨찾기 포함) 을 되살릴까요?\n(자막 API 키 · 비밀번호는 저장하지 않았으니 다시 넣어 주세요)": "应用为全新安装,没有设置。要恢复保存的 v{0} 设置 (包括视频列表 · 书签) 吗?\n(字幕 API 密钥 · 密码未保存,请重新输入)",
"앱을 지우면 다음은 되살릴 수 없습니다:": "删除应用后以下内容无法恢复:",
"앱이 켜져 있는 동안 늘 동기화합니다.": "应用运行期间始终同步。",
"약 {0} 남음": "约剩 {0}",
"양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "双向: 接收端较新的文件不会被覆盖 (-u)。",
"어둡게": "调暗",
"어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "在任何地方按下即可显示 JJ_MKVMaker 窗口",

View File

@ -89,6 +89,19 @@ Duration? upscaleEstimate(Map<String, double> 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<String> upscale4x(String input, {void Function(double? p)? onProgress}) async {
Future<String> 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);

View File

@ -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<int> filesTotal = List.filled(sources.length, 0);
late final List<int> filesDone = List.filled(sources.length, 0);
/// 진행 표시 (한 파일 0% 에 머물던 것): 앱이 직접 복사할 때 항목마다 바이트 크기와 보낸 바이트로 진행률을 센다.
/// 같은 드라이브에서 이름 바꾸기로 옮기면 보낸 바이트가 없으므로 파일 수로
late final List<int> 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) {

View File

@ -175,6 +175,8 @@ Future<String?> upscaleOne(BuildContext context, AppController c, {required Stri
final up = await _prepare(context, c);
if (up == null || !context.mounted) return null;
final progress = ValueNotifier<double?>(null);
// 167: 남은 시간 (타일 한 장에 걸린 초로) - 모르면 null
final left = ValueNotifier<Duration?>(null);
var cancelled = false;
var toPhoto = false;
// 관리자: CPU 뿐이라 자동이 만화용을 고르면 사진은 질감이 뭉개질 수 있다 - 알리고 바로 바꿀 수 있게
@ -196,7 +198,17 @@ Future<String?> 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<Duration?>(
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<String?> 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;

View File

@ -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));
});
}

View File

@ -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 = <double>[];
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();