MKV 만들기: 화면 비율 (가로 16:9 · 세로 9:16 · 1:1 · 4:5, 채우기 · 맞추기 · 흐린 배경) · 회전 · 색 보정 (밝기 · 대비 · 채도 · 색온도), 미리보기 창
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>main
parent
9044dd6d46
commit
1410dad2aa
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
"확인 · 압축 푸는 중…": "確認・展開中…",
|
||||
"확장자별 재생 프로그램": "拡張子ごとの再生プログラム",
|
||||
"환경 설정": "環境設定",
|
||||
"회전": "回転",
|
||||
"회전 없음": "回転なし",
|
||||
"회차": "話数",
|
||||
"히브리어": "ヘブライ語",
|
||||
"힌디어": "ヒンディー語"
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
"확인 · 압축 푸는 중…": "正在校验 · 解压…",
|
||||
"확장자별 재생 프로그램": "按扩展名设置播放程序",
|
||||
"환경 설정": "设置",
|
||||
"회전": "旋转",
|
||||
"회전 없음": "不旋转",
|
||||
"회차": "集数",
|
||||
"히브리어": "希伯来语",
|
||||
"힌디어": "印地语"
|
||||
|
|
|
|||
|
|
@ -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<void>.delayed(const Duration(milliseconds: 200)));
|
||||
await tester.pump();
|
||||
}
|
||||
await tester.runAsync(() => Future<void>.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)));
|
||||
}
|
||||
|
|
@ -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 = <SubtitleEntry, String>{};
|
||||
var started = false; // FFmpeg 가 출력 파일을 쓰기 시작했는지
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -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<String, dynamic> j) {
|
||||
T pick<T extends Enum>(List<T> 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<String>() ?? ['ko', 'en', 'ja']
|
||||
|
|
|
|||
|
|
@ -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 = <String>[];
|
||||
// 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<String> 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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<void> showVideoAdjust(BuildContext context, AppController c) => showDialog<void>(
|
||||
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<void> _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())),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 없음');
|
||||
}
|
||||
Loading…
Reference in New Issue