diff --git a/assets/l10n/en.json b/assets/l10n/en.json index d51919e..5f8f757 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -197,6 +197,7 @@ "LCM-LoRA (SD1.5, 4단계로 빠르게)": "LCM-LoRA (SD1.5, fast in 4 steps)", "LibreOffice 를 먼저 설치해야 합니다": "LibreOffice must be installed first", "MIT License - https://github.com/leejet/stable-diffusion.cpp ({0})\n\n함께 묶인 구성 요소: ggml (MIT) · libwebp · libwebm (BSD-3) · Oniguruma · Darts-clone (BSD-2) · utf8proc · nlohmann/json · miniz · stb (MIT) · zip (Unlicense)": "MIT License - https://github.com/leejet/stable-diffusion.cpp ({0})\n\nBundled components: ggml (MIT) · libwebp · libwebm (BSD-3) · Oniguruma · Darts-clone (BSD-2) · utf8proc · nlohmann/json · miniz · stb (MIT) · zip (Unlicense)", + "MKV {0}개 중 {1}번째": "MKV {1} of {0}", "MKV · AI 작업": "MKV · AI task", "MKV · 자막 (jj_mkv)": "MKV · subtitles (jj_mkv)", "MKV · 자막 저장 위치": "Where to save MKV · subtitles", @@ -561,6 +562,7 @@ "거꾸로": "Reverse", "거꾸로 된 범위: {0}": "Reversed range: {0}", "건너뛰기": "Skip", + "건너뜀 (이미 있음): {0}": "Skipped (already exists): {0}", "건너뜀 (출력 폴더의 파일): {0}": "Skipped (file in the output folder): {0}", "건너뜁니다": "skips it", "검색": "Search", @@ -758,6 +760,8 @@ "다른 언어로 다시": "Redo with another language", "다른 언어로 된 페이지를 화면 언어 ({0}) 로 번역합니다. 페이지의 글을 Google 번역으로 보냅니다. 주소창 옆 번역 버튼으로 원문 · 번역을 바꿀 수 있습니다.": "Translates pages in other languages into the display language ({0}). The page text is sent to Google Translate. Use the translate button next to the address bar to switch between original · translation.", "다른 언어로 번역 (AI · 이 {0}에서)": "Translate to another language (AI · on this {0})", + "다른 이름": "Another name", + "다른 이름 (이름 (2).mkv)": "Another name (name (2).mkv)", "다른 이름으로 저장": "Save as", "다른 이름으로 저장: {0} ({1})": "Save as: {0} ({1})", "다른 저장소로는 이름만 바꿀 수 없습니다": "Cannot just rename onto another storage", @@ -837,7 +841,10 @@ "더 보기": "More", "더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "When JJ_MKVMaker is chosen by double-click · default app (the right-click menu stays)", "더한 언어 (AI 자동 번역)": "Added language (AI auto-translated)", + "덮어쓰기": "Overwrite", "덮어쓰기 (원래 파일은 없어집니다)": "Overwrite (the existing file is replaced)", + "덮어쓰기여도 다 만든 뒤에만 바꿉니다 (도중에 취소 · 실패하면 예전 MKV 그대로)": "Even when overwriting, the old file is replaced only after the new one is finished (if cancelled or failed, the old MKV stays)", + "덮어쓰기여도 다 만든 뒤에만 바꿉니다. 도중에 취소 · 실패하면 예전 MKV 는 그대로 남습니다. 늘 같게 하려면 환경 설정 > MKV 만들기 > 만들 MKV 가 이미 있으면": "Even when overwriting, the old file is replaced only after the new one is finished. If cancelled or failed, the old MKV stays. To always do the same: Settings > Make MKV > If the MKV already exists", "덮어씁니다": "overwrites it", "덴마크어": "Danish", "독일어": "German", @@ -936,6 +943,7 @@ "마지막 화면입니다": "This is the last screen", "마지막으로: {0}": "To the last: {0}", "만든 그림 · 저장: {0}": "Made images · saved in: {0}", + "만들 MKV 가 이미 있으면": "If the MKV already exists", "만들 자막 언어 (파일명_언어코드.srt)": "Subtitle languages to make (filename_languagecode.srt)", "만들거나 쓰지 못했습니다": "Could not create or write", "만들기": "Make", @@ -1043,6 +1051,7 @@ "받는 쪽에서 {0}개가 지워집니다 (--delete, 되돌릴 수 없음)": "{0} items will be deleted on the receiving side (--delete, cannot be undone)", "받는 쪽에서 {0}개를 덮어씁니다 (내용이 다른 같은 이름 - 원래 것은 없어짐)": "{0} files on the receiving side will be overwritten (same name, different content - the original is lost)", "받는 쪽이 더 새것 {0} (건너뜀)": "Newer on receiving side {0} (skipped)", + "받는 쪽이 더 새것 {0}개 건너뜀": "{0} skipped (newer on the receiving side)", "받는 쪽이 더 새것이면 건너뛰기": "Skip when the receiving side is newer", "받는 크기: 약 {0}MB · 받은 파일 · AI 모델 · 설정은 그대로 둡니다": "Download size: about {0}MB · downloaded files · AI models · settings are kept", "받다 만 AI 모델": "Unfinished AI models", @@ -1367,6 +1376,9 @@ "아직 저장하지 못했습니다. 잠시 뒤 다시 시도하세요": "Still couldn't save. Please try again in a moment", "아직 지원하지 않는 플랫폼입니다: {0}": "This platform is not supported yet: {0}", "안내 닫기": "Close tip", + "안쪽 파일을 확인하는 중 …": "Checking files inside …", + "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "{0} files inside are newer on the receiving side - they are skipped when merging (they stay in the source)", + "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "{0} files inside are newer on the receiving side - merging overwrites them with older ones", "알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "Notifications show as JJ_MKVMaker and open the app when clicked (current user only, no admin rights)", "암호화 안 됨 (http)": "Not encrypted (http)", "압축 파일 안에 없습니다": "Not in the archive", @@ -1488,6 +1500,7 @@ "완료 시 자동 동영상 추가": "Auto-add videos when done", "완료 정리": "Clear completed", "완료: 성공 {0} / 전체 {1}": "Done: {0} succeeded / {1} total", + "완료: 성공 {0} · 건너뜀 {1} · 취소 {2} · 실패 {3} / 전체 {4}": "Done: {0} succeeded · {1} skipped · {2} cancelled · {3} failed / {4} total", "완료: 성공 {0} · 취소 {1} · 실패 {2} / 전체 {3}": "Done: {0} succeeded · {1} cancelled · {2} failed / {3} total", "외부": "External", "외부 브라우저": "External browser", @@ -1689,6 +1702,7 @@ "이름을 찾을 수 없습니다: {0} (DNS {1})": "Name not found: {0} (DNS {1})", "이미 그리는 중입니다": "Already drawing", "이미 동영상 목록에 있습니다 ({0}개).": "Already in the video list ({0}).", + "이미 만든 MKV 가 있습니다 ({0}개)": "Some MKVs already exist ({0})", "이미 목록에 있는 동영상입니다 ({0}개).": "These videos are already in the list ({0}).", "이미 받는 중이거나 받을 수 없는 주소입니다.": "Already downloading or a URL that cannot be downloaded.", "이미 실행 중인 창이 응답하지 않습니다.\n\n[예] 그래도 열기 - 같은 데이터 위에 창이 두 개가 되어 설정 · 목록이 서로 덮어쓸 수 있습니다.\n[아니요] 닫기 - 응답하지 않는 창을 작업 관리자에서 끝낸 뒤 다시 실행하세요.": "A window that is already running is not responding.\n\n[Yes] Open anyway - two windows will share the same data, and settings or lists may overwrite each other.\n[No] Close - end the unresponsive window in Task Manager, then run the app again.", @@ -1696,6 +1710,7 @@ "이미 이 폴더에 있습니다: {0}": "Already in this folder: {0}", "이미 이 폴더에 있어 건너뜀: {0}": "Skipped, already in this folder: {0}", "이미 있는 폴더입니다: {0}": "The folder already exists: {0}", + "이미 있어 건너뜀": "Skipped - already exists", "이미 작업 중입니다": "Already working", "이미지 · PDF · ZIP 보기": "Image · PDF · ZIP viewer", "이미지 자막은 번역할 수 없습니다": "Image subtitles cannot be translated", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 1636967..4376af1 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -197,6 +197,7 @@ "LCM-LoRA (SD1.5, 4단계로 빠르게)": "LCM-LoRA (SD1.5、4 ステップで高速)", "LibreOffice 를 먼저 설치해야 합니다": "先に LibreOffice をインストールする必要があります", "MIT License - https://github.com/leejet/stable-diffusion.cpp ({0})\n\n함께 묶인 구성 요소: ggml (MIT) · libwebp · libwebm (BSD-3) · Oniguruma · Darts-clone (BSD-2) · utf8proc · nlohmann/json · miniz · stb (MIT) · zip (Unlicense)": "MIT License - https://github.com/leejet/stable-diffusion.cpp ({0})\n\n同梱のコンポーネント: ggml (MIT) · libwebp · libwebm (BSD-3) · Oniguruma · Darts-clone (BSD-2) · utf8proc · nlohmann/json · miniz · stb (MIT) · zip (Unlicense)", + "MKV {0}개 중 {1}번째": "MKV {0}件中 {1}件目", "MKV · AI 작업": "MKV・AI 作業", "MKV · 자막 (jj_mkv)": "MKV・字幕(jj_mkv)", "MKV · 자막 저장 위치": "MKV・字幕の保存先", @@ -561,6 +562,7 @@ "거꾸로": "逆順", "거꾸로 된 범위: {0}": "逆順の範囲: {0}", "건너뛰기": "スキップ", + "건너뜀 (이미 있음): {0}": "スキップ (すでにあります): {0}", "건너뜀 (출력 폴더의 파일): {0}": "スキップ(出力フォルダーのファイル): {0}", "건너뜁니다": "スキップします", "검색": "検索", @@ -758,6 +760,8 @@ "다른 언어로 다시": "別の言語でやり直す", "다른 언어로 된 페이지를 화면 언어 ({0}) 로 번역합니다. 페이지의 글을 Google 번역으로 보냅니다. 주소창 옆 번역 버튼으로 원문 · 번역을 바꿀 수 있습니다.": "ほかの言語のページを表示言語 ({0}) に翻訳します。ページの文章は Google 翻訳に送られます。アドレスバー横の翻訳ボタンで原文 · 翻訳を切り替えられます。", "다른 언어로 번역 (AI · 이 {0}에서)": "他の言語に翻訳(AI・この{0}で)", + "다른 이름": "別の名前", + "다른 이름 (이름 (2).mkv)": "別の名前 (名前 (2).mkv)", "다른 이름으로 저장": "名前を付けて保存", "다른 이름으로 저장: {0} ({1})": "名前を付けて保存: {0}({1})", "다른 저장소로는 이름만 바꿀 수 없습니다": "別のストレージへは名前の変更だけではできません", @@ -837,7 +841,10 @@ "더 보기": "その他", "더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "ダブルクリック・既定のアプリで JJ_MKVMaker を選んだとき(右クリックメニューはそのまま)", "더한 언어 (AI 자동 번역)": "追加した言語(AI 自動翻訳)", + "덮어쓰기": "上書き", "덮어쓰기 (원래 파일은 없어집니다)": "上書き (元のファイルは消えます)", + "덮어쓰기여도 다 만든 뒤에만 바꿉니다 (도중에 취소 · 실패하면 예전 MKV 그대로)": "上書きでも作り終えてから置き換えます (途中でキャンセル · 失敗すると以前の MKV はそのまま)", + "덮어쓰기여도 다 만든 뒤에만 바꿉니다. 도중에 취소 · 실패하면 예전 MKV 는 그대로 남습니다. 늘 같게 하려면 환경 설정 > MKV 만들기 > 만들 MKV 가 이미 있으면": "上書きでも作り終えてから置き換えます。途中でキャンセル · 失敗すると以前の MKV はそのまま残ります。いつも同じにするには: 環境設定 > MKV 作成 > 作る MKV がすでにあるとき", "덮어씁니다": "上書きします", "덴마크어": "デンマーク語", "독일어": "ドイツ語", @@ -936,6 +943,7 @@ "마지막 화면입니다": "最後の画面です", "마지막으로: {0}": "最後へ: {0}", "만든 그림 · 저장: {0}": "作成した画像・保存先: {0}", + "만들 MKV 가 이미 있으면": "作る MKV がすでにあるとき", "만들 자막 언어 (파일명_언어코드.srt)": "作成する字幕の言語(ファイル名_言語コード.srt)", "만들거나 쓰지 못했습니다": "作成または書き込みできませんでした", "만들기": "作成", @@ -1043,6 +1051,7 @@ "받는 쪽에서 {0}개가 지워집니다 (--delete, 되돌릴 수 없음)": "受け取る側で {0} 個が削除されます (--delete、元に戻せません)", "받는 쪽에서 {0}개를 덮어씁니다 (내용이 다른 같은 이름 - 원래 것은 없어짐)": "受け取る側の {0} 件を上書きします (同じ名前で内容が違うもの - 元のものはなくなります)", "받는 쪽이 더 새것 {0} (건너뜀)": "受け取る側が新しい {0} (スキップ)", + "받는 쪽이 더 새것 {0}개 건너뜀": "受け取る側が新しい {0} 件をスキップ", "받는 쪽이 더 새것이면 건너뛰기": "受け取る側が新しければスキップ", "받는 크기: 약 {0}MB · 받은 파일 · AI 모델 · 설정은 그대로 둡니다": "ダウンロードサイズ: 約 {0}MB・ダウンロードしたファイル・AI モデル・設定はそのまま", "받다 만 AI 모델": "ダウンロード途中の AI モデル", @@ -1367,6 +1376,9 @@ "아직 저장하지 못했습니다. 잠시 뒤 다시 시도하세요": "まだ保存できませんでした。しばらくしてからもう一度お試しください", "아직 지원하지 않는 플랫폼입니다: {0}": "まだ対応していないプラットフォームです: {0}", "안내 닫기": "案内を閉じる", + "안쪽 파일을 확인하는 중 …": "中のファイルを確認中 …", + "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせてもスキップします (元に残ります)", + "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせると古いもので上書きします", "알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知が JJ_MKVMaker の名前で表示され、クリックするとアプリが開きます (管理者権限なしで現在のユーザーのみ)", "암호화 안 됨 (http)": "暗号化なし (http)", "압축 파일 안에 없습니다": "圧縮ファイル内にありません", @@ -1488,6 +1500,7 @@ "완료 시 자동 동영상 추가": "完了時に動画を自動追加", "완료 정리": "完了を整理", "완료: 성공 {0} / 전체 {1}": "完了: 成功 {0} / 全体 {1}", + "완료: 성공 {0} · 건너뜀 {1} · 취소 {2} · 실패 {3} / 전체 {4}": "完了: 成功 {0} · スキップ {1} · キャンセル {2} · 失敗 {3} / 全 {4}", "완료: 성공 {0} · 취소 {1} · 실패 {2} / 전체 {3}": "完了: 成功 {0} · 取消 {1} · 失敗 {2} / 全 {3}", "외부": "外部", "외부 브라우저": "外部ブラウザー", @@ -1689,6 +1702,7 @@ "이름을 찾을 수 없습니다: {0} (DNS {1})": "名前が見つかりません: {0} (DNS {1})", "이미 그리는 중입니다": "すでに描画中です", "이미 동영상 목록에 있습니다 ({0}개).": "既に動画リストにあります({0}個)。", + "이미 만든 MKV 가 있습니다 ({0}개)": "作成済みの MKV があります ({0}件)", "이미 목록에 있는 동영상입니다 ({0}개).": "既にリストにある動画です({0}個)。", "이미 받는 중이거나 받을 수 없는 주소입니다.": "既にダウンロード中か、ダウンロードできない URL です。", "이미 실행 중인 창이 응답하지 않습니다.\n\n[예] 그래도 열기 - 같은 데이터 위에 창이 두 개가 되어 설정 · 목록이 서로 덮어쓸 수 있습니다.\n[아니요] 닫기 - 응답하지 않는 창을 작업 관리자에서 끝낸 뒤 다시 실행하세요.": "すでに実行中のウィンドウが応答しません。\n\n[はい] それでも開く - 同じデータの上にウィンドウが 2 つになり、設定 · 一覧が互いに上書きされることがあります。\n[いいえ] 閉じる - 応答しないウィンドウをタスク マネージャーで終了してから、もう一度実行してください。", @@ -1696,6 +1710,7 @@ "이미 이 폴더에 있습니다: {0}": "すでにこのフォルダーにあります: {0}", "이미 이 폴더에 있어 건너뜀: {0}": "既にこのフォルダーにあるためスキップ: {0}", "이미 있는 폴더입니다: {0}": "既にあるフォルダーです: {0}", + "이미 있어 건너뜀": "すでにあるためスキップ", "이미 작업 중입니다": "すでに作業中です", "이미지 · PDF · ZIP 보기": "画像・PDF・ZIP ビューアー", "이미지 자막은 번역할 수 없습니다": "画像字幕は翻訳できません", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 4bb2528..102973a 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -197,6 +197,7 @@ "LCM-LoRA (SD1.5, 4단계로 빠르게)": "LCM-LoRA (SD1.5,4 步快速生成)", "LibreOffice 를 먼저 설치해야 합니다": "需要先安装 LibreOffice", "MIT License - https://github.com/leejet/stable-diffusion.cpp ({0})\n\n함께 묶인 구성 요소: ggml (MIT) · libwebp · libwebm (BSD-3) · Oniguruma · Darts-clone (BSD-2) · utf8proc · nlohmann/json · miniz · stb (MIT) · zip (Unlicense)": "MIT License - https://github.com/leejet/stable-diffusion.cpp ({0})\n\n一同打包的组件: ggml (MIT) · libwebp · libwebm (BSD-3) · Oniguruma · Darts-clone (BSD-2) · utf8proc · nlohmann/json · miniz · stb (MIT) · zip (Unlicense)", + "MKV {0}개 중 {1}번째": "MKV 第 {1} 个(共 {0} 个)", "MKV · AI 작업": "MKV · AI 任务", "MKV · 자막 (jj_mkv)": "MKV · 字幕(jj_mkv)", "MKV · 자막 저장 위치": "MKV · 字幕保存位置", @@ -561,6 +562,7 @@ "거꾸로": "倒序", "거꾸로 된 범위: {0}": "范围顺序颠倒: {0}", "건너뛰기": "跳过", + "건너뜀 (이미 있음): {0}": "已跳过(已存在):{0}", "건너뜀 (출력 폴더의 파일): {0}": "已跳过(输出文件夹中的文件): {0}", "건너뜁니다": "跳过", "검색": "搜索", @@ -758,6 +760,8 @@ "다른 언어로 다시": "用其他语言重做", "다른 언어로 된 페이지를 화면 언어 ({0}) 로 번역합니다. 페이지의 글을 Google 번역으로 보냅니다. 주소창 옆 번역 버튼으로 원문 · 번역을 바꿀 수 있습니다.": "将其他语言的页面翻译为界面语言 ({0})。页面文字会发送到 Google 翻译。可用地址栏旁的翻译按钮切换原文 · 译文。", "다른 언어로 번역 (AI · 이 {0}에서)": "翻译为其他语言(AI · 在此{0}上)", + "다른 이름": "另存为新名称", + "다른 이름 (이름 (2).mkv)": "另存为新名称(名称 (2).mkv)", "다른 이름으로 저장": "另存为", "다른 이름으로 저장: {0} ({1})": "另存为: {0}({1})", "다른 저장소로는 이름만 바꿀 수 없습니다": "不能仅通过重命名移到其他存储", @@ -837,7 +841,10 @@ "더 보기": "更多", "더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "双击 · 默认打开方式选择 JJ_MKVMaker 时(右键菜单不变)", "더한 언어 (AI 자동 번역)": "已添加的语言(AI 自动翻译)", + "덮어쓰기": "覆盖", "덮어쓰기 (원래 파일은 없어집니다)": "覆盖 (原文件将被替换)", + "덮어쓰기여도 다 만든 뒤에만 바꿉니다 (도중에 취소 · 실패하면 예전 MKV 그대로)": "即使覆盖,也只在制作完成后才替换(中途取消 · 失败时保留原来的 MKV)", + "덮어쓰기여도 다 만든 뒤에만 바꿉니다. 도중에 취소 · 실패하면 예전 MKV 는 그대로 남습니다. 늘 같게 하려면 환경 설정 > MKV 만들기 > 만들 MKV 가 이미 있으면": "即使覆盖,也只在制作完成后才替换。中途取消 · 失败时原来的 MKV 会保留。若要始终相同:设置 > 制作 MKV > 要制作的 MKV 已存在时", "덮어씁니다": "覆盖", "덴마크어": "丹麦语", "독일어": "德语", @@ -936,6 +943,7 @@ "마지막 화면입니다": "这是最后一个画面", "마지막으로: {0}": "到最后: {0}", "만든 그림 · 저장: {0}": "生成的图片 · 保存位置: {0}", + "만들 MKV 가 이미 있으면": "要制作的 MKV 已存在时", "만들 자막 언어 (파일명_언어코드.srt)": "要生成的字幕语言(文件名_语言代码.srt)", "만들거나 쓰지 못했습니다": "无法创建或写入", "만들기": "生成", @@ -1043,6 +1051,7 @@ "받는 쪽에서 {0}개가 지워집니다 (--delete, 되돌릴 수 없음)": "接收方将删除 {0} 个项目 (--delete,无法撤销)", "받는 쪽에서 {0}개를 덮어씁니다 (내용이 다른 같은 이름 - 원래 것은 없어짐)": "将覆盖接收端的 {0} 个文件(同名但内容不同 - 原文件将丢失)", "받는 쪽이 더 새것 {0} (건너뜀)": "接收端较新 {0}(跳过)", + "받는 쪽이 더 새것 {0}개 건너뜀": "已跳过接收端较新的 {0} 个", "받는 쪽이 더 새것이면 건너뛰기": "接收端较新时跳过", "받는 크기: 약 {0}MB · 받은 파일 · AI 모델 · 설정은 그대로 둡니다": "下载大小: 约 {0}MB · 已下载的文件 · AI 模型 · 设置均保留", "받다 만 AI 모델": "未下载完的 AI 模型", @@ -1367,6 +1376,9 @@ "아직 저장하지 못했습니다. 잠시 뒤 다시 시도하세요": "仍未能保存。请稍后再试", "아직 지원하지 않는 플랫폼입니다: {0}": "尚不支持此平台: {0}", "안내 닫기": "关闭提示", + "안쪽 파일을 확인하는 중 …": "正在检查里面的文件 …", + "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "里面有 {0} 个文件在接收端较新 - 合并时会跳过(保留在源中)", + "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "里面有 {0} 个文件在接收端较新 - 合并会用旧文件覆盖", "알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知以 JJ_MKVMaker 名义显示,点击即可打开应用 (仅当前用户,无需管理员权限)", "암호화 안 됨 (http)": "未加密 (http)", "압축 파일 안에 없습니다": "压缩文件中没有", @@ -1488,6 +1500,7 @@ "완료 시 자동 동영상 추가": "完成后自动添加视频", "완료 정리": "清理已完成", "완료: 성공 {0} / 전체 {1}": "完成: 成功 {0} / 共 {1}", + "완료: 성공 {0} · 건너뜀 {1} · 취소 {2} · 실패 {3} / 전체 {4}": "完成:成功 {0} · 跳过 {1} · 取消 {2} · 失败 {3} / 共 {4}", "완료: 성공 {0} · 취소 {1} · 실패 {2} / 전체 {3}": "完成:成功 {0} · 取消 {1} · 失败 {2} / 共 {3}", "외부": "外部", "외부 브라우저": "外部浏览器", @@ -1689,6 +1702,7 @@ "이름을 찾을 수 없습니다: {0} (DNS {1})": "找不到名称: {0} (DNS {1})", "이미 그리는 중입니다": "已在绘制中", "이미 동영상 목록에 있습니다 ({0}개).": "已在视频列表中({0} 个)。", + "이미 만든 MKV 가 있습니다 ({0}개)": "已有制作好的 MKV({0} 个)", "이미 목록에 있는 동영상입니다 ({0}개).": "这些视频已在列表中({0} 个)。", "이미 받는 중이거나 받을 수 없는 주소입니다.": "已在下载中,或该地址无法下载。", "이미 실행 중인 창이 응답하지 않습니다.\n\n[예] 그래도 열기 - 같은 데이터 위에 창이 두 개가 되어 설정 · 목록이 서로 덮어쓸 수 있습니다.\n[아니요] 닫기 - 응답하지 않는 창을 작업 관리자에서 끝낸 뒤 다시 실행하세요.": "已在运行的窗口没有响应。\n\n[是] 仍然打开 - 同一数据上将有两个窗口,设置和列表可能互相覆盖。\n[否] 关闭 - 请在任务管理器中结束无响应的窗口后重新运行。", @@ -1696,6 +1710,7 @@ "이미 이 폴더에 있습니다: {0}": "已在此文件夹中: {0}", "이미 이 폴더에 있어 건너뜀: {0}": "已在此文件夹中,已跳过: {0}", "이미 있는 폴더입니다: {0}": "文件夹已存在: {0}", + "이미 있어 건너뜀": "已存在,已跳过", "이미 작업 중입니다": "已在处理中", "이미지 · PDF · ZIP 보기": "图片 · PDF · ZIP 查看", "이미지 자막은 번역할 수 없습니다": "图像字幕无法翻译", diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index 07ad65d..9e27582 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -489,10 +489,10 @@ class AppController extends ChangeNotifier { } /// [targets] 의 AI 자막을 만들고, 이어서 그 동영상들만 MKV 로 만든다 (자막 만들기를 취소하면 거기서 멈춤) - Future aiThenBuild(List targets, AiOptions opts) async { + Future aiThenBuild(List targets, AiOptions opts, {String? onExisting}) async { await generateAiSubtitles(targets, opts); if (_aiCancelled) return; - await buildVideos(targets); + await buildVideos(targets, onExisting: onExisting); } /// 다 받은 동영상을 편집 목록에 추가 (이미 있으면 건너뜀). 새로 넣은 개수. @@ -1305,22 +1305,36 @@ class AppController extends ChangeNotifier { // ───────── MKV 만들기 ───────── /// 목록의 MKV 만들기 (다른 작업 중이면 대기열로) - Future buildAll() async { + /// [onExisting]: 결과 MKV 가 이미 있을 때 'overwrite' · 'skip' · 'rename' (없으면 환경 설정, "묻기" 인데 묻지 못한 곳은 'rename') + Future buildAll({String? onExisting}) async { if (ffmpegVersion == null) return; - return _enqueue(tr('MKV 만들기'), _runBuildAll); + return _enqueue(tr('MKV 만들기'), () => _runBuildAll(null, onExisting)); } /// 고른 동영상만 MKV 로 (이미 만든 것도 다시 만든다 - 자막이 바뀌었을 수 있으므로) - Future buildVideos(List targets) async { + Future buildVideos(List targets, {String? onExisting}) async { if (ffmpegVersion == null || targets.isEmpty) return; final label = targets.length == 1 ? trf('MKV 만들기: {0}', [targets.first.fileName]) : trf('MKV 만들기 {0}개', [targets.length]); - return _enqueue(label, () => _runBuildAll(targets)); + return _enqueue(label, () => _runBuildAll(targets, onExisting)); + } + + /// P0: 결과 MKV 가 이미 있는 동영상 (만들기 전에 덮어쓸지 묻는 데) + Future> withExistingOutput(List targets) async => [ + for (final v in targets) + if (await services.storage.exists(outputMkvPath(v.path))) v, + ]; + + /// P0: 결과 MKV 가 이미 있을 때 실제로 쓸 동작 ("묻기" 인데 묻지 못한 곳은 예전 것을 지키는 '다른 이름') + String _existingMode(String? asked) { + final m = asked ?? settings.mkvExisting; + return const ['overwrite', 'skip', 'rename'].contains(m) ? m : 'rename'; } /// 지금 MKV 를 만드는 중인지 (32: AI 자막 · 번역만 돌 때는 인코딩 설정을 막지 않는다) bool buildingMkv = false; - Future _runBuildAll([List? only]) async { + Future _runBuildAll([List? only, String? onExisting]) async { + final existing = _existingMode(onExisting); _buildCancelled = false; buildingMkv = true; notifyListeners(); @@ -1333,17 +1347,37 @@ class AppController extends ChangeNotifier { if (queue.length > 1) _log(trf('MKV 만들기: {0}개, 동시에 {1}개씩', [queue.length, limit.clamp(1, queue.length)])); _batchQueue = queue; _batchNext = 0; + // P0-2: 앞 실행의 ✓ · 실패가 이번 집계에 섞이지 않게 - 이번 묶음은 모두 "아직" 에서 시작 + for (final v in queue) { + v + ..status = JobStatus.ready + ..progress = 0 + ..message = null; + } + final skipped = {}; Future worker() async { while (!_buildCancelled && _batchNext < queue.length) { - await _build(queue[_batchNext++]); + final v = queue[_batchNext++]; + if (!await _build(v, existing)) skipped.add(v); } } await Future.wait([for (var i = 0; i < limit.clamp(1, queue.length); i++) worker()]); + // P0-2: [남은 것 모두 취소] 로 시작하지 못한 것은 "취소됨" + for (final v in queue) { + if (v.status == JobStatus.ready && !skipped.contains(v)) { + v + ..status = JobStatus.cancelled + ..message = tr('취소됨'); + } + } // 이번에 만든 것 기준 (목록의 다른 동영상은 세지 않음). 187: 취소는 실패로 세지 않는다 - int count(JobStatus s) => queue.where((v) => v.status == s).length; - _log(trf('완료: 성공 {0} · 취소 {1} · 실패 {2} / 전체 {3}', - [count(JobStatus.done), count(JobStatus.cancelled), count(JobStatus.failed), queue.length])); + int count(JobStatus s) => queue.where((v) => v.status == s && !skipped.contains(v)).length; + _log(skipped.isEmpty + ? trf('완료: 성공 {0} · 취소 {1} · 실패 {2} / 전체 {3}', + [count(JobStatus.done), count(JobStatus.cancelled), count(JobStatus.failed), queue.length]) + : trf('완료: 성공 {0} · 건너뜀 {1} · 취소 {2} · 실패 {3} / 전체 {4}', + [count(JobStatus.done), skipped.length, count(JobStatus.cancelled), count(JobStatus.failed), queue.length])); } finally { buildingMkv = false; _batchQueue = const []; @@ -1356,6 +1390,10 @@ class AppController extends ChangeNotifier { List _batchQueue = const []; int _batchNext = 0; + /// P0-4: 지금 MKV 묶음의 전체 수 · 시작한 수 (진행 글 "3개 중 2번째") + int get batchTotal => buildingMkv ? _batchQueue.length : 0; + int get batchStarted => buildingMkv ? _batchNext.clamp(0, _batchQueue.length) : 0; + /// 186: 지금 묶음에서 아직 시작하지 않은 동영상 수 int get batchWaiting => buildingMkv ? (_batchQueue.length - _batchNext).clamp(0, _batchQueue.length) : 0; @@ -1383,8 +1421,24 @@ class AppController extends ChangeNotifier { return [for (final v in targets) if (!live.contains(v)) v]; } - Future _build(VideoItem v) async { - final out = outputMkvPath(v.path); + /// 만들었으면 (또는 실패 · 취소) true, 결과가 이미 있어 건너뛰었으면 false. + /// P0-1: 임시 이름 (….mkv.jjpart) 에 만들고 끝났을 때만 결과 이름으로 바꿔 넣는다 - 취소 · 실패면 임시 파일만 지우고 + /// 예전에 완성한 MKV 는 그대로 (예전에는 FFmpeg -y 가 처음부터 결과 파일을 덮어쓰고, 실패 · 취소면 그것을 지웠다) + Future _build(VideoItem v, [String existing = 'rename']) async { + var out = outputMkvPath(v.path); + if (await services.storage.exists(out)) { + if (existing == 'skip') { + v + ..status = JobStatus.done + ..outputPath = out + ..message = tr('이미 있어 건너뜀'); + _log(trf('건너뜀 (이미 있음): {0}', [p.basename(out)])); + notifyListeners(); + return false; + } + if (existing == 'rename') out = await _freeName(out); + } + final part = '$out.jjpart'; final gen = _cancelGen; v ..status = JobStatus.running @@ -1410,7 +1464,7 @@ class AppController extends ChangeNotifier { if (_cancelledVideos.contains(v)) throw MediaToolException(tr('사용자가 취소했습니다.')); started = true; await _tool.runFfmpeg( - buildMuxArgs(v, out, encode: encode, encoders: encoders, utf8Copies: copies), + buildMuxArgs(v, part, encode: encode, encoders: encoders, utf8Copies: copies), tag: v, duration: v.info?.duration, onProgress: (x) { @@ -1418,6 +1472,8 @@ class AppController extends ChangeNotifier { notifyListeners(); }, ); + // 다 만들었을 때만 결과 이름으로 (덮어쓰기면 예전 것은 이때 바뀐다) + await services.storage.replaceFile(part, out); v ..status = JobStatus.done ..outputPath = out; @@ -1434,10 +1490,10 @@ class AppController extends ChangeNotifier { ..status = cancelled ? JobStatus.cancelled : JobStatus.failed ..message = cancelled ? tr('취소됨') : msg; _log(cancelled ? trf('취소됨: {0}', [v.fileName]) : trf('실패: {0}\n{1}', [v.fileName, msg])); - // 만들다 만 파일은 지운다 (0 바이트 MKV 가 남지 않도록). 시작 전에 실패했으면 전에 만든 MKV 는 그대로 둔다. + // 만들다 만 임시 파일만 지운다 - 결과 이름의 예전 MKV 는 건드리지 않는다 (P0-1) if (started) { try { - await services.storage.delete(out); + await services.storage.delete(part); } catch (_) {} } } finally { @@ -1449,6 +1505,17 @@ class AppController extends ChangeNotifier { } } notifyListeners(); + return true; + } + + /// "이름 (2).mkv" 처럼 비어 있는 이름 (P0-1 [다른 이름]) + Future _freeName(String path) async { + final dir = p.dirname(path), base = p.basenameWithoutExtension(path), ext = p.extension(path); + for (var i = 2; i < 1000; i++) { + final c = p.join(dir, '$base ($i)$ext'); + if (!await services.storage.exists(c)) return c; + } + return p.join(dir, '$base (${DateTime.now().millisecondsSinceEpoch})$ext'); } bool _buildCancelled = false; diff --git a/lib/app/settings.dart b/lib/app/settings.dart index fcd72b8..18b2d08 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -420,6 +420,10 @@ class AppSettings { /// 48: 복사 · 이동할 때 받는 폴더에 같은 이름이 있으면 'ask' (확인 창에서 고름, 처음 값) · 'rename' · 'overwrite' · 'skip' String copyConflict = 'ask'; + /// P0: MKV 를 만들 때 결과 MKV 가 이미 있으면 - 'ask' 묻기 (처음 값) · 'overwrite' 덮어쓰기 · 'skip' 건너뛰기 · 'rename' 다른 이름. + /// 덮어쓰기여도 다 만든 뒤에만 바꿔 넣는다 (도중에 취소 · 실패하면 예전 것 그대로) + String mkvExisting = 'ask'; + /// 229: 앱이 직접 하는 복사 · 이동 (현재 방식 · WebDAV 의 안의 것 맞추기) 에서 받는 쪽이 더 새것이면 건너뛴다 (rsync -u 와 같음). /// 처음 값 켜짐 - 예전 원본으로 새 파일을 덮어쓰고 (이동이면 원본까지 지워) 새 내용을 잃었다 (관리자 판단) bool copySkipNewer = true; @@ -727,6 +731,7 @@ class AppSettings { 'explorerPaths': explorerPaths, 'copyMethodFile': copyMethodFile, 'copyConflict': copyConflict, + 'mkvExisting': mkvExisting, 'copySkipNewer': copySkipNewer, 'copyMethodFolder': copyMethodFolder, 'rsyncPaths': rsyncPaths, @@ -938,6 +943,7 @@ class AppSettings { ..explorerPaths = [for (final x in (j['explorerPaths'] as List?) ?? const []) '$x'] ..copyMethodFile = _method(j['copyMethodFile']) ..copyConflict = const ['rename', 'overwrite', 'skip'].contains(j['copyConflict']) ? j['copyConflict'] as String : 'ask' + ..mkvExisting = const ['rename', 'overwrite', 'skip'].contains(j['mkvExisting']) ? j['mkvExisting'] as String : 'ask' ..copySkipNewer = j['copySkipNewer'] != false ..copyMethodFolder = _method(j['copyMethodFolder']) ..rsyncPaths = [for (final x in (j['rsyncPaths'] as List?) ?? const []) '$x'] diff --git a/lib/app/transfer_job.dart b/lib/app/transfer_job.dart index 872ca52..4c284d6 100644 --- a/lib/app/transfer_job.dart +++ b/lib/app/transfer_job.dart @@ -188,6 +188,9 @@ class TransferJob extends ChangeNotifier { /// 216: 이동이 끝난 뒤 원본에 남은 파일 수 (건너뛴 것) int leftInSource = 0; + /// 229: 폴더를 합칠 때 받는 쪽이 더 새것이라 건너뛴 파일 수 (복사 · 이동) + int keptNewer = 0; + /// 235: 끝난 이동에서 실제로 옮긴 파일 수 (원본에 남은 것은 뺀다 - robocopy 는 건너뛴 것도 다 한 것으로 센다) int get doneFiles => move && finished ? (allFiles - leftInSource).clamp(0, allFiles) : allDone; @@ -197,7 +200,9 @@ class TransferJob extends ChangeNotifier { final total = filesTotal.fold(0, (a, b) => a + b); final n = move ? (total - leftInSource).clamp(0, total) : allDone; final head = move ? trf('이동 끝: {0}개 · {1} → {2}', [n, from, vBasename(dest)]) : trf('복사 끝: {0}개 · {1} → {2}', [n, from, vBasename(dest)]); - return leftInSource > 0 ? '$head · ${trf('{0}개는 원본에 남음 (건너뜀)', [leftInSource])}' : head; + if (leftInSource > 0) return '$head · ${trf('{0}개는 원본에 남음 (건너뜀)', [leftInSource])}'; + // 229: 복사에서 받는 쪽이 더 새것이라 건너뛴 것 + return keptNewer > 0 ? '$head · ${trf('받는 쪽이 더 새것 {0}개 건너뜀', [keptNewer])}' : head; } /// 위쪽 진행률: 끝낸 항목 + 지금 항목의 파일 진행 @@ -322,6 +327,8 @@ class TransferJob extends ChangeNotifier { // 보낸 양으로 전송 속도 (WebDAV 포함) onBytes: _addBytes, conflict: conflict, + // 229: 폴더를 합칠 때도 안쪽의 더 새 파일은 건너뜀 + skipNewerInMerge: skipNewer, // 214: 이동은 맞춘 원본 파일만 지운다 onSettled: move ? (s, size, modified) => settled[s] = (size, modified) : null, ); @@ -336,6 +343,7 @@ class TransferJob extends ChangeNotifier { final r = move ? await _ops!.move([sources[i]], dest) : await _ops!.copy([sources[i]], dest); made.addAll(r); skipped.addAll(_ops!.skipped); + keptNewer += _ops!.keptNewer.length; } filesDone[i] = filesTotal[i]; // 이름 바꾸기로 옮긴 경우 } diff --git a/lib/core/file_ops.dart b/lib/core/file_ops.dart index a52bfdd..768d30f 100644 --- a/lib/core/file_ops.dart +++ b/lib/core/file_ops.dart @@ -321,7 +321,21 @@ class FileOps { /// 건너뛴 항목 (원본 경로) final skipped = []; - FileOps({this.bandwidthKBps = 0, this.onFileDone, this.onBytes, this.conflict = NameConflict.rename, this.onSettled}); + FileOps({ + this.bandwidthKBps = 0, + this.onFileDone, + this.onBytes, + this.conflict = NameConflict.rename, + this.onSettled, + this.skipNewerInMerge = false, + }); + + /// 229: 폴더를 [덮어쓰기] (합치기) 할 때 안쪽에서 받는 쪽이 더 새 파일은 건너뛴다 (환경 설정 "받는 쪽이 더 새것이면 건너뛰기"). + /// 맨 위 파일 하나를 사용자가 [덮어쓰기] 로 고른 것은 따른다 (창에 "(있는 것이 더 새것)" 이 보이므로) + final bool skipNewerInMerge; + + /// 229: 위 규칙으로 건너뛴 원본 파일 (이동이어도 원본에 남는다) + final keptNewer = []; /// 214: [mirror] 가 원본 파일 하나를 대상에 맞췄을 때 (지금 복사했거나, 엄격히 같다고 확인함) - 그 원본의 크기 · 바뀐 때. /// 이동은 이것으로 알려 온 파일만 원본에서 지운다 (건너뛴 것 · 도중에 생긴 것은 남긴다) @@ -424,13 +438,18 @@ class FileOps { onFileDone?.call(f.path); } - Future copyAny(String src, String target) async { + Future copyAny(String src, String target, {bool inner = false}) async { if (FileSystemEntity.isDirectorySync(src)) { await Directory(target).create(recursive: true); await for (final e in Directory(src).list(followLinks: false)) { - await copyAny(e.path, p.join(target, p.basename(e.path))); + await copyAny(e.path, p.join(target, p.basename(e.path)), inner: true); } } else { + // 229: 합치는 폴더 안에서 받는 쪽이 더 새것이면 예전 것으로 덮지 않는다 + if (inner && skipNewerInMerge && await isNewerThere(src, target)) { + keptNewer.add(src); + return; + } await copyFile(File(src), target); } } @@ -447,6 +466,55 @@ class FileOps { return made; } + /// 229: [dir] 안에서 [keep] 의 파일만 남기고 지운다 (비게 된 폴더도). [dir] 자체는 남는 것이 있으면 둔다 + Future _deleteExcept(String dir, Set keep) async { + Future walk(Directory d) async { + var empty = true; + await for (final e in d.list(followLinks: false)) { + if (e is Directory) { + if (await walk(e)) { + await e.delete(); + } else { + empty = false; + } + } else if (keep.contains(p.normalize(e.path))) { + empty = false; + } else { + await e.delete(); + } + } + return empty; + } + + if (await walk(Directory(dir))) await Directory(dir).delete(); + } + + /// 229: [target] 이 있고 [src] 보다 2초 넘게 새것인지 + static Future isNewerThere(String src, String target) async { + final t = File(target); + if (!await t.exists()) return false; + return (await t.lastModified()).isAfter((await File(src).lastModified()).add(const Duration(seconds: 2))); + } + + /// 229: 같은 이름 창 - 폴더를 합치면 안쪽에서 받는 쪽이 더 새 파일 수 ([limit] 개까지 셈, 로컬만) + static Future countNewerInside(String src, String dst, {int limit = 5000}) async { + var n = 0, seen = 0; + Future walk(String s, String d) async { + await for (final e in Directory(s).list(followLinks: false).handleError((_) {})) { + if (seen++ > limit) return; + final t = p.join(d, p.basename(e.path)); + if (e is Directory) { + if (FileSystemEntity.isDirectorySync(t)) await walk(e.path, t); + } else if (await isNewerThere(e.path, t)) { + n++; + } + } + } + + if (FileSystemEntity.isDirectorySync(src) && FileSystemEntity.isDirectorySync(dst)) await walk(src, dst); + return n; + } + /// 이동: 같은 드라이브면 이름 바꾸기, 아니면 복사한 뒤 원본 삭제 Future> move(List sources, String destDir, {CopyProgress? onProgress}) async { if (_anyDav([...sources, destDir])) return _moveV(sources, destDir, onProgress: onProgress); @@ -463,9 +531,16 @@ class FileOps { final exists = FileSystemEntity.typeSync(target) != FileSystemEntityType.notFound; if (exists && FileSystemEntity.isDirectorySync(s)) { // 덮어쓰기로 폴더를 합친다: 복사 (안의 같은 이름은 덮어씀) 뒤 원본 삭제 + final keptBefore = keptNewer.length; made.addAll(await copy([s], destDir, onProgress: onProgress)); _check(); - await delete([s]); + final kept = keptNewer.sublist(keptBefore); + if (kept.isEmpty) { + await delete([s]); + } else { + // 229: 건너뛴 (받는 쪽이 더 새) 원본은 남기고 옮긴 것만 지운다 + await _deleteExcept(s, kept.map(p.normalize).toSet()); + } continue; } try { diff --git a/lib/core/mkv_command_builder.dart b/lib/core/mkv_command_builder.dart index 114d582..231694b 100644 --- a/lib/core/mkv_command_builder.dart +++ b/lib/core/mkv_command_builder.dart @@ -81,7 +81,8 @@ List buildMuxArgs( args.addAll(['-disposition:s:$j', j == defaultIdx ? 'default' : '0']); } - args.addAll(['-progress', 'pipe:1', '-nostats', outputPath]); + // 임시 이름 (….mkv.jjpart) 에 써도 MKV 로 (확장자로 정하지 않게) + args.addAll(['-progress', 'pipe:1', '-nostats', '-f', 'matroska', outputPath]); return args; } diff --git a/lib/platform/android/ffmpeg_kit_media_tool.dart b/lib/platform/android/ffmpeg_kit_media_tool.dart index 0923a1a..e779c7c 100644 --- a/lib/platform/android/ffmpeg_kit_media_tool.dart +++ b/lib/platform/android/ffmpeg_kit_media_tool.dart @@ -80,7 +80,11 @@ class FfmpegKitMediaTool implements MediaTool { _running.remove(id); _tags.remove(id); final rc = await s.getReturnCode(); - if (_cancelled.remove(id) || ReturnCode.isCancel(rc)) { + // 끝까지 한 (성공) 뒤에 온 취소는 무시한다 - 다 만든 파일을 "취소됨" 으로 지우지 않게 + if (ReturnCode.isSuccess(rc)) { + _cancelled.remove(id); + done.complete(); + } else if (_cancelled.remove(id) || ReturnCode.isCancel(rc)) { done.completeError(MediaToolException(tr('사용자가 취소했습니다.'))); } else if (!ReturnCode.isSuccess(rc)) { done.completeError(MediaToolException(trf('ffmpeg 오류 (코드 {0})\n{1}', [rc?.getValue(), errTail.join('\n')]))); diff --git a/lib/platform/windows/desktop_storage_service.dart b/lib/platform/windows/desktop_storage_service.dart index 7c31523..a1e4e1b 100644 --- a/lib/platform/windows/desktop_storage_service.dart +++ b/lib/platform/windows/desktop_storage_service.dart @@ -98,6 +98,13 @@ class DesktopStorageService implements StorageService { if (await f.exists()) await f.delete(); } + @override + Future replaceFile(String from, String to) async { + final old = File(to); + if (await old.exists()) await old.delete(); + await File(from).rename(to); + } + @override Future tempDirectory() async { final dir = Directory(p.join(Directory.systemTemp.path, 'jj_mkvmaker')); diff --git a/lib/platform/windows/process_media_tool.dart b/lib/platform/windows/process_media_tool.dart index 9e150ba..9324838 100644 --- a/lib/platform/windows/process_media_tool.dart +++ b/lib/platform/windows/process_media_tool.dart @@ -146,7 +146,8 @@ class ProcessMediaTool implements MediaTool { _running.remove(proc); _tags.remove(proc); - if (_killed.remove(proc)) throw MediaToolException(tr('사용자가 취소했습니다.')); + // 끝까지 한 (코드 0) 뒤에 온 취소는 무시한다 - 다 만든 파일을 "취소됨" 으로 지우지 않게 + if (_killed.remove(proc) && code != 0) throw MediaToolException(tr('사용자가 취소했습니다.')); if (code != 0) { throw MediaToolException(trf('ffmpeg 오류 (코드 {0})\n{1}', [code, errTail.join('\n')])); } diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index d0274cd..a2564f6 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -33,6 +33,9 @@ abstract class StorageService { Future delete(String path); + /// P0: [from] (다 만든 임시 파일) 을 [to] 이름으로 바꿔 넣는다 ([to] 가 있으면 그것을 지우고) + Future replaceFile(String from, String to); + /// 앱 전용 임시 폴더 Future tempDirectory(); diff --git a/lib/ui/ai_dialog.dart b/lib/ui/ai_dialog.dart index 3b7ebaa..931b6e8 100644 --- a/lib/ui/ai_dialog.dart +++ b/lib/ui/ai_dialog.dart @@ -6,6 +6,7 @@ import '../app/app_controller.dart'; import '../core/languages.dart'; import '../core/models.dart'; import '../services/model_store.dart'; +import 'existing_output_dialog.dart'; import 'license_texts.dart' show NllbLicenseNote; import 'theme.dart'; import '../l10n/tr.dart'; @@ -20,7 +21,14 @@ Future showAiDialog(BuildContext context, AppController c, VideoItem curre final messenger = ScaffoldMessenger.maybeOf(context); final overlay = Navigator.maybeOf(context)?.overlay; Future run(List v, AiOptions o) async { - await (thenBuild ? c.aiThenBuild(v, o) : c.generateAiSubtitles(v, o)); + // P0: 자막 → MKV 면 결과 MKV 가 이미 있을 때 시작 전에 묻는다 + String? existing; + if (thenBuild) { + if (!context.mounted) return; + existing = await askExistingOutputs(context, c, v); + if (existing == null) return; + } + await (thenBuild ? c.aiThenBuild(v, o, onExisting: existing) : c.generateAiSubtitles(v, o)); if (o.source == undetermined && messenger != null) { showDetectedLanguage(messenger, overlay, v, onRetry: (lang) => run(v, o.copyWith(source: lang))); } diff --git a/lib/ui/existing_output_dialog.dart b/lib/ui/existing_output_dialog.dart new file mode 100644 index 0000000..875aa49 --- /dev/null +++ b/lib/ui/existing_output_dialog.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; + +import '../app/app_controller.dart'; +import '../core/models.dart'; +import '../core/output_paths.dart'; +import '../l10n/tr.dart'; + +/// P0: MKV 를 만들기 전에 결과 MKV 가 이미 있는 것이 있으면 묻는다 (환경 설정 "만들 MKV 가 이미 있으면" 이 "묻기" 일 때). +/// 돌려주는 것: 'overwrite' · 'skip' · 'rename', 이미 있는 것이 없으면 설정 값 그대로, [취소] 면 null (만들지 않음). +/// 예전에는 묻지 않고 시작해, 도중에 취소하면 예전에 완성한 MKV 까지 사라졌다 +Future askExistingOutputs(BuildContext context, AppController c, List targets) async { + final mode = c.settings.mkvExisting; + if (mode != 'ask') return mode; + final have = await c.withExistingOutput(targets); + if (have.isEmpty || !context.mounted) return 'ask'; + final names = [for (final v in have.take(5)) p.basename(outputMkvPath(v.path))]; + return showDialog( + context: context, + builder: (ctx) => AlertDialog( + scrollable: true, + actionsOverflowDirection: VerticalDirection.down, // [취소] 가 먼저 + title: Text(trf('이미 만든 MKV 가 있습니다 ({0}개)', [have.length])), + content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + for (final n in names) Text('· $n', style: const TextStyle(fontSize: 13)), + if (have.length > names.length) Text(trf('… 외 {0}개', [have.length - names.length]), style: const TextStyle(fontSize: 13)), + const SizedBox(height: 10), + Text(tr('덮어쓰기여도 다 만든 뒤에만 바꿉니다. 도중에 취소 · 실패하면 예전 MKV 는 그대로 남습니다. ' + '늘 같게 하려면 환경 설정 > MKV 만들기 > 만들 MKV 가 이미 있으면')), + ]), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))), + TextButton(onPressed: () => Navigator.pop(ctx, 'skip'), child: Text(tr('건너뛰기'))), + TextButton(onPressed: () => Navigator.pop(ctx, 'rename'), child: Text(tr('다른 이름'))), + FilledButton(onPressed: () => Navigator.pop(ctx, 'overwrite'), child: Text(tr('덮어쓰기'))), + ], + ), + ); +} + +/// 고른 동영상을 MKV 로 - 결과가 이미 있으면 먼저 묻는다 ([취소] 면 만들지 않음) +Future buildVideosAsking(BuildContext context, AppController c, List targets) async { + final mode = await askExistingOutputs(context, c, targets); + if (mode == null) return; + await c.buildVideos(targets, onExisting: mode); +} + +/// 아직 만들지 않은 것 모두 MKV 로 - 결과가 이미 있으면 먼저 묻는다 +Future buildAllAsking(BuildContext context, AppController c) async { + final mode = await askExistingOutputs(context, c, [for (final v in c.videos) if (v.status != JobStatus.done) v]); + if (mode == null) return; + await c.buildAll(onExisting: mode); +} diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 54d52f9..e0b8670 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -1943,11 +1943,30 @@ class _ExplorerPageState extends State { Text(trf('같은 이름이 이미 있습니다: {0}', [_namesOf([for (final x in clashes) x.src])]), style: const TextStyle(color: Colors.orangeAccent)), // 172: 두 파일의 크기 · 바뀐 날 (3개까지) - for (final x in clashes.take(3)) + for (final x in clashes.take(3)) ...[ Padding( padding: const EdgeInsets.only(top: 4, left: 8), child: Text(clashCompareText(x), style: const TextStyle(fontSize: 12)), ), + // 229: 폴더를 합치면 안쪽에서 받는 쪽이 더 새 파일 - 건너뛰는지 (환경 설정) 미리 + if (x.there.isDir && !isDav(x.src) && !isDav(dest)) + FutureBuilder( + future: FileOps.countNewerInside(x.src, vJoin(dest, vBasename(x.src))), + builder: (_, n) => Padding( + padding: const EdgeInsets.only(left: 20), + child: Text( + n.connectionState != ConnectionState.done + ? tr('안쪽 파일을 확인하는 중 …') + : (n.data ?? 0) == 0 + ? '' + : c.settings.copySkipNewer + ? trf('안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)', [n.data]) + : trf('안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다', [n.data]), + style: const TextStyle(fontSize: 12, color: Colors.orangeAccent), + ), + ), + ), + ], if (always != null) Text( trf('환경 설정대로 {0} (환경 설정 > 파일 탐색기 > 같은 이름이 있을 때)', [ diff --git a/lib/ui/home_page.dart b/lib/ui/home_page.dart index d462fa4..ec9fb64 100644 --- a/lib/ui/home_page.dart +++ b/lib/ui/home_page.dart @@ -16,6 +16,7 @@ import '../core/output_paths.dart'; import 'ai_dialog.dart'; import 'app_actions.dart'; import 'downloads_page.dart'; +import 'existing_output_dialog.dart'; import 'exit_guard.dart'; import 'package:path/path.dart' as p; @@ -265,7 +266,8 @@ class _TopBar extends StatelessWidget { ], ), ); - if (ok == true) await c.buildAll(); + // P0: 결과 MKV 가 이미 있으면 먼저 묻는다 + if (ok == true && context.mounted) await buildAllAsking(context, c); } static String _batchHint(AppController c, String what) => c.checked.isEmpty @@ -363,7 +365,7 @@ class _TopBar extends StatelessWidget { // 2: 좁은 화면 (폰 세로) 에서는 버튼 글자를 숨기지 않고 옆으로 밀어 본다. 가장 많이 쓰는 [MKV 만들기] 를 맨 앞에. if (compact) { VoidCallback? build0() => - !canBuild ? null : (batch.isEmpty ? () => _confirmBuildAll(context) : () => c.buildVideos(batch)); + !canBuild ? null : (batch.isEmpty ? () => _confirmBuildAll(context) : () => buildVideosAsking(context, c, batch)); return AppTopBar( nav: const AppNavButtons(onMkvPage: true), actions: AppActions(c: c, onExit: onExit, showUsage: false), @@ -398,14 +400,17 @@ class _TopBar extends StatelessWidget { 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), - const SizedBox(width: 8), - action(Icons.stop, tr('취소'), () => _cancelJobs(context), text: true, tip: tr('작업 취소'), color: JjColors.danger), - ], ]), ), + // P0-4: 작업 중에는 진행 · [취소] 를 줄 끝 (늘 보이는 곳) 에 - 예전에는 옆으로 미는 줄의 맨 끝이라 두 번 밀어야 보였다 + trailing: c.busy + ? Row(mainAxisSize: MainAxisSize.min, children: [ + const SizedBox(width: 6), + JobIndicator(c: c, compact: true, iconOnly: true), + const SizedBox(width: 4), + action(Icons.stop, tr('취소'), () => _cancelJobs(context), text: true, tip: tr('작업 취소'), color: JjColors.danger), + ]) + : null, ); } @@ -470,13 +475,13 @@ class _TopBar extends StatelessWidget { message: mkvText ? trf('MKV 만들기{0}', [count]) : '', child: mkvText ? FilledButton.icon( - onPressed: !canBuild ? null : (batch.isEmpty ? () => _confirmBuildAll(context) : () => c.buildVideos(batch)), + onPressed: !canBuild ? null : (batch.isEmpty ? () => _confirmBuildAll(context) : () => buildVideosAsking(context, c, batch)), icon: Icon(c.busy ? Icons.playlist_add : Icons.play_arrow, size: 20), label: Text(trf('MKV 만들기{0}{1}', [count, c.busy && jobText ? tr(' (대기열)') : ''])), ) : IconButton.filled( tooltip: trf('MKV 만들기{0}', [count]), // 241: TalkBack 이 읽는 이름 - onPressed: !canBuild ? null : (batch.isEmpty ? () => _confirmBuildAll(context) : () => c.buildVideos(batch)), + onPressed: !canBuild ? null : (batch.isEmpty ? () => _confirmBuildAll(context) : () => buildVideosAsking(context, c, batch)), icon: Icon(c.busy ? Icons.playlist_add : Icons.play_arrow, size: 20), ), ), diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index 5efeb61..73c6751 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -470,6 +470,21 @@ class _SettingsPageState extends State { onChanged: (v) => c.updateSettings((x) => x.tripleTapAction = v!), ), ), + // P0: 결과 MKV 가 이미 있을 때 (처음 값: 묻기) + SettingTile( + title: Text(tr('만들 MKV 가 이미 있으면')), + subtitle: Text(tr('덮어쓰기여도 다 만든 뒤에만 바꿉니다 (도중에 취소 · 실패하면 예전 MKV 그대로)')), + trailing: DropdownButton( + value: const ['ask', 'overwrite', 'skip', 'rename'].contains(s.mkvExisting) ? s.mkvExisting : 'ask', + items: [ + DropdownMenuItem(value: 'ask', child: Text(tr('묻기'))), + DropdownMenuItem(value: 'overwrite', child: Text(tr('덮어쓰기'))), + DropdownMenuItem(value: 'skip', child: Text(tr('건너뛰기'))), + DropdownMenuItem(value: 'rename', child: Text(tr('다른 이름 (이름 (2).mkv)'))), + ], + onChanged: (v) => c.updateSettings((x) => x.mkvExisting = v ?? 'ask'), + ), + ), SettingTile( title: Text(tr('동시 MKV 변환 수')), subtitle: Text(tr('여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)')), diff --git a/lib/ui/work_panel.dart b/lib/ui/work_panel.dart index 6e9b3bf..52af2f0 100644 --- a/lib/ui/work_panel.dart +++ b/lib/ui/work_panel.dart @@ -429,7 +429,11 @@ class JobIndicator extends StatelessWidget { ConstrainedBox( constraints: BoxConstraints(maxWidth: compact ? 160 : 240), child: Text( - '${c.currentJob ?? tr('작업 중')}${p == null ? '' : ' ${(p * 100).round()}%'}${wait > 0 ? trf(' · 대기 {0}', [wait]) : ''}', + // P0-4: 여러 개를 만들 때 한 개가 끝나면 81% → 36% 로 내려가 헷갈렸다 - "3개 중 2번째 · 36%" 처럼 지금 것의 진행 + c.batchTotal > 1 + ? '${trf('MKV {0}개 중 {1}번째', [c.batchTotal, c.batchStarted])}${p == null ? '' : ' · ${(p * 100).round()}%'}' + '${wait > 0 ? trf(' · 대기 {0}', [wait]) : ''}' + : '${c.currentJob ?? tr('작업 중')}${p == null ? '' : ' ${(p * 100).round()}%'}${wait > 0 ? trf(' · 대기 {0}', [wait]) : ''}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: JjColors.accent), diff --git a/test/batch_test.dart b/test/batch_test.dart index cad2fa9..f2a7537 100644 --- a/test/batch_test.dart +++ b/test/batch_test.dart @@ -255,6 +255,90 @@ void main() { expect(c.logs.join('\n'), contains('성공 2 · 취소 1 · 실패 0 / 전체 3')); }); + group('P0 (931): 결과 MKV 가 이미 있을 때 · 취소해도 예전 완성본은 그대로', () { + late Directory dir; + late ProcessMediaTool tool; + late AppController c; + late List files; + setUp(() async { + tool = ProcessMediaTool('ffmpeg', 'ffprobe'); + dir = Directory.systemTemp.createTempSync('jj_p0_'); + files = [for (final n in ['b1', 'b2', 'b3']) p.join(dir.path, '$n.mp4')]; + }); + tearDown(() => dir.deleteSync(recursive: true)); + + Future prepare({List seconds = const [1, 1, 1]}) async { + if (await tool.version() == null) return false; + for (final (i, f) in files.indexed) { + await tool.runFfmpeg(['-y', '-f', 'lavfi', '-i', 'testsrc=size=1280x720:rate=30', '-t', '${seconds[i]}', + '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', f]); + } + c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService())); + await c.init(); + c.settings.maxParallelJobs = 1; + c.encode = const EncodeSettings(codec: VideoCodecChoice.h264, quality: QualityChoice.high); + await c.addVideos(files); + return true; + } + + File out(int i) => File(outputMkvPath(files[i])); + void writeOld(int i) => (out(i)..createSync(recursive: true)).writeAsStringSync('OLD COMPLETE MKV'); + List parts() => [ + for (final e in Directory(p.dirname(out(0).path)).listSync()) + if (e.path.endsWith('.jjpart')) p.basename(e.path), + ]; + + test('덮어쓰기로 다시 만들다 취소하면 예전 완성본이 그대로 (예전에는 지워졌다) · 임시 파일도 남지 않음', () async { + if (!await prepare(seconds: [60, 1, 1])) return markTestSkipped('FFmpeg 없음'); + final v1 = c.videos.first; + writeOld(0); + final building = c.buildVideos([v1], onExisting: 'overwrite'); + for (var i = 0; i < 400 && !(v1.status == JobStatus.running && v1.progress > 0.02); i++) { + await Future.delayed(const Duration(milliseconds: 25)); + } + expect(out(0).readAsStringSync(), 'OLD COMPLETE MKV', reason: '만드는 동안에도 예전 것은 그대로 (임시 이름에 씀)'); + c.cancel(); + await building; + expect(v1.status, JobStatus.cancelled); + expect(out(0).readAsStringSync(), 'OLD COMPLETE MKV', reason: '취소해도 예전 완성본은 남는다'); + expect(parts(), isEmpty, reason: '만들다 만 임시 파일은 지움'); + }); + + test('덮어쓰기 · 건너뛰기 · 다른 이름', () async { + if (!await prepare()) return markTestSkipped('FFmpeg 없음'); + final [v1, v2, v3] = c.videos; + for (final i in [0, 1, 2]) { + writeOld(i); + } + await c.buildVideos([v1], onExisting: 'overwrite'); + expect(out(0).lengthSync(), greaterThan(100), reason: '다 만든 뒤 바꿔 넣음'); + await c.buildVideos([v2], onExisting: 'skip'); + expect(out(1).readAsStringSync(), 'OLD COMPLETE MKV', reason: '건너뜀 - 그대로'); + expect(c.logs.join('\n'), contains('건너뜀 (이미 있음): b2.mkv')); + await c.buildVideos([v3], onExisting: 'rename'); + expect(out(2).readAsStringSync(), 'OLD COMPLETE MKV', reason: '다른 이름 - 예전 것 그대로'); + expect(File(p.join(p.dirname(out(2).path), 'b3 (2).mkv')).lengthSync(), greaterThan(100)); + expect(v3.outputPath, endsWith('b3 (2).mkv')); + expect(parts(), isEmpty); + }); + + test('240 집계: 다시 만들다 [남은 것 모두 취소] - 앞 실행의 ✓ 가 섞이지 않고 시작 못 한 것은 취소 (성공 1 · 취소 2)', () async { + if (!await prepare(seconds: [1, 60, 1])) return markTestSkipped('FFmpeg 없음'); + final [v1, v2, v3] = c.videos; + await c.buildVideos([v1, v2, v3].sublist(0, 1), onExisting: 'overwrite'); + v2.status = JobStatus.done; // 앞 실행에서 ✓ 였던 것 + v3.status = JobStatus.done; + final building = c.buildVideos([v1, v2, v3], onExisting: 'overwrite'); + for (var i = 0; i < 800 && !(v2.status == JobStatus.running && v2.progress > 0.01); i++) { + await Future.delayed(const Duration(milliseconds: 25)); + } + c.cancel(); + await building; + expect([v1.status, v2.status, v3.status], [JobStatus.done, JobStatus.cancelled, JobStatus.cancelled]); + expect(c.logs.last, contains('성공 1 · 취소 2 · 실패 0 / 전체 3')); + }); + }); + test('239: 취소 창을 연 뒤 그 동영상이 끝나고 다음 것이 시작했으면 [지금 것만 취소] 는 다음 것을 취소하지 않는다', () async { final tool = ProcessMediaTool('ffmpeg', 'ffprobe'); if (await tool.version() == null) return markTestSkipped('FFmpeg 없음'); diff --git a/test/move_settled_test.dart b/test/move_settled_test.dart index fdf3d78..ccae87a 100644 --- a/test/move_settled_test.dart +++ b/test/move_settled_test.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/transfer_job.dart'; +import 'package:jj_mkvmaker/core/file_ops.dart' show FileOps, NameConflict; import 'package:jj_mkvmaker/core/sync_preview.dart'; import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/core/sync_tools.dart' show CopyMethod; @@ -189,6 +190,34 @@ void main() { expect(job.doneSummary(), contains('1개는 원본에 남음')); }); + test('229 폴더 충돌 (931): 폴더를 [덮어쓰기] 로 합쳐도 안쪽의 받는 쪽이 더 새 파일은 건너뛰고 원본에 남긴다', () async { + final base = Directory.systemTemp.createTempSync('jj_merge_'); + addTearDown(() => base.deleteSync(recursive: true)); + final s2 = p.join(base.path, 's2'), d2 = p.join(base.path, 'd2'); + final oldSame = File(p.join(s2, 'D', 'same.txt')) + ..createSync(recursive: true) + ..writeAsStringSync('old'); + oldSame.setLastModifiedSync(DateTime(2026, 1, 1)); + File(p.join(s2, 'D', 'other.txt')).writeAsStringSync('o'); + File(p.join(d2, 'D', 'same.txt')) + ..createSync(recursive: true) + ..writeAsStringSync('NEWER'); + expect(await FileOps.countNewerInside(p.join(s2, 'D'), p.join(d2, 'D')), 1, reason: '같은 이름 창에 미리 보일 수'); + final job = TransferJob(sources: [p.join(s2, 'D')], dest: d2, move: true, conflict: NameConflict.overwrite, skipNewer: true); + await job.run(); + expect(job.error, isNull); + expect(File(p.join(d2, 'D', 'same.txt')).readAsStringSync(), 'NEWER', reason: '더 새 파일 그대로'); + expect(oldSame.existsSync(), isTrue, reason: '건너뛴 원본은 이동이어도 남김'); + expect(File(p.join(s2, 'D', 'other.txt')).existsSync(), isFalse, reason: '나머지는 옮김'); + expect(File(p.join(d2, 'D', 'other.txt')).readAsStringSync(), 'o'); + expect(job.leftInSource, 1); + expect(job.doneSummary(), contains('1개는 원본에 남음')); + // 건너뛰기를 끄면 (사용자가 고름) 예전처럼 합치며 덮어쓴다 + final job2 = TransferJob(sources: [p.join(s2, 'D')], dest: d2, move: false, conflict: NameConflict.overwrite, skipNewer: false); + await job2.run(); + expect(File(p.join(d2, 'D', 'same.txt')).readAsStringSync(), 'old'); + }); + test('229: 건너뛰기를 끄면 (사용자가 고름) 예전처럼 덮어쓴다', () async { final old = File(p.join(src, 'older.txt'))..writeAsStringSync('old in src'); old.setLastModifiedSync(DateTime.now().subtract(const Duration(days: 2))); diff --git a/test/queue_test.dart b/test/queue_test.dart index 86d4531..86b50fb 100644 --- a/test/queue_test.dart +++ b/test/queue_test.dart @@ -68,6 +68,8 @@ class _Tool implements MediaTool { now++; if (now > peak) peak = now; await Future.delayed(const Duration(milliseconds: 30)); + // 진짜 FFmpeg 처럼 출력 (맨 끝 인수 - MKV 는 임시 이름 ….jjpart) 을 만든다 + File(args.last).writeAsStringSync('mkv'); now--; } diff --git a/test/ui_test.dart b/test/ui_test.dart index db56ace..f167d78 100644 --- a/test/ui_test.dart +++ b/test/ui_test.dart @@ -54,6 +54,8 @@ class _FakeStorage implements StorageService { @override Future exists(String path) async => true; @override + Future replaceFile(String from, String to) async {} + @override Future> readHead(String path, int maxBytes) async => [0x31]; @override Future readBytes(String path) async => Uint8List(0); @@ -191,7 +193,9 @@ void main() { expect(find.text('한국어 (ko)'), findsOneWidget); expect(find.text('영어 (en)'), findsOneWidget); - // 체크한 것이 없으면 세부 정보에 보이는 동영상을 바로 만든다 (묻지 않음) + // 체크한 것이 없으면 세부 정보에 보이는 동영상을 바로 만든다 (묻지 않음). + // P0: 이 시험의 가짜 저장소는 모든 파일이 "있다" 고 하므로, 결과 MKV 가 있을 때 묻지 않게 덮어쓰기로 + c.settings.mkvExisting = 'overwrite'; await tester.tap(find.text('MKV 만들기')); await tester.pumpAndSettle(); expect(find.text('전체 만들기'), findsNothing);