통합: fix_mkv (c41954b: P0 MKV 다시 만들기 · 묶음 집계, 229 폴더 덮어쓰기) 를 합침

관리자 지시 (10/12, 마지막 후보). 겹친 곳 없이 자동으로 합쳐짐 (번역 json 3개만 양쪽이 고침 - 정렬 · 모양 그대로 확인).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
통합
RyuJongJin 2026-10-12 07:56:17 +09:00
commit 4ad66dd5a1
22 changed files with 478 additions and 38 deletions

View File

@ -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",

View File

@ -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 ビューアー",
"이미지 자막은 번역할 수 없습니다": "画像字幕は翻訳できません",

View File

@ -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 查看",
"이미지 자막은 번역할 수 없습니다": "图像字幕无法翻译",

View File

@ -489,10 +489,10 @@ class AppController extends ChangeNotifier {
}
/// [targets] 의 AI 자막을 만들고, 이어서 그 동영상들만 MKV 로 만든다 (자막 만들기를 취소하면 거기서 멈춤)
Future<void> aiThenBuild(List<VideoItem> targets, AiOptions opts) async {
Future<void> aiThenBuild(List<VideoItem> 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<void> buildAll() async {
/// [onExisting]: 결과 MKV 가 이미 있을 때 'overwrite' · 'skip' · 'rename' (없으면 환경 설정, "묻기" 인데 묻지 못한 곳은 'rename')
Future<void> buildAll({String? onExisting}) async {
if (ffmpegVersion == null) return;
return _enqueue(tr('MKV 만들기'), _runBuildAll);
return _enqueue(tr('MKV 만들기'), () => _runBuildAll(null, onExisting));
}
/// 고른 동영상만 MKV 로 (이미 만든 것도 다시 만든다 - 자막이 바뀌었을 수 있으므로)
Future<void> buildVideos(List<VideoItem> targets) async {
Future<void> buildVideos(List<VideoItem> 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<List<VideoItem>> withExistingOutput(List<VideoItem> 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<void> _runBuildAll([List<VideoItem>? only]) async {
Future<void> _runBuildAll([List<VideoItem>? 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 = <VideoItem>{};
Future<void> 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<VideoItem> _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<void> _build(VideoItem v) async {
final out = outputMkvPath(v.path);
/// 만들었으면 (또는 실패 · 취소) true, 결과가 이미 있어 건너뛰었으면 false.
/// P0-1: 임시 이름 (….mkv.jjpart) 에 만들고 끝났을 때만 결과 이름으로 바꿔 넣는다 - 취소 · 실패면 임시 파일만 지우고
/// 예전에 완성한 MKV 는 그대로 (예전에는 FFmpeg -y 가 처음부터 결과 파일을 덮어쓰고, 실패 · 취소면 그것을 지웠다)
Future<bool> _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<String> _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;

View File

@ -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']

View File

@ -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]; // 이름 바꾸기로 옮긴 경우
}

View File

@ -321,7 +321,21 @@ class FileOps {
/// 건너뛴 항목 (원본 경로)
final skipped = <String>[];
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 = <String>[];
/// 214: [mirror] 가 원본 파일 하나를 대상에 맞췄을 때 (지금 복사했거나, 엄격히 같다고 확인함) - 그 원본의 크기 · 바뀐 때.
/// 이동은 이것으로 알려 온 파일만 원본에서 지운다 (건너뛴 것 · 도중에 생긴 것은 남긴다)
@ -424,13 +438,18 @@ class FileOps {
onFileDone?.call(f.path);
}
Future<void> copyAny(String src, String target) async {
Future<void> 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<void> _deleteExcept(String dir, Set<String> keep) async {
Future<bool> 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<bool> 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<int> countNewerInside(String src, String dst, {int limit = 5000}) async {
var n = 0, seen = 0;
Future<void> 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<List<String>> move(List<String> 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 {

View File

@ -81,7 +81,8 @@ List<String> 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;
}

View File

@ -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')])));

View File

@ -98,6 +98,13 @@ class DesktopStorageService implements StorageService {
if (await f.exists()) await f.delete();
}
@override
Future<void> replaceFile(String from, String to) async {
final old = File(to);
if (await old.exists()) await old.delete();
await File(from).rename(to);
}
@override
Future<String> tempDirectory() async {
final dir = Directory(p.join(Directory.systemTemp.path, 'jj_mkvmaker'));

View File

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

View File

@ -33,6 +33,9 @@ abstract class StorageService {
Future<void> delete(String path);
/// P0: [from] (다 만든 임시 파일) 을 [to] 이름으로 바꿔 넣는다 ([to] 가 있으면 그것을 지우고)
Future<void> replaceFile(String from, String to);
/// 앱 전용 임시 폴더
Future<String> tempDirectory();

View File

@ -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<void> showAiDialog(BuildContext context, AppController c, VideoItem curre
final messenger = ScaffoldMessenger.maybeOf(context);
final overlay = Navigator.maybeOf(context)?.overlay;
Future<void> run(List<VideoItem> 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)));
}

View File

@ -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<String?> askExistingOutputs(BuildContext context, AppController c, List<VideoItem> 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<String>(
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<void> buildVideosAsking(BuildContext context, AppController c, List<VideoItem> targets) async {
final mode = await askExistingOutputs(context, c, targets);
if (mode == null) return;
await c.buildVideos(targets, onExisting: mode);
}
/// 아직 만들지 않은 것 모두 MKV 로 - 결과가 이미 있으면 먼저 묻는다
Future<void> 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);
}

View File

@ -1943,11 +1943,30 @@ class _ExplorerPageState extends State<ExplorerPage> {
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<int>(
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} (환경 설정 > 파일 탐색기 > 같은 이름이 있을 때)', [

View File

@ -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),
),
),

View File

@ -470,6 +470,21 @@ class _SettingsPageState extends State<SettingsPage> {
onChanged: (v) => c.updateSettings((x) => x.tripleTapAction = v!),
),
),
// P0: 결과 MKV 가 이미 있을 때 (처음 값: 묻기)
SettingTile(
title: Text(tr('만들 MKV 가 이미 있으면')),
subtitle: Text(tr('덮어쓰기여도 다 만든 뒤에만 바꿉니다 (도중에 취소 · 실패하면 예전 MKV 그대로)')),
trailing: DropdownButton<String>(
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 를 많이 씁니다)')),

View File

@ -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),

View File

@ -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<String> 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<bool> prepare({List<int> 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<String> 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<void>.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<void>.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 없음');

View File

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

View File

@ -68,6 +68,8 @@ class _Tool implements MediaTool {
now++;
if (now > peak) peak = now;
await Future<void>.delayed(const Duration(milliseconds: 30));
// 진짜 FFmpeg 처럼 출력 (맨 끝 인수 - MKV 는 임시 이름 ….jjpart) 을 만든다
File(args.last).writeAsStringSync('mkv');
now--;
}

View File

@ -54,6 +54,8 @@ class _FakeStorage implements StorageService {
@override
Future<bool> exists(String path) async => true;
@override
Future<void> replaceFile(String from, String to) async {}
@override
Future<List<int>> readHead(String path, int maxBytes) async => [0x31];
@override
Future<Uint8List> 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);