From eee38aa2fd2ccd65895274b001cace23e73f9cdb Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:05:26 +0900 Subject: [PATCH] =?UTF-8?q?WIP=20AI=20=ED=95=B4=EC=83=81=EB=8F=84=20?= =?UTF-8?q?=EC=98=AC=EB=A6=AC=EA=B8=B0:=20=EB=B0=B0=EC=9C=A8=20"1=EB=B0=B0?= =?UTF-8?q?=20(=EC=84=A0=EB=AA=85=ED=95=98=EA=B2=8C)"=20=C2=B7=20=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=ED=99=94=EB=A9=B4=20[=ED=92=88=EC=A7=88=20?= =?UTF-8?q?=EC=98=AC=EB=A6=AC=EA=B8=B0]=20(=EA=B7=B8=EB=A6=BC=20=E2=86=92?= =?UTF-8?q?=20=EA=B7=B8=EB=A6=BC=20=EC=95=BD=ED=95=98=EA=B2=8C=20+=20?= =?UTF-8?q?=ED=95=B4=EC=83=81=EB=8F=84=20=EC=98=AC=EB=A6=AC=EA=B8=B0,=20?= =?UTF-8?q?=EC=B2=98=EC=9D=8C=20=EC=88=A8=EA=B9=80)=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 관리자 판단 (10/17): 답이 없는 사용자 질문이라 둘 다 넣고 기본 꺼짐. (a) 환경 설정 > AI 해상도 올리기 > 배율에 "1배 (선명하게)" - 크기는 그대로, 모델로 선명하게만 (4배 결과를 원본 크기로). 처음 값은 지금 그대로 2배. 이름은 "이름_x1". 확인 창 글도 "크기는 그대로 두고 선명하게". (b) 보기 화면 [품질 올리기 (이 장)] - 설정 "품질 올리기 단추 보이기" (처음 값 끔) 를 켰을 때만. 1/2 AI 그림 (그림 → 그림, 바꾸는 정도 0.25 · 원본 비율 · 긴 쪽 1024 까지 · 64 배수) 으로 다듬고 2/2 해상도 올리기 (설정의 배율 - 원본 크기 기준). 새 파일 "이름_q" (덮어쓰지 않음, 원본 그대로). AI 그림 모델이 없으면 "환경 설정 > AI 그림에서 받으라" 는 안내. 진행 창 · 취소, 작업 (AiJobs) 으로 작업 알림에. - 내 판단: 바꾸는 정도 0.25 · 다듬기 글 (high quality, detailed …) 은 고정 (설정 없음). - 시험: ai_quality_test (설정 · 1배 크기 · "_q" 이름 · 그림 → 그림 크기 · 모델 없음 안내). Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 11 +++ assets/l10n/ja.json | 11 +++ assets/l10n/zh-Hans.json | 11 +++ lib/app/ai_upscale.dart | 16 +++-- lib/app/settings.dart | 8 ++- lib/ui/ai_settings.dart | 14 +++- lib/ui/ai_upscale_ui.dart | 137 +++++++++++++++++++++++++++++++++++++- lib/ui/reader_page.dart | 8 +++ test/ai_quality_test.dart | 74 ++++++++++++++++++++ 9 files changed, 283 insertions(+), 7 deletions(-) create mode 100644 test/ai_quality_test.dart diff --git a/assets/l10n/en.json b/assets/l10n/en.json index b7ef40f..628e27e 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -67,9 +67,12 @@ ".{0} 재생 프로그램 (exe)": ".{0} player (exe)", "0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = unlimited. Applies to every method: rsync --bwlimit, the built-in method throttles itself, robocopy approximates with /IPG. You can also put it in the options field (that takes priority)", "0 으로": "Reset to 0", + "1/2 · AI 그림으로 다듬는 중": "1/2 · Refining with AI image", "10초 뒤로 (←)": "Back 10 seconds (←)", "10초 앞으로 (→)": "Forward 10 seconds (→)", + "1배 (선명하게)": "1× (sharpen)", "1분 안": "under 1 min", + "2/2 · 해상도 올리는 중": "2/2 · Upscaling", "5초 뒤로": "Back 5 seconds", "5초 앞으로": "Forward 5 seconds", "90° 회전 (시계 방향)": "Rotate 90° (clockwise)", @@ -1193,6 +1196,7 @@ "보관해 둔 설정을 되살렸습니다: {0}": "Restored the kept settings: {0}", "보기": "View", "보기 {0}초 · Ctrl+휠 확대/축소": "View {0} s · Ctrl+wheel to zoom", + "보기 화면에 [품질 올리기]: AI 그림 (그림 → 그림, 약하게) 으로 다듬은 뒤 해상도 올리기를 이어서. 새 파일 (이름_q) 로 저장하며 AI 그림 모델이 필요합니다": "Shows [Improve quality] in the viewer: refines with AI image (image → image, light) and then upscales. Saves a new file (name_q); needs the AI image model", "보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "How often [Slideshow] in the viewer turns to the next page", "보내지 못했습니다": "Could not send", "보낼 것 {0} / 있는 것 {1}": "sending {0} / existing {1}", @@ -2214,6 +2218,7 @@ "크기 · 바뀐 날 · 종류를 넣으면 파일만 찾습니다.": "With size, date or type conditions, only files are found.", "크기 · 보정을 원래대로 돌려 코덱도 \"원본 유지\" 로 되돌렸습니다.": "Size and adjustments are back to original, so the codec was set back to \"Keep original\".", "크기 이상": "Min size", + "크기는 그대로 두고 선명하게 해 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.": "Sharpens without changing the size and saves new files. The originals and ZIP stay unchanged. It continues after you close the screen; the task notification shows remaining pages and estimated time.", "크기를 바꾸면 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "Changing the size requires re-encoding, so the codec was changed from \"Keep original\" to H.264.", "크기순 정렬일 때만 (기본)": "Only when sorting by size (default)", "크메르어": "Khmer", @@ -2331,6 +2336,12 @@ "푸는 중": "Extracting", "푸는 중: {0}": "Extracting: {0}", "풀지 못했습니다: {0}": "Could not extract: {0}", + "품질 올리기": "Improve quality", + "품질 올리기 (이 장)": "Improve quality (this page)", + "품질 올리기 단추 보이기": "Show Improve quality button", + "품질 올리기: {0}": "Improve quality: {0}", + "품질 올리기는 AI 그림 모델이 필요합니다. 환경 설정 > AI 그림에서 모델을 받은 뒤 다시 눌러 주세요.": "Improve quality needs the AI image model. Download it in Settings > AI image and try again.", + "품질 올리기를 끝내지 못했습니다: {0}": "Couldn't finish improving quality: {0}", "프랑스어": "French", "프로그램": "Program", "프로그램 정보": "About", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index ef5f2fe..a775dae 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -67,9 +67,12 @@ ".{0} 재생 프로그램 (exe)": ".{0} 再生プログラム(exe)", "0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 制限なし。すべての方法に適用: rsync は --bwlimit、現在の方式はアプリが調整、robocopy は /IPG で近似。オプション欄に直接入れても構いません (そちらが優先)", "0 으로": "0 に戻す", + "1/2 · AI 그림으로 다듬는 중": "1/2 · AI 画像で整え中", "10초 뒤로 (←)": "10秒戻る(←)", "10초 앞으로 (→)": "10秒進む(→)", + "1배 (선명하게)": "1倍 (くっきり)", "1분 안": "1 分以内", + "2/2 · 해상도 올리는 중": "2/2 · 解像度を上げています", "5초 뒤로": "5秒戻る", "5초 앞으로": "5秒進む", "90° 회전 (시계 방향)": "90° 回転 (時計回り)", @@ -1193,6 +1196,7 @@ "보관해 둔 설정을 되살렸습니다: {0}": "保管した設定を復元しました: {0}", "보기": "表示", "보기 {0}초 · Ctrl+휠 확대/축소": "表示 {0}秒・Ctrl+ホイールで拡大/縮小", + "보기 화면에 [품질 올리기]: AI 그림 (그림 → 그림, 약하게) 으로 다듬은 뒤 해상도 올리기를 이어서. 새 파일 (이름_q) 로 저장하며 AI 그림 모델이 필요합니다": "ビューアーに [画質を上げる]: AI 画像 (画像 → 画像、弱め) で整えてから解像度を上げます。新しいファイル (名前_q) に保存し、AI 画像モデルが必要です", "보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "表示画面の [連続表示] が次のページへ進む間隔", "보내지 못했습니다": "送信できませんでした", "보낼 것 {0} / 있는 것 {1}": "送る方 {0} / 既存 {1}", @@ -2214,6 +2218,7 @@ "크기 · 바뀐 날 · 종류를 넣으면 파일만 찾습니다.": "サイズ・更新日・種類を指定すると、ファイルだけを探します。", "크기 · 보정을 원래대로 돌려 코덱도 \"원본 유지\" 로 되돌렸습니다.": "サイズ・補正を元に戻したので、コーデックも「元のまま」に戻しました。", "크기 이상": "最小サイズ", + "크기는 그대로 두고 선명하게 해 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.": "サイズはそのままでくっきりさせ、新しいファイルに保存します。元のファイル・ZIP はそのままです。画面を閉じても続き、作業通知に残り枚数と予想時間が表示されます。", "크기를 바꾸면 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "サイズを変えると再エンコードが必要なため、コーデックを「元のまま」から H.264 に変更しました。", "크기순 정렬일 때만 (기본)": "サイズ順のときだけ (既定)", "크메르어": "クメール語", @@ -2331,6 +2336,12 @@ "푸는 중": "展開中", "푸는 중: {0}": "展開中: {0}", "풀지 못했습니다: {0}": "展開できませんでした: {0}", + "품질 올리기": "画質を上げる", + "품질 올리기 (이 장)": "画質を上げる (このページ)", + "품질 올리기 단추 보이기": "[画質を上げる] ボタンを表示", + "품질 올리기: {0}": "画質を上げる: {0}", + "품질 올리기는 AI 그림 모델이 필요합니다. 환경 설정 > AI 그림에서 모델을 받은 뒤 다시 눌러 주세요.": "画質を上げるには AI 画像モデルが必要です。設定 > AI 画像でモデルを入手してから、もう一度押してください。", + "품질 올리기를 끝내지 못했습니다: {0}": "画質を上げる処理を完了できませんでした: {0}", "프랑스어": "フランス語", "프로그램": "プログラム", "프로그램 정보": "プログラム情報", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 87b985e..ec319a6 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -67,9 +67,12 @@ ".{0} 재생 프로그램 (exe)": ".{0} 播放程序(exe)", "0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 不限制。适用于所有方式: rsync 用 --bwlimit,当前方式由应用调节,robocopy 用 /IPG 近似。也可直接写在选项栏中 (优先)", "0 으로": "归零", + "1/2 · AI 그림으로 다듬는 중": "1/2 · 正在用 AI 图片优化", "10초 뒤로 (←)": "后退 10 秒(←)", "10초 앞으로 (→)": "前进 10 秒(→)", + "1배 (선명하게)": "1倍 (锐化)", "1분 안": "1 分钟以内", + "2/2 · 해상도 올리는 중": "2/2 · 正在提高分辨率", "5초 뒤로": "后退 5 秒", "5초 앞으로": "前进 5 秒", "90° 회전 (시계 방향)": "旋转 90° (顺时针)", @@ -1193,6 +1196,7 @@ "보관해 둔 설정을 되살렸습니다: {0}": "已恢复保存的设置: {0}", "보기": "查看", "보기 {0}초 · Ctrl+휠 확대/축소": "显示 {0} 秒 · Ctrl+滚轮缩放", + "보기 화면에 [품질 올리기]: AI 그림 (그림 → 그림, 약하게) 으로 다듬은 뒤 해상도 올리기를 이어서. 새 파일 (이름_q) 로 저장하며 AI 그림 모델이 필요합니다": "在查看器中显示 [提升画质]: 先用 AI 图片 (图生图,轻度) 优化再提高分辨率。保存为新文件 (名称_q),需要 AI 图片模型", "보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "浏览画面中 [连续浏览] 翻到下一页的间隔", "보내지 못했습니다": "无法发送", "보낼 것 {0} / 있는 것 {1}": "要发送 {0} / 已有 {1}", @@ -2214,6 +2218,7 @@ "크기 · 바뀐 날 · 종류를 넣으면 파일만 찾습니다.": "设置大小、修改时间或类型时,只查找文件。", "크기 · 보정을 원래대로 돌려 코덱도 \"원본 유지\" 로 되돌렸습니다.": "大小 · 调整已恢复原样,编解码器也恢复为“保持原样”。", "크기 이상": "最小大小", + "크기는 그대로 두고 선명하게 해 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.": "保持尺寸只做锐化并保存为新文件。原文件和 ZIP 保持不变。关闭画面后仍会继续,任务通知中会显示剩余张数和预计时间。", "크기를 바꾸면 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "更改大小需要重新编码,因此编解码器已从“保持原样”改为 H.264。", "크기순 정렬일 때만 (기본)": "仅在按大小排序时 (默认)", "크메르어": "高棉语", @@ -2331,6 +2336,12 @@ "푸는 중": "正在解压", "푸는 중: {0}": "正在解压: {0}", "풀지 못했습니다: {0}": "无法解压: {0}", + "품질 올리기": "提升画质", + "품질 올리기 (이 장)": "提升画质 (本页)", + "품질 올리기 단추 보이기": "显示提升画质按钮", + "품질 올리기: {0}": "提升画质: {0}", + "품질 올리기는 AI 그림 모델이 필요합니다. 환경 설정 > AI 그림에서 모델을 받은 뒤 다시 눌러 주세요.": "提升画质需要 AI 图片模型。请在 设置 > AI 图片 中下载后再试。", + "품질 올리기를 끝내지 못했습니다: {0}": "未能完成提升画质: {0}", "프랑스어": "法语", "프로그램": "程序", "프로그램 정보": "程序信息", diff --git a/lib/app/ai_upscale.dart b/lib/app/ai_upscale.dart index 0baaf9b..2bb92ab 100644 --- a/lib/app/ai_upscale.dart +++ b/lib/app/ai_upscale.dart @@ -42,9 +42,9 @@ String upscaleExt(String source, String format) { } /// 저장할 이름: 원본 옆 (또는 [outDir]) 에 "이름_x2.png". 이미 있으면 "이름_x2 (2).png" … (덮어쓰지 않음) -String upscaledPath(String source, {required int scale, required String ext, String outDir = ''}) { +String upscaledPath(String source, {required int scale, required String ext, String outDir = '', String? suffix}) { final dir = outDir.isEmpty ? p.dirname(source) : outDir; - final base = '${p.basenameWithoutExtension(source)}_x$scale'; + final base = '${p.basenameWithoutExtension(source)}${suffix ?? '_x$scale'}'; var path = p.join(dir, '$base.$ext'); for (var n = 2; File(path).existsSync(); n++) { path = p.join(dir, '$base ($n).$ext'); @@ -236,17 +236,25 @@ class AiUpscaler { } /// 4배 결과 [fourX] 를 설정대로 줄이고 담아 새 파일로 저장한다 (이름은 [source] 기준, 크기는 [original] 기준) -Future saveUpscaled(String fourX, {required String source, required String original, required UpscaleOptions o}) async { +Future saveUpscaled(String fourX, {required String source, required String original, required UpscaleOptions o, String? suffix}) async { final orig = img.decodeImage(await File(original).readAsBytes()); final ext = upscaleExt(source, o.format); final bytes = await finishUpscale(await File(fourX).readAsBytes(), origW: orig?.width ?? 0, origH: orig?.height ?? 0, scale: o.scale, ext: ext, quality: o.jpgQuality); - final path = upscaledPath(source, scale: o.scale, ext: ext, outDir: o.outDir); + final path = upscaledPath(source, scale: o.scale, ext: ext, outDir: o.outDir, suffix: suffix); await Directory(p.dirname(path)).create(recursive: true); await File(path).writeAsBytes(bytes, flush: true); return path; } +/// [품질 올리기] 의 그림 → 그림 크기: 원본 비율 그대로, 긴 쪽 [maxSide] 까지, 64 의 배수 (엔진이 요구), 작은 쪽 256 이상 +(int, int) qualitySize(int w, int h, {int maxSide = 1024}) { + if (w <= 0 || h <= 0) return (512, 512); + final k = (w > h ? w : h) > maxSide ? maxSide / (w > h ? w : h) : 1.0; + int r64(double v) => ((v / 64).round() * 64).clamp(256, maxSide); + return (r64(w * k), r64(h * k)); +} + /// 156: 쓸 모델 - 자동이면 고른 것을 받아 두지 않았을 때 받아 둔 다른 것을 쓴다 (없으면 null) String? installedUpscaleModel(bool Function(String id) installed, String setting, {required bool gpu}) { final want = pickUpscaleModel(setting, gpu: gpu); diff --git a/lib/app/settings.dart b/lib/app/settings.dart index 8d3dd6f..bed523f 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -597,6 +597,9 @@ class AppSettings { /// 배율 2 · 3 · 4 int aiUpScale = 2; + /// 관리자 판단 (10/17): 보기 화면의 [품질 올리기] 단추 (그림 → 그림 약하게 + 해상도 올리기). 처음 값: 끔 + bool aiQualityButton = false; + /// 저장 형식 'same' (원본과 같게, 못 쓰는 형식은 PNG) · 'png' · 'jpg' · JPG 품질 String aiUpFormat = 'same'; int aiUpJpgQuality = 92; @@ -809,6 +812,7 @@ class AppSettings { 'aiUpMemory': aiUpMemory, 'aiUpSecPerMp': aiUpSecPerMp, 'aiUpScale': aiUpScale, + 'aiQualityButton': aiQualityButton, 'aiUpFormat': aiUpFormat, 'aiUpJpgQuality': aiUpJpgQuality, 'aiUpDir': aiUpDir, @@ -1044,7 +1048,9 @@ class AppSettings { for (final e in m.entries) if (e.value is num && (e.value as num) > 0) '${e.key}': (e.value as num).toDouble(), } - ..aiUpScale = const [3, 4].contains(j['aiUpScale']) ? j['aiUpScale'] as int : 2 + // 1 = 크기는 그대로 선명하게만 (관리자 판단 10/17 - 사용자 질문에 답이 없어 넣되 처음 값은 2배 그대로) + ..aiUpScale = const [1, 3, 4].contains(j['aiUpScale']) ? j['aiUpScale'] as int : 2 + ..aiQualityButton = j['aiQualityButton'] == true ..aiUpFormat = const ['png', 'jpg'].contains(j['aiUpFormat']) ? j['aiUpFormat'] as String : 'same' ..aiUpJpgQuality = ((j['aiUpJpgQuality'] as num?)?.toInt() ?? 92).clamp(50, 100) ..aiUpDir = j['aiUpDir'] as String? ?? '' diff --git a/lib/ui/ai_settings.dart b/lib/ui/ai_settings.dart index ff653e1..090fa79 100644 --- a/lib/ui/ai_settings.dart +++ b/lib/ui/ai_settings.dart @@ -257,10 +257,22 @@ class _AiSettingsState extends State { title: Text(tr('배율')), trailing: DropdownButton( value: s.aiUpScale, - items: [for (final n in const [2, 3, 4]) DropdownMenuItem(value: n, child: Text(trf('{0}배', [n])))], + // 1배 = 크기는 그대로 두고 모델로 선명하게만 (이름_x1) + items: [ + for (final n in const [1, 2, 3, 4]) + DropdownMenuItem(value: n, child: Text(n == 1 ? tr('1배 (선명하게)') : trf('{0}배', [n]))), + ], onChanged: (v) => c.updateSettings((x) => x.aiUpScale = v ?? 2), ), ), + // 관리자 판단 (10/17): [품질 올리기] 한 단추 - 처음 값 끔 + SettingTile( + leading: const Icon(Icons.auto_fix_high), + title: Text(tr('품질 올리기 단추 보이기')), + subtitle: Text(tr('보기 화면에 [품질 올리기]: AI 그림 (그림 → 그림, 약하게) 으로 다듬은 뒤 해상도 올리기를 이어서. ' + '새 파일 (이름_q) 로 저장하며 AI 그림 모델이 필요합니다')), + trailing: Switch(value: s.aiQualityButton, onChanged: (v) => c.updateSettings((x) => x.aiQualityButton = v)), + ), SettingTile( leading: const Icon(Icons.image_outlined), title: Text(tr('저장 형식')), diff --git a/lib/ui/ai_upscale_ui.dart b/lib/ui/ai_upscale_ui.dart index e56e150..635262a 100644 --- a/lib/ui/ai_upscale_ui.dart +++ b/lib/ui/ai_upscale_ui.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show MethodChannel; +import 'package:image/image.dart' as img; import 'package:path/path.dart' as p; import '../app/ai_local.dart'; @@ -10,6 +11,7 @@ import '../app/ai_upscale.dart'; import '../app/app_controller.dart'; import '../core/ai_catalog.dart'; import '../core/reader_sources.dart'; +import '../core/sd_cli.dart' show ImageGenRequest; import '../core/vfs.dart'; import '../l10n/tr.dart'; import '../services/image_ai.dart' show ImageAiException; @@ -375,7 +377,9 @@ Future upscaleAll(BuildContext context, AppController c, ReaderSource src) context, title: trf('{0}장 모두 해상도 올리기', [n]), body: [ - trf('{0}배로 올려 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.', [c.settings.aiUpScale]), + c.settings.aiUpScale == 1 + ? tr('크기는 그대로 두고 선명하게 해 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.') + : trf('{0}배로 올려 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.', [c.settings.aiUpScale]), ?estimate, ].join('\n\n'), ok: tr('시작'), @@ -421,6 +425,137 @@ Future upscaleAll(BuildContext context, AppController c, ReaderSource src) } /// 원본 / 올린 것을 바꿔 보며 비교 (확대해서 볼 수 있음). [저장] 이면 true, 저장하지 않고 닫으면 false +/// 관리자 판단 (10/17, 사용자 질문에 답이 없어 넣되 단추는 처음에 숨김): [품질 올리기] - +/// AI 그림 (그림 → 그림, 약하게 0.25) 으로 잡음 · 뭉개짐을 다듬은 뒤 이어서 해상도 올리기 (설정의 배율). +/// 원본은 덮지 않고 새 파일 "이름_q" 로 저장한다. AI 그림 모델이 없으면 받으라는 안내. +Future qualityUp(BuildContext context, AppController c, {required String input, required String nameFrom, required String outDir}) async { + if (AiJobs.instance.busy) { + ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(tr('이미 작업 중입니다')))); + return null; + } + final store = AiStore.instance ??= AiStore(Directory.systemTemp.path); + final models = store.sd15Paths(taesd: c.settings.aiTaesd); + if (models == null) { + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(tr('품질 올리기')), + content: Text(tr('품질 올리기는 AI 그림 모델이 필요합니다. 환경 설정 > AI 그림에서 모델을 받은 뒤 다시 눌러 주세요.')), + actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기')))], + ), + ); + return null; + } + // 해상도 올리기 모델 · 엔진 (없으면 받는 창) + final up = await _prepare(context, c); + if (up == null || !context.mounted) return null; + final device = await pickAiDevice(c, store); + if (!context.mounted) return null; + if (device == null) { + ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(tr('AI 엔진이 없습니다. 환경 설정 > AI 그림에서 받아 주세요.')))); + return null; + } + final orig = img.decodeImage(await File(input).readAsBytes()); + final (w, h) = qualitySize(orig?.width ?? 0, orig?.height ?? 0); + final tmp = await Directory.systemTemp.createTemp('jj_quality_'); + final engine = LocalSdEngine(device: device, models: models); + final step = ValueNotifier(tr('준비하는 중')); + final progress = ValueNotifier(null); + var cancelled = false; + if (!context.mounted) return null; + final nav = Navigator.of(context); + var dialogOpen = true; + unawaited(showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => AlertDialog( + title: Text(tr('품질 올리기')), + content: SizedBox( + width: 420, + child: ValueListenableBuilder( + valueListenable: progress, + builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('${p.basename(nameFrom)} · ${device.label}'), + const SizedBox(height: 10), + LinearProgressIndicator(value: v), + const SizedBox(height: 4), + ValueListenableBuilder(valueListenable: step, builder: (_, t, _) => Text(t, style: const TextStyle(fontSize: 12))), + ]), + ), + ), + actions: [ + TextButton( + onPressed: () { + cancelled = true; + engine.cancel(); + up.cancel(); + }, + child: Text(tr('취소')), + ), + ], + ), + ).whenComplete(() => dialogOpen = false)); + String? saved; + Object? error; + try { + await AiJobs.instance.runTask(trf('품질 올리기: {0}', [p.basename(nameFrom)]), 1, (_, prog) async { + // 1) 그림 → 그림 (약하게): 전체의 앞 40% + step.value = tr('1/2 · AI 그림으로 다듬는 중'); + final made = await engine.generate( + ImageGenRequest( + prompt: 'high quality, detailed, sharp focus, clean', + negative: 'blurry, lowres, noise, jpeg artifacts, deformed', + width: w, + height: h, + steps: c.settings.aiSteps, + cfg: c.settings.aiCfg, + initImage: input, + strength: 0.25, + ), + outDir: tmp.path, + baseName: 'q', + onProgress: (v, _) { + final x = v == null ? null : v * 0.4; + progress.value = x; + prog(x); + }, + ); + if (cancelled) return; + // 2) 해상도 올리기: 뒤 60% + step.value = tr('2/2 · 해상도 올리는 중'); + final four = await up.upscale4x(made.first.path, onProgress: (v) { + final x = v == null ? null : 0.4 + v * 0.6; + progress.value = x; + prog(x); + }); + if (cancelled) return; + // 크기는 원본 기준 (설정의 배율, 1배면 원본 크기 그대로) · 이름_q + final o = upscaleOptionsOf(c, outDir: outDir); + saved = await saveUpscaled(four, source: nameFrom, original: input, o: o, suffix: '_q'); + }, onCancel: () { + cancelled = true; + engine.cancel(); + up.cancel(); + }, uses: const {'esrgan-x4plus', 'esrgan-anime6b', 'engine-vulkan', 'engine-cuda', 'engine-cudart'}); + } catch (e) { + error = e is ImageAiException ? e.message : e; + } finally { + try { + await tmp.delete(recursive: true); + } catch (_) {} + } + if (dialogOpen) nav.pop(); + final messenger = context.mounted ? ScaffoldMessenger.maybeOf(context) : null; + if (cancelled) return null; + if (saved == null) { + messenger?.showSnackBar(SnackBar(content: Text(trf('품질 올리기를 끝내지 못했습니다: {0}', [error ?? ''])))); + return null; + } + messenger?.showSnackBar(SnackBar(content: Text(trf('저장했습니다: {0}', [saved])))); + c.note('${tr('품질 올리기')}: $saved'); + return saved; +} + class UpscaleCompareView extends StatefulWidget { const UpscaleCompareView({super.key, required this.original, required this.upscaled, required this.title}); final String original; diff --git a/lib/ui/reader_page.dart b/lib/ui/reader_page.dart index e50188c..497168c 100644 --- a/lib/ui/reader_page.dart +++ b/lib/ui/reader_page.dart @@ -633,6 +633,8 @@ class _ReaderPageState extends State { if (_aiOn && src is! PdfSource) ...[ one(_Act(const Icon(Icons.hd_outlined), tr('AI 해상도 올리기 (이 장)'), _upscalePage)), one(_Act(const Icon(Icons.burst_mode_outlined), tr('AI 해상도 올리기 (모든 장)'), () => upscaleAll(context, c, src))), + // 관리자 판단 (10/17): [품질 올리기] - 설정으로 켰을 때만 + if (c.settings.aiQualityButton) one(_Act(const Icon(Icons.auto_fix_high), tr('품질 올리기 (이 장)'), _qualityPage)), ], // 123: 이 그림을 바탕으로 AI 그림 (그림 → 그림, 원본은 그대로) if (_aiSource case final path?) @@ -659,6 +661,12 @@ class _ReaderPageState extends State { } } + Future _qualityPage() async { + final file = await readerPageFile(c, src, _cur.page); + if (file == null || !mounted) return; + await qualityUp(context, c, input: file.$1, nameFrom: file.$2, outDir: file.$3); + } + /// 지금 보는 그림 파일 (이 기기의 그림 파일일 때, AI 그림 컴포넌트가 켜져 있을 때) String? get _aiSource { final s = src; diff --git a/test/ai_quality_test.dart b/test/ai_quality_test.dart new file mode 100644 index 0000000..fb64289 --- /dev/null +++ b/test/ai_quality_test.dart @@ -0,0 +1,74 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'package:jj_mkvmaker/app/ai_local.dart'; +import 'package:jj_mkvmaker/app/ai_upscale.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/ai_upscale_ui.dart'; +import 'package:path/path.dart' as p; + +/// 관리자 판단 (10/17, 사용자 질문에 답이 없어 둘 다 넣고 기본 꺼짐): +/// (a) 해상도 올리기 배율 "1배 (선명하게)" - 크기 그대로 · 처음 값은 2배 그대로 +/// (b) [품질 올리기] - 그림 → 그림 약하게 + 해상도 올리기, 단추는 설정으로 (처음 끔), 새 파일 "이름_q", 모델이 없으면 안내 +void main() { + late Directory tmp; + setUp(() => tmp = Directory.systemTemp.createTempSync('jj_quality_t_')); + tearDown(() => tmp.deleteSync(recursive: true)); + + test('설정: 배율 1 을 받아들이고 처음 값은 2 · 품질 올리기 단추는 처음 끔 · 저장', () { + expect(AppSettings().aiUpScale, 2); + expect(AppSettings.fromJson({'aiUpScale': 1}).aiUpScale, 1); + expect(AppSettings.fromJson({'aiUpScale': 7}).aiUpScale, 2); + expect(AppSettings().aiQualityButton, isFalse); + expect(AppSettings.fromJson((AppSettings()..aiQualityButton = true).toJson()).aiQualityButton, isTrue); + }); + + test('1배: 4배 결과를 원본 크기로 줄인다 (선명하게만)', () async { + final four = img.encodePng(img.Image(width: 40, height: 24)); + final out = img.decodePng(await finishUpscale(four, origW: 10, origH: 6, scale: 1, ext: 'png'))!; + expect((out.width, out.height), (10, 6)); + expect(p.basename(upscaledPath(p.join(tmp.path, 'a.jpg'), scale: 1, ext: 'jpg')), 'a_x1.jpg'); + }); + + test('품질 올리기: 저장 이름 "이름_q" (덮어쓰지 않음) · 그림 → 그림 크기 (64 배수 · 긴 쪽 1024 까지)', () { + final src = p.join(tmp.path, 'b.png'); + expect(p.basename(upscaledPath(src, scale: 2, ext: 'png', suffix: '_q')), 'b_q.png'); + File(p.join(tmp.path, 'b_q.png')).writeAsStringSync('x'); + expect(p.basename(upscaledPath(src, scale: 2, ext: 'png', suffix: '_q')), 'b_q (2).png'); + expect(qualitySize(800, 600), (832, 576)); + expect(qualitySize(4000, 3000), (1024, 768)); + expect(qualitySize(100, 50), (256, 256)); + expect(qualitySize(0, 0), (512, 512)); + }); + + testWidgets('품질 올리기: AI 그림 모델이 없으면 받으라는 안내 (원본은 그대로)', (tester) async { + final input = p.join(tmp.path, 'page.png'); + File(input).writeAsBytesSync(img.encodePng(img.Image(width: 8, height: 6))); + final before = AiStore.instance; + AiStore.instance = AiStore(p.join(tmp.path, 'ai')); // 받은 모델이 없는 곳 + addTearDown(() => AiStore.instance = before); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Builder( + builder: (ctx) => TextButton( + onPressed: () => qualityUp(ctx, c, input: input, nameFrom: input, outDir: tmp.path), + child: const Text('품질'), + ), + ), + ), + )); + await tester.tap(find.text('품질')); + await tester.pumpAndSettle(); + expect(find.textContaining('AI 그림 모델이 필요합니다'), findsOneWidget); + await tester.tap(find.text('닫기')); + await tester.pumpAndSettle(); + expect(tmp.listSync().map((e) => p.basename(e.path)).where((n) => n.contains('_q')), isEmpty); + }); +}