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
RyuJongJin 2026-10-17 14:05:26 +09:00
parent 863b5e2ea0
commit eee38aa2fd
9 changed files with 283 additions and 7 deletions

View File

@ -67,9 +67,12 @@
".{0} 재생 프로그램 (exe)": ".{0} player (exe)", ".{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 = 제한 없음. 모든 방법에 적용: 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", "0 으로": "Reset to 0",
"1/2 · AI 그림으로 다듬는 중": "1/2 · Refining with AI image",
"10초 뒤로 (←)": "Back 10 seconds (←)", "10초 뒤로 (←)": "Back 10 seconds (←)",
"10초 앞으로 (→)": "Forward 10 seconds (→)", "10초 앞으로 (→)": "Forward 10 seconds (→)",
"1배 (선명하게)": "1× (sharpen)",
"1분 안": "under 1 min", "1분 안": "under 1 min",
"2/2 · 해상도 올리는 중": "2/2 · Upscaling",
"5초 뒤로": "Back 5 seconds", "5초 뒤로": "Back 5 seconds",
"5초 앞으로": "Forward 5 seconds", "5초 앞으로": "Forward 5 seconds",
"90° 회전 (시계 방향)": "Rotate 90° (clockwise)", "90° 회전 (시계 방향)": "Rotate 90° (clockwise)",
@ -1193,6 +1196,7 @@
"보관해 둔 설정을 되살렸습니다: {0}": "Restored the kept settings: {0}", "보관해 둔 설정을 되살렸습니다: {0}": "Restored the kept settings: {0}",
"보기": "View", "보기": "View",
"보기 {0}초 · Ctrl+휠 확대/축소": "View {0} s · Ctrl+wheel to zoom", "보기 {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", "보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "How often [Slideshow] in the viewer turns to the next page",
"보내지 못했습니다": "Could not send", "보내지 못했습니다": "Could not send",
"보낼 것 {0} / 있는 것 {1}": "sending {0} / existing {1}", "보낼 것 {0} / 있는 것 {1}": "sending {0} / existing {1}",
@ -2214,6 +2218,7 @@
"크기 · 바뀐 날 · 종류를 넣으면 파일만 찾습니다.": "With size, date or type conditions, only files are found.", "크기 · 바뀐 날 · 종류를 넣으면 파일만 찾습니다.": "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\".", "크기 · 보정을 원래대로 돌려 코덱도 \"원본 유지\" 로 되돌렸습니다.": "Size and adjustments are back to original, so the codec was set back to \"Keep original\".",
"크기 이상": "Min size", "크기 이상": "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.", "크기를 바꾸면 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 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)", "크기순 정렬일 때만 (기본)": "Only when sorting by size (default)",
"크메르어": "Khmer", "크메르어": "Khmer",
@ -2331,6 +2336,12 @@
"푸는 중": "Extracting", "푸는 중": "Extracting",
"푸는 중: {0}": "Extracting: {0}", "푸는 중: {0}": "Extracting: {0}",
"풀지 못했습니다: {0}": "Could not extract: {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", "프랑스어": "French",
"프로그램": "Program", "프로그램": "Program",
"프로그램 정보": "About", "프로그램 정보": "About",

View File

