Android 전체 기능 점검에서 찾은 문제 수정

- CP949 등 UTF-8 이 아닌 자막: MKV 만들기 · 자막 편집기 열기가 실패하던 것 (Android 의 FFmpeg 에 iconv 없음)
  → 앱이 UTF-8 사본을 만들어 넘김 (모든 플랫폼 같은 방식)
- 플레이어: CP949 SMI 자막이 보이지 않던 것 → UTF-8 SRT 사본으로 (Android). 'auto' 를 켜진 자막으로 여겨
  jj_mkv 자막 (번역 · 받은 자막) 을 자동으로 켜지 않던 것. 같은 자막이 메뉴에 두 번 보이던 것
- 자막 편집기 미리보기: 같은 폴더 자막을 열지 못하면 "영상 재생 오류" 가 뜨던 것
- MKV 만들기 실패 시 0 바이트 MKV 가 남던 것 (시작 전 실패는 전에 만든 MKV 를 그대로 둠)
- AV1 영상 재인코딩 (Android FFmpeg 에 AV1 디코더 없음): "ffmpeg 오류 (코드 69)" 대신 원본 유지 안내
- 임시 파일: 캐시 폴더 → 앱 데이터 폴더 (저장 공간이 모자라면 Android 가 쓰는 중인 임시 파일을 지움)
- [폴더 열기] · 다운로드 폴더 · 외부 브라우저로 열기: Android 에서 아무 동작도 안 하던 것 → 파일 앱 · 기본 브라우저
- 플레이어의 팝업 · 창 크기 · 끌어다 놓기 안내 숨김 (PC 전용), "이 PC" → "이 기기"
- 취소한 다운로드에 속도 · 남은 시간이 남던 것

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
RyuJongJin 2026-10-03 00:59:01 +09:00
parent 42bf9f990b
commit 4592dc7943
14 changed files with 216 additions and 36 deletions

View File

@ -27,6 +27,8 @@ class MainActivity : FlutterActivity() {
result.success(null)
}
"storageRoot" -> result.success(Environment.getExternalStorageDirectory().path)
"openUrl" -> result.success(openUrl(call.argument<String>("url") ?: ""))
"openFolder" -> result.success(openFolder(call.argument<String>("path") ?: ""))
"keepAlive" -> {
keepAlive(call.argument<String>("text") ?: "", call.argument<Int>("progress") ?: -1)
result.success(null)
@ -44,6 +46,35 @@ class MainActivity : FlutterActivity() {
}
}
/// 웹 주소를 기기의 기본 브라우저로
private fun openUrl(url: String): Boolean = try {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
true
} catch (e: Exception) {
false
}
/// 폴더를 파일 앱으로 연다 (내장 저장소 안의 폴더만). 열 앱이 없으면 false.
private fun openFolder(path: String): Boolean {
val root = Environment.getExternalStorageDirectory().path
if (!path.startsWith(root)) return false
val rel = path.removePrefix(root).trim('/')
val uri = android.provider.DocumentsContract.buildDocumentUri(
"com.android.externalstorage.documents", "primary:$rel"
)
for (type in listOf(android.provider.DocumentsContract.Document.MIME_TYPE_DIR, "resource/folder")) {
try {
startActivity(
Intent(Intent.ACTION_VIEW).setDataAndType(uri, type)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
)
return true
} catch (e: Exception) {
}
}
return false
}
/// 작업 진행 알림 (포그라운드 서비스) 시작 · 갱신. 처음 한 번 알림 권한을 묻는다 (Android 13+, 거절해도 작업은 계속)
private var askedNotifications = false

View File

