diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 2bad5bd..60ee451 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -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", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 7cee552..13f0866 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -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}", "항목마다 따로 (기본)": "項目ごとに (既定)", "해상도 올리기 모델": "高解像度化モデル", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index fce6e8e..383bea4 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -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}", "항목마다 따로 (기본)": "逐个项目 (默认)", "해상도 올리기 모델": "提高分辨率模型", diff --git a/integration_test/windows_i18n_shot_test.dart b/integration_test/windows_i18n_shot_test.dart index 61615fd..60e3c37 100644 --- a/integration_test/windows_i18n_shot_test.dart +++ b/integration_test/windows_i18n_shot_test.dart @@ -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()); diff --git a/lib/ui/ai_dialog.dart b/lib/ui/ai_dialog.dart index 1989828..5d4bada 100644 --- a/lib/ui/ai_dialog.dart +++ b/lib/ui/ai_dialog.dart @@ -18,29 +18,109 @@ Future showAiDialog(BuildContext context, AppController c, VideoItem curre final list = targets ?? [current]; Future run(List 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.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 videos, AiOptions o, {required bool thenBuild}) { + final known = videos.map((v) => v.info?.duration).whereType().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 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().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 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( @@ -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('시작')), diff --git a/lib/ui/home_page.dart b/lib/ui/home_page.dart index 4a4b885..fe9dc18 100644 --- a/lib/ui/home_page.dart +++ b/lib/ui/home_page.dart @@ -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 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), ], ]), ), diff --git a/test/ai_confirm_test.dart b/test/ai_confirm_test.dart new file mode 100644 index 0000000..e09bc61 --- /dev/null +++ b/test/ai_confirm_test.dart @@ -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); + }); +}