From 1410dad2aa401c8e955e50de28bcdd94d0829126 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:19:35 +0900 Subject: [PATCH] =?UTF-8?q?MKV=20=EB=A7=8C=EB=93=A4=EA=B8=B0:=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EB=B9=84=EC=9C=A8=20(=EA=B0=80=EB=A1=9C=2016:9=20?= =?UTF-8?q?=C2=B7=20=EC=84=B8=EB=A1=9C=209:16=20=C2=B7=201:1=20=C2=B7=204:?= =?UTF-8?q?5,=20=EC=B1=84=EC=9A=B0=EA=B8=B0=20=C2=B7=20=EB=A7=9E=EC=B6=94?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=ED=9D=90=EB=A6=B0=20=EB=B0=B0=EA=B2=BD)?= =?UTF-8?q?=20=C2=B7=20=ED=9A=8C=EC=A0=84=20=C2=B7=20=EC=83=89=20=EB=B3=B4?= =?UTF-8?q?=EC=A0=95=20(=EB=B0=9D=EA=B8=B0=20=C2=B7=20=EB=8C=80=EB=B9=84?= =?UTF-8?q?=20=C2=B7=20=EC=B1=84=EB=8F=84=20=C2=B7=20=EC=83=89=EC=98=A8?= =?UTF-8?q?=EB=8F=84),=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=EC=B0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 29 ++ assets/l10n/ja.json | 29 ++ assets/l10n/zh-Hans.json | 29 ++ integration_test/video_adjust_e2e_test.dart | 81 ++++++ lib/app/app_controller.dart | 16 +- lib/app/settings.dart | 17 ++ lib/core/encode_options.dart | 187 +++++++++++- lib/ui/home_page.dart | 15 +- lib/ui/video_adjust_dialog.dart | 298 ++++++++++++++++++++ test/video_adjust_test.dart | 77 +++++ 10 files changed, 762 insertions(+), 16 deletions(-) create mode 100644 integration_test/video_adjust_e2e_test.dart create mode 100644 lib/ui/video_adjust_dialog.dart create mode 100644 test/video_adjust_test.dart diff --git a/assets/l10n/en.json b/assets/l10n/en.json index aa9aa1d..6714733 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -29,6 +29,7 @@ "(설명 없음)": "(no description)", "+ 여기에 새 줄": "+ New line here", ".{0} 재생 프로그램 (exe)": ".{0} player (exe)", + "0 으로": "Reset to 0", "10초 뒤로 (←)": "Back 10 seconds (←)", "10초 앞으로 (→)": "Forward 10 seconds (→)", "5초 뒤로": "Back 5 seconds", @@ -86,6 +87,7 @@ "MKV 만들기: {0}개, 동시에 {1}개씩": "Make MKV: {0}, {1} at a time", "MKV 만들기{0}": "Make MKV{0}", "MKV 만들기{0}{1}": "Make MKV{0}{1}", + "MKV 만들기에 적용됩니다 (영상을 다시 인코딩)": "Applied when making MKVs (the video is re-encoded)", "MKV 만들기의 동영상 목록에 {0}개를 추가했습니다.{1}": "Added {0} to the Make MKV video list.{1}", "MKV 완성": "MKV done", "MKV 화면": "MKV screen", @@ -166,6 +168,7 @@ "· 백그라운드로: 창만 숨기고 다운로드 · 변환을 계속합니다 ({0} 또는 트레이 아이콘으로 다시 열기)\n· 모두 종료: 다운로드 · 변환을 멈추고 프로그램을 끝냅니다": "· Background: only hides the window and keeps downloading · converting (reopen with {0} or the tray icon)\n· Quit all: stops downloads · conversions and closes the program", "※ 다른 작업 중이면 대기열에 넣고 차례대로 합니다. 번역하는 동안 브라우저를 써도 됩니다.": "※ If another job is running, this is queued and done in order. You can use the browser while translating.", "※ 시간이 오래 걸립니다 (영상 1시간 ≈ 음성인식 15~30분 + 언어당 번역 10~20분)": "※ This takes a long time (1 hour of video ≈ 15–30 min speech recognition + 10–20 min translation per language)", + "− 차갑게 · + 따뜻하게": "− cooler · + warmer", "⋮⋮ 를 끌어 순서 바꾸기 · 아이콘을 끌어 폴더에 넣기 · 오른쪽 클릭 메뉴": "Drag ⋮⋮ to reorder · drag the icon into a folder · right-click menu", "── 시작 {0} (Android) ──": "── Start {0} (Android) ──", "── 시작 {0} ({1}) ──": "── Start {0} ({1}) ──", @@ -173,6 +176,7 @@ "⚠ FFmpeg 를 찾을 수 없습니다. MKV 만들기를 사용할 수 없습니다.": "⚠ FFmpeg not found. Make MKV is unavailable.", "⚠ Windows COM 준비 상태가 풀려 있어 다시 준비했습니다 (브라우저 · 끌어다 놓기에 필요)": "⚠ Windows COM was not ready, so it was prepared again (needed for the browser · drag and drop)", "⚠ 지난 실행이 정상적으로 끝나지 않았습니다 (시작 {0}, 마지막 확인 {1}). 그 전의 기록은 Logs 폴더의 app.log 에 있습니다.": "⚠ The last run did not end normally (started {0}, last check {1}). Earlier records are in app.log in the Logs folder.", + "가로 16:9": "Landscape 16:9", "가로 고정 (기본)": "Landscape lock (default)", "가로 고정 · 세로 고정, 또는 자동 (기기를 돌리는 대로, 기기의 자동 회전 설정을 따름)": "Lock landscape · lock portrait, or auto (follows how you hold the device and its auto-rotate setting)", "가입 → 프로필 → API consumers → New consumer": "Sign up → Profile → API consumers → New consumer", @@ -243,6 +247,7 @@ "네덜란드어": "Dutch", "네팔어": "Nepali", "노르웨이어": "Norwegian", + "누르고 있으면 조정 전": "Hold to see before", "다 받는 대로 MKV 만들기의 동영상 목록에 넣고, 다운로드 목록에서는 뺍니다 (다운로드 화면의 체크 상자와 같음)": "As soon as they finish, put them in the Make MKV video list and remove them from the download list (same as the checkbox on the download screen)", "다 받으면 MKV 만들기의 동영상 목록에 자동으로 넣고, 이 다운로드 목록에서는 뺍니다 (받은 파일은 그대로)": "When finished, automatically add to the Make MKV video list and remove from this download list (the downloaded file stays)", "다른 브라우저의 즐겨찾기 가져오기": "Import bookmarks from another browser", @@ -286,6 +291,7 @@ "대기 {0}개:": "{0} waiting:", "대기 중인 작업 {0}개 취소": "Cancel {0} waiting jobs", "대기열에 추가: {0} (대기 {1}개)": "Added to queue: {0} ({1} waiting)", + "대비": "Contrast", "대상 폴더 열기": "Open containing folder", "더 보기": "More", "더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "When JJ_MKVMaker is chosen by double-click · default app (the right-click menu stays)", @@ -322,6 +328,9 @@ "마지막 인코딩 설정을 기억합니다": "Remembers the last encoding settings", "만들 자막 언어 (파일명_언어코드.srt)": "Subtitle languages to make (filename_languagecode.srt)", "말레이어": "Malay", + "맞추기 (검은 여백)": "Fit (black bars)", + "맞추기 (비율이 다를 때)": "Conform (when the aspect differs)", + "맞추기 (흐린 배경)": "Fit (blurred background)", "매번 고르기": "Ask every time", "모델 내려받는 중 {0}%": "Downloading model {0}%", "모델 내려받는 중: {0} ({1})": "Downloading model: {0} ({1})", @@ -330,6 +339,7 @@ "모두 내장 플레이어": "Built-in player for all", "모두 종료": "Quit all", "모두 지우기": "Clear all", + "모두 초기화": "Reset all", "모든 작업 취소": "Cancel all jobs", "목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "Hides ad areas in lists · next to · on top of videos", "목록 보기": "View list", @@ -338,9 +348,11 @@ "몽골어": "Mongolian", "무한": "Unlimited", "문자셋 경고": "Charset warning", + "미리보기: 목록에서 동영상을 고르세요": "Preview: choose a video in the list", "미얀마어": "Burmese", "미지정": "Unspecified", "바꾸면 바로 저장됩니다": "Changes are saved right away", + "바꾼 것이 없습니다 (원본 그대로)": "Nothing changed (original as is)", "바뀐 내용": "Changes", "바로 재생 (기본)": "Play right away (default)", "바로 전 작업: {0}": "Previous job: {0}", @@ -353,6 +365,7 @@ "받은 파일에 libcef.dll 이 없습니다.": "The downloaded file has no libcef.dll.", "받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "The downloaded file is corrupt or a different file (SHA256 mismatch). Installation was stopped.", "받음 {0}": "Received {0}", + "밝기": "Brightness", "백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)": "Background: hides only the window and keeps downloading · converting. Reopen with {0} or the tray icon.\nTo quit completely: right-click the tray icon > Quit (always quits whatever the setting)", "백그라운드로": "To background", "백그라운드로 계속 (기본)": "Keep running in the background (default)", @@ -396,6 +409,8 @@ "새 폴더 추가": "Add new folder", "새 폴더…": "New folder…", "새로고침 (F5)": "Reload (F5)", + "색 보정": "Color correction", + "색온도": "Temperature", "선택 {0} / {1}개": "Selected {0} / {1}", "선택 줄 재생": "Play selected line", "선택 줄부터 여기로 맞추기": "Align from the selected line to here", @@ -415,6 +430,8 @@ "설치하는 중": "Installing", "설치하지 못했습니다.\n{0}": "Could not install.\n{0}", "성공: {0}": "Succeeded: {0}", + "세로 4:5": "Portrait 4:5", + "세로 9:16": "Portrait 9:16", "세로 고정": "Portrait lock", "수정": "Edit", "수정 · 이동": "Edit · move", @@ -477,6 +494,7 @@ "예: Ctrl+Shift+X (Ctrl·Shift·Alt·Win + 영문자·숫자·F1~F12)": "e.g. Ctrl+Shift+X (Ctrl·Shift·Alt·Win + letter·digit·F1~F12)", "예전 구조의 프로그램 파일 정리 ({0}개): {1}": "Cleaned up program files of the old layout ({0}): {1}", "오늘 받을 수 있는 자막 수를 모두 사용했습니다. ({0})": "You have used all of today's subtitle downloads. ({0})", + "오른쪽 90°": "Right 90°", "오픈 소스 라이선스": "Open source licenses", "올린 이 {0}": "Uploaded by {0}", "완료": "Done", @@ -487,10 +505,12 @@ "외부 브라우저": "External browser", "외부 브라우저로 열기": "Open in external browser", "외부 프로그램으로 재생: {0} ← {1}개": "Play with external program: {0} ← {1}", + "왼쪽 90°": "Left 90°", "왼쪽 손잡이로 순서 바꾸기 · 아이콘을 끌어 왼쪽 폴더나 목록의 폴더 위에 놓으면 그 폴더로 이동 · 오른쪽 클릭 메뉴": "Reorder with the handle on the left · drag the icon onto a folder on the left or in the list to move it there · right-click menu", "요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "Too many requests. Try again in a moment.", "우르두어": "Urdu", "우크라이나어": "Ukrainian", + "원본 비율": "Original aspect", "원본 유지 (재인코딩 없음)": "Keep original (no re-encoding)", "원본 크기": "Original size", "원본의 {0}배": "{0}× original", @@ -639,9 +659,11 @@ "전체 화면": "Full screen", "전체 화면 (F)": "Full screen (F)", "점검": "Check", + "정사각 1:1": "Square 1:1", "정지": "Stop", "정하지 않음: MKV 목록에서 [이동] 을 처음 누를 때 고릅니다": "Not set: you choose it the first time you press [Move] in the MKV list", "제목": "Title", + "조정 전": "Before", "종료": "Quit", "종료 (창 닫기 ✕ · 종료 버튼) 를 누르면": "When you press quit (close window ✕ · quit button)", "종료 시작 {0}": "Quit started {0}", @@ -688,6 +710,8 @@ "창 종료": "Close window", "찾는 즐겨찾기가 없습니다.": "No matching bookmarks.", "찾은 자막이 없습니다. 제목을 영어 원제로 바꾸거나 언어를 추가해 보세요.": "No subtitles found. Try the original English title or add languages.", + "채도": "Saturation", + "채우기 (가운데 잘라냄)": "Fill (crop the center)", "처음 사용 시 내려받기 {0}": "Downloads {0} on first use", "첫 줄 추가": "Add first line", "청각장애인용": "Hearing impaired", @@ -778,8 +802,11 @@ "홈 화면": "Home screen", "홈 화면 ({0}) · 환경 설정에서 바꿈": "Home screen ({0}) · change in Settings", "화면": "Display", + "화면 · 색 보정": "Frame · color", "화면 글자 전체를 이 기기에서 번역합니다. 몇 분 걸릴 수 있습니다.": "Translates all of the program's text on this device. It may take a few minutes.", "화면 방향": "Screen orientation", + "화면 비율": "Aspect ratio", + "화면 비율 (가로 · 세로) · 회전 · 밝기 · 대비 · 채도 · 색온도": "Aspect ratio (landscape · portrait) · rotation · brightness · contrast · saturation · temperature", "화면 작게": "Smaller", "화면 크게": "Larger", "화면 크기": "Window size", @@ -788,6 +815,8 @@ "확인 · 압축 푸는 중…": "Verifying · extracting…", "확장자별 재생 프로그램": "Player per file extension", "환경 설정": "Settings", + "회전": "Rotation", + "회전 없음": "No rotation", "회차": "Episode", "히브리어": "Hebrew", "힌디어": "Hindi" diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 6ead769..47e5d83 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -29,6 +29,7 @@ "(설명 없음)": "(説明なし)", "+ 여기에 새 줄": "+ ここに新しい行", ".{0} 재생 프로그램 (exe)": ".{0} 再生プログラム(exe)", + "0 으로": "0 に戻す", "10초 뒤로 (←)": "10秒戻る(←)", "10초 앞으로 (→)": "10秒進む(→)", "5초 뒤로": "5秒戻る", @@ -86,6 +87,7 @@ "MKV 만들기: {0}개, 동시에 {1}개씩": "MKV 作成: {0}個、同時に {1}個ずつ", "MKV 만들기{0}": "MKV 作成{0}", "MKV 만들기{0}{1}": "MKV 作成{0}{1}", + "MKV 만들기에 적용됩니다 (영상을 다시 인코딩)": "MKV 作成に適用されます(映像を再エンコード)", "MKV 만들기의 동영상 목록에 {0}개를 추가했습니다.{1}": "MKV 作成の動画リストに {0}個を追加しました。{1}", "MKV 완성": "MKV 完成", "MKV 화면": "MKV 画面", @@ -166,6 +168,7 @@ "· 백그라운드로: 창만 숨기고 다운로드 · 변환을 계속합니다 ({0} 또는 트레이 아이콘으로 다시 열기)\n· 모두 종료: 다운로드 · 변환을 멈추고 프로그램을 끝냅니다": "· バックグラウンドへ: ウィンドウだけ隠してダウンロード・変換を続けます({0} またはトレイアイコンで再表示)\n· すべて終了: ダウンロード・変換を止めてプログラムを終了します", "※ 다른 작업 중이면 대기열에 넣고 차례대로 합니다. 번역하는 동안 브라우저를 써도 됩니다.": "※ 他の作業中ならキューに入れて順番に行います。翻訳中もブラウザーを使えます。", "※ 시간이 오래 걸립니다 (영상 1시간 ≈ 음성인식 15~30분 + 언어당 번역 10~20분)": "※ 時間がかかります(動画 1時間 ≈ 音声認識 15〜30分 + 言語ごとの翻訳 10〜20分)", + "− 차갑게 · + 따뜻하게": "− 寒色 · + 暖色", "⋮⋮ 를 끌어 순서 바꾸기 · 아이콘을 끌어 폴더에 넣기 · 오른쪽 클릭 메뉴": "⋮⋮ をドラッグして並べ替え・アイコンをドラッグしてフォルダーへ・右クリックメニュー", "── 시작 {0} (Android) ──": "── 開始 {0}(Android)──", "── 시작 {0} ({1}) ──": "── 開始 {0}({1})──", @@ -173,6 +176,7 @@ "⚠ FFmpeg 를 찾을 수 없습니다. MKV 만들기를 사용할 수 없습니다.": "⚠ FFmpeg が見つかりません。MKV 作成は使えません。", "⚠ Windows COM 준비 상태가 풀려 있어 다시 준비했습니다 (브라우저 · 끌어다 놓기에 필요)": "⚠ Windows COM の準備状態が解除されていたため、再準備しました(ブラウザー・ドラッグ&ドロップに必要)", "⚠ 지난 실행이 정상적으로 끝나지 않았습니다 (시작 {0}, 마지막 확인 {1}). 그 전의 기록은 Logs 폴더의 app.log 에 있습니다.": "⚠ 前回の実行が正常に終了しませんでした(開始 {0}、最終確認 {1})。それ以前の記録は Logs フォルダーの app.log にあります。", + "가로 16:9": "横 16:9", "가로 고정 (기본)": "横向き固定(既定)", "가로 고정 · 세로 고정, 또는 자동 (기기를 돌리는 대로, 기기의 자동 회전 설정을 따름)": "横向き固定・縦向き固定、または自動(端末の向きに合わせ、端末の自動回転設定に従う)", "가입 → 프로필 → API consumers → New consumer": "登録 → プロフィール → API consumers → New consumer", @@ -243,6 +247,7 @@ "네덜란드어": "オランダ語", "네팔어": "ネパール語", "노르웨이어": "ノルウェー語", + "누르고 있으면 조정 전": "押している間は調整前", "다 받는 대로 MKV 만들기의 동영상 목록에 넣고, 다운로드 목록에서는 뺍니다 (다운로드 화면의 체크 상자와 같음)": "ダウンロードが終わり次第 MKV 作成の動画リストに入れ、ダウンロードリストからは外します(ダウンロード画面のチェックボックスと同じ)", "다 받으면 MKV 만들기의 동영상 목록에 자동으로 넣고, 이 다운로드 목록에서는 뺍니다 (받은 파일은 그대로)": "ダウンロードが終わると MKV 作成の動画リストに自動で入れ、このダウンロードリストからは外します(ダウンロードしたファイルはそのまま)", "다른 브라우저의 즐겨찾기 가져오기": "他のブラウザーのブックマークを取り込む", @@ -286,6 +291,7 @@ "대기 {0}개:": "待機 {0}個:", "대기 중인 작업 {0}개 취소": "待機中の作業 {0}個をキャンセル", "대기열에 추가: {0} (대기 {1}개)": "キューに追加: {0}(待機 {1}個)", + "대비": "コントラスト", "대상 폴더 열기": "対象フォルダーを開く", "더 보기": "その他", "더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "ダブルクリック・既定のアプリで JJ_MKVMaker を選んだとき(右クリックメニューはそのまま)", @@ -322,6 +328,9 @@ "마지막 인코딩 설정을 기억합니다": "最後のエンコード設定を記憶します", "만들 자막 언어 (파일명_언어코드.srt)": "作成する字幕の言語(ファイル名_言語コード.srt)", "말레이어": "マレー語", + "맞추기 (검은 여백)": "フィット(黒い余白)", + "맞추기 (비율이 다를 때)": "合わせ方(比率が違うとき)", + "맞추기 (흐린 배경)": "フィット(ぼかし背景)", "매번 고르기": "毎回選ぶ", "모델 내려받는 중 {0}%": "モデルをダウンロード中 {0}%", "모델 내려받는 중: {0} ({1})": "モデルをダウンロード中: {0}({1})", @@ -330,6 +339,7 @@ "모두 내장 플레이어": "すべて内蔵プレーヤー", "모두 종료": "すべて終了", "모두 지우기": "すべて削除", + "모두 초기화": "すべてリセット", "모든 작업 취소": "すべての作業をキャンセル", "목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "リスト・動画の横・動画の上に出る広告エリアを隠します", "목록 보기": "リストを見る", @@ -338,9 +348,11 @@ "몽골어": "モンゴル語", "무한": "無制限", "문자셋 경고": "文字コードの警告", + "미리보기: 목록에서 동영상을 고르세요": "プレビュー: リストで動画を選んでください", "미얀마어": "ミャンマー語", "미지정": "未指定", "바꾸면 바로 저장됩니다": "変更するとすぐ保存されます", + "바꾼 것이 없습니다 (원본 그대로)": "変更なし(元のまま)", "바뀐 내용": "変更内容", "바로 재생 (기본)": "すぐ再生(既定)", "바로 전 작업: {0}": "直前の作業: {0}", @@ -353,6 +365,7 @@ "받은 파일에 libcef.dll 이 없습니다.": "ダウンロードしたファイルに libcef.dll がありません。", "받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "ダウンロードしたファイルが壊れているか別のファイルです(SHA256 不一致)。インストールを中止しました。", "받음 {0}": "受信 {0}", + "밝기": "明るさ", "백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)": "バックグラウンド: ウィンドウだけ隠してダウンロード・変換は続けます。{0} またはトレイアイコンで再表示します。\n完全に終了するには トレイアイコンを右クリック > 終了(どの設定でも常に終了)", "백그라운드로": "バックグラウンドへ", "백그라운드로 계속 (기본)": "バックグラウンドで続ける(既定)", @@ -396,6 +409,8 @@ "새 폴더 추가": "新しいフォルダーを追加", "새 폴더…": "新しいフォルダー…", "새로고침 (F5)": "再読み込み(F5)", + "색 보정": "カラー補正", + "색온도": "色温度", "선택 {0} / {1}개": "選択 {0} / {1}個", "선택 줄 재생": "選択行を再生", "선택 줄부터 여기로 맞추기": "選択行からここに合わせる", @@ -415,6 +430,8 @@ "설치하는 중": "インストール中", "설치하지 못했습니다.\n{0}": "インストールできませんでした。\n{0}", "성공: {0}": "成功: {0}", + "세로 4:5": "縦 4:5", + "세로 9:16": "縦 9:16", "세로 고정": "縦向き固定", "수정": "編集", "수정 · 이동": "編集・移動", @@ -477,6 +494,7 @@ "예: Ctrl+Shift+X (Ctrl·Shift·Alt·Win + 영문자·숫자·F1~F12)": "例: Ctrl+Shift+X(Ctrl・Shift・Alt・Win + 英字・数字・F1〜F12)", "예전 구조의 프로그램 파일 정리 ({0}개): {1}": "旧構成のプログラムファイルを整理({0}個): {1}", "오늘 받을 수 있는 자막 수를 모두 사용했습니다. ({0})": "今日ダウンロードできる字幕数をすべて使いました。({0})", + "오른쪽 90°": "右 90°", "오픈 소스 라이선스": "オープンソースライセンス", "올린 이 {0}": "投稿者 {0}", "완료": "完了", @@ -487,10 +505,12 @@ "외부 브라우저": "外部ブラウザー", "외부 브라우저로 열기": "外部ブラウザーで開く", "외부 프로그램으로 재생: {0} ← {1}개": "外部プログラムで再生: {0} ← {1}個", + "왼쪽 90°": "左 90°", "왼쪽 손잡이로 순서 바꾸기 · 아이콘을 끌어 왼쪽 폴더나 목록의 폴더 위에 놓으면 그 폴더로 이동 · 오른쪽 클릭 메뉴": "左のハンドルで並べ替え・アイコンを左のフォルダーかリスト内のフォルダーにドラッグするとそのフォルダーへ移動・右クリックメニュー", "요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "リクエストが多すぎます。しばらくしてからもう一度お試しください。", "우르두어": "ウルドゥー語", "우크라이나어": "ウクライナ語", + "원본 비율": "元の比率", "원본 유지 (재인코딩 없음)": "元のまま(再エンコードなし)", "원본 크기": "元のサイズ", "원본의 {0}배": "元の {0}倍", @@ -639,9 +659,11 @@ "전체 화면": "全画面", "전체 화면 (F)": "全画面(F)", "점검": "点検", + "정사각 1:1": "正方形 1:1", "정지": "停止", "정하지 않음: MKV 목록에서 [이동] 을 처음 누를 때 고릅니다": "未設定: MKV リストで[移動]を初めて押したときに選びます", "제목": "タイトル", + "조정 전": "調整前", "종료": "終了", "종료 (창 닫기 ✕ · 종료 버튼) 를 누르면": "終了(ウィンドウを閉じる ✕・終了ボタン)を押したとき", "종료 시작 {0}": "終了開始 {0}", @@ -688,6 +710,8 @@ "창 종료": "ウィンドウを閉じる", "찾는 즐겨찾기가 없습니다.": "該当するブックマークがありません。", "찾은 자막이 없습니다. 제목을 영어 원제로 바꾸거나 언어를 추가해 보세요.": "字幕が見つかりませんでした。タイトルを英語の原題に変えるか、言語を追加してみてください。", + "채도": "彩度", + "채우기 (가운데 잘라냄)": "フィル(中央を切り抜き)", "처음 사용 시 내려받기 {0}": "初回使用時にダウンロード {0}", "첫 줄 추가": "最初の行を追加", "청각장애인용": "聴覚障害者用", @@ -778,8 +802,11 @@ "홈 화면": "ホーム画面", "홈 화면 ({0}) · 환경 설정에서 바꿈": "ホーム画面({0})・環境設定で変更", "화면": "画面", + "화면 · 색 보정": "画面・カラー補正", "화면 글자 전체를 이 기기에서 번역합니다. 몇 분 걸릴 수 있습니다.": "画面の文字をすべてこの端末で翻訳します。数分かかることがあります。", "화면 방향": "画面の向き", + "화면 비율": "画面比率", + "화면 비율 (가로 · 세로) · 회전 · 밝기 · 대비 · 채도 · 색온도": "画面比率(横・縦)・回転・明るさ・コントラスト・彩度・色温度", "화면 작게": "画面を小さく", "화면 크게": "画面を大きく", "화면 크기": "画面サイズ", @@ -788,6 +815,8 @@ "확인 · 압축 푸는 중…": "確認・展開中…", "확장자별 재생 프로그램": "拡張子ごとの再生プログラム", "환경 설정": "環境設定", + "회전": "回転", + "회전 없음": "回転なし", "회차": "話数", "히브리어": "ヘブライ語", "힌디어": "ヒンディー語" diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 4620120..952b970 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -29,6 +29,7 @@ "(설명 없음)": "(无说明)", "+ 여기에 새 줄": "+ 在此新增一行", ".{0} 재생 프로그램 (exe)": ".{0} 播放程序(exe)", + "0 으로": "归零", "10초 뒤로 (←)": "后退 10 秒(←)", "10초 앞으로 (→)": "前进 10 秒(→)", "5초 뒤로": "后退 5 秒", @@ -86,6 +87,7 @@ "MKV 만들기: {0}개, 동시에 {1}개씩": "制作 MKV: {0} 个,每次同时 {1} 个", "MKV 만들기{0}": "制作 MKV{0}", "MKV 만들기{0}{1}": "制作 MKV{0}{1}", + "MKV 만들기에 적용됩니다 (영상을 다시 인코딩)": "应用于制作 MKV(视频将重新编码)", "MKV 만들기의 동영상 목록에 {0}개를 추가했습니다.{1}": "已向制作 MKV 的视频列表添加 {0} 个。{1}", "MKV 완성": "MKV 完成", "MKV 화면": "MKV 界面", @@ -166,6 +168,7 @@ "· 백그라운드로: 창만 숨기고 다운로드 · 변환을 계속합니다 ({0} 또는 트레이 아이콘으로 다시 열기)\n· 모두 종료: 다운로드 · 변환을 멈추고 프로그램을 끝냅니다": "· 转到后台: 仅隐藏窗口,继续下载 · 转换(用 {0} 或托盘图标重新打开)\n· 全部退出: 停止下载 · 转换并关闭程序", "※ 다른 작업 중이면 대기열에 넣고 차례대로 합니다. 번역하는 동안 브라우저를 써도 됩니다.": "※ 如有其他任务正在进行,将加入队列依次处理。翻译期间可以使用浏览器。", "※ 시간이 오래 걸립니다 (영상 1시간 ≈ 음성인식 15~30분 + 언어당 번역 10~20분)": "※ 耗时较长(1 小时视频 ≈ 语音识别 15~30 分钟 + 每种语言翻译 10~20 分钟)", + "− 차갑게 · + 따뜻하게": "− 偏冷 · + 偏暖", "⋮⋮ 를 끌어 순서 바꾸기 · 아이콘을 끌어 폴더에 넣기 · 오른쪽 클릭 메뉴": "拖动 ⋮⋮ 调整顺序 · 拖动图标放入文件夹 · 右键菜单", "── 시작 {0} (Android) ──": "── 启动 {0}(Android)──", "── 시작 {0} ({1}) ──": "── 启动 {0}({1})──", @@ -173,6 +176,7 @@ "⚠ FFmpeg 를 찾을 수 없습니다. MKV 만들기를 사용할 수 없습니다.": "⚠ 找不到 FFmpeg。无法使用制作 MKV。", "⚠ Windows COM 준비 상태가 풀려 있어 다시 준비했습니다 (브라우저 · 끌어다 놓기에 필요)": "⚠ Windows COM 未就绪,已重新初始化(浏览器 · 拖放需要)", "⚠ 지난 실행이 정상적으로 끝나지 않았습니다 (시작 {0}, 마지막 확인 {1}). 그 전의 기록은 Logs 폴더의 app.log 에 있습니다.": "⚠ 上次运行未正常结束(开始 {0},最后检查 {1})。之前的记录在 Logs 文件夹的 app.log 中。", + "가로 16:9": "横屏 16:9", "가로 고정 (기본)": "锁定横屏(默认)", "가로 고정 · 세로 고정, 또는 자동 (기기를 돌리는 대로, 기기의 자동 회전 설정을 따름)": "锁定横屏 · 锁定竖屏,或自动(随设备方向旋转,遵循设备的自动旋转设置)", "가입 → 프로필 → API consumers → New consumer": "注册 → 个人资料 → API consumers → New consumer", @@ -243,6 +247,7 @@ "네덜란드어": "荷兰语", "네팔어": "尼泊尔语", "노르웨이어": "挪威语", + "누르고 있으면 조정 전": "按住查看调整前", "다 받는 대로 MKV 만들기의 동영상 목록에 넣고, 다운로드 목록에서는 뺍니다 (다운로드 화면의 체크 상자와 같음)": "下载完成后立即放入制作 MKV 的视频列表,并从下载列表移除(与下载界面的复选框相同)", "다 받으면 MKV 만들기의 동영상 목록에 자동으로 넣고, 이 다운로드 목록에서는 뺍니다 (받은 파일은 그대로)": "下载完成后自动加入制作 MKV 的视频列表,并从此下载列表移除(已下载的文件保留)", "다른 브라우저의 즐겨찾기 가져오기": "导入其他浏览器的书签", @@ -286,6 +291,7 @@ "대기 {0}개:": "等待 {0} 个:", "대기 중인 작업 {0}개 취소": "取消 {0} 个等待中的任务", "대기열에 추가: {0} (대기 {1}개)": "已加入队列: {0}(等待 {1} 个)", + "대비": "对比度", "대상 폴더 열기": "打开所在文件夹", "더 보기": "更多", "더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "双击 · 默认打开方式选择 JJ_MKVMaker 时(右键菜单不变)", @@ -322,6 +328,9 @@ "마지막 인코딩 설정을 기억합니다": "记住上次的编码设置", "만들 자막 언어 (파일명_언어코드.srt)": "要生成的字幕语言(文件名_语言代码.srt)", "말레이어": "马来语", + "맞추기 (검은 여백)": "适应(黑边)", + "맞추기 (비율이 다를 때)": "适配方式(比例不同时)", + "맞추기 (흐린 배경)": "适应(模糊背景)", "매번 고르기": "每次选择", "모델 내려받는 중 {0}%": "正在下载模型 {0}%", "모델 내려받는 중: {0} ({1})": "正在下载模型: {0}({1})", @@ -330,6 +339,7 @@ "모두 내장 플레이어": "全部使用内置播放器", "모두 종료": "全部退出", "모두 지우기": "全部清除", + "모두 초기화": "全部重置", "모든 작업 취소": "取消所有任务", "목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "隐藏列表 · 视频旁 · 视频上方出现的广告区域", "목록 보기": "查看列表", @@ -338,9 +348,11 @@ "몽골어": "蒙古语", "무한": "无限制", "문자셋 경고": "字符集警告", + "미리보기: 목록에서 동영상을 고르세요": "预览: 请在列表中选择视频", "미얀마어": "缅甸语", "미지정": "未指定", "바꾸면 바로 저장됩니다": "更改后立即保存", + "바꾼 것이 없습니다 (원본 그대로)": "未做更改(保持原样)", "바뀐 내용": "更改内容", "바로 재생 (기본)": "立即播放(默认)", "바로 전 작업: {0}": "上一个任务: {0}", @@ -353,6 +365,7 @@ "받은 파일에 libcef.dll 이 없습니다.": "下载的文件中没有 libcef.dll。", "받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "下载的文件已损坏或不是正确的文件(SHA256 不匹配)。已停止安装。", "받음 {0}": "已接收 {0}", + "밝기": "亮度", "백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)": "后台: 仅隐藏窗口,下载 · 转换继续进行。用 {0} 或托盘图标重新打开。\n要完全退出: 右键托盘图标 > 退出(无论何种设置都会退出)", "백그라운드로": "转到后台", "백그라운드로 계속 (기본)": "在后台继续(默认)", @@ -396,6 +409,8 @@ "새 폴더 추가": "添加新文件夹", "새 폴더…": "新建文件夹…", "새로고침 (F5)": "刷新(F5)", + "색 보정": "色彩校正", + "색온도": "色温", "선택 {0} / {1}개": "已选 {0} / {1} 个", "선택 줄 재생": "播放所选行", "선택 줄부터 여기로 맞추기": "从所选行起对齐到此处", @@ -415,6 +430,8 @@ "설치하는 중": "正在安装", "설치하지 못했습니다.\n{0}": "未能安装。\n{0}", "성공: {0}": "成功: {0}", + "세로 4:5": "竖屏 4:5", + "세로 9:16": "竖屏 9:16", "세로 고정": "锁定竖屏", "수정": "编辑", "수정 · 이동": "编辑 · 移动", @@ -477,6 +494,7 @@ "예: Ctrl+Shift+X (Ctrl·Shift·Alt·Win + 영문자·숫자·F1~F12)": "例: Ctrl+Shift+X(Ctrl·Shift·Alt·Win + 字母·数字·F1~F12)", "예전 구조의 프로그램 파일 정리 ({0}개): {1}": "已清理旧结构的程序文件({0} 个): {1}", "오늘 받을 수 있는 자막 수를 모두 사용했습니다. ({0})": "今天可下载的字幕数已用完。({0})", + "오른쪽 90°": "向右 90°", "오픈 소스 라이선스": "开源许可", "올린 이 {0}": "上传者 {0}", "완료": "完成", @@ -487,10 +505,12 @@ "외부 브라우저": "外部浏览器", "외부 브라우저로 열기": "在外部浏览器中打开", "외부 프로그램으로 재생: {0} ← {1}개": "用外部程序播放: {0} ← {1} 个", + "왼쪽 90°": "向左 90°", "왼쪽 손잡이로 순서 바꾸기 · 아이콘을 끌어 왼쪽 폴더나 목록의 폴더 위에 놓으면 그 폴더로 이동 · 오른쪽 클릭 메뉴": "用左侧手柄调整顺序 · 将图标拖到左侧文件夹或列表中的文件夹即可移入 · 右键菜单", "요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "请求过多。请稍后再试。", "우르두어": "乌尔都语", "우크라이나어": "乌克兰语", + "원본 비율": "原始比例", "원본 유지 (재인코딩 없음)": "保持原样(不重新编码)", "원본 크기": "原始尺寸", "원본의 {0}배": "原始的 {0} 倍", @@ -639,9 +659,11 @@ "전체 화면": "全屏", "전체 화면 (F)": "全屏(F)", "점검": "检查", + "정사각 1:1": "正方形 1:1", "정지": "停止", "정하지 않음: MKV 목록에서 [이동] 을 처음 누를 때 고릅니다": "未设置: 首次在 MKV 列表中点击[移动]时选择", "제목": "标题", + "조정 전": "调整前", "종료": "退出", "종료 (창 닫기 ✕ · 종료 버튼) 를 누르면": "点击退出(关闭窗口 ✕ · 退出按钮)时", "종료 시작 {0}": "开始退出 {0}", @@ -688,6 +710,8 @@ "창 종료": "关闭窗口", "찾는 즐겨찾기가 없습니다.": "没有匹配的书签。", "찾은 자막이 없습니다. 제목을 영어 원제로 바꾸거나 언어를 추가해 보세요.": "未找到字幕。请尝试改用英文原名或添加语言。", + "채도": "饱和度", + "채우기 (가운데 잘라냄)": "填充(裁切中间)", "처음 사용 시 내려받기 {0}": "首次使用时下载 {0}", "첫 줄 추가": "添加第一行", "청각장애인용": "听障字幕", @@ -778,8 +802,11 @@ "홈 화면": "主屏幕", "홈 화면 ({0}) · 환경 설정에서 바꿈": "主屏幕({0})· 在设置中更改", "화면": "显示", + "화면 · 색 보정": "画面 · 色彩", "화면 글자 전체를 이 기기에서 번역합니다. 몇 분 걸릴 수 있습니다.": "将在此设备上翻译所有界面文字。可能需要几分钟。", "화면 방향": "屏幕方向", + "화면 비율": "画面比例", + "화면 비율 (가로 · 세로) · 회전 · 밝기 · 대비 · 채도 · 색온도": "画面比例(横屏 · 竖屏)· 旋转 · 亮度 · 对比度 · 饱和度 · 色温", "화면 작게": "缩小界面", "화면 크게": "放大界面", "화면 크기": "窗口大小", @@ -788,6 +815,8 @@ "확인 · 압축 푸는 중…": "正在校验 · 解压…", "확장자별 재생 프로그램": "按扩展名设置播放程序", "환경 설정": "设置", + "회전": "旋转", + "회전 없음": "不旋转", "회차": "集数", "히브리어": "希伯来语", "힌디어": "印地语" diff --git a/integration_test/video_adjust_e2e_test.dart b/integration_test/video_adjust_e2e_test.dart new file mode 100644 index 0000000..ea70673 --- /dev/null +++ b/integration_test/video_adjust_e2e_test.dart @@ -0,0 +1,81 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/core/encode_options.dart'; +import 'package:jj_mkvmaker/core/models.dart'; +import 'package:jj_mkvmaker/core/output_paths.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; +import 'package:jj_mkvmaker/ui/video_adjust_dialog.dart'; +import 'package:path/path.dart' as p; + +/// 실제 FFmpeg: 가로 영상 → 화면 · 색 보정 창 미리보기 → 세로 9:16 + 채도 MKV 만들기 +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('화면 · 색 보정: 미리보기와 세로 9:16 MKV', (tester) async { + final dir = Directory.systemTemp.createTempSync('jj_adj_e2e_'); + final video = p.join(dir.path, '가로 영상.mp4'); + final ffmpeg = p.join(Directory.current.path, 'third_party', 'ffmpeg', 'windows', 'ffmpeg.exe'); + final r = await Process.run(ffmpeg, [ + '-y', '-v', 'error', '-f', 'lavfi', '-i', 'testsrc2=size=640x360:rate=10:duration=3', // + '-c:v', 'libx264', '-pix_fmt', 'yuv420p', video, + ]); + expect(r.exitCode, 0, reason: '${r.stderr}'); + + final c = AppController(PlatformServices.create()); + await tester.runAsync(() async { + await c.init(); + await c.addVideos([video]); + }); + c.setAdjust(c.encode.copyWith(frame: FrameChoice.portrait, fit: FitChoice.blur, saturation: 40, temperature: 30)); + expect(c.encode.codec, isNot(VideoCodecChoice.copy), reason: '조정하면 재인코딩'); + + // 창을 열고 미리보기가 그려질 때까지 + tester.view.physicalSize = const Size(1200, 760); + tester.view.devicePixelRatio = 1; + final key = GlobalKey(); + await tester.pumpWidget(MaterialApp( + theme: buildTheme(), + // 대화 상자 (Navigator 위) 까지 사진에 담기게 앱 전체를 감싼다 + builder: (context, child) => RepaintBoundary(key: key, child: child), + home: Scaffold(body: Builder(builder: (context) { + return Center( + child: FilledButton(onPressed: () => showVideoAdjust(context, c), child: const Text('open')), + ); + })), + )); + await tester.tap(find.text('open')); + for (var i = 0; i < 60 && find.byType(Image).evaluate().isEmpty; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 200))); + await tester.pump(); + } + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 800))); + await tester.pump(); + expect(find.byType(Image), findsOneWidget, reason: '미리보기 장면'); + final shot = Platform.environment['JJ_SHOT_DIR']; + if (shot != null) { + final boundary = key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = await tester.runAsync(() => boundary.toImage()); + final bytes = await tester.runAsync(() => image!.toByteData(format: ui.ImageByteFormat.png)); + File('$shot/adjust_dialog.png').writeAsBytesSync(bytes!.buffer.asUint8List()); + } + await tester.tap(find.byTooltip('닫기')); + await tester.pumpAndSettle(); + + // MKV 만들기 → 세로 360x640 + await tester.runAsync(() => c.buildAll()); + final v = c.videos.single; + expect(v.status, JobStatus.done, reason: '${v.message}\n${c.logs.join('\n')}'); + final info = await tester.runAsync(() => c.services.mediaTool.probe(outputMkvPath(video))); + final vs = info!.ofType('video').single; + expect([vs.width, vs.height], [360, 640]); + tester.view.reset(); + dir.deleteSync(recursive: true); + }, timeout: const Timeout(Duration(minutes: 5))); +} diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index 3e3247b..6c5ebb6 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -184,10 +184,16 @@ class AppController extends ChangeNotifier { c == VideoCodecChoice.copy || c.pickEncoder(encoders) != null; void setCodec(VideoCodecChoice c) { - // 원본 유지(복사)는 크기를 바꿀 수 없으므로 원본 크기로 - encode = encode.copyWith( - codec: c, - resolution: c == VideoCodecChoice.copy ? ResolutionChoice.original : null); + // 원본 유지(복사)는 크기 · 화면 비율 · 회전 · 색 보정을 할 수 없으므로 처음으로 + encode = c == VideoCodecChoice.copy + ? encode.resetAdjust().copyWith(codec: c, resolution: ResolutionChoice.original) + : encode.copyWith(codec: c); + _encodeChanged(); + } + + /// 화면 비율 · 회전 · 색 보정 바꾸기 (재인코딩이 필요하면 기본 H.264 로) + void setAdjust(EncodeSettings e) { + encode = e.adjusts && e.codec == VideoCodecChoice.copy ? e.copyWith(codec: VideoCodecChoice.h264) : e; _encodeChanged(); } @@ -1180,7 +1186,7 @@ class AppController extends ChangeNotifier { ..progress = 0 ..message = null; notifyListeners(); - _log(trf('시작: {0} → {1}\\{2}' '{3}', [v.fileName, outputFolderName, p.basename(out), encode.reencode ? ' [${encode.codec.label} · ${encode.resolution.label} · ${encode.quality.label}]' : ''])); + _log(trf('시작: {0} → {1}\\{2}' '{3}', [v.fileName, outputFolderName, p.basename(out), encode.reencode ? ' [${encode.codec.label} · ${encode.resolution.label} · ${encode.quality.label}${encode.adjusts ? ' · ${encode.adjustSummary}' : ''}]' : ''])); final copies = {}; var started = false; // FFmpeg 가 출력 파일을 쓰기 시작했는지 try { diff --git a/lib/app/settings.dart b/lib/app/settings.dart index a92a918..9b0a84b 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -171,12 +171,22 @@ class AppSettings { 'codec': encode.codec.name, 'resolution': encode.resolution.name, 'quality': encode.quality.name, + 'frame': encode.frame.name, + 'fit': encode.fit.name, + 'rotate': encode.rotate.name, + 'brightness': encode.brightness, + 'contrast': encode.contrast, + 'saturation': encode.saturation, + 'temperature': encode.temperature, }, 'aiSource': aiSource, 'aiTargets': aiTargets, 'aiWhisper': aiWhisper, }; + /// 색 보정 값 (-100 ~ 100) + static int _adj(Object? v) => ((v as num?)?.round() ?? 0).clamp(-100, 100); + factory AppSettings.fromJson(Map j) { T pick(List values, Object? name, T fallback) => values.firstWhere((v) => v.name == name, orElse: () => fallback); @@ -226,6 +236,13 @@ class AppSettings { codec: pick(VideoCodecChoice.values, e['codec'], VideoCodecChoice.copy), resolution: pick(ResolutionChoice.values, e['resolution'], ResolutionChoice.original), quality: pick(QualityChoice.values, e['quality'], QualityChoice.normal), + frame: pick(FrameChoice.values, e['frame'], FrameChoice.original), + fit: pick(FitChoice.values, e['fit'], FitChoice.fill), + rotate: pick(RotateChoice.values, e['rotate'], RotateChoice.none), + brightness: _adj(e['brightness']), + contrast: _adj(e['contrast']), + saturation: _adj(e['saturation']), + temperature: _adj(e['temperature']), ) ..aiSource = j['aiSource'] as String? ?? 'und' ..aiTargets = (j['aiTargets'] as List?)?.cast() ?? ['ko', 'en', 'ja'] diff --git a/lib/core/encode_options.dart b/lib/core/encode_options.dart index c0f9334..8215595 100644 --- a/lib/core/encode_options.dart +++ b/lib/core/encode_options.dart @@ -63,29 +63,202 @@ enum QualityChoice { const QualityChoice(this.koLabel); } +/// 출력 화면 비율 (파이널 컷의 프로젝트 화면 비율). 원본이 아니면 [FitChoice] 로 맞춘다. +enum FrameChoice { + original('원본 비율', null), + landscape('가로 16:9', 16 / 9), + portrait('세로 9:16', 9 / 16), + square('정사각 1:1', 1.0), + portrait45('세로 4:5', 4 / 5); + + /// 한국어 원문 (번역 사전의 열쇠) + final String koLabel; + + /// 화면에 보일 이름 (화면 언어로) + String get label => tr(koLabel); + + /// 너비 / 높이 (원본이면 null) + final double? aspect; + const FrameChoice(this.koLabel, this.aspect); +} + +/// 비율이 다를 때 맞추는 방법 (파이널 컷의 공간 맞추기: 채우기 · 맞추기) +enum FitChoice { + fill('채우기 (가운데 잘라냄)'), + fit('맞추기 (검은 여백)'), + blur('맞추기 (흐린 배경)'); + + /// 한국어 원문 (번역 사전의 열쇠) + final String koLabel; + + /// 화면에 보일 이름 (화면 언어로) + String get label => tr(koLabel); + const FitChoice(this.koLabel); +} + +/// 회전 (시계 방향) +enum RotateChoice { + none('회전 없음', 0), + cw90('오른쪽 90°', 90), + r180('180°', 180), + ccw90('왼쪽 90°', 270); + + /// 한국어 원문 (번역 사전의 열쇠) + final String koLabel; + + /// 화면에 보일 이름 (화면 언어로) + String get label => tr(koLabel); + final int degrees; + const RotateChoice(this.koLabel, this.degrees); + + /// 가로 · 세로가 바뀌는지 + bool get swaps => degrees == 90 || degrees == 270; +} + class EncodeSettings { final VideoCodecChoice codec; final ResolutionChoice resolution; final QualityChoice quality; + /// 화면 비율 · 맞추는 방법 · 회전 + final FrameChoice frame; + final FitChoice fit; + final RotateChoice rotate; + + /// 색 보정 (-100 ~ 100, 0 = 그대로): 밝기 · 대비 · 채도 · 색온도 (+ 따뜻하게 / − 차갑게) + final int brightness, contrast, saturation, temperature; + const EncodeSettings({ this.codec = VideoCodecChoice.copy, this.resolution = ResolutionChoice.original, this.quality = QualityChoice.normal, + this.frame = FrameChoice.original, + this.fit = FitChoice.fill, + this.rotate = RotateChoice.none, + this.brightness = 0, + this.contrast = 0, + this.saturation = 0, + this.temperature = 0, }); bool get reencode => codec != VideoCodecChoice.copy; + /// 색 보정을 하는지 + bool get hasColor => brightness != 0 || contrast != 0 || saturation != 0 || temperature != 0; + + /// 화면 비율 · 회전 · 색 보정 중 하나라도 바꿨는지 (재인코딩이 필요함) + bool get adjusts => frame != FrameChoice.original || rotate != RotateChoice.none || hasColor; + + /// 화면 비율 · 회전 · 색 보정을 처음으로 + EncodeSettings resetAdjust() => EncodeSettings(codec: codec, resolution: resolution, quality: quality); + EncodeSettings copyWith({ VideoCodecChoice? codec, ResolutionChoice? resolution, QualityChoice? quality, + FrameChoice? frame, + FitChoice? fit, + RotateChoice? rotate, + int? brightness, + int? contrast, + int? saturation, + int? temperature, }) => EncodeSettings( codec: codec ?? this.codec, resolution: resolution ?? this.resolution, quality: quality ?? this.quality, + frame: frame ?? this.frame, + fit: fit ?? this.fit, + rotate: rotate ?? this.rotate, + brightness: brightness ?? this.brightness, + contrast: contrast ?? this.contrast, + saturation: saturation ?? this.saturation, + temperature: temperature ?? this.temperature, ); + + /// 짧은 설명 ("세로 9:16 · 채우기 · 채도 +20"). 바꾼 것이 없으면 빈 글. + String get adjustSummary => [ + if (frame != FrameChoice.original) '${frame.label} · ${fit.label}', + if (rotate != RotateChoice.none) rotate.label, + if (brightness != 0) '${tr('밝기')} ${_signed(brightness)}', + if (contrast != 0) '${tr('대비')} ${_signed(contrast)}', + if (saturation != 0) '${tr('채도')} ${_signed(saturation)}', + if (temperature != 0) '${tr('색온도')} ${_signed(temperature)}', + ].join(' · '); + + static String _signed(int v) => v > 0 ? '+$v' : '$v'; +} + +/// 영상 필터 (회전 → 색 보정 → 화면 비율 맞추기 → 크기). 바꿀 것이 없으면 null. +/// +/// [source] 는 원본 영상 정보 (가로 · 세로 크기). [preview] 면 크기 바꾸기 ([ResolutionChoice]) 는 빼고 +/// 미리보기용으로 작게 만든다. +String? buildVideoFilter(EncodeSettings s, StreamInfo? source, {bool preview = false}) { + final f = []; + // 1. 회전 + switch (s.rotate) { + case RotateChoice.cw90: + f.add('transpose=1'); + case RotateChoice.ccw90: + f.add('transpose=2'); + case RotateChoice.r180: + f.add('hflip,vflip'); + case RotateChoice.none: + break; + } + // 2. 색 보정: 밝기 · 대비 · 채도 (eq), 색온도 (colorbalance: 붉은색 ↔ 푸른색) + if (s.brightness != 0 || s.contrast != 0 || s.saturation != 0) { + String n(double v) => v.toStringAsFixed(3); + f.add('eq=brightness=${n(s.brightness / 100 * 0.25)}' + ':contrast=${n(1 + s.contrast / 100 * 0.5)}' + ':saturation=${n(1 + s.saturation / 100)}'); + } + if (s.temperature != 0) { + final k = (s.temperature / 100 * 0.25).toStringAsFixed(3); + final m = (-s.temperature / 100 * 0.25).toStringAsFixed(3); + f.add('colorbalance=rs=$k:rm=$k:rh=$k:bs=$m:bm=$m:bh=$m'); + } + // 회전 뒤의 원본 크기 + var w = source?.width, h = source?.height; + if (s.rotate.swaps && w != null && h != null) (w, h) = (h, w); + final side = preview ? null : s.resolution.shortSide; + final aspect = s.frame.aspect; + String? last; + if (aspect != null) { + // 3. 화면 비율: 짧은 변은 고른 크기 (없으면 원본의 짧은 변) 로, 긴 변은 비율대로 (짝수) + final srcShort = (w != null && h != null) ? (w < h ? w : h) : 1080; + final short = preview ? 360 : (side ?? srcShort); + int even(double v) => (v / 2).round() * 2; + final ow = aspect >= 1 ? even(short * aspect) : even(short.toDouble()); + final oh = aspect >= 1 ? even(short.toDouble()) : even(short / aspect); + final cover = 'scale=$ow:$oh:force_original_aspect_ratio=increase:flags=lanczos,crop=$ow:$oh'; + final contain = 'scale=$ow:$oh:force_original_aspect_ratio=decrease:flags=lanczos'; + switch (s.fit) { + case FitChoice.fill: + f.add(cover); + case FitChoice.fit: + f.add('$contain,pad=$ow:$oh:(ow-iw)/2:(oh-ih)/2:black'); + case FitChoice.blur: + // 같은 영상을 크게 채워 흐리게 깔고, 그 위에 전체가 보이게 얹는다 + last = 'split[jjbg][jjfg];[jjbg]$cover,boxblur=20:2[jjb];[jjfg]$contain[jjf];' + '[jjb][jjf]overlay=(W-w)/2:(H-h)/2'; + } + } else if (preview) { + f.add('scale=480:480:force_original_aspect_ratio=decrease'); + } else if (side != null) { + // 4. 크기만: 가로 영상은 높이, 세로 영상은 너비를 맞추고 다른 변은 비율 유지 (짝수) + final portrait = w != null && h != null && h > w; + f.add(portrait ? 'scale=$side:-2:flags=lanczos' : 'scale=-2:$side:flags=lanczos'); + } + if (last != null) { + // 흐린 배경은 여러 갈래라 앞의 필터를 이어 붙인 뒤 마지막에 + final head = f.isEmpty ? '' : '${f.join(',')},'; + return '$head$last,setsar=1'; + } + if (f.isEmpty) return null; + if (aspect != null) f.add('setsar=1'); + return f.join(','); } /// 영상 인코딩 인수 (-c:v 이하). 재인코딩하지 않으면 빈 목록. @@ -120,16 +293,10 @@ List buildVideoEncodeArgs( args.addAll(['-q:v', '${[2, 4, 6][q]}']); } args.addAll(['-pix_fmt', 'yuv420p']); - - final side = s.resolution.shortSide; - if (side != null) { - // 가로 영상은 높이, 세로 영상은 너비를 맞추고 다른 변은 비율 유지 (짝수) - final portrait = source?.width != null && - source?.height != null && - source!.height! > source.width!; - args.addAll(['-vf', - portrait ? 'scale=$side:-2:flags=lanczos' : 'scale=-2:$side:flags=lanczos']); - } + final vf = buildVideoFilter(s, source); + if (vf != null) args.addAll(['-vf', vf]); + // 회전은 필터로 했으므로 원본의 회전 표시는 지운다 + if (s.rotate != RotateChoice.none) args.addAll(['-metadata:s:v:0', 'rotate=0']); return args; } diff --git a/lib/ui/home_page.dart b/lib/ui/home_page.dart index 9f405c1..d437139 100644 --- a/lib/ui/home_page.dart +++ b/lib/ui/home_page.dart @@ -20,6 +20,7 @@ import 'subtitle_editor_page.dart'; import 'subtitle_search_dialog.dart'; import 'theme.dart'; import 'translate_dialog.dart'; +import 'video_adjust_dialog.dart'; import 'work_panel.dart'; import '../l10n/tr.dart'; @@ -382,9 +383,21 @@ class _EncodeBar extends StatelessWidget { drop(s.quality, QualityChoice.values, (q) => q.label, c.setQuality), const SizedBox(width: 20), ], + // 화면 비율 (가로 · 세로) · 회전 · 색 보정: 미리보기를 보며 고르는 창 + Tooltip( + message: s.adjusts ? '${tr('화면 · 색 보정')}: ${s.adjustSummary}' : tr('화면 비율 (가로 · 세로) · 회전 · 밝기 · 대비 · 채도 · 색온도'), + child: (s.adjusts ? FilledButton.tonalIcon : OutlinedButton.icon)( + onPressed: locked ? null : () => showVideoAdjust(context, c), + icon: const Icon(Icons.tune, size: 18), + label: Text(tr('화면 · 색 보정')), + ), + ), + const SizedBox(width: 12), Expanded( child: Text( - !s.reencode + s.adjusts + ? s.adjustSummary + : !s.reencode ? tr('영상·음성을 그대로 복사합니다 (빠름, 화질 손실 없음)') : trf('영상을 다시 인코딩합니다 (시간이 오래 걸림, 음성은 그대로)' '{0}', [up > 0 ? trf(' · ⚠ {0}개는 원본보다 커서 화질 향상 없이 용량만 늘어납니다', [up]) : '']), overflow: TextOverflow.ellipsis, diff --git a/lib/ui/video_adjust_dialog.dart b/lib/ui/video_adjust_dialog.dart new file mode 100644 index 0000000..6ad0287 --- /dev/null +++ b/lib/ui/video_adjust_dialog.dart @@ -0,0 +1,298 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; + +import '../app/app_controller.dart'; +import '../core/encode_options.dart'; +import '../core/models.dart'; +import '../l10n/tr.dart'; +import 'theme.dart'; + +/// 화면 · 색 보정 (파이널 컷의 뷰어 + 인스펙터처럼): 왼쪽은 지금 보고 있는 동영상의 한 장면 미리보기, +/// 오른쪽은 화면 비율 · 맞추기 · 회전 · 색 보정. 바꾸면 바로 MKV 만들기 설정에 들어간다. +Future showVideoAdjust(BuildContext context, AppController c) => showDialog( + context: context, + builder: (_) => Dialog( + insetPadding: const EdgeInsets.all(16), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1100, maxHeight: 720), + child: _VideoAdjust(c: c), + ), + ), + ); + +class _VideoAdjust extends StatefulWidget { + final AppController c; + const _VideoAdjust({required this.c}); + + @override + State<_VideoAdjust> createState() => _VideoAdjustState(); +} + +class _VideoAdjustState extends State<_VideoAdjust> { + AppController get c => widget.c; + EncodeSettings get s => c.encode; + VideoItem? get v => c.selected; + + Uint8List? _after, _before; + bool _showBefore = false, _busy = false; + String? _error; + Timer? _debounce; + int _seq = 0; + String? _dir; + + @override + void initState() { + super.initState(); + _render(before: true); + _render(); + } + + @override + void dispose() { + _debounce?.cancel(); + super.dispose(); + } + + /// 미리보기 장면 (영상 길이의 1/3, 모르면 5초) + Duration get _at { + final d = v?.info?.duration; + return d == null || d == Duration.zero ? const Duration(seconds: 5) : d ~/ 3; + } + + /// FFmpeg 로 한 장면을 그려 PNG 로 (조정 전 · 후) + Future _render({bool before = false}) async { + final video = v; + if (video == null) return; + final seq = before ? -1 : ++_seq; + if (!before) setState(() => _busy = true); + try { + _dir ??= await c.services.storage.tempDirectory(); + final out = p.join(_dir!, 'jj_adjust_${before ? 'before' : 'after'}_${DateTime.now().microsecondsSinceEpoch}.png'); + final settings = before ? const EncodeSettings() : s; + final vf = buildVideoFilter(settings, video.info?.ofType('video').firstOrNull, preview: true) ?? + 'scale=480:480:force_original_aspect_ratio=decrease'; + final at = _at; + await c.services.mediaTool.runFfmpeg([ + '-hide_banner', '-v', 'error', '-nostdin', // + '-ss', '${at.inMilliseconds / 1000}', '-i', video.path, + '-frames:v', '1', '-vf', vf, '-y', out, + ]); + final bytes = await File(out).readAsBytes(); + unawaited(File(out).delete().catchError((_) => File(out))); + if (!mounted) return; + if (before) { + setState(() => _before = bytes); + } else if (seq == _seq) { + setState(() { + _after = bytes; + _error = null; + _busy = false; + }); + } + } catch (e) { + if (mounted && (before || seq == _seq)) { + setState(() { + _error = '$e'; + _busy = false; + }); + } + } + } + + void _set(EncodeSettings e) { + c.setAdjust(e); + setState(() {}); + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 250), _render); + } + + @override + Widget build(BuildContext context) { + final wide = MediaQuery.sizeOf(context).width >= 760; + final viewer = _viewer(); + final inspector = _inspector(); + return Column(children: [ + // 제목 줄 + Container( + color: JjColors.panelHigh, + padding: const EdgeInsets.fromLTRB(16, 8, 8, 8), + child: Row(children: [ + const Icon(Icons.tune, color: JjColors.accent, size: 20), + const SizedBox(width: 8), + Text(tr('화면 · 색 보정'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + const SizedBox(width: 12), + Expanded( + child: Text(tr('MKV 만들기에 적용됩니다 (영상을 다시 인코딩)'), + overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + ), + TextButton.icon( + onPressed: s.adjusts ? () => _set(s.resetAdjust()) : null, + icon: const Icon(Icons.restart_alt, size: 18), + label: Text(tr('모두 초기화')), + ), + IconButton(tooltip: tr('닫기'), icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)), + ]), + ), + Expanded( + child: wide + ? Row(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + Expanded(child: viewer), + SizedBox(width: 340, child: inspector), + ]) + : Column(children: [ + SizedBox(height: 260, child: viewer), + Expanded(child: inspector), + ]), + ), + ]); + } + + /// 뷰어: 조정 후 장면 (누르고 있으면 조정 전) + Widget _viewer() { + final img = _showBefore ? _before : _after; + return Container( + color: Colors.black, + child: Stack(children: [ + Positioned.fill( + child: v == null + ? Center(child: Text(tr('미리보기: 목록에서 동영상을 고르세요'), style: const TextStyle(color: JjColors.textDim))) + : _error != null + ? Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text(_error!, style: const TextStyle(color: JjColors.danger, fontSize: 12)), + )) + : img == null + ? const Center(child: CircularProgressIndicator()) + : Padding( + padding: const EdgeInsets.all(12), + child: Image.memory(img, fit: BoxFit.contain, gaplessPlayback: true), + ), + ), + if (v != null) + Positioned( + left: 8, + bottom: 8, + child: GestureDetector( + onTapDown: (_) => setState(() => _showBefore = true), + onTapUp: (_) => setState(() => _showBefore = false), + onTapCancel: () => setState(() => _showBefore = false), + child: Chip( + avatar: const Icon(Icons.compare, size: 16), + label: Text(_showBefore ? tr('조정 전') : tr('누르고 있으면 조정 전')), + ), + ), + ), + if (_busy) + const Positioned(right: 12, top: 12, child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))), + if (v != null) + Positioned( + right: 8, + bottom: 8, + child: Text('${v!.fileName} · ${_fmt(_at)}', + style: const TextStyle(fontSize: 11, color: Colors.white70)), + ), + ]), + ); + } + + static String _fmt(Duration d) => + '${d.inMinutes}:${(d.inSeconds % 60).toString().padLeft(2, '0')}'; + + /// 인스펙터: 화면 비율 · 맞추기 · 회전 · 색 보정 + Widget _inspector() { + Widget head(String t) => Padding( + padding: const EdgeInsets.fromLTRB(0, 16, 0, 8), + child: Text(t, style: const TextStyle(fontSize: 13, color: JjColors.accent, fontWeight: FontWeight.w600)), + ); + const frameIcons = { + FrameChoice.original: Icons.crop_original, + FrameChoice.landscape: Icons.crop_landscape, + FrameChoice.portrait: Icons.crop_portrait, + FrameChoice.square: Icons.crop_square, + FrameChoice.portrait45: Icons.crop_portrait, + }; + return Container( + color: JjColors.panel, + child: ListView(padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), children: [ + head(tr('화면 비율')), + Wrap(spacing: 6, runSpacing: 6, children: [ + for (final f in FrameChoice.values) + ChoiceChip( + avatar: Icon(frameIcons[f], size: 16), + label: Text(f.label), + selected: s.frame == f, + onSelected: (_) => _set(s.copyWith(frame: f)), + ), + ]), + head(tr('맞추기 (비율이 다를 때)')), + Wrap(spacing: 6, runSpacing: 6, children: [ + for (final f in FitChoice.values) + ChoiceChip( + label: Text(f.label), + selected: s.fit == f, + onSelected: s.frame == FrameChoice.original ? null : (_) => _set(s.copyWith(fit: f)), + ), + ]), + head(tr('회전')), + Wrap(spacing: 6, runSpacing: 6, children: [ + for (final r in RotateChoice.values) + ChoiceChip( + label: Text(r.label), + selected: s.rotate == r, + onSelected: (_) => _set(s.copyWith(rotate: r)), + ), + ]), + head(tr('색 보정')), + _slider(tr('밝기'), Icons.wb_sunny_outlined, s.brightness, (x) => s.copyWith(brightness: x)), + _slider(tr('대비'), Icons.contrast, s.contrast, (x) => s.copyWith(contrast: x)), + _slider(tr('채도'), Icons.palette_outlined, s.saturation, (x) => s.copyWith(saturation: x)), + _slider(tr('색온도'), Icons.thermostat, s.temperature, (x) => s.copyWith(temperature: x), + hint: tr('− 차갑게 · + 따뜻하게')), + const SizedBox(height: 12), + Text( + s.adjusts ? '${tr('적용')}: ${s.adjustSummary}' : tr('바꾼 것이 없습니다 (원본 그대로)'), + style: const TextStyle(fontSize: 12, color: JjColors.textDim), + ), + ]), + ); + } + + Widget _slider(String label, IconData icon, int value, EncodeSettings Function(int) apply, {String? hint}) { + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Icon(icon, size: 16, color: JjColors.textDim), + const SizedBox(width: 6), + Text(label, style: const TextStyle(fontSize: 13)), + if (hint != null) ...[ + const SizedBox(width: 6), + Text(hint, style: const TextStyle(fontSize: 11, color: JjColors.textDim)), + ], + const Spacer(), + SizedBox( + width: 40, + child: Text(value > 0 ? '+$value' : '$value', textAlign: TextAlign.right, style: const TextStyle(fontSize: 13)), + ), + IconButton( + tooltip: tr('0 으로'), + visualDensity: VisualDensity.compact, + iconSize: 16, + icon: const Icon(Icons.restart_alt), + onPressed: value == 0 ? null : () => _set(apply(0)), + ), + ]), + Slider( + min: -100, + max: 100, + divisions: 40, + value: value.toDouble(), + label: value > 0 ? '+$value' : '$value', + onChanged: (x) => _set(apply(x.round())), + ), + ]); + } +} diff --git a/test/video_adjust_test.dart b/test/video_adjust_test.dart new file mode 100644 index 0000000..019f12f --- /dev/null +++ b/test/video_adjust_test.dart @@ -0,0 +1,77 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/core/encode_options.dart'; +import 'package:jj_mkvmaker/core/models.dart'; +import 'package:path/path.dart' as p; + +const _land = StreamInfo(index: 0, type: 'video', codec: 'h264', width: 1920, height: 1080); + +void main() { + test('필터: 바꾼 것이 없으면 없음, 크기만 바꾸면 예전과 같음', () { + expect(buildVideoFilter(const EncodeSettings(codec: VideoCodecChoice.h264), _land), isNull); + expect(buildVideoFilter(const EncodeSettings(codec: VideoCodecChoice.h264, resolution: ResolutionChoice.p720), _land), + 'scale=-2:720:flags=lanczos'); + }); + + test('필터: 세로 9:16 채우기 · 맞추기 · 흐린 배경, 회전, 색 보정', () { + const s = EncodeSettings(codec: VideoCodecChoice.h264, frame: FrameChoice.portrait); + expect(buildVideoFilter(s, _land), + 'scale=1080:1920:force_original_aspect_ratio=increase:flags=lanczos,crop=1080:1920,setsar=1'); + expect(buildVideoFilter(s.copyWith(fit: FitChoice.fit), _land), contains('pad=1080:1920')); + expect(buildVideoFilter(s.copyWith(fit: FitChoice.blur), _land), contains('boxblur')); + // 720p 를 고르면 짧은 변 720 + expect(buildVideoFilter(s.copyWith(resolution: ResolutionChoice.p720), _land), contains('crop=720:1280')); + expect(buildVideoFilter(const EncodeSettings(rotate: RotateChoice.cw90), _land), 'transpose=1'); + expect(buildVideoFilter(const EncodeSettings(saturation: 20, temperature: -40), _land), + 'eq=brightness=0.000:contrast=1.000:saturation=1.200,colorbalance=rs=-0.100:rm=-0.100:rh=-0.100:bs=0.100:bm=0.100:bh=0.100'); + expect(const EncodeSettings(frame: FrameChoice.portrait, saturation: 20).adjustSummary, contains('채도 +20')); + }); + + test('설정 저장: 화면 비율 · 맞추기 · 회전 · 색 보정', () { + final s = AppSettings() + ..encode = const EncodeSettings( + codec: VideoCodecChoice.h264, + frame: FrameChoice.square, + fit: FitChoice.blur, + rotate: RotateChoice.ccw90, + brightness: 10, + contrast: -5, + saturation: 30, + temperature: 100); + final e = AppSettings.fromJson(s.toJson()).encode; + expect([e.frame, e.fit, e.rotate], [FrameChoice.square, FitChoice.blur, RotateChoice.ccw90]); + expect([e.brightness, e.contrast, e.saturation, e.temperature], [10, -5, 30, 100]); + expect(AppSettings.fromJson({'encode': {'saturation': 999}}).encode.saturation, 100); + }); + + // 실제 FFmpeg 로 만들어 보고 출력 크기 확인 + final ffmpeg = p.join(Directory.current.path, 'third_party', 'ffmpeg', 'windows', 'ffmpeg.exe'); + test('실제 FFmpeg: 가로 영상 → 세로 9:16 (세 가지 맞추기) · 회전 · 색 보정이 오류 없이 되고 크기가 맞다', () async { + final dir = Directory.systemTemp.createTempSync('jj_adj_'); + addTearDown(() => dir.deleteSync(recursive: true)); + const src = StreamInfo(index: 0, type: 'video', codec: 'h264', width: 640, height: 360); + final cases = { + 'fill': (const EncodeSettings(codec: VideoCodecChoice.h264, frame: FrameChoice.portrait), '360x640'), + 'fit': (const EncodeSettings(codec: VideoCodecChoice.h264, frame: FrameChoice.portrait, fit: FitChoice.fit), '360x640'), + 'blur': ( + const EncodeSettings( + codec: VideoCodecChoice.h264, frame: FrameChoice.portrait, fit: FitChoice.blur, saturation: 30, temperature: 50), + '360x640' + ), + 'rotate': (const EncodeSettings(codec: VideoCodecChoice.h264, rotate: RotateChoice.cw90, brightness: 20), '360x640'), + 'square': (const EncodeSettings(codec: VideoCodecChoice.h264, frame: FrameChoice.square, contrast: 40), '360x360'), + }; + for (final MapEntry(key: name, value: (s, size)) in cases.entries) { + final out = p.join(dir.path, '$name.png'); + final r = await Process.run(ffmpeg, [ + '-hide_banner', '-v', 'error', '-f', 'lavfi', '-i', 'testsrc2=size=640x360:duration=1', // + '-frames:v', '1', '-vf', buildVideoFilter(s, src)!, '-y', out, + ]); + expect(r.exitCode, 0, reason: '$name: ${r.stderr}'); + final probe = await Process.run(ffmpeg, ['-hide_banner', '-i', out]); + expect('${probe.stderr}', contains(size), reason: name); + } + }, skip: File(ffmpeg).existsSync() ? false : 'ffmpeg 없음'); +}