@ -483,16 +483,32 @@ class AppController extends ChangeNotifier {
}
final tmp = p.join(await storage.tempDirectory(),
'edit_${DateTime.now().microsecondsSinceEpoch}.srt');
final copy = await _utf8Copy(s);
try {
await _tool.runFfmpeg(s.kind == SubtitleKind.external
? buildToSrtArgs(input: s.path!, output: tmp, charset: s.charset)
? buildToSrtArgs(input: copy ?? s.path!, output: tmp, charset: copy == null ? s.charset : null)
: buildToSrtArgs(input: v.path, output: tmp, streamIndex: s.streamIndex));
return parseSrt(decodeText(await storage.readBytes(tmp), 'UTF-8'));
} finally {
await storage.delete(tmp);
if (copy != null) await storage.delete(copy);
}
}
/// UTF-8 이 아닌 외부 자막 (CP949 SMI 등) 의 UTF-8 임시 사본. 필요 없으면 null.
/// Android 의 FFmpeg (ffmpeg-kit) 에는 문자셋 변환 (iconv) 이 없어 -sub_charenc 를 쓸 수 없으므로
/// 앱이 바꿔서 넘긴다 (Windows 도 같은 방식으로).
Future<String?> _utf8Copy(SubtitleEntry s) async {
final cs = s.charset;
if (s.kind != SubtitleKind.external || s.path == null || cs == null || cs.startsWith('UTF-')) return null;
final storage = services.storage;
final text = decodeText(await storage.readBytes(s.path!), cs);
final copy = p.join(await storage.tempDirectory(),
'utf8_${DateTime.now().microsecondsSinceEpoch}_${p.basename(s.path!)}');
await storage.writeBytes(copy, utf8.encode(text));
return copy;
}
/// 편집한 자막을 jj_mkv\파일명_언어코드.srt 로 저장하고 MKV 에 반영되도록 목록을 바꾼다.
/// - 내장 자막: 원래 트랙은 '삭제' 표시, 편집본을 외부 자막으로 추가
/// - 외부 자막: 같은 자리의 항목을 편집본으로 교체 (원본 파일은 그대로 둠)
@ -981,6 +997,32 @@ class AppController extends ChangeNotifier {
}
/// 재생 중 고를 수 있는 외부 자막: 같은 폴더의 자막 + jj_mkv 안의 파일명_*.srt
/// 플레이어에 넘길 자막 파일 (Android). Android 의 mpv 는 문자셋 변환 (iconv) 이 없고 SAMI (SMI) 도 열지 못하므로
/// UTF-8 이 아닌 자막은 UTF-8 사본으로, SMI 는 SRT 로 바꿔 임시 폴더에 만든다. 그 밖에는 그대로.
Future<String> playableSubtitle(String path) async {
if (!Platform.isAndroid) return path;
try {
final storage = services.storage;
final ext = p.extension(path).toLowerCase();
final isSami = ext == '.smi' || ext == '.sami';
final cs = detectCharset(Uint8List.fromList(await storage.readHead(path, 64 * 1024)));
if (cs == 'UTF-8' && !isSami) return path;
final tmp = await storage.tempDirectory();
final key = path.hashCode.toUnsigned(32);
var src = path;
if (cs != 'UTF-8') {
src = p.join(tmp, 'play_${key}_${p.basename(path)}');
await storage.writeBytes(src, utf8.encode(decodeText(await storage.readBytes(path), cs)));
}
if (!isSami) return src;
final srt = p.join(tmp, 'play_$key.srt');
await _tool.runFfmpeg(buildToSrtArgs(input: src, output: srt));
return srt;
} catch (_) {
return path;
}
}
Future<List<String>> externalSubtitlesFor(String video) async {
final out = <String>[];
final storage = services.storage;
@ -1043,10 +1085,23 @@ class AppController extends ChangeNotifier {
notifyListeners();
_log('시작: ${v.fileName} → $outputFolderName\\${p.basename(out)}'
'${encode.reencode ? ' [${encode.codec.label} · ${encode.resolution.label} · ${encode.quality.label}]' : ''}');
final copies = <SubtitleEntry, String>{};
var started = false; // FFmpeg 가 출력 파일을 쓰기 시작했는지
try {
// Android 의 FFmpeg (ffmpeg-kit) 에는 AV1 디코더가 없어 다시 인코딩할 수 없다 (원본 유지는 된다)
final vcodec = v.info?.ofType('video').firstOrNull?.codec;
if (Platform.isAndroid && encode.reencode && vcodec == 'av1') {
throw const MediaToolException('AV1 영상은 이 기기에서 다시 인코딩할 수 없습니다. '
'코덱을 "원본 유지" 로 바꿔 MKV 를 만드세요. (PC 판은 됩니다)');
}
await services.storage.ensureDirectory(outputDirFor(v.path));
for (final s in v.subtitles.where((s) => s.enabled)) {
final c = await _utf8Copy(s);
if (c != null) copies[s] = c;
}
started = true;
await _tool.runFfmpeg(
buildMuxArgs(v, out, encode: encode, encoders: encoders),
buildMuxArgs(v, out, encode: encode, encoders: encoders, utf8Copies: copies),
duration: v.info?.duration,
onProgress: (x) {
v.progress = x;
@ -1067,6 +1122,18 @@ class AppController extends ChangeNotifier {
..status = JobStatus.failed
..message = msg;
_log('실패: ${v.fileName}\n$msg');
// 만들다 만 파일은 지운다 (0 바이트 MKV 가 남지 않도록). 시작 전에 실패했으면 전에 만든 MKV 는 그대로 둔다.
if (started) {
try {
await services.storage.delete(out);
} catch (_) {}
}
} finally {
for (final c in copies.values) {
try {
await services.storage.delete(c);
} catch (_) {}
}
}
notifyListeners();
}

View File

@ -19,11 +19,14 @@ const preferredDefaultLanguage = 'kor';
/// 동영상 + 자막 → MKV 로 합치는 FFmpeg 인수.
///
/// [encode] 가 재인코딩이면 영상은 선택한 코덱·크기로 인코딩하고, 음성은 항상 그대로 복사한다.
///
/// [utf8Copies]: UTF-8 로 바꿔 둔 외부 자막 사본 (원래 항목 → 사본 경로). 사본은 문자셋 지정 없이 읽는다.
List<String> buildMuxArgs(
VideoItem video,
String outputPath, {
EncodeSettings encode = const EncodeSettings(),
Set<String> encoders = const {},
Map<SubtitleEntry, String> utf8Copies = const {},
}) {
final externals = video.subtitles
.where((s) => s.enabled && s.kind == SubtitleKind.external)
@ -39,12 +42,13 @@ List<String> buildMuxArgs(
// 입력 1..n: 외부 자막 (-sub_charenc 는 입력 옵션이므로 -i 앞에 둔다)
for (final s in externals) {
final copy = utf8Copies[s];
final cs = s.charset;
// UTF-8 / UTF-16(BOM) 은 FFmpeg 가 스스로 처리
if (cs != null && !cs.startsWith('UTF-')) {
if (copy == null && cs != null && !cs.startsWith('UTF-')) {
args.addAll(['-sub_charenc', cs]);
}
args.addAll(['-i', s.path!]);
args.addAll(['-i', copy ?? s.path!]);
}
// 영상(표지 그림 제외)·음성 트랙 전부, 첨부 파일(폰트) 유지

View File

@ -1,9 +1,13 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;
import '../../services/app_shell.dart';
/// Android: 창 · 트레이 · 단축키 · 탐색기 연결은 없음. 종료와 전체 화면만.
/// Android: 창 · 트레이 · 단축키 · 탐색기 연결은 없음. 종료 · 전체 화면 · 웹 주소 열기 · 폴더 열기 (파일 앱).
class AndroidShell extends NoopShell {
static const _ch = MethodChannel('jj_mkvmaker/android');
bool _full = false;
@override
@ -17,4 +21,21 @@ class AndroidShell extends NoopShell {
@override
Future<bool> isFullScreen() async => _full;
/// 기기의 기본 브라우저로 (고른 브라우저는 PC 에서만)
@override
Future<void> openUrl(String url, {String browser = 'system'}) async {
try {
await _ch.invokeMethod<bool>('openUrl', {'url': url});
} catch (_) {}
}
/// 파일이 있는 폴더 (폴더면 그 폴더) 를 파일 앱으로
@override
Future<void> revealFile(String path) async {
final dir = await FileSystemEntity.isDirectory(path) ? path : p.dirname(path);
try {
await _ch.invokeMethod<bool>('openFolder', {'path': dir});
} catch (_) {}
}
}

View File

@ -58,10 +58,20 @@ class AndroidStorageService extends DesktopStorageService {
Future<List<String>> pickSubtitles({String? initialDirectory}) =>
_pick('자막 파일 선택', subtitleExtensions, initialDirectory: initialDirectory);
/// 임시 폴더: 캐시 폴더가 아니라 앱 데이터 폴더 아래 tmp.
/// 저장 공간이 모자라면 Android 가 캐시 폴더를 마음대로 비워, 쓰는 중인 임시 파일 (AI 음성 · 자막 사본) 이
/// 사라질 수 있다. 대신 앱을 켤 때 (처음 쓸 때) 지난 실행의 임시 파일을 지운다.
static Future<String>? _tmp;
@override
Future<String> tempDirectory() async {
final dir = Directory(p.join((await getTemporaryDirectory()).path, 'jj_mkvmaker'));
await dir.create(recursive: true);
return dir.path;
}
Future<String> tempDirectory() => _tmp ??= () async {
final dir = Directory(p.join((await getApplicationSupportDirectory()).path, 'tmp'));
if (await dir.exists()) {
try {
await dir.delete(recursive: true);
} catch (_) {}
}
await dir.create(recursive: true);
return dir.path;
}();
}

View File

@ -14,7 +14,8 @@ import '../../services/media_player.dart';
bool isNonFatalMpvMessage(String m) =>
m.contains('Could not open codec') ||
m.contains('hardware decoding') ||
m.contains('Could not create device');
m.contains('Could not create device') ||
m.contains('Can not open external file');
/// mpv(media_kit) 동영상 플레이어. Windows·Android 공용.
class MediaKitFullPlayer implements MediaPlayer {
@ -87,7 +88,8 @@ class MediaKitFullPlayer implements MediaPlayer {
audioTracks: audio,
subtitleTracks: subs,
audioId: st.track.audio.id,
subtitleId: _externalSub?.id ?? (sid == 'no' ? null : sid),
// mpv 의 'auto' 는 고른 자막이 없을 수도 있다 → 실제 목록에 있을 때만 켜진 것으로
subtitleId: _externalSub?.id ?? (subs.any((t) => t.id == sid) ? sid : null),
);
}

View File

@ -198,7 +198,10 @@ class Aria2Backend implements DownloadBackend {
} catch (_) {}
}
_tasks.remove(t.id);
t.state = DownloadState.cancelled;
t
..state = DownloadState.cancelled
..speed = ''
..eta = '';
await Future<void>.delayed(const Duration(milliseconds: 300)); // 파일 핸들 해제 대기
for (final f in t.files) {
for (final path in [f, '$f.aria2']) {

View File

@ -272,7 +272,10 @@ class YtDlpBackend implements DownloadBackend {
@override
Future<void> cancel(DownloadTask t) async {
await _stop(t);
t.state = DownloadState.cancelled;
t
..state = DownloadState.cancelled
..speed = ''
..eta = '';
for (final f in t.files) {
for (final path in [f, '$f.part', '$f.ytdl']) {
try {

View File

@ -232,7 +232,12 @@ class _DownloadRow extends StatelessWidget {
tooltip: '폴더 열기',
icon: const Icon(Icons.folder_open, size: 18, color: JjColors.textDim),
onPressed: () {
if (Platform.isWindows) Process.run('explorer', [t.dir]);
if (Platform.isWindows) {
Process.run('explorer', [t.dir]);
} else {
// Android: 파일 앱으로
AppScope.maybeOf(context)?.controller.services.shell.revealFile(t.dir);
}
},
),
]),

View File

@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart';
import '../app/app_controller.dart';
@ -834,7 +836,7 @@ class _SubtitleRow extends StatelessWidget {
),
if (c.services.createTranslator != null)
IconButton(
tooltip: c.canEdit(s) ? '다른 언어로 번역 (AI · 이 PC 에서)' : '이미지 자막은 번역할 수 없습니다',
tooltip: c.canEdit(s) ? '다른 언어로 번역 (AI · 이 ${Platform.isAndroid ? '기기' : 'PC'} 에서)' : '이미지 자막은 번역할 수 없습니다',
iconSize: 18,
onPressed: locked || off || !c.canEdit(s) ? null : () => showTranslateDialog(context, c, v, s),
icon: const Icon(Icons.translate, color: JjColors.accent),

View File

@ -92,8 +92,17 @@ class _PlayerPageState extends State<PlayerPage> {
final i = pl.state.value.index;
if (i != _subsForIndex && i >= 0 && i < pl.playlist.length) {
_subsForIndex = i;
c.externalSubtitlesFor(pl.playlist[i]).then((s) {
if (mounted) setState(() => _externalSubs = s);
c.externalSubtitlesFor(pl.playlist[i]).then((s) async {
if (!mounted) return;
setState(() => _externalSubs = s);
// 영상 안 · 같은 폴더 자막이 없고 jj_mkv 에 만든 자막 (번역 · 받은 자막) 만 있으면 하나를 켠다 (한국어 먼저).
// 같은 폴더 자막은 mpv 가 스스로 켜므로 그 뒤에 (1초) 확인한다.
await Future<void>.delayed(const Duration(seconds: 1));
final st = pl.state.value;
if (!mounted || s.isEmpty || st.index != i || st.subtitleId != null || st.subtitleTracks.isNotEmpty) return;
final pick = s.firstWhere((f) => RegExp(r'[._-](ko|kor)[._]', caseSensitive: false).hasMatch(p.basename(f)),
orElse: () => s.first);
await _setSub(TrackInfo('file:$pick', p.basename(pick), file: pick));
});
}
}
@ -179,17 +188,29 @@ class _PlayerPageState extends State<PlayerPage> {
void _toggleSubtitles() {
final s = pl.state.value;
if (s.subtitleId != null) {
pl.setSubtitleTrack(null);
_setSub(null);
} else {
final all = _subtitleChoices();
if (all.isNotEmpty) pl.setSubtitleTrack(all.first);
if (all.isNotEmpty) _setSub(all.first);
}
}
List<TrackInfo> _subtitleChoices() => [
...pl.state.value.subtitleTracks,
for (final f in _externalSubs) TrackInfo('file:$f', p.basename(f), file: f),
];
/// 자막 고르기. 파일 자막은 플레이어가 읽을 수 있는 형태로 (Android: UTF-8 사본) 넘긴다 - 고른 표시는 원래 id 로.
Future<void> _setSub(TrackInfo? t) async {
final f = t?.file;
if (t != null && f != null) t = TrackInfo(t.id, t.label, file: await c.playableSubtitle(f));
await pl.setSubtitleTrack(t);
}
List<TrackInfo> _subtitleChoices() {
final tracks = pl.state.value.subtitleTracks;
return [
...tracks,
// mpv 가 같은 폴더에서 스스로 불러온 파일은 다시 넣지 않는다 (목록에 두 번 보이지 않게)
for (final f in _externalSubs)
if (!tracks.any((t) => t.label.contains(p.basename(f)))) TrackInfo('file:$f', p.basename(f), file: f),
];
}
Future<void> _addDropped(List<String> paths) async {
final videos = await collectVideos(paths,
@ -330,15 +351,18 @@ class _PlayerPageState extends State<PlayerPage> {
item('다음', () => pl.next(), key: 'N'),
const Divider(height: 1),
item('전체 화면', _toggleFull, key: 'F', checked: _full),
item('팝업 보기', _togglePopup, checked: _popup),
SubmenuButton(menuChildren: [
for (final f in const [0.5, 1.0, 2.0, 4.0]) item('원본의 $f배', () => _fit(f)),
], child: const Text('화면 크기')),
// 팝업 (항상 위 작은 창) · 창 크기는 PC 만
if (!Platform.isAndroid) ...[
item('팝업 보기', _togglePopup, checked: _popup),
SubmenuButton(menuChildren: [
for (final f in const [0.5, 1.0, 2.0, 4.0]) item('원본의 $f배', () => _fit(f)),
], child: const Text('화면 크기')),
],
const Divider(height: 1),
item('자막 보기', _toggleSubtitles, key: 'S', checked: s.subtitleId != null),
SubmenuButton(menuChildren: [
item('끄기', () => pl.setSubtitleTrack(null), checked: s.subtitleId == null),
for (final t in subs) item(t.label, () => pl.setSubtitleTrack(t), checked: s.subtitleId == t.id),
item('끄기', () => _setSub(null), checked: s.subtitleId == null),
for (final t in subs) item(t.label, () => _setSub(t), checked: s.subtitleId == t.id),
], child: const Text('자막 선택')),
if (s.audioTracks.length > 1)
SubmenuButton(menuChildren: [
@ -414,9 +438,9 @@ class _PlayerPageState extends State<PlayerPage> {
(label: '${r}x', checked: s.rate == r, onTap: () => pl.setRate(r)),
]),
_menuButton(Icons.subtitles, '자막 (S)', [
(label: '끄기', checked: s.subtitleId == null, onTap: () => pl.setSubtitleTrack(null)),
(label: '끄기', checked: s.subtitleId == null, onTap: () => _setSub(null)),
for (final t in _subtitleChoices())
(label: t.label, checked: s.subtitleId == t.id, onTap: () => pl.setSubtitleTrack(t)),
(label: t.label, checked: s.subtitleId == t.id, onTap: () => _setSub(t)),
]),
if (s.audioTracks.length > 1)
_menuButton(Icons.audiotrack, '음성', [
@ -425,8 +449,9 @@ class _PlayerPageState extends State<PlayerPage> {
]),
btn(Icons.playlist_play, '재생 목록 (L)', () => setState(() => _showList = !_showList)),
],
btn(_popup ? Icons.close_fullscreen : Icons.picture_in_picture_alt, _popup ? '팝업 끄기' : '팝업 보기',
_togglePopup),
if (!Platform.isAndroid)
btn(_popup ? Icons.close_fullscreen : Icons.picture_in_picture_alt, _popup ? '팝업 끄기' : '팝업 보기',
_togglePopup),
btn(_full ? Icons.fullscreen_exit : Icons.fullscreen, '전체 화면 (F)', _toggleFull),
]),
]),
@ -479,6 +504,7 @@ class _PlayerPageState extends State<PlayerPage> {
),
),
),
if (!Platform.isAndroid)
const Padding(
padding: EdgeInsets.all(8),
child: Text('동영상을 끌어다 놓으면 목록에 추가됩니다',

View File

@ -435,7 +435,7 @@ class _SettingsPageState extends State<SettingsPage> {
(v) => c.updateSettings((x) => x.openSubtitlesKey = v.trim())),
_textTile('아이디 (선택)', s.openSubtitlesUser, '로그인하면 하루 받기 횟수가 늘어납니다',
(v) => c.updateSettings((x) => x.openSubtitlesUser = v.trim())),
_textTile('비밀번호 (선택)', s.openSubtitlesPassword, '이 PC 의 설정 파일에 저장됩니다',
_textTile('비밀번호 (선택)', s.openSubtitlesPassword, '이 ${desk ? 'PC' : '기기'} 의 설정 파일에 저장됩니다',
(v) => c.updateSettings((x) => x.openSubtitlesPassword = v), obscure: true),
ListTile(
title: const Text('YouTube 받을 형식'),

View File

@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart';
import '../app/app_controller.dart';
@ -84,7 +86,7 @@ class _TranslateDialogState extends State<_TranslateDialog> {
]),
const SizedBox(height: 16),
Row(children: [
const Text('번역 모델: NLLB-200 (이 PC 에서만) ', style: TextStyle(fontSize: 13)),
Text('번역 모델: NLLB-200 (이 ${Platform.isAndroid ? '기기' : 'PC'} 에서만) ', style: const TextStyle(fontSize: 13)),
if (_installed != null)
Text(_installed! ? '설치됨' : '처음 사용 시 내려받기 ${nllbModel.sizeLabel}',
style: TextStyle(fontSize: 11, color: _installed! ? JjColors.success : JjColors.textDim)),

View File

@ -112,6 +112,10 @@ At least one output file must be specified
// 문자셋은 해당 입력 앞에만
expect(a, contains('-sub_charenc CP949 -i a.ko.smi -i a.ja.srt'));
// 앱이 UTF-8 로 바꿔 둔 사본을 쓰면 문자셋 지정 없이 사본을 읽는다 (Android 의 FFmpeg 는 iconv 가 없음)
final copy = buildMuxArgs(v, 'out.mkv', utf8Copies: {v.subtitles[3]: 'tmp/utf8_a.ko.smi'}).join(' ');
expect(copy, contains('-i in.mkv -i tmp/utf8_a.ko.smi -i a.ja.srt'));
expect(copy, isNot(contains('-sub_charenc')));
// 삭제된 트랙(4)은 매핑하지 않음
expect(a, contains('-map 0:2 -map 0:3 -map 1:0 -map 2:0'));
expect(a, isNot(contains('0:4')));