[MKV 만들기] 와 AI 버튼 구별 · AI 작업 전에 계획 보이기 (138)

[MKV 만들기] 와 [자막 만들기 & MKV 만들기] 를 헷갈려 오래 걸리는 AI 자막이 돌던 것.
- AI 버튼 (자막 만들기 · 자막 & MKV) 은 보라 테두리 · 글자 · ✨ 아이콘, [MKV 만들기] 는 청록 채움 · ▶.
  좁은 화면에서는 AI 버튼을 맨 뒤 구분선 너머로 (넓은 화면은 왼쪽 · 오른쪽으로 원래 떨어져 있음).
- AI 설정 창 맨 위에 계획: 할 일 · 대상 수 · 자막 언어 · 예상 시간 (영상 1시간 ≈ 음성인식 15~30분 + 번역 언어마다 10~20분).
- 환경 설정에서 "매번 묻기" 를 끈 경우는 사용자 설정대로 묻지 않되, 3초 알림
  "AI 자막 (동영상 n개 · 약 m분) 을 시작합니다 [취소]" 뒤에 시작해 잘못 누른 것을 되돌릴 수 있게.
- ai_confirm_test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음4
RyuJongJin 2026-10-10 03:03:57 +09:00
parent 8c1ef5a03b
commit 3a220ab073
7 changed files with 244 additions and 40 deletions

View File

@ -80,9 +80,13 @@
"AI 엔진이 해상도를 올리지 못했습니다 (처리 장치를 바꿔 보세요)": "The AI engine could not upscale the image (try another processing device)",
"AI 인식": "AI recognition",
"AI 자막": "AI subtitles",
"AI 자막 ({0}) 을 시작합니다": "Starting AI subtitles ({0})",
"AI 자막 {0}개": "AI subtitles for {0}",
"AI 자막 → MKV ({0}) 을 시작합니다": "Starting AI subtitles → MKV ({0})",
"AI 자막 대기 중": "Waiting for AI subtitles",
"AI 자막 만들기": "Make AI subtitles",
"AI 자막 만들기 (MKV 는 만들지 않음)": "Make AI subtitles (no MKV)",
"AI 자막 만들기 → 이어서 MKV 만들기": "Make AI subtitles → then make MKV",
"AI 자막 만들기{0}{1}": "Make AI subtitles{0}{1}",
"AI 자막 시작: {0}": "AI subtitles started: {0}",
"AI 자막 실패: {0}\n{1}": "AI subtitles failed: {0}\n{1}",
@ -91,6 +95,7 @@
"AI 자막 취소: {0}": "AI subtitles cancelled: {0}",
"AI 자막: {0}": "AI subtitles: {0}",
"AI 자막용 음성 (wav) · 자막 변환 사본 · 미리보기 그림 등 작업하다 남은 파일": "Leftovers from jobs: audio for AI subtitles (wav) · converted subtitle copies · preview images",
"AI 자막을 만든 뒤 MKV 만들기": "Make MKV after AI subtitles",
"AI 자막을 만들고 이어서 MKV 로 만듭니다": "makes AI subtitles and then makes the MKV",
"AI 자막을 만듭니다": "makes AI subtitles",
"AI 작업 중이라 이번에는 건너뜁니다": "Skipped this time because an AI job is running",
@ -694,8 +699,10 @@
"동시에 처리할 개수 (0 = 무한)": "Number processed at once (0 = unlimited)",
"동영상": "Video",
"동영상 + 자막을 MKV 로 합치기 · AI 자막 · 화면 보정": "Merge video + subtitles into MKV · AI subtitles · picture adjustments",
"동영상 {0}개": "{0} videos",
"동영상 {0}개 · 전체 선택": "{0} videos · select all",
"동영상 {0}개를 MKV 목록에서 뺍니다. 파일은 지우지 않습니다.": "Removes {0} videos from the MKV list. Files are not deleted.",
"동영상 길이를 몰라 셀 수 없습니다 (영상 1시간 ≈ 음성인식 15~30분 + 언어당 번역 10~20분)": "Cannot estimate without the video length (1 hour of video ≈ 15–30 min recognition + 10–20 min per language)",
"동영상 목록 · 즐겨찾기 · 파일은 그대로입니다": "The video list, bookmarks and files are kept",
"동영상 목록 · 창 위치 저장": "Save video list · window position",
"동영상 목록에 추가: {0}": "Added to the video list: {0}",
@ -1051,6 +1058,7 @@
"선택한 파일 재생{0}": "Play selected files{0}",
"설정 (WebDAV 서버 목록 포함, 비밀번호는 빼고) · 동영상 목록 · 즐겨찾기 - Download/JJ_MKVMaker/설정 보관": "Settings (including the WebDAV server list, without passwords) · video list · bookmarks - Download/JJ_MKVMaker/설정 보관",
"설정 되살리기": "Restore settings",
"설정 바꾸기": "Change settings",
"설정 반영 후 실행": "Apply and run",
"설정 파일을 읽지 못해 백업으로 되살림 (보관: {0}): {1}": "Could not read the settings file; restored from backup (kept: {0}): {1}",
"설정 파일을 읽지 못해 처음 설정으로 켬 (보관: {0}): {1}": "Could not read the settings file; started with default settings (kept: {0}): {1}",
@ -1190,6 +1198,8 @@
"앱을 지우면 다음은 되살릴 수 없습니다:": "Removing the app loses the following for good:",
"앱이 켜져 있는 동안 늘 동기화합니다.": "Always syncs while the app is running.",
"약 {0} 남음": "about {0} left",
"약 {0}~{1}분": "about {0}–{1} min",
"약 {0}~{1}분 (동영상 {2})": "about {0}–{1} min (videos: {2})",
"양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-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",
@ -1242,6 +1252,7 @@
"예: http://100.x.x.x:8188 (ComfyUI)": "e.g. http://100.x.x.x:8188 (ComfyUI)",
"예: https://api.openai.com (경로 /v1/images/generations 를 붙여 부름)": "e.g. https://api.openai.com (called with the path /v1/images/generations)",
"예: https://nas.local:5006/home · https://cloud.example.com/remote.php/dav/files/아이디": "e.g. https://nas.local:5006/home · https://cloud.example.com/remote.php/dav/files/username",
"예상 시간": "Estimated time",
"예전 구조의 프로그램 파일 정리 ({0}개): {1}": "Cleaned up program files of the old layout ({0}): {1}",
"예전 버전": "an earlier version",
"예전 버전 v{0} 으로 되돌리기": "Roll back to earlier version v{0}",
@ -1508,6 +1519,7 @@
"자막 보기": "Show subtitles",
"자막 불러오는 중": "Loading subtitles",
"자막 선택": "Choose subtitles",
"자막 언어": "Subtitle languages",
"자막 언어를 알 수 없습니다. 자막 줄에서 언어를 먼저 고르세요.": "The subtitle language is unknown. Choose the language in the subtitle row first.",
"자막 이동 실패: {0} ({1})": "Failed to move subtitle: {0} ({1})",
"자막 저장: {0} ({1}, {2}개 줄){3}": "Subtitles saved: {0} ({1}, {2} lines){3}",
@ -1918,6 +1930,7 @@
"한 창": "One pane",
"한국어": "Korean",
"한글": "Hangul",
"할 일": "Task",
"항목 {0}/{1}": "Items {0}/{1}",
"항목마다 따로 (기본)": "Each item separately (default)",
"해상도 올리기 모델": "Upscaling model",

View File

@ -80,9 +80,13 @@
"AI 엔진이 해상도를 올리지 못했습니다 (처리 장치를 바꿔 보세요)": "AI エンジンが解像度を上げられませんでした (処理装置を変えてみてください)",
"AI 인식": "AI 認識",
"AI 자막": "AI 字幕",
"AI 자막 ({0}) 을 시작합니다": "AI 字幕 ({0}) を開始します",
"AI 자막 {0}개": "AI 字幕 {0}個",
"AI 자막 → MKV ({0}) 을 시작합니다": "AI 字幕 → MKV ({0}) を開始します",
"AI 자막 대기 중": "AI 字幕待機中",
"AI 자막 만들기": "AI 字幕を作成",
"AI 자막 만들기 (MKV 는 만들지 않음)": "AI 字幕を作成 (MKV は作成しない)",
"AI 자막 만들기 → 이어서 MKV 만들기": "AI 字幕を作成 → 続けて MKV を作成",
"AI 자막 만들기{0}{1}": "AI 字幕を作成{0}{1}",
"AI 자막 시작: {0}": "AI 字幕開始: {0}",
"AI 자막 실패: {0}\n{1}": "AI 字幕失敗: {0}\n{1}",
@ -91,6 +95,7 @@
"AI 자막 취소: {0}": "AI 字幕キャンセル: {0}",
"AI 자막: {0}": "AI 字幕: {0}",
"AI 자막용 음성 (wav) · 자막 변환 사본 · 미리보기 그림 등 작업하다 남은 파일": "作業の残り: AI 字幕用の音声(wav)・字幕変換のコピー・プレビュー画像など",
"AI 자막을 만든 뒤 MKV 만들기": "AI 字幕を作ってから MKV を作成",
"AI 자막을 만들고 이어서 MKV 로 만듭니다": "AI 字幕を作成し、続けて MKV を作成します",
"AI 자막을 만듭니다": "AI 字幕を作成します",
"AI 작업 중이라 이번에는 건너뜁니다": "AI 作業中のため今回はスキップします",
@ -694,8 +699,10 @@
"동시에 처리할 개수 (0 = 무한)": "同時に処理する数(0 = 無制限)",
"동영상": "動画",
"동영상 + 자막을 MKV 로 합치기 · AI 자막 · 화면 보정": "動画 + 字幕を MKV に結合・AI 字幕・画面補正",
"동영상 {0}개": "動画 {0} 件",
"동영상 {0}개 · 전체 선택": "動画 {0}個・すべて選択",
"동영상 {0}개를 MKV 목록에서 뺍니다. 파일은 지우지 않습니다.": "動画 {0} 本を MKV 一覧から外します。ファイルは削除しません。",
"동영상 길이를 몰라 셀 수 없습니다 (영상 1시간 ≈ 음성인식 15~30분 + 언어당 번역 10~20분)": "動画の長さが分からないため計算できません (動画 1 時間 ≈ 音声認識 15~30 分 + 言語ごとの翻訳 10~20 分)",
"동영상 목록 · 즐겨찾기 · 파일은 그대로입니다": "動画リスト・お気に入り・ファイルはそのままです",
"동영상 목록 · 창 위치 저장": "動画リスト・ウィンドウ位置を保存",
"동영상 목록에 추가: {0}": "動画リストに追加: {0}",
@ -1051,6 +1058,7 @@
"선택한 파일 재생{0}": "選択したファイルを再生{0}",
"설정 (WebDAV 서버 목록 포함, 비밀번호는 빼고) · 동영상 목록 · 즐겨찾기 - Download/JJ_MKVMaker/설정 보관": "設定 (WebDAV サーバー一覧を含む、パスワードを除く)・動画リスト・お気に入り - Download/JJ_MKVMaker/설정 보관",
"설정 되살리기": "設定の復元",
"설정 바꾸기": "設定を変更",
"설정 반영 후 실행": "反映して実行",
"설정 파일을 읽지 못해 백업으로 되살림 (보관: {0}): {1}": "設定ファイルを読めずバックアップから復元 (保管: {0}): {1}",
"설정 파일을 읽지 못해 처음 설정으로 켬 (보관: {0}): {1}": "設定ファイルを読めず初期設定で起動 (保管: {0}): {1}",
@ -1190,6 +1198,8 @@
"앱을 지우면 다음은 되살릴 수 없습니다:": "アプリを削除すると次のものは復元できません:",
"앱이 켜져 있는 동안 늘 동기화합니다.": "アプリの起動中は常に同期します。",
"약 {0} 남음": "残り約 {0}",
"약 {0}~{1}분": "約 {0}~{1} 分",
"약 {0}~{1}분 (동영상 {2})": "約 {0}~{1} 分 (動画 {2})",
"양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "双方向: 受け取る側の方が新しいファイルは上書きしません (-u)。",
"어둡게": "暗く",
"어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "どこからでも押すと JJ_MKVMaker ウィンドウが表示されます",
@ -1242,6 +1252,7 @@
"예: http://100.x.x.x:8188 (ComfyUI)": "例: http://100.x.x.x:8188 (ComfyUI)",
"예: https://api.openai.com (경로 /v1/images/generations 를 붙여 부름)": "例: https://api.openai.com (パス /v1/images/generations を付けて呼び出し)",
"예: https://nas.local:5006/home · https://cloud.example.com/remote.php/dav/files/아이디": "例: https://nas.local:5006/home · https://cloud.example.com/remote.php/dav/files/ユーザー名",
"예상 시간": "予想時間",
"예전 구조의 프로그램 파일 정리 ({0}개): {1}": "旧構成のプログラムファイルを整理({0}個): {1}",
"예전 버전": "以前のバージョン",
"예전 버전 v{0} 으로 되돌리기": "以前のバージョン v{0} に戻す",
@ -1508,6 +1519,7 @@
"자막 보기": "字幕を表示",
"자막 불러오는 중": "字幕を読み込み中",
"자막 선택": "字幕を選択",
"자막 언어": "字幕の言語",
"자막 언어를 알 수 없습니다. 자막 줄에서 언어를 먼저 고르세요.": "字幕の言語がわかりません。字幕の行で先に言語を選んでください。",
"자막 이동 실패: {0} ({1})": "字幕の移動失敗: {0}({1})",
"자막 저장: {0} ({1}, {2}개 줄){3}": "字幕を保存: {0}({1}、{2}行){3}",
@ -1918,6 +1930,7 @@
"한 창": "1 ペイン",
"한국어": "韓国語",
"한글": "ハングル",
"할 일": "内容",
"항목 {0}/{1}": "項目 {0}/{1}",
"항목마다 따로 (기본)": "項目ごとに (既定)",
"해상도 올리기 모델": "高解像度化モデル",

