WIP AI 해상도 올리기: 배율 "1배 (선명하게)" · 보기 화면 [품질 올리기] (그림 → 그림 약하게 + 해상도 올리기, 처음 숨김) (검토 대기)
관리자 판단 (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 <noreply@anthropic.com>
묶음6
parent
863b5e2ea0
commit
eee38aa2fd
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
"프랑스어": "フランス語",
|
||||
"프로그램": "プログラム",
|
||||
"프로그램 정보": "プログラム情報",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
"프랑스어": "法语",
|
||||
"프로그램": "程序",
|
||||
"프로그램 정보": "程序信息",
|
||||
|
|
|
|||
|
|
@ -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<String> saveUpscaled(String fourX, {required String source, required String original, required UpscaleOptions o}) async {
|
||||
Future<String> 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);
|
||||
|
|
|
|||
|
|
@ -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? ?? ''
|
||||
|
|
|
|||
|
|
@ -257,10 +257,22 @@ class _AiSettingsState extends State<AiSettings> {
|
|||
title: Text(tr('배율')),
|
||||
trailing: DropdownButton<int>(
|
||||
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('저장 형식')),
|
||||
|
|
|
|||
|
|
@ -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<void> 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<void> upscaleAll(BuildContext context, AppController c, ReaderSource src)
|
|||
}
|
||||
|
||||
/// 원본 / 올린 것을 바꿔 보며 비교 (확대해서 볼 수 있음). [저장] 이면 true, 저장하지 않고 닫으면 false
|
||||
/// 관리자 판단 (10/17, 사용자 질문에 답이 없어 넣되 단추는 처음에 숨김): [품질 올리기] -
|
||||
/// AI 그림 (그림 → 그림, 약하게 0.25) 으로 잡음 · 뭉개짐을 다듬은 뒤 이어서 해상도 올리기 (설정의 배율).
|
||||
/// 원본은 덮지 않고 새 파일 "이름_q" 로 저장한다. AI 그림 모델이 없으면 받으라는 안내.
|
||||
Future<String?> 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<void>(
|
||||
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<String>(tr('준비하는 중'));
|
||||
final progress = ValueNotifier<double?>(null);
|
||||
var cancelled = false;
|
||||
if (!context.mounted) return null;
|
||||
final nav = Navigator.of(context);
|
||||
var dialogOpen = true;
|
||||
unawaited(showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(tr('품질 올리기')),
|
||||
content: SizedBox(
|
||||
width: 420,
|
||||
child: ValueListenableBuilder<double?>(
|
||||
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<String>(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;
|
||||
|
|
|
|||
|
|
@ -633,6 +633,8 @@ class _ReaderPageState extends State<ReaderPage> {
|
|||
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<ReaderPage> {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue