통합: fix_mkv (c41954b: P0 MKV 다시 만들기 · 묶음 집계, 229 폴더 덮어쓰기) 를 합침
관리자 지시 (10/12, 마지막 후보). 겹친 곳 없이 자동으로 합쳐짐 (번역 json 3개만 양쪽이 고침 - 정렬 · 모양 그대로 확인). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>통합
commit
4ad66dd5a1
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 ビューアー",
|
||||
"이미지 자막은 번역할 수 없습니다": "画像字幕は翻訳できません",
|
||||
|
|
|
|||
|
|
@ -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 查看",
|
||||
"이미지 자막은 번역할 수 없습니다": "图像字幕无法翻译",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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]; // 이름 바꾸기로 옮긴 경우
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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')])));
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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')]));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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} (환경 설정 > 파일 탐색기 > 같은 이름이 있을 때)', [
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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 를 많이 씁니다)')),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 없음');
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -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--;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue