JJ_MKVMaker/lib/core/encode_options.dart

310 lines
11 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import 'models.dart';
import '../l10n/tr.dart';
/// 영상 코덱 (기본 목록: 별도 설치·설정 없이 동봉 FFmpeg 로 인코딩)
enum VideoCodecChoice {
copy('원본 유지 (재인코딩 없음)', []),
h264('H.264', ['libx264']),
h265('H.265 (HEVC)', ['libx265']),
vp9('VP9', ['libvpx-vp9']),
av1('AV1', ['libsvtav1', 'libaom-av1']), // SVT-AV1 이 있으면 우선 (더 빠름)
mpeg4('MPEG-4 (Xvid)', ['libxvid', 'mpeg4']);
/// 한국어 원문 (번역 사전의 열쇠)
final String koLabel;
/// 화면에 보일 이름 (화면 언어로)
String get label => tr(koLabel);
/// 사용할 FFmpeg 인코더 후보 (앞쪽 우선)
final List<String> encoders;
const VideoCodecChoice(this.koLabel, this.encoders);
/// 사용 가능한 인코더 중 첫 번째 (없으면 null)
String? pickEncoder(Set<String> available) {
for (final e in encoders) {
if (available.contains(e)) return e;
}
return null;
}
}
/// 화면 크기 (짧은 변 기준 픽셀)
enum ResolutionChoice {
original('원본 크기', null),
p720('720p (HD)', 720),
p1080('1080p (FHD)', 1080),
k2('2K (1440p)', 1440),
k4('4K (2160p)', 2160),
k8('8K (4320p)', 4320);
/// 한국어 원문 (번역 사전의 열쇠)
final String koLabel;
/// 화면에 보일 이름 (화면 언어로)
String get label => tr(koLabel);
final int? shortSide;
const ResolutionChoice(this.koLabel, this.shortSide);
}
enum QualityChoice {
high('고화질'),
normal('표준'),
small('작은 용량');
/// 한국어 원문 (번역 사전의 열쇠)
final String koLabel;
/// 화면에 보일 이름 (화면 언어로)
String get label => tr(koLabel);
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 이하). 재인코딩하지 않으면 빈 목록.
///
/// [available] 은 FFmpeg 가 지원하는 인코더 이름 목록.
List<String> buildVideoEncodeArgs(
EncodeSettings s, Set<String> available, StreamInfo? source) {
if (!s.reencode) return const [];
final enc = s.codec.pickEncoder(available);
if (enc == null) {
throw ArgumentError(trf('{0} 인코더가 FFmpeg 에 없습니다.', [s.codec.label]));
}
final q = s.quality.index; // 0 고화질, 1 표준, 2 작은 용량
final args = <String>['-c:v', enc];
switch (enc) {
case 'libx264':
args.addAll(['-preset', 'medium', '-crf', '${[18, 23, 28][q]}']);
case 'libx265':
args.addAll(['-preset', 'medium', '-crf', '${[22, 28, 32][q]}',
'-x265-params', 'log-level=error']);
case 'libvpx-vp9':
args.addAll(['-b:v', '0', '-crf', '${[28, 33, 38][q]}',
'-deadline', 'good', '-cpu-used', '4', '-row-mt', '1']);
case 'libsvtav1':
args.addAll(['-crf', '${[28, 35, 42][q]}', '-preset', '8']);
case 'libaom-av1':
args.addAll(['-b:v', '0', '-crf', '${[26, 32, 38][q]}',
'-cpu-used', '6', '-row-mt', '1', '-tiles', '2x2']);
case 'libxvid':
case 'mpeg4':
args.addAll(['-q:v', '${[2, 4, 6][q]}']);
}
args.addAll(['-pix_fmt', 'yuv420p']);
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;
}
/// 원본보다 큰 크기를 골랐는지 (화질 향상 없음 경고용)
bool isUpscale(ResolutionChoice r, StreamInfo? source) {
final side = r.shortSide;
if (side == null || source?.width == null || source?.height == null) return false;
final srcShort = source!.width! < source.height! ? source.width! : source.height!;
return side > srcShort;
}