View File

@ -80,9 +80,13 @@
"AI 엔진이 해상도를 올리지 못했습니다 (처리 장치를 바꿔 보세요)": "AI 引擎未能提高分辨率 (请尝试更换处理设备)",
"AI 인식": "AI 识别",
"AI 자막": "AI 字幕",
"AI 자막 ({0}) 을 시작합니다": "开始 AI 字幕({0})",
"AI 자막 {0}개": "AI 字幕 {0} 个",
"AI 자막 → MKV ({0}) 을 시작합니다": "开始 AI 字幕 → MKV({0})",
"AI 자막 대기 중": "等待 AI 字幕",
"AI 자막 만들기": "生成 AI 字幕",
"AI 자막 만들기 (MKV 는 만들지 않음)": "制作 AI 字幕(不制作 MKV)",
"AI 자막 만들기 → 이어서 MKV 만들기": "制作 AI 字幕 → 接着制作 MKV",
"AI 자막 만들기{0}{1}": "生成 AI 字幕{0}{1}",
"AI 자막 시작: {0}": "AI 字幕开始: {0}",
"AI 자막 실패: {0}\n{1}": "AI 字幕失败: {0}\n{1}",
@ -91,6 +95,7 @@
"AI 자막 취소: {0}": "AI 字幕已取消: {0}",
"AI 자막: {0}": "AI 字幕: {0}",
"AI 자막용 음성 (wav) · 자막 변환 사본 · 미리보기 그림 등 작업하다 남은 파일": "任务残留: AI 字幕用音频(wav)· 字幕转换副本 · 预览图片等",
"AI 자막을 만든 뒤 MKV 만들기": "制作 AI 字幕后制作 MKV",
"AI 자막을 만들고 이어서 MKV 로 만듭니다": "生成 AI 字幕后接着制作 MKV",
"AI 자막을 만듭니다": "生成 AI 字幕",
"AI 작업 중이라 이번에는 건너뜁니다": "正在进行 AI 任务,本次跳过",
@ -694,8 +699,10 @@
"동시에 처리할 개수 (0 = 무한)": "同时处理的数量(0 = 无限制)",
"동영상": "视频",
"동영상 + 자막을 MKV 로 합치기 · AI 자막 · 화면 보정": "将视频 + 字幕合并为 MKV · AI 字幕 · 画面调整",
"동영상 {0}개": "{0} 个视频",
"동영상 {0}개 · 전체 선택": "视频 {0} 个 · 全选",
"동영상 {0}개를 MKV 목록에서 뺍니다. 파일은 지우지 않습니다.": "从 MKV 列表中移除 {0} 个视频。不会删除文件。",
"동영상 길이를 몰라 셀 수 없습니다 (영상 1시간 ≈ 음성인식 15~30분 + 언어당 번역 10~20분)": "不知道视频长度,无法估算(1 小时视频 ≈ 语音识别 15~30 分钟 + 每种语言翻译 10~20 分钟)",
"동영상 목록 · 즐겨찾기 · 파일은 그대로입니다": "视频列表 · 收藏 · 文件保持不变",
"동영상 목록 · 창 위치 저장": "保存视频列表 · 窗口位置",
"동영상 목록에 추가: {0}": "已加入视频列表: {0}",
@ -1051,6 +1058,7 @@
"선택한 파일 재생{0}": "播放所选文件{0}",
"설정 (WebDAV 서버 목록 포함, 비밀번호는 빼고) · 동영상 목록 · 즐겨찾기 - Download/JJ_MKVMaker/설정 보관": "设置 (含 WebDAV 服务器列表,不含密码) · 视频列表 · 收藏 - Download/JJ_MKVMaker/설정 보관",
"설정 되살리기": "恢复设置",
"설정 바꾸기": "更改设置",
"설정 반영 후 실행": "应用后运行",
"설정 파일을 읽지 못해 백업으로 되살림 (보관: {0}): {1}": "无法读取设置文件,已从备份恢复 (保存于: {0}): {1}",
"설정 파일을 읽지 못해 처음 설정으로 켬 (보관: {0}): {1}": "无法读取设置文件,已以初始设置启动 (保存于: {0}): {1}",
@ -1190,6 +1198,8 @@
"앱을 지우면 다음은 되살릴 수 없습니다:": "删除应用后以下内容无法恢复:",
"앱이 켜져 있는 동안 늘 동기화합니다.": "应用运行期间始终同步。",
"약 {0} 남음": "约剩 {0}",
"약 {0}~{1}분": "约 {0}~{1} 分钟",
"약 {0}~{1}분 (동영상 {2})": "约 {0}~{1} 分钟(视频 {2})",
"양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "双向: 接收端较新的文件不会被覆盖 (-u)。",
"어둡게": "调暗",
"어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "在任何地方按下即可显示 JJ_MKVMaker 窗口",
@ -1242,6 +1252,7 @@
"예: http://100.x.x.x:8188 (ComfyUI)": "例: http://100.x.x.x:8188 (ComfyUI)",
"예: https://api.openai.com (경로 /v1/images/generations 를 붙여 부름)": "例: https://api.openai.com (调用时附加路径 /v1/images/generations)",
"예: https://nas.local:5006/home · https://cloud.example.com/remote.php/dav/files/아이디": "例: https://nas.local:5006/home · https://cloud.example.com/remote.php/dav/files/用户名",
"예상 시간": "预计时间",
"예전 구조의 프로그램 파일 정리 ({0}개): {1}": "已清理旧结构的程序文件({0} 个): {1}",
"예전 버전": "以前的版本",
"예전 버전 v{0} 으로 되돌리기": "回退到以前的版本 v{0}",
@ -1508,6 +1519,7 @@
"자막 보기": "显示字幕",
"자막 불러오는 중": "正在加载字幕",
"자막 선택": "选择字幕",
"자막 언어": "字幕语言",
"자막 언어를 알 수 없습니다. 자막 줄에서 언어를 먼저 고르세요.": "无法确定字幕语言。请先在字幕行中选择语言。",
"자막 이동 실패: {0} ({1})": "字幕移动失败: {0}({1})",
"자막 저장: {0} ({1}, {2}개 줄){3}": "已保存字幕: {0}({1},{2} 行){3}",
@ -1918,6 +1930,7 @@
"한 창": "单窗格",
"한국어": "韩语",
"한글": "韩文",
"할 일": "任务",
"항목 {0}/{1}": "项目 {0}/{1}",
"항목마다 따로 (기본)": "逐个项目 (默认)",
"해상도 올리기 모델": "提高分辨率模型",

