From 051c259d895b813e8745feabe00548e6894d0a1a Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:46:32 +0900 Subject: [PATCH] =?UTF-8?q?app=5Fcontroller=20509=20=E2=86=92=20498?= =?UTF-8?q?=EC=A4=84:=20=EC=9E=90=EB=A7=89=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=C2=B7=20=EB=8B=A4=EB=A5=B8=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EC=A0=80=EC=9E=A5=20=C2=B7=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8A=94=EC=A7=80?= =?UTF-8?q?=EB=A5=BC=20SubtitleFiles=20=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- lib/app/app_controller.dart | 19 ++------ lib/app/controller/subtitle_files.dart | 24 ++++++++++ test/controller_modules_contract_test.dart | 51 ++++++++++++++++++++++ 3 files changed, 79 insertions(+), 15 deletions(-) diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index a927727..4e30df3 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -7,13 +7,10 @@ import 'package:path/path.dart' as p; import '../core/encode_options.dart'; import '../core/languages.dart'; -import '../core/mkv_command_builder.dart' show bitmapSubtitleCodecs; import '../core/models.dart'; import '../core/srt.dart'; -import '../core/subtitle_detector.dart'; import '../core/subtitle_search.dart'; import '../services/subtitle_provider.dart'; -import '../core/text_codec.dart'; import '../services/media_tool.dart'; import '../services/platform_services.dart'; import '../services/system_usage.dart'; @@ -261,12 +258,7 @@ class AppController extends ChangeNotifier { // ───────── 자막 ───────── Future pickSubtitlesFor(VideoItem v) async { - final paths = await services.storage.pickSubtitles(initialDirectory: v.directory); - 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)); - } + await _files.pickFor(v); notifyListeners(); } @@ -293,9 +285,7 @@ class AppController extends ChangeNotifier { // ───────── 자막 편집 (2단계) ───────── /// 편집 가능 여부 (이미지 자막은 불가) - bool canEdit(SubtitleEntry s) => - !(s.kind == SubtitleKind.embedded && bitmapSubtitleCodecs.contains(s.codec)) && - (s.kind == SubtitleKind.external || ffmpegVersion != null); + bool canEdit(SubtitleEntry s) => SubtitleFiles.canEdit(s, hasFfmpeg: ffmpegVersion != null); /// 자막을 큐 목록으로 불러온다 ([SubtitleFiles.loadCues]) Future> loadCues(VideoItem v, SubtitleEntry s) => _files.loadCues(v, s); @@ -311,10 +301,9 @@ class AppController extends ChangeNotifier { /// 다른 이름으로 저장 (목록은 바꾸지 않음) Future<(String?, int)> saveCuesAs(VideoItem v, List cues, String charset, String fileName) async { - final enc = encodeText(formatSrt(cues), charset); - final path = await services.storage.saveAs(fileName: fileName, bytes: enc.bytes, initialDirectory: v.directory); + final (path, lost) = await _files.saveAs(v, cues, charset, fileName); if (path != null) _log(trf('다른 이름으로 저장: {0} ({1})', [path, charset])); - return (path, enc.lostChars); + return (path, lost); } // ───────── AI 자막 (4단계) · 자막 번역 ([AiSubtitleRunner]) ───────── diff --git a/lib/app/controller/subtitle_files.dart b/lib/app/controller/subtitle_files.dart index a24ce9b..b14e40a 100644 --- a/lib/app/controller/subtitle_files.dart +++ b/lib/app/controller/subtitle_files.dart @@ -22,6 +22,30 @@ class SubtitleFiles { final MediaTool tool; SubtitleFiles(this.storage, this.tool); + /// 자막 파일을 골라 [v] 에 더한다 (이미 있는 경로는 건너뜀, 언어는 파일 이름으로). 더한 수 + Future 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 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) Future external(String path, Language lang) async { String charset = 'UTF-8'; diff --git a/test/controller_modules_contract_test.dart b/test/controller_modules_contract_test.dart index 1e4f173..9640b88 100644 --- a/test/controller_modules_contract_test.dart +++ b/test/controller_modules_contract_test.dart @@ -1,15 +1,21 @@ import 'dart:async'; import 'dart:io'; +import 'dart:typed_data'; import 'package:flutter_test/flutter_test.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/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_list.dart'; import 'package:jj_mkvmaker/app/controller/video_list_share.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/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; /// app_controller 를 나눈 모듈의 약속 (실제 파일 · 실제 Future 로) @@ -133,4 +139,49 @@ void main() { list.clear(); 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 picked; + String? saveTo; + + @override + Future> pickSubtitles({String? initialDirectory}) async => picked; + + @override + Future saveAs({required String fileName, required Uint8List bytes, String? initialDirectory}) async { + if (saveTo == null) return null; + File(saveTo!).writeAsBytesSync(bytes); + return saveTo; + } }