@ -67,9 +67,12 @@
".{0} 재생 프로그램 (exe)": ".{0} 再生プログラム(exe)", ".{0} 재생 프로그램 (exe)": ".{0} 再生プログラム(exe)",
"0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 制限なし。すべての方法に適用: rsync は --bwlimit、現在の方式はアプリが調整、robocopy は /IPG で近似。オプション欄に直接入れても構いません (そちらが優先)", "0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 制限なし。すべての方法に適用: rsync は --bwlimit、現在の方式はアプリが調整、robocopy は /IPG で近似。オプション欄に直接入れても構いません (そちらが優先)",
"0 으로": "0 に戻す", "0 으로": "0 に戻す",
"1/2 · AI 그림으로 다듬는 중": "1/2 · AI 画像で整え中",
"10초 뒤로 (←)": "10秒戻る(←)", "10초 뒤로 (←)": "10秒戻る(←)",
"10초 앞으로 (→)": "10秒進む(→)", "10초 앞으로 (→)": "10秒進む(→)",
"1배 (선명하게)": "1倍 (くっきり)",
"1분 안": "1 分以内", "1분 안": "1 分以内",
"2/2 · 해상도 올리는 중": "2/2 · 解像度を上げています",
"5초 뒤로": "5秒戻る", "5초 뒤로": "5秒戻る",
"5초 앞으로": "5秒進む", "5초 앞으로": "5秒進む",
"90° 회전 (시계 방향)": "90° 回転 (時計回り)", "90° 회전 (시계 방향)": "90° 回転 (時計回り)",
@ -1193,6 +1196,7 @@
"보관해 둔 설정을 되살렸습니다: {0}": "保管した設定を復元しました: {0}", "보관해 둔 설정을 되살렸습니다: {0}": "保管した設定を復元しました: {0}",
"보기": "表示", "보기": "表示",
"보기 {0}초 · Ctrl+휠 확대/축소": "表示 {0}秒・Ctrl+ホイールで拡大/縮小", "보기 {0}초 · Ctrl+휠 확대/축소": "表示 {0}秒・Ctrl+ホイールで拡大/縮小",
"보기 화면에 [품질 올리기]: AI 그림 (그림 → 그림, 약하게) 으로 다듬은 뒤 해상도 올리기를 이어서. 새 파일 (이름_q) 로 저장하며 AI 그림 모델이 필요합니다": "ビューアーに [画質を上げる]: AI 画像 (画像 → 画像、弱め) で整えてから解像度を上げます。新しいファイル (名前_q) に保存し、AI 画像モデルが必要です",
"보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "表示画面の [連続表示] が次のページへ進む間隔", "보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "表示画面の [連続表示] が次のページへ進む間隔",
"보내지 못했습니다": "送信できませんでした", "보내지 못했습니다": "送信できませんでした",
"보낼 것 {0} / 있는 것 {1}": "送る方 {0} / 既存 {1}", "보낼 것 {0} / 있는 것 {1}": "送る方 {0} / 既存 {1}",
@ -2214,6 +2218,7 @@
"크기 · 바뀐 날 · 종류를 넣으면 파일만 찾습니다.": "サイズ・更新日・種類を指定すると、ファイルだけを探します。", "크기 · 바뀐 날 · 종류를 넣으면 파일만 찾습니다.": "サイズ・更新日・種類を指定すると、ファイルだけを探します。",
"크기 · 보정을 원래대로 돌려 코덱도 \"원본 유지\" 로 되돌렸습니다.": "サイズ・補正を元に戻したので、コーデックも「元のまま」に戻しました。", "크기 · 보정을 원래대로 돌려 코덱도 \"원본 유지\" 로 되돌렸습니다.": "サイズ・補正を元に戻したので、コーデックも「元のまま」に戻しました。",
"크기 이상": "最小サイズ", "크기 이상": "最小サイズ",
"크기는 그대로 두고 선명하게 해 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.": "サイズはそのままでくっきりさせ、新しいファイルに保存します。元のファイル・ZIP はそのままです。画面を閉じても続き、作業通知に残り枚数と予想時間が表示されます。",
"크기를 바꾸면 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "サイズを変えると再エンコードが必要なため、コーデックを「元のまま」から H.264 に変更しました。", "크기를 바꾸면 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "サイズを変えると再エンコードが必要なため、コーデックを「元のまま」から H.264 に変更しました。",
"크기순 정렬일 때만 (기본)": "サイズ順のときだけ (既定)", "크기순 정렬일 때만 (기본)": "サイズ順のときだけ (既定)",
"크메르어": "クメール語", "크메르어": "クメール語",
@ -2331,6 +2336,12 @@
"푸는 중": "展開中", "푸는 중": "展開中",
"푸는 중: {0}": "展開中: {0}", "푸는 중: {0}": "展開中: {0}",
"풀지 못했습니다: {0}": "展開できませんでした: {0}", "풀지 못했습니다: {0}": "展開できませんでした: {0}",
"품질 올리기": "画質を上げる",
"품질 올리기 (이 장)": "画質を上げる (このページ)",
"품질 올리기 단추 보이기": "[画質を上げる] ボタンを表示",
"품질 올리기: {0}": "画質を上げる: {0}",
"품질 올리기는 AI 그림 모델이 필요합니다. 환경 설정 > AI 그림에서 모델을 받은 뒤 다시 눌러 주세요.": "画質を上げるには AI 画像モデルが必要です。設定 > AI 画像でモデルを入手してから、もう一度押してください。",
"품질 올리기를 끝내지 못했습니다: {0}": "画質を上げる処理を完了できませんでした: {0}",
"프랑스어": "フランス語", "프랑스어": "フランス語",
"프로그램": "プログラム", "프로그램": "プログラム",
"프로그램 정보": "プログラム情報", "프로그램 정보": "プログラム情報",

