130 lines
3.9 KiB
Dart
130 lines
3.9 KiB
Dart
import 'models.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 label;
|
|
|
|
/// 사용할 FFmpeg 인코더 후보 (앞쪽 우선)
|
|
final List<String> encoders;
|
|
|
|
const VideoCodecChoice(this.label, 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 label;
|
|
final int? shortSide;
|
|
|
|
const ResolutionChoice(this.label, this.shortSide);
|
|
}
|
|
|
|
enum QualityChoice {
|
|
high('고화질'),
|
|
normal('표준'),
|
|
small('작은 용량');
|
|
|
|
final String label;
|
|
|
|
const QualityChoice(this.label);
|
|
}
|
|
|
|
class EncodeSettings {
|
|
final VideoCodecChoice codec;
|
|
final ResolutionChoice resolution;
|
|
final QualityChoice quality;
|
|
|
|
const EncodeSettings({
|
|
this.codec = VideoCodecChoice.copy,
|
|
this.resolution = ResolutionChoice.original,
|
|
this.quality = QualityChoice.normal,
|
|
});
|
|
|
|
bool get reencode => codec != VideoCodecChoice.copy;
|
|
|
|
EncodeSettings copyWith({
|
|
VideoCodecChoice? codec,
|
|
ResolutionChoice? resolution,
|
|
QualityChoice? quality,
|
|
}) =>
|
|
EncodeSettings(
|
|
codec: codec ?? this.codec,
|
|
resolution: resolution ?? this.resolution,
|
|
quality: quality ?? this.quality,
|
|
);
|
|
}
|
|
|
|
/// 영상 인코딩 인수 (-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('${s.codec.label} 인코더가 FFmpeg 에 없습니다.');
|
|
}
|
|
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 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']);
|
|
}
|
|
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;
|
|
}
|