View File

@ -81,7 +81,7 @@ void main() {
// 61: 일본어 가로 (태블릿 크기) 에서 맨 위 버튼 글이 잘리는지 - MKV 첫 화면의 맨 위 줄.
// 실제 서비스 (AI 자막 · 플레이어 버튼이 보이게), 체크 0개 · 50개 ("(50)" 이 붙으면 더 좁다). S10 Ultra 가로 ≈ 1480 · 앱 크기 90% ≈ 1644
for (final (w, h) in [(1644, 1027), (1480, 924), (1280, 800), (1138, 712), (960, 600)]) {
for (final (w, h) in [(1644, 1027), (1480, 924), (1280, 800), (1138, 712), (960, 600), (412, 900)]) {
for (final checked in [0, 50]) {
testWidgets('61: 일본어 가로 ${w}x$h · 체크 $checked - 맨 위 버튼', (t) async {
t.view.physicalSize = Size(w.toDouble(), h.toDouble());

View File

@ -18,29 +18,109 @@ Future<void> showAiDialog(BuildContext context, AppController c, VideoItem curre
final list = targets ?? [current];
Future<void> run(List<VideoItem> v, AiOptions o) =>
thenBuild ? c.aiThenBuild(v, o) : c.generateAiSubtitles(v, o);
// 환경 설정에서 "매번 묻기" 를 끄면 마지막 설정으로 바로 시작
Future<(AiOptions, bool)?> full() => showDialog<(AiOptions, bool)>(
context: context,
builder: (_) => _AiDialog(c: c, videos: list, videoCount: targets == null ? c.videos.length : 1, thenBuild: thenBuild),
);
// 환경 설정에서 "매번 묻기" 를 끄면 사용자가 고른 대로 묻지 않는다 (마지막 설정으로).
// 138: 다만 [MKV 만들기] 와 헷갈려 잘못 누른 것을 되돌릴 수 있게 3초 알림 ([취소]) 뒤에 시작한다.
if (!c.settings.askAiOptions) {
await run(list, c.aiOptions);
return;
final messenger = ScaffoldMessenger.maybeOf(context);
if (messenger == null) return run(list, c.aiOptions);
var cancelled = false;
messenger.showSnackBar(SnackBar(
duration: aiStartDelay,
content: Text(aiStartNotice(list, c.aiOptions, thenBuild: thenBuild)),
action: SnackBarAction(label: tr('취소'), onPressed: () => cancelled = true),
));
await Future<void>.delayed(aiStartDelay);
if (cancelled) return;
return run(list, c.aiOptions);
}
final result = await showDialog<(AiOptions, bool)>(
context: context,
builder: (_) => _AiDialog(
c: c, videoCount: targets == null ? c.videos.length : 1, targetCount: list.length, thenBuild: thenBuild),
);
final result = await full();
if (result == null) return;
final (opts, all) = result;
await run(all ? List.of(c.videos) : list, opts);
}
/// 138: "매번 묻기" 를 껐을 때 시작 전에 [취소] 할 수 있는 시간
const aiStartDelay = Duration(seconds: 3);
/// 138: "AI 자막 (동영상 n개 · 약 m분) 을 시작합니다" (길이를 모르면 시간 없이)
String aiStartNotice(List<VideoItem> videos, AiOptions o, {required bool thenBuild}) {
final known = videos.map((v) => v.info?.duration).whereType<Duration>().toList();
final est = known.length == videos.length ? aiEstimateMinutes(known.fold(Duration.zero, (a, b) => a + b), aiTranslateCount(o)) : null;
final what = [trf('동영상 {0}개', [videos.length]), if (est != null) trf('약 {0}~{1}분', [est.$1, est.$2])].join(' · ');
return thenBuild ? trf('AI 자막 → MKV ({0}) 을 시작합니다', [what]) : trf('AI 자막 ({0}) 을 시작합니다', [what]);
}
/// 138: AI 작업 (자막 · 자막 & MKV) 의 색 - [MKV 만들기] (청록) 와 헷갈리지 않게
const aiColor = Color(0xFFB388FF);
/// 138: AI 작업 시간 어림 (분): 영상 1시간 ≈ 음성인식 15~30분 + 번역할 언어마다 10~20분. 길이를 모르면 null
(int, int)? aiEstimateMinutes(Duration total, int translateLanguages) {
if (total <= Duration.zero) return null;
final h = total.inSeconds / 3600;
return ((h * (15 + 10 * translateLanguages)).ceil(), (h * (30 + 20 * translateLanguages)).ceil());
}
/// 번역할 언어 수 (원어와 같은 언어는 번역하지 않음, 원어 자동 감지면 하나는 원어로 본다)
int aiTranslateCount(AiOptions o) => o.source == undetermined
? (o.targets.length - 1).clamp(0, 99)
: o.targets.where((t) => t.code != o.source.code).length;
/// 138: 시작 전에 보여 줄 계획: 할 일 · 대상 수 · 자막 언어 · 예상 시간
class AiPlanSummary extends StatelessWidget {
final List<VideoItem> videos;
final AiOptions o;
final bool thenBuild;
const AiPlanSummary({super.key, required this.videos, required this.o, required this.thenBuild});
@override
Widget build(BuildContext context) {
final known = videos.map((v) => v.info?.duration).whereType<Duration>().toList();
final total = known.fold(Duration.zero, (a, b) => a + b);
final est = known.length == videos.length ? aiEstimateMinutes(total, aiTranslateCount(o)) : null;
String hm(Duration d) => d.inHours > 0 ? trf('{0}시간 {1}분', [d.inHours, d.inMinutes % 60]) : trf('{0}분', [d.inMinutes.clamp(1, 59)]);
final rows = [
(tr('할 일'), thenBuild ? tr('AI 자막 만들기 → 이어서 MKV 만들기') : tr('AI 자막 만들기 (MKV 는 만들지 않음)')),
(tr('대상'), trf('동영상 {0}개', [videos.length])),
(tr('자막 언어'), [for (final l in o.targets) l.name].join(' · ')),
(
tr('예상 시간'),
est == null
? tr('동영상 길이를 몰라 셀 수 없습니다 (영상 1시간 ≈ 음성인식 15~30분 + 언어당 번역 10~20분)')
: trf('약 {0}~{1}분 (동영상 {2})', [est.$1, est.$2, hm(total)]),
),
];
return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: aiColor.withValues(alpha: 0.08), border: Border.all(color: aiColor.withValues(alpha: 0.5)), borderRadius: BorderRadius.circular(8)),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
for (final (k, v) in rows)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
SizedBox(width: 84, child: Text(k, style: const TextStyle(fontSize: 12, color: JjColors.textDim))),
Expanded(child: Text(v, style: const TextStyle(fontSize: 13))),
]),
),
]),
);
}
}
class _AiDialog extends StatefulWidget {
final AppController c;
final int videoCount;
/// 이번에 자막을 만들 동영상 수 · 이어서 MKV 도 만드는지 (제목에 표시)
final int targetCount;
/// 이번에 자막을 만들 동영상 · 이어서 MKV 도 만드는지 (제목 · 계획에 표시)
final List<VideoItem> videos;
final bool thenBuild;
const _AiDialog({required this.c, required this.videoCount, this.targetCount = 1, this.thenBuild = false});
const _AiDialog({required this.c, required this.videos, required this.videoCount, this.thenBuild = false});
int get targetCount => videos.length;
@override
State<_AiDialog> createState() => _AiDialogState();
@ -82,6 +162,9 @@ class _AiDialogState extends State<_AiDialog> {
children: [
Text(trf('영상의 음성을 인식하고 선택한 언어로 번역합니다. 모든 처리는 이 {0}에서 이루어집니다.', [Platform.isAndroid ? tr('기기') : 'PC']),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 10),
// 138: 무엇을 할지 먼저 (바꾸면 따라 바뀐다)
AiPlanSummary(videos: _all ? widget.c.videos : widget.videos, o: _o, thenBuild: widget.thenBuild),
const SizedBox(height: 16),
_label(tr('원어 (영상 속 언어)')),
DropdownButton<Language>(
@ -160,6 +243,7 @@ class _AiDialogState extends State<_AiDialog> {
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('취소'))),
FilledButton.icon(
style: FilledButton.styleFrom(backgroundColor: aiColor),
onPressed: () => Navigator.pop(context, (_o, _all)),
icon: const Icon(Icons.auto_awesome, size: 18),
label: Text(tr('시작')),

View File

@ -252,22 +252,40 @@ class _TopBar extends StatelessWidget {
final addTip = Platform.isAndroid ? tr('동영상 추가') : tr('동영상 추가 (탐색기에서 끌어다 놓아도 됩니다)');
// 글이 있는 버튼, 또는 (좁을 때) 아이콘만 있는 버튼
// 138: [ai] 는 AI 가 들어가는 버튼 (보라 테두리 · 글자) - 청록 [MKV 만들기] 와 한눈에 구별되게
Widget action(IconData icon, String label, VoidCallback? onPressed,
{required bool text, required String tip, Color? color}) {
{required bool text, required String tip, Color? color, bool ai = false}) {
final fg = ai ? aiColor : null;
final side = BorderSide(color: ai && onPressed != null ? aiColor.withValues(alpha: 0.7) : JjColors.border);
return Tooltip(
message: tip,
child: text
? OutlinedButton.icon(
onPressed: onPressed, icon: Icon(icon, size: 18, color: color), label: Text(label))
style: ai ? OutlinedButton.styleFrom(foregroundColor: fg, side: side) : null,
onPressed: onPressed,
icon: Icon(icon, size: 18, color: color ?? fg),
label: Text(label))
: IconButton.outlined(
onPressed: onPressed,
icon: Icon(icon, size: 20, color: onPressed == null ? null : color),
icon: Icon(icon, size: 20, color: onPressed == null ? null : color ?? fg),
style: IconButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
side: const BorderSide(color: JjColors.border))),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), side: side)),
);
}
// AI 버튼 둘 (자막 만들기 · 자막 만들기 & MKV 만들기)
List<Widget> aiButtons(bool text) => [
action(Icons.auto_awesome, trf('자막 만들기{0}', [count]),
canBatch ? () => showAiDialog(context, c, batch.first, targets: batch) : null,
text: text, ai: true, tip: trf('자막 만들기: {0}', [_batchHint(c, tr('AI 자막을 만듭니다'))])),
const SizedBox(width: 8),
action(Icons.auto_awesome_motion, trf('자막 만들기 & MKV 만들기{0}', [count]),
canBatch ? () => showAiDialog(context, c, batch.first, targets: batch, thenBuild: true) : null,
text: text,
ai: true,
tip: trf('자막 만들기 & MKV 만들기: {0}', [_batchHint(c, tr('AI 자막을 만들고 이어서 MKV 로 만듭니다'))])),
];
// 2: 좁은 화면 (폰 세로) 에서는 버튼 글자를 숨기지 않고 옆으로 밀어 본다. 가장 많이 쓰는 [MKV 만들기] 를 맨 앞에.
if (compact) {
VoidCallback? build0() =>
@ -297,20 +315,15 @@ class _TopBar extends StatelessWidget {
text: true,
tip: tr('선택한 파일 재생: 지금 보고 있는 동영상 (여러 개는 목록에서 체크)')),
],
if (hasAi) ...[
const SizedBox(width: 8),
action(Icons.auto_awesome, trf('자막 만들기{0}', [count]),
canBatch ? () => showAiDialog(context, c, batch.first, targets: batch) : null,
text: true, tip: trf('자막 만들기: {0}', [_batchHint(c, tr('AI 자막을 만듭니다'))])),
const SizedBox(width: 8),
action(Icons.auto_mode, trf('자막 만들기 & MKV 만들기{0}', [count]),
canBatch ? () => showAiDialog(context, c, batch.first, targets: batch, thenBuild: true) : null,
text: true,
tip: trf('자막 만들기 & MKV 만들기: {0}', [_batchHint(c, tr('AI 자막을 만들고 이어서 MKV 로 만듭니다'))])),
],
const SizedBox(width: 8),
action(Icons.folder_special_outlined, tr('결과 폴더'), c.selected == null ? null : () => _openOutput(context),
text: true, tip: tr('결과 폴더 열기')),
// 138: AI 버튼은 [MKV 만들기] 와 떨어뜨려 맨 뒤에, 구분선 너머로
if (hasAi) ...[
const SizedBox(width: 8),
const SizedBox(height: 28, child: VerticalDivider(width: 16, color: JjColors.border)),
...aiButtons(true),
],
if (c.busy) ...[
const SizedBox(width: 8),
JobIndicator(c: c, compact: true, iconOnly: false),
@ -338,20 +351,10 @@ class _TopBar extends StatelessWidget {
child: Row(children: [
action(Icons.add, tr('동영상 추가'), c.pickVideos,
text: leftText, tip: addTip),
// 138: AI 버튼 (보라) 은 왼쪽, [MKV 만들기] (청록) 는 오른쪽 - 떨어져 있다
if (hasAi) ...[
const SizedBox(width: 8),
action(Icons.auto_awesome, trf('자막 만들기{0}', [count]),
canBatch ? () => showAiDialog(context, c, batch.first, targets: batch) : null,
text: leftText, tip: trf('자막 만들기: {0}', [_batchHint(c, tr('AI 자막을 만듭니다'))])),
const SizedBox(width: 8),
action(
Icons.auto_mode,
trf('자막 만들기 & MKV 만들기{0}', [count]),
canBatch
? () => showAiDialog(context, c, batch.first, targets: batch, thenBuild: true)
: null,
text: leftText,
tip: trf('자막 만들기 & MKV 만들기: {0}', [_batchHint(c, tr('AI 자막을 만들고 이어서 MKV 로 만듭니다'))])),
...aiButtons(leftText),
],
]),
),

78
test/ai_confirm_test.dart Normal file
View File

@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/core/languages.dart';
import 'package:jj_mkvmaker/core/models.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/model_store.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/ai_dialog.dart';
/// 138: [MKV 만들기] 와 헷갈려 오래 걸리는 AI 작업이 돌지 않게 - AI 쪽은 늘 할 일 · 대상 수 · 자막 언어 · 예상 시간을 보이고 묻는다
void main() {
test('예상 시간: 영상 1시간 ≈ 음성인식 15~30분 + 번역할 언어마다 10~20분', () {
expect(aiEstimateMinutes(const Duration(hours: 1), 0), (15, 30));
expect(aiEstimateMinutes(const Duration(hours: 2), 2), (70, 140));
expect(aiEstimateMinutes(Duration.zero, 2), isNull);
final ko = languageOf('ko'), en = languageOf('en'), ja = languageOf('ja');
expect(aiTranslateCount(AiOptions(source: ko, targets: {ko, en, ja}, whisper: whisperModels.first)), 2);
expect(aiTranslateCount(AiOptions(targets: {ko, en, ja}, whisper: whisperModels.first)), 2, reason: '자동 감지면 하나는 원어로');
});
testWidgets('"매번 묻기" 를 끄면 묻지 않되 (사용자 설정) 3초 알림 [취소] 뒤에 시작 - [취소] 면 시작하지 않음', (tester) async {
tester.view.physicalSize = const Size(1400, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.askAiOptions = false;
final v1 = VideoItem(r'C:\v\a.mp4')..info = const MediaInfo(duration: Duration(minutes: 30));
final v2 = VideoItem(r'C:\v\b.mp4')..info = const MediaInfo(duration: Duration(minutes: 30));
late BuildContext ctx;
await tester.pumpWidget(MaterialApp(home: Scaffold(body: Builder(builder: (context) {
ctx = context;
return const SizedBox();
}))));
final logs0 = c.logs.length;
final done = showAiDialog(ctx, c, v1, targets: [v1, v2], thenBuild: true);
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(find.byType(AlertDialog), findsNothing, reason: '묻는 창은 띄우지 않는다 (사용자 설정)');
expect(find.text('AI 자막 → MKV (동영상 2개 · 약 35~70분) 을 시작합니다'), findsOneWidget);
expect(c.busy, isFalse, reason: '3초 동안은 시작하지 않음');
await tester.tap(find.text('취소'));
await tester.pump(const Duration(seconds: 4));
await done;
expect(c.busy, isFalse);
expect(c.logs.length, logs0, reason: '[취소] 면 아무것도 하지 않음');
});
test('3초 알림 글: 길이를 모르면 시간 없이', () {
final o = AiOptions(targets: {languageOf('ko')}, whisper: whisperModels.first);
expect(aiStartNotice([VideoItem(r'C:\v\a.mp4')], o, thenBuild: false), 'AI 자막 (동영상 1개) 을 시작합니다');
});
testWidgets('묻기가 켜져 있으면 (처음 값) 설정 창 맨 위에 계획 (할 일 · 대상 · 언어 · 예상 시간)', (tester) async {
tester.view.physicalSize = const Size(1400, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
expect(c.settings.askAiOptions, isTrue);
final v1 = VideoItem(r'C:\v\a.mp4')..info = const MediaInfo(duration: Duration(hours: 1));
late BuildContext ctx;
await tester.pumpWidget(MaterialApp(home: Scaffold(body: Builder(builder: (context) {
ctx = context;
return const SizedBox();
}))));
final done = showAiDialog(ctx, c, v1, targets: [v1]);
await tester.pumpAndSettle();
expect(find.text('AI 자막 만들기 (MKV 는 만들지 않음)'), findsOneWidget);
expect(find.text('동영상 1개'), findsOneWidget);
expect(find.text('예상 시간'), findsOneWidget);
expect(find.textContaining('1시간 0분'), findsOneWidget);
await tester.tap(find.text('취소'));
await tester.pumpAndSettle();
await done;
expect(c.busy, isFalse);
});
}