View File

@ -67,9 +67,12 @@
".{0} 재생 프로그램 (exe)": ".{0} 播放程序(exe)", ".{0} 재생 프로그램 (exe)": ".{0} 播放程序(exe)",
"0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 不限制。适用于所有方式: rsync 用 --bwlimit,当前方式由应用调节,robocopy 用 /IPG 近似。也可直接写在选项栏中 (优先)", "0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 不限制。适用于所有方式: rsync 用 --bwlimit,当前方式由应用调节,robocopy 用 /IPG 近似。也可直接写在选项栏中 (优先)",
"0 으로": "归零", "0 으로": "归零",
"1/2 · AI 그림으로 다듬는 중": "1/2 · 正在用 AI 图片优化",
"10초 뒤로 (←)": "后退 10 秒(←)", "10초 뒤로 (←)": "后退 10 秒(←)",
"10초 앞으로 (→)": "前进 10 秒(→)", "10초 앞으로 (→)": "前进 10 秒(→)",
"1배 (선명하게)": "1倍 (锐化)",
"1분 안": "1 分钟以内", "1분 안": "1 分钟以内",
"2/2 · 해상도 올리는 중": "2/2 · 正在提高分辨率",
"5초 뒤로": "后退 5 秒", "5초 뒤로": "后退 5 秒",
"5초 앞으로": "前进 5 秒", "5초 앞으로": "前进 5 秒",
"90° 회전 (시계 방향)": "旋转 90° (顺时针)", "90° 회전 (시계 방향)": "旋转 90° (顺时针)",
@ -1193,6 +1196,7 @@
"보관해 둔 설정을 되살렸습니다: {0}": "已恢复保存的设置: {0}", "보관해 둔 설정을 되살렸습니다: {0}": "已恢复保存的设置: {0}",
"보기": "查看", "보기": "查看",
"보기 {0}초 · Ctrl+휠 확대/축소": "显示 {0} 秒 · Ctrl+滚轮缩放", "보기 {0}초 · Ctrl+휠 확대/축소": "显示 {0} 秒 · Ctrl+滚轮缩放",
"보기 화면에 [품질 올리기]: AI 그림 (그림 → 그림, 약하게) 으로 다듬은 뒤 해상도 올리기를 이어서. 새 파일 (이름_q) 로 저장하며 AI 그림 모델이 필요합니다": "在查看器中显示 [提升画质]: 先用 AI 图片 (图生图,轻度) 优化再提高分辨率。保存为新文件 (名称_q),需要 AI 图片模型",
"보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "浏览画面中 [连续浏览] 翻到下一页的间隔", "보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "浏览画面中 [连续浏览] 翻到下一页的间隔",
"보내지 못했습니다": "无法发送", "보내지 못했습니다": "无法发送",
"보낼 것 {0} / 있는 것 {1}": "要发送 {0} / 已有 {1}", "보낼 것 {0} / 있는 것 {1}": "要发送 {0} / 已有 {1}",
@ -2214,6 +2218,7 @@
"크기 · 바뀐 날 · 종류를 넣으면 파일만 찾습니다.": "设置大小、修改时间或类型时,只查找文件。", "크기 · 바뀐 날 · 종류를 넣으면 파일만 찾습니다.": "设置大小、修改时间或类型时,只查找文件。",
"크기 · 보정을 원래대로 돌려 코덱도 \"원본 유지\" 로 되돌렸습니다.": "大小 · 调整已恢复原样,编解码器也恢复为“保持原样”。", "크기 · 보정을 원래대로 돌려 코덱도 \"원본 유지\" 로 되돌렸습니다.": "大小 · 调整已恢复原样,编解码器也恢复为“保持原样”。",
"크기 이상": "最小大小", "크기 이상": "最小大小",
"크기는 그대로 두고 선명하게 해 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.": "保持尺寸只做锐化并保存为新文件。原文件和 ZIP 保持不变。关闭画面后仍会继续,任务通知中会显示剩余张数和预计时间。",
"크기를 바꾸면 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "更改大小需要重新编码,因此编解码器已从“保持原样”改为 H.264。", "크기를 바꾸면 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "更改大小需要重新编码,因此编解码器已从“保持原样”改为 H.264。",
"크기순 정렬일 때만 (기본)": "仅在按大小排序时 (默认)", "크기순 정렬일 때만 (기본)": "仅在按大小排序时 (默认)",
"크메르어": "高棉语", "크메르어": "高棉语",
@ -2331,6 +2336,12 @@
"푸는 중": "正在解压", "푸는 중": "正在解压",
"푸는 중: {0}": "正在解压: {0}", "푸는 중: {0}": "正在解压: {0}",
"풀지 못했습니다: {0}": "无法解压: {0}", "풀지 못했습니다: {0}": "无法解压: {0}",
"품질 올리기": "提升画质",
"품질 올리기 (이 장)": "提升画质 (本页)",
"품질 올리기 단추 보이기": "显示提升画质按钮",
"품질 올리기: {0}": "提升画质: {0}",
"품질 올리기는 AI 그림 모델이 필요합니다. 환경 설정 > AI 그림에서 모델을 받은 뒤 다시 눌러 주세요.": "提升画质需要 AI 图片模型。请在 设置 > AI 图片 中下载后再试。",
"품질 올리기를 끝내지 못했습니다: {0}": "未能完成提升画质: {0}",
"프랑스어": "法语", "프랑스어": "法语",
"프로그램": "程序", "프로그램": "程序",
"프로그램 정보": "程序信息", "프로그램 정보": "程序信息",

