MKV 다시 만들기: 임시 이름에 만들고 다 됐을 때만 바꿔 넣음 · 이미 있으면 묻기 · 묶음 집계 · 끝난 뒤 취소 무시 (P0, 931)
- 데이터 (버그, 기기 확인 · 10.10 에도 있음): FFmpeg -y 가 처음부터 결과 MKV 에 써서, 다시 만들다 취소 · 실패하면 예전 완성본이 사라졌다 → 이름.mkv.jjpart 에 만들고 다 된 뒤에만 storage.replaceFile, 취소 · 실패면 임시 파일만 지움 - 결과가 이미 있으면 시작 전에 묻기 (덮어쓰기 · 건너뛰기 · 다른 이름) + 환경 설정 '만들 MKV 가 이미 있으면' (처음 값 묻기) - 240 집계: 이번 묶음의 이전 상태를 비우고, 시작 못 한 것은 취소로 셈 - 끝난 (코드 0) 뒤에 온 취소는 무시 - 성공으로 (Windows · Android) - 폰 세로 작업 중 [취소] · 진행을 늘 보이는 자리에, 진행 글 'MKV 3개 중 2번째 · 36%' - 시험: 가짜 FFmpeg 가 출력을 만들게 (queue_test), 모든 파일이 '있는' 가짜 저장소에서는 덮어쓰기 (ui_test) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>fix_mkv
parent
614584c78c
commit
bf4b27a5f6
|
|
@ -196,6 +196,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",
|
||||
|
|
@ -560,6 +561,7 @@
|
|||
"거꾸로": "Reverse",
|
||||
"거꾸로 된 범위: {0}": "Reversed range: {0}",
|
||||
"건너뛰기": "Skip",
|
||||
"건너뜀 (이미 있음): {0}": "Skipped (already exists): {0}",
|
||||
"건너뜀 (출력 폴더의 파일): {0}": "Skipped (file in the output folder): {0}",
|
||||
"건너뜁니다": "skips it",
|
||||
"검색": "Search",
|
||||
|
|
@ -757,6 +759,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",
|
||||
|
|
@ -836,7 +840,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",
|
||||
|
|
@ -935,6 +942,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",
|
||||
|
|
@ -1487,6 +1495,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",
|
||||
|
|
@ -1687,6 +1696,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.",
|
||||
|
|
@ -1694,6 +1704,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",
|
||||
|
|
|
|||
|
|
@ -196,6 +196,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・字幕の保存先",
|
||||
|
|
@ -560,6 +561,7 @@
|
|||
"거꾸로": "逆順",
|
||||
"거꾸로 된 범위: {0}": "逆順の範囲: {0}",
|
||||
"건너뛰기": "スキップ",
|
||||
"건너뜀 (이미 있음): {0}": "スキップ (すでにあります): {0}",
|
||||
"건너뜀 (출력 폴더의 파일): {0}": "スキップ(出力フォルダーのファイル): {0}",
|
||||
"건너뜁니다": "スキップします",
|
||||
"검색": "検索",
|
||||
|
|
@ -757,6 +759,8 @@
|
|||
"다른 언어로 다시": "別の言語でやり直す",
|
||||
"다른 언어로 된 페이지를 화면 언어 ({0}) 로 번역합니다. 페이지의 글을 Google 번역으로 보냅니다. 주소창 옆 번역 버튼으로 원문 · 번역을 바꿀 수 있습니다.": "ほかの言語のページを表示言語 ({0}) に翻訳します。ページの文章は Google 翻訳に送られます。アドレスバー横の翻訳ボタンで原文 · 翻訳を切り替えられます。",
|
||||
"다른 언어로 번역 (AI · 이 {0}에서)": "他の言語に翻訳(AI・この{0}で)",
|
||||
"다른 이름": "別の名前",
|
||||
"다른 이름 (이름 (2).mkv)": "別の名前 (名前 (2).mkv)",
|
||||
"다른 이름으로 저장": "名前を付けて保存",
|
||||
"다른 이름으로 저장: {0} ({1})": "名前を付けて保存: {0}({1})",
|
||||
"다른 저장소로는 이름만 바꿀 수 없습니다": "別のストレージへは名前の変更だけではできません",
|
||||
|
|
@ -836,7 +840,10 @@
|
|||
"더 보기": "その他",
|
||||
"더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "ダブルクリック・既定のアプリで JJ_MKVMaker を選んだとき(右クリックメニューはそのまま)",
|
||||
"더한 언어 (AI 자동 번역)": "追加した言語(AI 自動翻訳)",
|
||||
"덮어쓰기": "上書き",
|
||||
"덮어쓰기 (원래 파일은 없어집니다)": "上書き (元のファイルは消えます)",
|
||||
"덮어쓰기여도 다 만든 뒤에만 바꿉니다 (도중에 취소 · 실패하면 예전 MKV 그대로)": "上書きでも作り終えてから置き換えます (途中でキャンセル · 失敗すると以前の MKV はそのまま)",
|
||||
"덮어쓰기여도 다 만든 뒤에만 바꿉니다. 도중에 취소 · 실패하면 예전 MKV 는 그대로 남습니다. 늘 같게 하려면 환경 설정 > MKV 만들기 > 만들 MKV 가 이미 있으면": "上書きでも作り終えてから置き換えます。途中でキャンセル · 失敗すると以前の MKV はそのまま残ります。いつも同じにするには: 環境設定 > MKV 作成 > 作る MKV がすでにあるとき",
|
||||
"덮어씁니다": "上書きします",
|
||||
"덴마크어": "デンマーク語",
|
||||
"독일어": "ドイツ語",
|
||||
|
|
@ -935,6 +942,7 @@
|
|||
"마지막 화면입니다": "最後の画面です",
|
||||
"마지막으로: {0}": "最後へ: {0}",
|
||||
"만든 그림 · 저장: {0}": "作成した画像・保存先: {0}",
|
||||
"만들 MKV 가 이미 있으면": "作る MKV がすでにあるとき",
|
||||
"만들 자막 언어 (파일명_언어코드.srt)": "作成する字幕の言語(ファイル名_言語コード.srt)",
|
||||
"만들거나 쓰지 못했습니다": "作成または書き込みできませんでした",
|
||||
"만들기": "作成",
|
||||
|
|
@ -1487,6 +1495,7 @@
|
|||
"완료 시 자동 동영상 추가": "完了時に動画を自動追加",
|
||||
"완료 정리": "完了を整理",
|
||||
"완료: 성공 {0} / 전체 {1}": "完了: 成功 {0} / 全体 {1}",
|
||||
"완료: 성공 {0} · 건너뜀 {1} · 취소 {2} · 실패 {3} / 전체 {4}": "完了: 成功 {0} · スキップ {1} · キャンセル {2} · 失敗 {3} / 全 {4}",
|
||||
"완료: 성공 {0} · 취소 {1} · 실패 {2} / 전체 {3}": "完了: 成功 {0} · 取消 {1} · 失敗 {2} / 全 {3}",
|
||||
"외부": "外部",
|
||||
"외부 브라우저": "外部ブラウザー",
|
||||
|
|
@ -1687,6 +1696,7 @@
|
|||
"이름을 찾을 수 없습니다: {0} (DNS {1})": "名前が見つかりません: {0} (DNS {1})",
|
||||
"이미 그리는 중입니다": "すでに描画中です",
|
||||
"이미 동영상 목록에 있습니다 ({0}개).": "既に動画リストにあります({0}個)。",
|
||||
"이미 만든 MKV 가 있습니다 ({0}개)": "作成済みの MKV があります ({0}件)",
|
||||
"이미 목록에 있는 동영상입니다 ({0}개).": "既にリストにある動画です({0}個)。",
|
||||
"이미 받는 중이거나 받을 수 없는 주소입니다.": "既にダウンロード中か、ダウンロードできない URL です。",
|
||||
"이미 실행 중인 창이 응답하지 않습니다.\n\n[예] 그래도 열기 - 같은 데이터 위에 창이 두 개가 되어 설정 · 목록이 서로 덮어쓸 수 있습니다.\n[아니요] 닫기 - 응답하지 않는 창을 작업 관리자에서 끝낸 뒤 다시 실행하세요.": "すでに実行中のウィンドウが応答しません。\n\n[はい] それでも開く - 同じデータの上にウィンドウが 2 つになり、設定 · 一覧が互いに上書きされることがあります。\n[いいえ] 閉じる - 応答しないウィンドウをタスク マネージャーで終了してから、もう一度実行してください。",
|
||||
|
|
@ -1694,6 +1704,7 @@
|
|||
"이미 이 폴더에 있습니다: {0}": "すでにこのフォルダーにあります: {0}",
|
||||
"이미 이 폴더에 있어 건너뜀: {0}": "既にこのフォルダーにあるためスキップ: {0}",
|
||||
"이미 있는 폴더입니다: {0}": "既にあるフォルダーです: {0}",
|
||||
"이미 있어 건너뜀": "すでにあるためスキップ",
|
||||
"이미 작업 중입니다": "すでに作業中です",
|
||||
"이미지 · PDF · ZIP 보기": "画像・PDF・ZIP ビューアー",
|
||||
"이미지 자막은 번역할 수 없습니다": "画像字幕は翻訳できません",
|
||||
|
|
|
|||
|
|
@ -196,6 +196,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 · 字幕保存位置",
|
||||
|
|
@ -560,6 +561,7 @@
|
|||
"거꾸로": "倒序",
|
||||
"거꾸로 된 범위: {0}": "范围顺序颠倒: {0}",
|
||||
"건너뛰기": "跳过",
|
||||
"건너뜀 (이미 있음): {0}": "已跳过(已存在):{0}",
|
||||
"건너뜀 (출력 폴더의 파일): {0}": "已跳过(输出文件夹中的文件): {0}",
|
||||
"건너뜁니다": "跳过",
|
||||
"검색": "搜索",
|
||||
|
|
@ -757,6 +759,8 @@
|
|||
"다른 언어로 다시": "用其他语言重做",
|
||||
"다른 언어로 된 페이지를 화면 언어 ({0}) 로 번역합니다. 페이지의 글을 Google 번역으로 보냅니다. 주소창 옆 번역 버튼으로 원문 · 번역을 바꿀 수 있습니다.": "将其他语言的页面翻译为界面语言 ({0})。页面文字会发送到 Google 翻译。可用地址栏旁的翻译按钮切换原文 · 译文。",
|
||||
"다른 언어로 번역 (AI · 이 {0}에서)": "翻译为其他语言(AI · 在此{0}上)",
|
||||
"다른 이름": "另存为新名称",
|
||||
"다른 이름 (이름 (2).mkv)": "另存为新名称(名称 (2).mkv)",
|
||||
"다른 이름으로 저장": "另存为",
|
||||
"다른 이름으로 저장: {0} ({1})": "另存为: {0}({1})",
|
||||
"다른 저장소로는 이름만 바꿀 수 없습니다": "不能仅通过重命名移到其他存储",
|
||||
|
|
@ -836,7 +840,10 @@
|
|||
"더 보기": "更多",
|
||||
"더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "双击 · 默认打开方式选择 JJ_MKVMaker 时(右键菜单不变)",
|
||||
"더한 언어 (AI 자동 번역)": "已添加的语言(AI 自动翻译)",
|
||||
"덮어쓰기": "覆盖",
|
||||
"덮어쓰기 (원래 파일은 없어집니다)": "覆盖 (原文件将被替换)",
|
||||
"덮어쓰기여도 다 만든 뒤에만 바꿉니다 (도중에 취소 · 실패하면 예전 MKV 그대로)": "即使覆盖,也只在制作完成后才替换(中途取消 · 失败时保留原来的 MKV)",
|
||||
"덮어쓰기여도 다 만든 뒤에만 바꿉니다. 도중에 취소 · 실패하면 예전 MKV 는 그대로 남습니다. 늘 같게 하려면 환경 설정 > MKV 만들기 > 만들 MKV 가 이미 있으면": "即使覆盖,也只在制作完成后才替换。中途取消 · 失败时原来的 MKV 会保留。若要始终相同:设置 > 制作 MKV > 要制作的 MKV 已存在时",
|
||||
"덮어씁니다": "覆盖",
|
||||
"덴마크어": "丹麦语",
|
||||
"독일어": "德语",
|
||||
|
|
@ -935,6 +942,7 @@
|
|||
"마지막 화면입니다": "这是最后一个画面",
|
||||
"마지막으로: {0}": "到最后: {0}",
|
||||
"만든 그림 · 저장: {0}": "生成的图片 · 保存位置: {0}",
|
||||
"만들 MKV 가 이미 있으면": "要制作的 MKV 已存在时",
|
||||
"만들 자막 언어 (파일명_언어코드.srt)": "要生成的字幕语言(文件名_语言代码.srt)",
|
||||
"만들거나 쓰지 못했습니다": "无法创建或写入",
|
||||
"만들기": "生成",
|
||||
|
|
@ -1487,6 +1495,7 @@
|
|||
"완료 시 자동 동영상 추가": "完成后自动添加视频",
|
||||
"완료 정리": "清理已完成",
|
||||
"완료: 성공 {0} / 전체 {1}": "完成: 成功 {0} / 共 {1}",
|
||||
"완료: 성공 {0} · 건너뜀 {1} · 취소 {2} · 실패 {3} / 전체 {4}": "完成:成功 {0} · 跳过 {1} · 取消 {2} · 失败 {3} / 共 {4}",
|
||||
"완료: 성공 {0} · 취소 {1} · 실패 {2} / 전체 {3}": "完成:成功 {0} · 取消 {1} · 失败 {2} / 共 {3}",
|
||||
"외부": "外部",
|
||||
"외부 브라우저": "外部浏览器",
|
||||
|
|
@ -1687,6 +1696,7 @@
|
|||
"이름을 찾을 수 없습니다: {0} (DNS {1})": "找不到名称: {0} (DNS {1})",
|
||||
"이미 그리는 중입니다": "已在绘制中",
|
||||
"이미 동영상 목록에 있습니다 ({0}개).": "已在视频列表中({0} 个)。",
|
||||
"이미 만든 MKV 가 있습니다 ({0}개)": "已有制作好的 MKV({0} 个)",
|
||||
"이미 목록에 있는 동영상입니다 ({0}개).": "这些视频已在列表中({0} 个)。",
|
||||
"이미 받는 중이거나 받을 수 없는 주소입니다.": "已在下载中,或该地址无法下载。",
|
||||
"이미 실행 중인 창이 응답하지 않습니다.\n\n[예] 그래도 열기 - 같은 데이터 위에 창이 두 개가 되어 설정 · 목록이 서로 덮어쓸 수 있습니다.\n[아니요] 닫기 - 응답하지 않는 창을 작업 관리자에서 끝낸 뒤 다시 실행하세요.": "已在运行的窗口没有响应。\n\n[是] 仍然打开 - 同一数据上将有两个窗口,设置和列表可能互相覆盖。\n[否] 关闭 - 请在任务管理器中结束无响应的窗口后重新运行。",
|
||||
|
|
@ -1694,6 +1704,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']
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 없음');
|
||||
|
|
|
|||
|
|
@ -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