From bf4b27a5f65ec3e098e4e361d81951cada8d76a4 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:11:52 +0900 Subject: [PATCH] =?UTF-8?q?MKV=20=EB=8B=A4=EC=8B=9C=20=EB=A7=8C=EB=93=A4?= =?UTF-8?q?=EA=B8=B0:=20=EC=9E=84=EC=8B=9C=20=EC=9D=B4=EB=A6=84=EC=97=90?= =?UTF-8?q?=20=EB=A7=8C=EB=93=A4=EA=B3=A0=20=EB=8B=A4=20=EB=90=90=EC=9D=84?= =?UTF-8?q?=20=EB=95=8C=EB=A7=8C=20=EB=B0=94=EA=BF=94=20=EB=84=A3=EC=9D=8C?= =?UTF-8?q?=20=C2=B7=20=EC=9D=B4=EB=AF=B8=20=EC=9E=88=EC=9C=BC=EB=A9=B4=20?= =?UTF-8?q?=EB=AC=BB=EA=B8=B0=20=C2=B7=20=EB=AC=B6=EC=9D=8C=20=EC=A7=91?= =?UTF-8?q?=EA=B3=84=20=C2=B7=20=EB=81=9D=EB=82=9C=20=EB=92=A4=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=20=EB=AC=B4=EC=8B=9C=20(P0,=20931)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 데이터 (버그, 기기 확인 · 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 --- assets/l10n/en.json | 11 +++ assets/l10n/ja.json | 11 +++ assets/l10n/zh-Hans.json | 11 +++ lib/app/app_controller.dart | 99 ++++++++++++++++--- lib/app/settings.dart | 6 ++ lib/core/mkv_command_builder.dart | 3 +- .../android/ffmpeg_kit_media_tool.dart | 6 +- .../windows/desktop_storage_service.dart | 7 ++ lib/platform/windows/process_media_tool.dart | 3 +- lib/services/storage_service.dart | 3 + lib/ui/ai_dialog.dart | 10 +- lib/ui/existing_output_dialog.dart | 53 ++++++++++ lib/ui/home_page.dart | 25 +++-- lib/ui/settings_page.dart | 15 +++ lib/ui/work_panel.dart | 6 +- test/batch_test.dart | 84 ++++++++++++++++ test/queue_test.dart | 2 + test/ui_test.dart | 6 +- 18 files changed, 329 insertions(+), 32 deletions(-) create mode 100644 lib/ui/existing_output_dialog.dart diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 3394ead..d17dda7 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -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", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index b43e046..9bac117 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -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 ビューアー", "이미지 자막은 번역할 수 없습니다": "画像字幕は翻訳できません", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 1140ad5..37ac763 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -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 查看", "이미지 자막은 번역할 수 없습니다": "图像字幕无法翻译", diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index 07ad65d..9e27582 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -489,10 +489,10 @@ class AppController extends ChangeNotifier { } /// [targets] 의 AI 자막을 만들고, 이어서 그 동영상들만 MKV 로 만든다 (자막 만들기를 취소하면 거기서 멈춤) - Future aiThenBuild(List targets, AiOptions opts) async { + Future aiThenBuild(List targets, AiOptions opts, {String? onExisting}) async { await generateAiSubtitles(targets, opts); if (_aiCancelled) return; - await buildVideos(targets); + await buildVideos(targets, onExisting: onExisting); } /// 다 받은 동영상을 편집 목록에 추가 (이미 있으면 건너뜀). 새로 넣은 개수. @@ -1305,22 +1305,36 @@ class AppController extends ChangeNotifier { // ───────── MKV 만들기 ───────── /// 목록의 MKV 만들기 (다른 작업 중이면 대기열로) - Future buildAll() async { + /// [onExisting]: 결과 MKV 가 이미 있을 때 'overwrite' · 'skip' · 'rename' (없으면 환경 설정, "묻기" 인데 묻지 못한 곳은 'rename') + Future buildAll({String? onExisting}) async { if (ffmpegVersion == null) return; - return _enqueue(tr('MKV 만들기'), _runBuildAll); + return _enqueue(tr('MKV 만들기'), () => _runBuildAll(null, onExisting)); } /// 고른 동영상만 MKV 로 (이미 만든 것도 다시 만든다 - 자막이 바뀌었을 수 있으므로) - Future buildVideos(List targets) async { + Future buildVideos(List targets, {String? onExisting}) async { if (ffmpegVersion == null || targets.isEmpty) return; final label = targets.length == 1 ? trf('MKV 만들기: {0}', [targets.first.fileName]) : trf('MKV 만들기 {0}개', [targets.length]); - return _enqueue(label, () => _runBuildAll(targets)); + return _enqueue(label, () => _runBuildAll(targets, onExisting)); + } + + /// P0: 결과 MKV 가 이미 있는 동영상 (만들기 전에 덮어쓸지 묻는 데) + Future> withExistingOutput(List targets) async => [ + for (final v in targets) + if (await services.storage.exists(outputMkvPath(v.path))) v, + ]; + + /// P0: 결과 MKV 가 이미 있을 때 실제로 쓸 동작 ("묻기" 인데 묻지 못한 곳은 예전 것을 지키는 '다른 이름') + String _existingMode(String? asked) { + final m = asked ?? settings.mkvExisting; + return const ['overwrite', 'skip', 'rename'].contains(m) ? m : 'rename'; } /// 지금 MKV 를 만드는 중인지 (32: AI 자막 · 번역만 돌 때는 인코딩 설정을 막지 않는다) bool buildingMkv = false; - Future _runBuildAll([List? only]) async { + Future _runBuildAll([List? only, String? onExisting]) async { + final existing = _existingMode(onExisting); _buildCancelled = false; buildingMkv = true; notifyListeners(); @@ -1333,17 +1347,37 @@ class AppController extends ChangeNotifier { if (queue.length > 1) _log(trf('MKV 만들기: {0}개, 동시에 {1}개씩', [queue.length, limit.clamp(1, queue.length)])); _batchQueue = queue; _batchNext = 0; + // P0-2: 앞 실행의 ✓ · 실패가 이번 집계에 섞이지 않게 - 이번 묶음은 모두 "아직" 에서 시작 + for (final v in queue) { + v + ..status = JobStatus.ready + ..progress = 0 + ..message = null; + } + final skipped = {}; Future worker() async { while (!_buildCancelled && _batchNext < queue.length) { - await _build(queue[_batchNext++]); + final v = queue[_batchNext++]; + if (!await _build(v, existing)) skipped.add(v); } } await Future.wait([for (var i = 0; i < limit.clamp(1, queue.length); i++) worker()]); + // P0-2: [남은 것 모두 취소] 로 시작하지 못한 것은 "취소됨" + for (final v in queue) { + if (v.status == JobStatus.ready && !skipped.contains(v)) { + v + ..status = JobStatus.cancelled + ..message = tr('취소됨'); + } + } // 이번에 만든 것 기준 (목록의 다른 동영상은 세지 않음). 187: 취소는 실패로 세지 않는다 - int count(JobStatus s) => queue.where((v) => v.status == s).length; - _log(trf('완료: 성공 {0} · 취소 {1} · 실패 {2} / 전체 {3}', - [count(JobStatus.done), count(JobStatus.cancelled), count(JobStatus.failed), queue.length])); + int count(JobStatus s) => queue.where((v) => v.status == s && !skipped.contains(v)).length; + _log(skipped.isEmpty + ? trf('완료: 성공 {0} · 취소 {1} · 실패 {2} / 전체 {3}', + [count(JobStatus.done), count(JobStatus.cancelled), count(JobStatus.failed), queue.length]) + : trf('완료: 성공 {0} · 건너뜀 {1} · 취소 {2} · 실패 {3} / 전체 {4}', + [count(JobStatus.done), skipped.length, count(JobStatus.cancelled), count(JobStatus.failed), queue.length])); } finally { buildingMkv = false; _batchQueue = const []; @@ -1356,6 +1390,10 @@ class AppController extends ChangeNotifier { List _batchQueue = const []; int _batchNext = 0; + /// P0-4: 지금 MKV 묶음의 전체 수 · 시작한 수 (진행 글 "3개 중 2번째") + int get batchTotal => buildingMkv ? _batchQueue.length : 0; + int get batchStarted => buildingMkv ? _batchNext.clamp(0, _batchQueue.length) : 0; + /// 186: 지금 묶음에서 아직 시작하지 않은 동영상 수 int get batchWaiting => buildingMkv ? (_batchQueue.length - _batchNext).clamp(0, _batchQueue.length) : 0; @@ -1383,8 +1421,24 @@ class AppController extends ChangeNotifier { return [for (final v in targets) if (!live.contains(v)) v]; } - Future _build(VideoItem v) async { - final out = outputMkvPath(v.path); + /// 만들었으면 (또는 실패 · 취소) true, 결과가 이미 있어 건너뛰었으면 false. + /// P0-1: 임시 이름 (….mkv.jjpart) 에 만들고 끝났을 때만 결과 이름으로 바꿔 넣는다 - 취소 · 실패면 임시 파일만 지우고 + /// 예전에 완성한 MKV 는 그대로 (예전에는 FFmpeg -y 가 처음부터 결과 파일을 덮어쓰고, 실패 · 취소면 그것을 지웠다) + Future _build(VideoItem v, [String existing = 'rename']) async { + var out = outputMkvPath(v.path); + if (await services.storage.exists(out)) { + if (existing == 'skip') { + v + ..status = JobStatus.done + ..outputPath = out + ..message = tr('이미 있어 건너뜀'); + _log(trf('건너뜀 (이미 있음): {0}', [p.basename(out)])); + notifyListeners(); + return false; + } + if (existing == 'rename') out = await _freeName(out); + } + final part = '$out.jjpart'; final gen = _cancelGen; v ..status = JobStatus.running @@ -1410,7 +1464,7 @@ class AppController extends ChangeNotifier { if (_cancelledVideos.contains(v)) throw MediaToolException(tr('사용자가 취소했습니다.')); started = true; await _tool.runFfmpeg( - buildMuxArgs(v, out, encode: encode, encoders: encoders, utf8Copies: copies), + buildMuxArgs(v, part, encode: encode, encoders: encoders, utf8Copies: copies), tag: v, duration: v.info?.duration, onProgress: (x) { @@ -1418,6 +1472,8 @@ class AppController extends ChangeNotifier { notifyListeners(); }, ); + // 다 만들었을 때만 결과 이름으로 (덮어쓰기면 예전 것은 이때 바뀐다) + await services.storage.replaceFile(part, out); v ..status = JobStatus.done ..outputPath = out; @@ -1434,10 +1490,10 @@ class AppController extends ChangeNotifier { ..status = cancelled ? JobStatus.cancelled : JobStatus.failed ..message = cancelled ? tr('취소됨') : msg; _log(cancelled ? trf('취소됨: {0}', [v.fileName]) : trf('실패: {0}\n{1}', [v.fileName, msg])); - // 만들다 만 파일은 지운다 (0 바이트 MKV 가 남지 않도록). 시작 전에 실패했으면 전에 만든 MKV 는 그대로 둔다. + // 만들다 만 임시 파일만 지운다 - 결과 이름의 예전 MKV 는 건드리지 않는다 (P0-1) if (started) { try { - await services.storage.delete(out); + await services.storage.delete(part); } catch (_) {} } } finally { @@ -1449,6 +1505,17 @@ class AppController extends ChangeNotifier { } } notifyListeners(); + return true; + } + + /// "이름 (2).mkv" 처럼 비어 있는 이름 (P0-1 [다른 이름]) + Future _freeName(String path) async { + final dir = p.dirname(path), base = p.basenameWithoutExtension(path), ext = p.extension(path); + for (var i = 2; i < 1000; i++) { + final c = p.join(dir, '$base ($i)$ext'); + if (!await services.storage.exists(c)) return c; + } + return p.join(dir, '$base (${DateTime.now().millisecondsSinceEpoch})$ext'); } bool _buildCancelled = false; diff --git a/lib/app/settings.dart b/lib/app/settings.dart index fcd72b8..18b2d08 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -420,6 +420,10 @@ class AppSettings { /// 48: 복사 · 이동할 때 받는 폴더에 같은 이름이 있으면 'ask' (확인 창에서 고름, 처음 값) · 'rename' · 'overwrite' · 'skip' String copyConflict = 'ask'; + /// P0: MKV 를 만들 때 결과 MKV 가 이미 있으면 - 'ask' 묻기 (처음 값) · 'overwrite' 덮어쓰기 · 'skip' 건너뛰기 · 'rename' 다른 이름. + /// 덮어쓰기여도 다 만든 뒤에만 바꿔 넣는다 (도중에 취소 · 실패하면 예전 것 그대로) + String mkvExisting = 'ask'; + /// 229: 앱이 직접 하는 복사 · 이동 (현재 방식 · WebDAV 의 안의 것 맞추기) 에서 받는 쪽이 더 새것이면 건너뛴다 (rsync -u 와 같음). /// 처음 값 켜짐 - 예전 원본으로 새 파일을 덮어쓰고 (이동이면 원본까지 지워) 새 내용을 잃었다 (관리자 판단) bool copySkipNewer = true; @@ -727,6 +731,7 @@ class AppSettings { 'explorerPaths': explorerPaths, 'copyMethodFile': copyMethodFile, 'copyConflict': copyConflict, + 'mkvExisting': mkvExisting, 'copySkipNewer': copySkipNewer, 'copyMethodFolder': copyMethodFolder, 'rsyncPaths': rsyncPaths, @@ -938,6 +943,7 @@ class AppSettings { ..explorerPaths = [for (final x in (j['explorerPaths'] as List?) ?? const []) '$x'] ..copyMethodFile = _method(j['copyMethodFile']) ..copyConflict = const ['rename', 'overwrite', 'skip'].contains(j['copyConflict']) ? j['copyConflict'] as String : 'ask' + ..mkvExisting = const ['rename', 'overwrite', 'skip'].contains(j['mkvExisting']) ? j['mkvExisting'] as String : 'ask' ..copySkipNewer = j['copySkipNewer'] != false ..copyMethodFolder = _method(j['copyMethodFolder']) ..rsyncPaths = [for (final x in (j['rsyncPaths'] as List?) ?? const []) '$x'] diff --git a/lib/core/mkv_command_builder.dart b/lib/core/mkv_command_builder.dart index 114d582..231694b 100644 --- a/lib/core/mkv_command_builder.dart +++ b/lib/core/mkv_command_builder.dart @@ -81,7 +81,8 @@ List buildMuxArgs( args.addAll(['-disposition:s:$j', j == defaultIdx ? 'default' : '0']); } - args.addAll(['-progress', 'pipe:1', '-nostats', outputPath]); + // 임시 이름 (….mkv.jjpart) 에 써도 MKV 로 (확장자로 정하지 않게) + args.addAll(['-progress', 'pipe:1', '-nostats', '-f', 'matroska', outputPath]); return args; } diff --git a/lib/platform/android/ffmpeg_kit_media_tool.dart b/lib/platform/android/ffmpeg_kit_media_tool.dart index 0923a1a..e779c7c 100644 --- a/lib/platform/android/ffmpeg_kit_media_tool.dart +++ b/lib/platform/android/ffmpeg_kit_media_tool.dart @@ -80,7 +80,11 @@ class FfmpegKitMediaTool implements MediaTool { _running.remove(id); _tags.remove(id); final rc = await s.getReturnCode(); - if (_cancelled.remove(id) || ReturnCode.isCancel(rc)) { + // 끝까지 한 (성공) 뒤에 온 취소는 무시한다 - 다 만든 파일을 "취소됨" 으로 지우지 않게 + if (ReturnCode.isSuccess(rc)) { + _cancelled.remove(id); + done.complete(); + } else if (_cancelled.remove(id) || ReturnCode.isCancel(rc)) { done.completeError(MediaToolException(tr('사용자가 취소했습니다.'))); } else if (!ReturnCode.isSuccess(rc)) { done.completeError(MediaToolException(trf('ffmpeg 오류 (코드 {0})\n{1}', [rc?.getValue(), errTail.join('\n')]))); diff --git a/lib/platform/windows/desktop_storage_service.dart b/lib/platform/windows/desktop_storage_service.dart index 7c31523..a1e4e1b 100644 --- a/lib/platform/windows/desktop_storage_service.dart +++ b/lib/platform/windows/desktop_storage_service.dart @@ -98,6 +98,13 @@ class DesktopStorageService implements StorageService { if (await f.exists()) await f.delete(); } + @override + Future replaceFile(String from, String to) async { + final old = File(to); + if (await old.exists()) await old.delete(); + await File(from).rename(to); + } + @override Future tempDirectory() async { final dir = Directory(p.join(Directory.systemTemp.path, 'jj_mkvmaker')); diff --git a/lib/platform/windows/process_media_tool.dart b/lib/platform/windows/process_media_tool.dart index 9e150ba..9324838 100644 --- a/lib/platform/windows/process_media_tool.dart +++ b/lib/platform/windows/process_media_tool.dart @@ -146,7 +146,8 @@ class ProcessMediaTool implements MediaTool { _running.remove(proc); _tags.remove(proc); - if (_killed.remove(proc)) throw MediaToolException(tr('사용자가 취소했습니다.')); + // 끝까지 한 (코드 0) 뒤에 온 취소는 무시한다 - 다 만든 파일을 "취소됨" 으로 지우지 않게 + if (_killed.remove(proc) && code != 0) throw MediaToolException(tr('사용자가 취소했습니다.')); if (code != 0) { throw MediaToolException(trf('ffmpeg 오류 (코드 {0})\n{1}', [code, errTail.join('\n')])); } diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index d0274cd..a2564f6 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -33,6 +33,9 @@ abstract class StorageService { Future delete(String path); + /// P0: [from] (다 만든 임시 파일) 을 [to] 이름으로 바꿔 넣는다 ([to] 가 있으면 그것을 지우고) + Future replaceFile(String from, String to); + /// 앱 전용 임시 폴더 Future tempDirectory(); diff --git a/lib/ui/ai_dialog.dart b/lib/ui/ai_dialog.dart index 3b7ebaa..931b6e8 100644 --- a/lib/ui/ai_dialog.dart +++ b/lib/ui/ai_dialog.dart @@ -6,6 +6,7 @@ import '../app/app_controller.dart'; import '../core/languages.dart'; import '../core/models.dart'; import '../services/model_store.dart'; +import 'existing_output_dialog.dart'; import 'license_texts.dart' show NllbLicenseNote; import 'theme.dart'; import '../l10n/tr.dart'; @@ -20,7 +21,14 @@ Future showAiDialog(BuildContext context, AppController c, VideoItem curre final messenger = ScaffoldMessenger.maybeOf(context); final overlay = Navigator.maybeOf(context)?.overlay; Future run(List v, AiOptions o) async { - await (thenBuild ? c.aiThenBuild(v, o) : c.generateAiSubtitles(v, o)); + // P0: 자막 → MKV 면 결과 MKV 가 이미 있을 때 시작 전에 묻는다 + String? existing; + if (thenBuild) { + if (!context.mounted) return; + existing = await askExistingOutputs(context, c, v); + if (existing == null) return; + } + await (thenBuild ? c.aiThenBuild(v, o, onExisting: existing) : c.generateAiSubtitles(v, o)); if (o.source == undetermined && messenger != null) { showDetectedLanguage(messenger, overlay, v, onRetry: (lang) => run(v, o.copyWith(source: lang))); } diff --git a/lib/ui/existing_output_dialog.dart b/lib/ui/existing_output_dialog.dart new file mode 100644 index 0000000..875aa49 --- /dev/null +++ b/lib/ui/existing_output_dialog.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; + +import '../app/app_controller.dart'; +import '../core/models.dart'; +import '../core/output_paths.dart'; +import '../l10n/tr.dart'; + +/// P0: MKV 를 만들기 전에 결과 MKV 가 이미 있는 것이 있으면 묻는다 (환경 설정 "만들 MKV 가 이미 있으면" 이 "묻기" 일 때). +/// 돌려주는 것: 'overwrite' · 'skip' · 'rename', 이미 있는 것이 없으면 설정 값 그대로, [취소] 면 null (만들지 않음). +/// 예전에는 묻지 않고 시작해, 도중에 취소하면 예전에 완성한 MKV 까지 사라졌다 +Future askExistingOutputs(BuildContext context, AppController c, List targets) async { + final mode = c.settings.mkvExisting; + if (mode != 'ask') return mode; + final have = await c.withExistingOutput(targets); + if (have.isEmpty || !context.mounted) return 'ask'; + final names = [for (final v in have.take(5)) p.basename(outputMkvPath(v.path))]; + return showDialog( + context: context, + builder: (ctx) => AlertDialog( + scrollable: true, + actionsOverflowDirection: VerticalDirection.down, // [취소] 가 먼저 + title: Text(trf('이미 만든 MKV 가 있습니다 ({0}개)', [have.length])), + content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + for (final n in names) Text('· $n', style: const TextStyle(fontSize: 13)), + if (have.length > names.length) Text(trf('… 외 {0}개', [have.length - names.length]), style: const TextStyle(fontSize: 13)), + const SizedBox(height: 10), + Text(tr('덮어쓰기여도 다 만든 뒤에만 바꿉니다. 도중에 취소 · 실패하면 예전 MKV 는 그대로 남습니다. ' + '늘 같게 하려면 환경 설정 > MKV 만들기 > 만들 MKV 가 이미 있으면')), + ]), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))), + TextButton(onPressed: () => Navigator.pop(ctx, 'skip'), child: Text(tr('건너뛰기'))), + TextButton(onPressed: () => Navigator.pop(ctx, 'rename'), child: Text(tr('다른 이름'))), + FilledButton(onPressed: () => Navigator.pop(ctx, 'overwrite'), child: Text(tr('덮어쓰기'))), + ], + ), + ); +} + +/// 고른 동영상을 MKV 로 - 결과가 이미 있으면 먼저 묻는다 ([취소] 면 만들지 않음) +Future buildVideosAsking(BuildContext context, AppController c, List targets) async { + final mode = await askExistingOutputs(context, c, targets); + if (mode == null) return; + await c.buildVideos(targets, onExisting: mode); +} + +/// 아직 만들지 않은 것 모두 MKV 로 - 결과가 이미 있으면 먼저 묻는다 +Future buildAllAsking(BuildContext context, AppController c) async { + final mode = await askExistingOutputs(context, c, [for (final v in c.videos) if (v.status != JobStatus.done) v]); + if (mode == null) return; + await c.buildAll(onExisting: mode); +} diff --git a/lib/ui/home_page.dart b/lib/ui/home_page.dart index d462fa4..ec9fb64 100644 --- a/lib/ui/home_page.dart +++ b/lib/ui/home_page.dart @@ -16,6 +16,7 @@ import '../core/output_paths.dart'; import 'ai_dialog.dart'; import 'app_actions.dart'; import 'downloads_page.dart'; +import 'existing_output_dialog.dart'; import 'exit_guard.dart'; import 'package:path/path.dart' as p; @@ -265,7 +266,8 @@ class _TopBar extends StatelessWidget { ], ), ); - if (ok == true) await c.buildAll(); + // P0: 결과 MKV 가 이미 있으면 먼저 묻는다 + if (ok == true && context.mounted) await buildAllAsking(context, c); } static String _batchHint(AppController c, String what) => c.checked.isEmpty @@ -363,7 +365,7 @@ class _TopBar extends StatelessWidget { // 2: 좁은 화면 (폰 세로) 에서는 버튼 글자를 숨기지 않고 옆으로 밀어 본다. 가장 많이 쓰는 [MKV 만들기] 를 맨 앞에. if (compact) { VoidCallback? build0() => - !canBuild ? null : (batch.isEmpty ? () => _confirmBuildAll(context) : () => c.buildVideos(batch)); + !canBuild ? null : (batch.isEmpty ? () => _confirmBuildAll(context) : () => buildVideosAsking(context, c, batch)); return AppTopBar( nav: const AppNavButtons(onMkvPage: true), actions: AppActions(c: c, onExit: onExit, showUsage: false), @@ -398,14 +400,17 @@ class _TopBar extends StatelessWidget { const SizedBox(height: 28, child: VerticalDivider(width: 16, color: JjColors.border)), ...aiButtons(true), ], - if (c.busy) ...[ - const SizedBox(width: 8), - JobIndicator(c: c, compact: true, iconOnly: false), - const SizedBox(width: 8), - action(Icons.stop, tr('취소'), () => _cancelJobs(context), text: true, tip: tr('작업 취소'), color: JjColors.danger), - ], ]), ), + // P0-4: 작업 중에는 진행 · [취소] 를 줄 끝 (늘 보이는 곳) 에 - 예전에는 옆으로 미는 줄의 맨 끝이라 두 번 밀어야 보였다 + trailing: c.busy + ? Row(mainAxisSize: MainAxisSize.min, children: [ + const SizedBox(width: 6), + JobIndicator(c: c, compact: true, iconOnly: true), + const SizedBox(width: 4), + action(Icons.stop, tr('취소'), () => _cancelJobs(context), text: true, tip: tr('작업 취소'), color: JjColors.danger), + ]) + : null, ); } @@ -470,13 +475,13 @@ class _TopBar extends StatelessWidget { message: mkvText ? trf('MKV 만들기{0}', [count]) : '', child: mkvText ? FilledButton.icon( - onPressed: !canBuild ? null : (batch.isEmpty ? () => _confirmBuildAll(context) : () => c.buildVideos(batch)), + onPressed: !canBuild ? null : (batch.isEmpty ? () => _confirmBuildAll(context) : () => buildVideosAsking(context, c, batch)), icon: Icon(c.busy ? Icons.playlist_add : Icons.play_arrow, size: 20), label: Text(trf('MKV 만들기{0}{1}', [count, c.busy && jobText ? tr(' (대기열)') : ''])), ) : IconButton.filled( tooltip: trf('MKV 만들기{0}', [count]), // 241: TalkBack 이 읽는 이름 - onPressed: !canBuild ? null : (batch.isEmpty ? () => _confirmBuildAll(context) : () => c.buildVideos(batch)), + onPressed: !canBuild ? null : (batch.isEmpty ? () => _confirmBuildAll(context) : () => buildVideosAsking(context, c, batch)), icon: Icon(c.busy ? Icons.playlist_add : Icons.play_arrow, size: 20), ), ), diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index 5efeb61..73c6751 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -470,6 +470,21 @@ class _SettingsPageState extends State { onChanged: (v) => c.updateSettings((x) => x.tripleTapAction = v!), ), ), + // P0: 결과 MKV 가 이미 있을 때 (처음 값: 묻기) + SettingTile( + title: Text(tr('만들 MKV 가 이미 있으면')), + subtitle: Text(tr('덮어쓰기여도 다 만든 뒤에만 바꿉니다 (도중에 취소 · 실패하면 예전 MKV 그대로)')), + trailing: DropdownButton( + value: const ['ask', 'overwrite', 'skip', 'rename'].contains(s.mkvExisting) ? s.mkvExisting : 'ask', + items: [ + DropdownMenuItem(value: 'ask', child: Text(tr('묻기'))), + DropdownMenuItem(value: 'overwrite', child: Text(tr('덮어쓰기'))), + DropdownMenuItem(value: 'skip', child: Text(tr('건너뛰기'))), + DropdownMenuItem(value: 'rename', child: Text(tr('다른 이름 (이름 (2).mkv)'))), + ], + onChanged: (v) => c.updateSettings((x) => x.mkvExisting = v ?? 'ask'), + ), + ), SettingTile( title: Text(tr('동시 MKV 변환 수')), subtitle: Text(tr('여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)')), diff --git a/lib/ui/work_panel.dart b/lib/ui/work_panel.dart index 6e9b3bf..52af2f0 100644 --- a/lib/ui/work_panel.dart +++ b/lib/ui/work_panel.dart @@ -429,7 +429,11 @@ class JobIndicator extends StatelessWidget { ConstrainedBox( constraints: BoxConstraints(maxWidth: compact ? 160 : 240), child: Text( - '${c.currentJob ?? tr('작업 중')}${p == null ? '' : ' ${(p * 100).round()}%'}${wait > 0 ? trf(' · 대기 {0}', [wait]) : ''}', + // P0-4: 여러 개를 만들 때 한 개가 끝나면 81% → 36% 로 내려가 헷갈렸다 - "3개 중 2번째 · 36%" 처럼 지금 것의 진행 + c.batchTotal > 1 + ? '${trf('MKV {0}개 중 {1}번째', [c.batchTotal, c.batchStarted])}${p == null ? '' : ' · ${(p * 100).round()}%'}' + '${wait > 0 ? trf(' · 대기 {0}', [wait]) : ''}' + : '${c.currentJob ?? tr('작업 중')}${p == null ? '' : ' ${(p * 100).round()}%'}${wait > 0 ? trf(' · 대기 {0}', [wait]) : ''}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: JjColors.accent), diff --git a/test/batch_test.dart b/test/batch_test.dart index cad2fa9..f2a7537 100644 --- a/test/batch_test.dart +++ b/test/batch_test.dart @@ -255,6 +255,90 @@ void main() { expect(c.logs.join('\n'), contains('성공 2 · 취소 1 · 실패 0 / 전체 3')); }); + group('P0 (931): 결과 MKV 가 이미 있을 때 · 취소해도 예전 완성본은 그대로', () { + late Directory dir; + late ProcessMediaTool tool; + late AppController c; + late List files; + setUp(() async { + tool = ProcessMediaTool('ffmpeg', 'ffprobe'); + dir = Directory.systemTemp.createTempSync('jj_p0_'); + files = [for (final n in ['b1', 'b2', 'b3']) p.join(dir.path, '$n.mp4')]; + }); + tearDown(() => dir.deleteSync(recursive: true)); + + Future prepare({List seconds = const [1, 1, 1]}) async { + if (await tool.version() == null) return false; + for (final (i, f) in files.indexed) { + await tool.runFfmpeg(['-y', '-f', 'lavfi', '-i', 'testsrc=size=1280x720:rate=30', '-t', '${seconds[i]}', + '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', f]); + } + c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService())); + await c.init(); + c.settings.maxParallelJobs = 1; + c.encode = const EncodeSettings(codec: VideoCodecChoice.h264, quality: QualityChoice.high); + await c.addVideos(files); + return true; + } + + File out(int i) => File(outputMkvPath(files[i])); + void writeOld(int i) => (out(i)..createSync(recursive: true)).writeAsStringSync('OLD COMPLETE MKV'); + List parts() => [ + for (final e in Directory(p.dirname(out(0).path)).listSync()) + if (e.path.endsWith('.jjpart')) p.basename(e.path), + ]; + + test('덮어쓰기로 다시 만들다 취소하면 예전 완성본이 그대로 (예전에는 지워졌다) · 임시 파일도 남지 않음', () async { + if (!await prepare(seconds: [60, 1, 1])) return markTestSkipped('FFmpeg 없음'); + final v1 = c.videos.first; + writeOld(0); + final building = c.buildVideos([v1], onExisting: 'overwrite'); + for (var i = 0; i < 400 && !(v1.status == JobStatus.running && v1.progress > 0.02); i++) { + await Future.delayed(const Duration(milliseconds: 25)); + } + expect(out(0).readAsStringSync(), 'OLD COMPLETE MKV', reason: '만드는 동안에도 예전 것은 그대로 (임시 이름에 씀)'); + c.cancel(); + await building; + expect(v1.status, JobStatus.cancelled); + expect(out(0).readAsStringSync(), 'OLD COMPLETE MKV', reason: '취소해도 예전 완성본은 남는다'); + expect(parts(), isEmpty, reason: '만들다 만 임시 파일은 지움'); + }); + + test('덮어쓰기 · 건너뛰기 · 다른 이름', () async { + if (!await prepare()) return markTestSkipped('FFmpeg 없음'); + final [v1, v2, v3] = c.videos; + for (final i in [0, 1, 2]) { + writeOld(i); + } + await c.buildVideos([v1], onExisting: 'overwrite'); + expect(out(0).lengthSync(), greaterThan(100), reason: '다 만든 뒤 바꿔 넣음'); + await c.buildVideos([v2], onExisting: 'skip'); + expect(out(1).readAsStringSync(), 'OLD COMPLETE MKV', reason: '건너뜀 - 그대로'); + expect(c.logs.join('\n'), contains('건너뜀 (이미 있음): b2.mkv')); + await c.buildVideos([v3], onExisting: 'rename'); + expect(out(2).readAsStringSync(), 'OLD COMPLETE MKV', reason: '다른 이름 - 예전 것 그대로'); + expect(File(p.join(p.dirname(out(2).path), 'b3 (2).mkv')).lengthSync(), greaterThan(100)); + expect(v3.outputPath, endsWith('b3 (2).mkv')); + expect(parts(), isEmpty); + }); + + test('240 집계: 다시 만들다 [남은 것 모두 취소] - 앞 실행의 ✓ 가 섞이지 않고 시작 못 한 것은 취소 (성공 1 · 취소 2)', () async { + if (!await prepare(seconds: [1, 60, 1])) return markTestSkipped('FFmpeg 없음'); + final [v1, v2, v3] = c.videos; + await c.buildVideos([v1, v2, v3].sublist(0, 1), onExisting: 'overwrite'); + v2.status = JobStatus.done; // 앞 실행에서 ✓ 였던 것 + v3.status = JobStatus.done; + final building = c.buildVideos([v1, v2, v3], onExisting: 'overwrite'); + for (var i = 0; i < 800 && !(v2.status == JobStatus.running && v2.progress > 0.01); i++) { + await Future.delayed(const Duration(milliseconds: 25)); + } + c.cancel(); + await building; + expect([v1.status, v2.status, v3.status], [JobStatus.done, JobStatus.cancelled, JobStatus.cancelled]); + expect(c.logs.last, contains('성공 1 · 취소 2 · 실패 0 / 전체 3')); + }); + }); + test('239: 취소 창을 연 뒤 그 동영상이 끝나고 다음 것이 시작했으면 [지금 것만 취소] 는 다음 것을 취소하지 않는다', () async { final tool = ProcessMediaTool('ffmpeg', 'ffprobe'); if (await tool.version() == null) return markTestSkipped('FFmpeg 없음'); diff --git a/test/queue_test.dart b/test/queue_test.dart index 86d4531..86b50fb 100644 --- a/test/queue_test.dart +++ b/test/queue_test.dart @@ -68,6 +68,8 @@ class _Tool implements MediaTool { now++; if (now > peak) peak = now; await Future.delayed(const Duration(milliseconds: 30)); + // 진짜 FFmpeg 처럼 출력 (맨 끝 인수 - MKV 는 임시 이름 ….jjpart) 을 만든다 + File(args.last).writeAsStringSync('mkv'); now--; } diff --git a/test/ui_test.dart b/test/ui_test.dart index db56ace..f167d78 100644 --- a/test/ui_test.dart +++ b/test/ui_test.dart @@ -54,6 +54,8 @@ class _FakeStorage implements StorageService { @override Future exists(String path) async => true; @override + Future replaceFile(String from, String to) async {} + @override Future> readHead(String path, int maxBytes) async => [0x31]; @override Future readBytes(String path) async => Uint8List(0); @@ -191,7 +193,9 @@ void main() { expect(find.text('한국어 (ko)'), findsOneWidget); expect(find.text('영어 (en)'), findsOneWidget); - // 체크한 것이 없으면 세부 정보에 보이는 동영상을 바로 만든다 (묻지 않음) + // 체크한 것이 없으면 세부 정보에 보이는 동영상을 바로 만든다 (묻지 않음). + // P0: 이 시험의 가짜 저장소는 모든 파일이 "있다" 고 하므로, 결과 MKV 가 있을 때 묻지 않게 덮어쓰기로 + c.settings.mkvExisting = 'overwrite'; await tester.tap(find.text('MKV 만들기')); await tester.pumpAndSettle(); expect(find.text('전체 만들기'), findsNothing);