View File

@ -42,9 +42,9 @@ String upscaleExt(String source, String format) {
} }
/// 저장할 이름: 원본 옆 (또는 [outDir]) 에 "이름_x2.png". 이미 있으면 "이름_x2 (2).png" … (덮어쓰지 않음) /// 저장할 이름: 원본 옆 (또는 [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 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'); var path = p.join(dir, '$base.$ext');
for (var n = 2; File(path).existsSync(); n++) { for (var n = 2; File(path).existsSync(); n++) {
path = p.join(dir, '$base ($n).$ext'); path = p.join(dir, '$base ($n).$ext');
@ -236,17 +236,25 @@ class AiUpscaler {
} }
/// 4배 결과 [fourX] 를 설정대로 줄이고 담아 새 파일로 저장한다 (이름은 [source] 기준, 크기는 [original] 기준) /// 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 orig = img.decodeImage(await File(original).readAsBytes());
final ext = upscaleExt(source, o.format); final ext = upscaleExt(source, o.format);
final bytes = await finishUpscale(await File(fourX).readAsBytes(), final bytes = await finishUpscale(await File(fourX).readAsBytes(),
origW: orig?.width ?? 0, origH: orig?.height ?? 0, scale: o.scale, ext: ext, quality: o.jpgQuality); 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 Directory(p.dirname(path)).create(recursive: true);
await File(path).writeAsBytes(bytes, flush: true); await File(path).writeAsBytes(bytes, flush: true);
return path; 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) /// 156: 쓸 모델 - 자동이면 고른 것을 받아 두지 않았을 때 받아 둔 다른 것을 쓴다 (없으면 null)
String? installedUpscaleModel(bool Function(String id) installed, String setting, {required bool gpu}) { String? installedUpscaleModel(bool Function(String id) installed, String setting, {required bool gpu}) {
final want = pickUpscaleModel(setting, gpu: gpu); final want = pickUpscaleModel(setting, gpu: gpu);

View File

@ -597,6 +597,9 @@ class AppSettings {
/// 배율 2 · 3 · 4 /// 배율 2 · 3 · 4
int aiUpScale = 2; int aiUpScale = 2;
/// 관리자 판단 (10/17): 보기 화면의 [품질 올리기] 단추 (그림 → 그림 약하게 + 해상도 올리기). 처음 값: 끔
bool aiQualityButton = false;
/// 저장 형식 'same' (원본과 같게, 못 쓰는 형식은 PNG) · 'png' · 'jpg' · JPG 품질 /// 저장 형식 'same' (원본과 같게, 못 쓰는 형식은 PNG) · 'png' · 'jpg' · JPG 품질
String aiUpFormat = 'same'; String aiUpFormat = 'same';
int aiUpJpgQuality = 92; int aiUpJpgQuality = 92;
@ -809,6 +812,7 @@ class AppSettings {
'aiUpMemory': aiUpMemory, 'aiUpMemory': aiUpMemory,
'aiUpSecPerMp': aiUpSecPerMp, 'aiUpSecPerMp': aiUpSecPerMp,
'aiUpScale': aiUpScale, 'aiUpScale': aiUpScale,
'aiQualityButton': aiQualityButton,
'aiUpFormat': aiUpFormat, 'aiUpFormat': aiUpFormat,
'aiUpJpgQuality': aiUpJpgQuality, 'aiUpJpgQuality': aiUpJpgQuality,
'aiUpDir': aiUpDir, 'aiUpDir': aiUpDir,
@ -1044,7 +1048,9 @@ class AppSettings {
for (final e in m.entries) for (final e in m.entries)
if (e.value is num && (e.value as num) > 0) '${e.key}': (e.value as num).toDouble(), 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' ..aiUpFormat = const ['png', 'jpg'].contains(j['aiUpFormat']) ? j['aiUpFormat'] as String : 'same'
..aiUpJpgQuality = ((j['aiUpJpgQuality'] as num?)?.toInt() ?? 92).clamp(50, 100) ..aiUpJpgQuality = ((j['aiUpJpgQuality'] as num?)?.toInt() ?? 92).clamp(50, 100)
..aiUpDir = j['aiUpDir'] as String? ?? '' ..aiUpDir = j['aiUpDir'] as String? ?? ''

View File

@ -257,10 +257,22 @@ class _AiSettingsState extends State<AiSettings> {
title: Text(tr('배율')), title: Text(tr('배율')),
trailing: DropdownButton<int>( trailing: DropdownButton<int>(
value: s.aiUpScale, 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), 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( SettingTile(
leading: const Icon(Icons.image_outlined), leading: const Icon(Icons.image_outlined),
title: Text(tr('저장 형식')), title: Text(tr('저장 형식')),

View File

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show MethodChannel; import 'package:flutter/services.dart' show MethodChannel;
import 'package:image/image.dart' as img;
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import '../app/ai_local.dart'; import '../app/ai_local.dart';
@ -10,6 +11,7 @@ import '../app/ai_upscale.dart';
import '../app/app_controller.dart'; import '../app/app_controller.dart';
import '../core/ai_catalog.dart'; import '../core/ai_catalog.dart';
import '../core/reader_sources.dart'; import '../core/reader_sources.dart';
import '../core/sd_cli.dart' show ImageGenRequest;
import '../core/vfs.dart'; import '../core/vfs.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import '../services/image_ai.dart' show ImageAiException; import '../services/image_ai.dart' show ImageAiException;
@ -375,7 +377,9 @@ Future<void> upscaleAll(BuildContext context, AppController c, ReaderSource src)
context, context,
title: trf('{0}장 모두 해상도 올리기', [n]), title: trf('{0}장 모두 해상도 올리기', [n]),
body: [ body: [
trf('{0}배로 올려 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.', [c.settings.aiUpScale]), c.settings.aiUpScale == 1
? tr('크기는 그대로 두고 선명하게 해 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.')
: trf('{0}배로 올려 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.', [c.settings.aiUpScale]),
?estimate, ?estimate,
].join('\n\n'), ].join('\n\n'),
ok: tr('시작'), ok: tr('시작'),
@ -421,6 +425,137 @@ Future<void> upscaleAll(BuildContext context, AppController c, ReaderSource src)
} }
/// 원본 / 올린 것을 바꿔 보며 비교 (확대해서 볼 수 있음). [저장] 이면 true, 저장하지 않고 닫으면 false /// 원본 / 올린 것을 바꿔 보며 비교 (확대해서 볼 수 있음). [저장] 이면 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 { class UpscaleCompareView extends StatefulWidget {
const UpscaleCompareView({super.key, required this.original, required this.upscaled, required this.title}); const UpscaleCompareView({super.key, required this.original, required this.upscaled, required this.title});
final String original; final String original;

View File

@ -633,6 +633,8 @@ class _ReaderPageState extends State<ReaderPage> {
if (_aiOn && src is! PdfSource) ...[ if (_aiOn && src is! PdfSource) ...[
one(_Act(const Icon(Icons.hd_outlined), tr('AI 해상도 올리기 (이 장)'), _upscalePage)), one(_Act(const Icon(Icons.hd_outlined), tr('AI 해상도 올리기 (이 장)'), _upscalePage)),
one(_Act(const Icon(Icons.burst_mode_outlined), tr('AI 해상도 올리기 (모든 장)'), () => upscaleAll(context, c, src))), 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 그림 (그림 → 그림, 원본은 그대로) // 123: 이 그림을 바탕으로 AI 그림 (그림 → 그림, 원본은 그대로)
if (_aiSource case final path?) 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 그림 컴포넌트가 켜져 있을 때) /// 지금 보는 그림 파일 (이 기기의 그림 파일일 때, AI 그림 컴포넌트가 켜져 있을 때)
String? get _aiSource { String? get _aiSource {
final s = src; final s = src;

74
test/ai_quality_test.dart Normal file
View File

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