app_controller 509 → 498줄: 자막 파일 고르기 · 다른 이름 저장 · 편집할 수 있는지를 SubtitleFiles 로

- SubtitleFiles.pickFor (있는 경로 건너뜀 · 이름으로 언어, 더한 수) · saveAs (SRT 바이트 · 표현 못 한 글자 수) · canEdit (static)
- app_controller 는 부르기만 (알림 · 기록은 그대로)
- 약속 시험: 실제 파일 · 실제 SRT 바이트, 고르기 · 저장 창 대신 정한 것을 돌려주는 저장소 (사용자가 고른 것)

확인: analyze 0 · 단위 1475 ×2 · 통합 6/6 ×2 · base10 214장 0px

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-24 07:46:32 +09:00
parent 093cf3252e
commit 051c259d89
3 changed files with 79 additions and 15 deletions

View File

@ -7,13 +7,10 @@ import 'package:path/path.dart' as p;
import '../core/encode_options.dart'; import '../core/encode_options.dart';
import '../core/languages.dart'; import '../core/languages.dart';
import '../core/mkv_command_builder.dart' show bitmapSubtitleCodecs;
import '../core/models.dart'; import '../core/models.dart';
import '../core/srt.dart'; import '../core/srt.dart';
import '../core/subtitle_detector.dart';
import '../core/subtitle_search.dart'; import '../core/subtitle_search.dart';
import '../services/subtitle_provider.dart'; import '../services/subtitle_provider.dart';
import '../core/text_codec.dart';
import '../services/media_tool.dart'; import '../services/media_tool.dart';
import '../services/platform_services.dart'; import '../services/platform_services.dart';
import '../services/system_usage.dart'; import '../services/system_usage.dart';
@ -261,12 +258,7 @@ class AppController extends ChangeNotifier {
// ───────── 자막 ───────── // ───────── 자막 ─────────
Future<void> pickSubtitlesFor(VideoItem v) async { Future<void> pickSubtitlesFor(VideoItem v) async {
final paths = await services.storage.pickSubtitles(initialDirectory: v.directory); await _files.pickFor(v);
for (final path in paths) {
if (v.subtitles.any((s) => s.path != null && p.equals(s.path!, path))) continue;
final d = findSiblingSubtitles(v.path, [path]);
v.subtitles.add(await _files.external(path, d.isEmpty ? undetermined : d.first.language));
}
notifyListeners(); notifyListeners();
} }
@ -293,9 +285,7 @@ class AppController extends ChangeNotifier {
// ───────── 자막 편집 (2단계) ───────── // ───────── 자막 편집 (2단계) ─────────
/// 편집 가능 여부 (이미지 자막은 불가) /// 편집 가능 여부 (이미지 자막은 불가)
bool canEdit(SubtitleEntry s) => bool canEdit(SubtitleEntry s) => SubtitleFiles.canEdit(s, hasFfmpeg: ffmpegVersion != null);
!(s.kind == SubtitleKind.embedded && bitmapSubtitleCodecs.contains(s.codec)) &&
(s.kind == SubtitleKind.external || ffmpegVersion != null);
/// 자막을 큐 목록으로 불러온다 ([SubtitleFiles.loadCues]) /// 자막을 큐 목록으로 불러온다 ([SubtitleFiles.loadCues])
Future<List<Cue>> loadCues(VideoItem v, SubtitleEntry s) => _files.loadCues(v, s); Future<List<Cue>> loadCues(VideoItem v, SubtitleEntry s) => _files.loadCues(v, s);
@ -311,10 +301,9 @@ class AppController extends ChangeNotifier {
/// 다른 이름으로 저장 (목록은 바꾸지 않음) /// 다른 이름으로 저장 (목록은 바꾸지 않음)
Future<(String?, int)> saveCuesAs(VideoItem v, List<Cue> cues, String charset, String fileName) async { Future<(String?, int)> saveCuesAs(VideoItem v, List<Cue> cues, String charset, String fileName) async {
final enc = encodeText(formatSrt(cues), charset); final (path, lost) = await _files.saveAs(v, cues, charset, fileName);
final path = await services.storage.saveAs(fileName: fileName, bytes: enc.bytes, initialDirectory: v.directory);
if (path != null) _log(trf('다른 이름으로 저장: {0} ({1})', [path, charset])); if (path != null) _log(trf('다른 이름으로 저장: {0} ({1})', [path, charset]));
return (path, enc.lostChars); return (path, lost);
} }
// ───────── AI 자막 (4단계) · 자막 번역 ([AiSubtitleRunner]) ───────── // ───────── AI 자막 (4단계) · 자막 번역 ([AiSubtitleRunner]) ─────────

View File

@ -22,6 +22,30 @@ class SubtitleFiles {
final MediaTool tool; final MediaTool tool;
SubtitleFiles(this.storage, this.tool); SubtitleFiles(this.storage, this.tool);
/// 자막 파일을 골라 [v] 에 더한다 (이미 있는 경로는 건너뜀, 언어는 파일 이름으로). 더한 수
Future<int> pickFor(VideoItem v) async {
var n = 0;
for (final path in await storage.pickSubtitles(initialDirectory: v.directory)) {
if (v.subtitles.any((s) => s.path != null && p.equals(s.path!, path))) continue;
final d = findSiblingSubtitles(v.path, [path]);
v.subtitles.add(await external(path, d.isEmpty ? undetermined : d.first.language));
n++;
}
return n;
}
/// 편집할 수 있는 자막인지 (이미지 자막은 아님 · 내장 자막은 FFmpeg 가 있어야 꺼냄)
static bool canEdit(SubtitleEntry s, {required bool hasFfmpeg}) =>
!(s.kind == SubtitleKind.embedded && bitmapSubtitleCodecs.contains(s.codec)) &&
(s.kind == SubtitleKind.external || hasFfmpeg);
/// [cues] 를 SRT 로 다른 이름 저장 (목록은 바꾸지 않음). (저장한 경로 - 취소면 null, 표현할 수 없어 '?' 로 바뀐 글자 수)
Future<(String?, int)> saveAs(VideoItem v, List<Cue> cues, String charset, String fileName) async {
final enc = encodeText(formatSrt(cues), charset);
final path = await storage.saveAs(fileName: fileName, bytes: enc.bytes, initialDirectory: v.directory);
return (path, enc.lostChars);
}
/// 외부 자막 항목 (문자셋은 앞 64KB 로 알아낸다, 못 읽으면 UTF-8) /// 외부 자막 항목 (문자셋은 앞 64KB 로 알아낸다, 못 읽으면 UTF-8)
Future<SubtitleEntry> external(String path, Language lang) async { Future<SubtitleEntry> external(String path, Language lang) async {
String charset = 'UTF-8'; String charset = 'UTF-8';

View File

@ -1,15 +1,21 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/controller/app_log.dart'; import 'package:jj_mkvmaker/app/controller/app_log.dart';
import 'package:jj_mkvmaker/app/controller/encode_choice.dart'; import 'package:jj_mkvmaker/app/controller/encode_choice.dart';
import 'package:jj_mkvmaker/app/controller/job_queue.dart'; import 'package:jj_mkvmaker/app/controller/job_queue.dart';
import 'package:jj_mkvmaker/app/controller/subtitle_files.dart';
import 'package:jj_mkvmaker/app/controller/video_files.dart'; import 'package:jj_mkvmaker/app/controller/video_files.dart';
import 'package:jj_mkvmaker/app/controller/video_list.dart'; import 'package:jj_mkvmaker/app/controller/video_list.dart';
import 'package:jj_mkvmaker/app/controller/video_list_share.dart'; import 'package:jj_mkvmaker/app/controller/video_list_share.dart';
import 'package:jj_mkvmaker/core/encode_options.dart'; import 'package:jj_mkvmaker/core/encode_options.dart';
import 'package:jj_mkvmaker/core/languages.dart';
import 'package:jj_mkvmaker/core/models.dart'; import 'package:jj_mkvmaker/core/models.dart';
import 'package:jj_mkvmaker/core/srt.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
/// app_controller 를 나눈 모듈의 약속 (실제 파일 · 실제 Future 로) /// app_controller 를 나눈 모듈의 약속 (실제 파일 · 실제 Future 로)
@ -133,4 +139,49 @@ void main() {
list.clear(); list.clear();
expect([list.items, list.selected], [isEmpty, null]); expect([list.items, list.selected], [isEmpty, null]);
}); });
test('약속 SubtitleFiles: 고른 자막 더하기 (있는 것 건너뜀 · 이름으로 언어) · 다른 이름 저장 · 편집할 수 있는지', () async {
final video = p.join(tmp.path, 'movie.mkv');
File(video).writeAsStringSync('v');
final ko = File(p.join(tmp.path, 'movie.ko.srt'))..writeAsStringSync('1\n00:00:01,000 --> 00:00:02,000\n안녕\n');
final storage = _ChosenStorage([ko.path, ko.path]);
final files = SubtitleFiles(storage, ProcessMediaTool('x', 'y'));
final v = VideoItem(video);
expect(await files.pickFor(v), 1, reason: '같은 경로는 한 번만');
expect([v.subtitles.single.path, v.subtitles.single.language.code], [ko.path, 'ko']);
expect(await files.pickFor(v), 0, reason: '이미 있는 경로는 건너뜀');
// 다른 이름 저장: 실제 SRT 바이트 · 표현할 수 없는 글자 수
storage.saveTo = p.join(tmp.path, 'out.srt');
final (path, lost) = await files.saveAs(v, [Cue(const Duration(seconds: 1), const Duration(seconds: 2), '안녕')], 'UTF-8', 'out.srt');
expect([path, lost], [storage.saveTo, 0]);
expect(File(path!).readAsStringSync(), contains('00:00:01,000 --> 00:00:02,000'));
storage.saveTo = null;
expect((await files.saveAs(v, const [], 'UTF-8', 'x.srt')).$1, isNull, reason: '취소');
// 편집: 외부 자막은 늘 · 내장 글 자막은 FFmpeg 가 있을 때만 · 이미지 자막은 아님
final emb = SubtitleEntry.embedded(streamIndex: 2, codec: 'subrip', language: undetermined);
final pgs = SubtitleEntry.embedded(streamIndex: 3, codec: 'hdmv_pgs_subtitle', language: undetermined);
expect([
SubtitleFiles.canEdit(v.subtitles.single, hasFfmpeg: false),
SubtitleFiles.canEdit(emb, hasFfmpeg: true),
SubtitleFiles.canEdit(emb, hasFfmpeg: false),
SubtitleFiles.canEdit(pgs, hasFfmpeg: true),
], [true, true, false, false]);
});
}
/// 고르기 · 저장 창 대신 정한 것을 돌려주는 저장소 (사용자가 고른 것) - 나머지는 실제 데스크톱 저장소
class _ChosenStorage extends DesktopStorageService {
_ChosenStorage(this.picked);
final List<String> picked;
String? saveTo;
@override
Future<List<String>> pickSubtitles({String? initialDirectory}) async => picked;
@override
Future<String?> saveAs({required String fileName, required Uint8List bytes, String? initialDirectory}) async {
if (saveTo == null) return null;
File(saveTo!).writeAsBytesSync(bytes);
return saveTo;
}
} }