화면 · 색 보정 칩 ✓ 만 · 자막 편집 제목 / 파일 이름 두 줄 · F9 자막 표 좁으면 두 단 · F10 미리보기 글 위로 · ftp28 처음 연결만 한 번 다시 (관리자 지시 10/21, 28 · 15 찾음)

- 화면 · 색 보정 창 (28): 고른 비율 칩 ("세로 9:16" 등) 은 그림이 있는 칩이라 기본 체크가 그림 위에 겹쳤다 → 고르면 그림 자리에 ✓ 만
  (showCheckmark: false). 맞추기 · 회전 칩은 원래 ✓ 만
- 자막 편집 화면 (28): 960 에서도 "자막 편집 · …" 로 잘려 파일 이름이 안 보였다 → 제목은 "자막 편집", 파일 이름은 그 아래 한 줄
  (path_label.middleEllipsis 로 가운데를 줄여 끝 .srt 가 보임, Tooltip 으로 길게 누르면 · 마우스를 올리면 전체). 열쇠 '자막 편집 · {0}' → '자막 편집'
- F9: 자막 편집 표의 칸 폭 (# 48 · 시작 130 · 끝 130 · 단추 96) 이 412 에서 넘쳤다 (24 · 27px) → 내용 칸까지 한 줄에 들지 않으면 두 단
  (위: # · 시작 · 끝 · 단추, 아래: 내용). 시간 칸은 줄이지 않고, 두 단일 때 # 칸 28 · 단추 누를 자리 40 으로 위 단이 412 에 듬. 넓으면 그대로
- F10: 화면 · 색 보정 미리보기의 파일 이름 · 시각 글이 412 에서 [누르고 있으면 조정 전] 위에 겹쳤다 → 좁을 때 (< 560) 만 미리보기 위쪽으로
  (한 줄 · 줄임, 돌아가는 표시 자리는 비움). 단추 자리는 그대로
- ftp28 (28 원문: 부하 중 시험 서버가 처음 연결에 16초 답하지 않음): 시험 도우미가 처음 연결 (서버 준비) 이 "닿지 않음" 일 때만 2초 뒤
  한 번 다시 시도, 맞추기 도중 실패 · 비교 차이는 다시 시도하지 않음. 다시 시도했으면 jj_ftp28_fail 기록에 "다시 시도함" (통과해도)
- 캡처 windows_ui2_shot_test (ko · ja × 412 · 960): 칩 ✓ 만 · 제목 "자막 편집" · 파일 이름 끝 .srt · 넘침 없음 - 4개 통과, 직접 봄.
  ftp_test · ui_test 15개 통과, l10n 빠진 것 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rc2^2
RyuJongJin 2026-10-21 19:38:39 +09:00
parent 73fcd97e90
commit 62ba78dfeb
7 changed files with 236 additions and 49 deletions

View File

@ -2253,7 +2253,7 @@
"자막 파일 선택": "Choose subtitle files",
"자막 파일 추가": "Add subtitle files",
"자막 파일을 받을 수 없습니다 ({0})": "Cannot download the subtitle file ({0})",
"자막 편집 · {0}": "Edit subtitles · {0}",
"자막 편집": "Edit subtitles",
"자막을 불러올 수 없습니다: {0}": "Cannot load subtitles: {0}",
"자막이 비어 있습니다.": "The subtitles are empty.",
"자막이 없습니다.": "No subtitles.",

View File

@ -2253,7 +2253,7 @@
"자막 파일 선택": "字幕ファイルを選択",
"자막 파일 추가": "字幕ファイルを追加",
"자막 파일을 받을 수 없습니다 ({0})": "字幕ファイルをダウンロードできません({0})",
"자막 편집 · {0}": "字幕編集・{0}",
"자막 편집": "字幕編集",
"자막을 불러올 수 없습니다: {0}": "字幕を読み込めません: {0}",
"자막이 비어 있습니다.": "字幕が空です。",
"자막이 없습니다.": "字幕がありません。",

View File

@ -2253,7 +2253,7 @@
"자막 파일 선택": "选择字幕文件",
"자막 파일 추가": "添加字幕文件",
"자막 파일을 받을 수 없습니다 ({0})": "无法下载字幕文件({0})",
"자막 편집 · {0}": "编辑字幕 · {0}",
"자막 편집": "编辑字幕",
"자막을 불러올 수 없습니다: {0}": "无法加载字幕: {0}",
"자막이 비어 있습니다.": "字幕为空。",
"자막이 없습니다.": "没有字幕。",

View File

@ -0,0 +1,113 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/i18n_controller.dart';
import 'package:jj_mkvmaker/app/subtitle_editor_controller.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/l10n/tr.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/subtitle_editor_page.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'package:jj_mkvmaker/ui/video_adjust_dialog.dart';
import 'package:path/path.dart' as p;
import 'support/temp_dir.dart';
/// 관리자 지시 (10/21, 28 찾음): ① 화면 · 색 보정 창 - 고른 비율 칩 ("세로 9:16") 은 ✓ 만 (그림 위에 체크가 겹치지 않게)
/// ② 자막 편집 화면 - 제목은 "자막 편집", 파일 이름은 그 아래 한 줄 (가운데를 줄여 끝 .srt 가 보이게). ko · ja × 412 · 960
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final out = Platform.environment['JJ_SHOT_DIR'] ?? '';
for (final lang in ['ko', 'ja']) {
for (final (w, h) in [(412, 900), (960, 600)]) {
testWidgets('화면 · 색 보정 칩 · 자막 편집 제목 $lang ${w}x$h', (t) async {
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
await t.runAsync(() => i18n.apply(lang, save: false));
addTearDown(() => i18n.apply('ko', save: false));
final tmp = testTempDir('jj_ui2shot_');
appTempInto(tmp);
final video = p.join(tmp.path, '아주 긴 이름의 시험용 동영상 파일 - 제1화 고화질 버전.mp4');
final ffmpeg = p.join(Directory.current.path, 'third_party', 'ffmpeg', 'windows', 'ffmpeg.exe');
final r = await t.runAsync(() => Process.run(ffmpeg, [
'-y', '-v', 'error', '-f', 'lavfi', '-i', 'testsrc2=size=640x360:rate=10:duration=2', //
'-c:v', 'libx264', '-pix_fmt', 'yuv420p', video,
]));
expect(r!.exitCode, 0, reason: '${r.stderr}');
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool.locate(), storage: DesktopStorageService()));
await t.runAsync(() async {
await c.init();
await c.addVideos([video]);
});
final shotKey = GlobalKey();
final navKey = GlobalKey<NavigatorState>();
await t.pumpWidget(MaterialApp(
theme: buildTheme(),
debugShowCheckedModeBanner: false,
navigatorKey: navKey,
builder: (context, child) => RepaintBoundary(key: shotKey, child: child),
home: const Scaffold(body: SizedBox.expand()),
));
Future<void> settle([int n = 10]) async {
for (var i = 0; i < n; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 80)));
await t.pump();
}
}
Future<void> shot(String name) async {
await settle(3);
expect(t.takeException(), isNull, reason: '$name 넘침');
if (out.isEmpty) return;
final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
final img = await t.runAsync(() => b.toImage());
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
Directory(out).createSync(recursive: true);
File(p.join(out, 'ui2_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
}
// ① 화면 · 색 보정 - "세로 9:16" 을 고른다
unawaited(showVideoAdjust(navKey.currentContext!, c));
await settle(20);
final portrait = find.text(FrameChoice.portrait.label);
await t.ensureVisible(portrait);
await t.tap(portrait);
await settle();
final chip = find.ancestor(of: portrait, matching: find.byType(ChoiceChip));
expect(t.widget<ChoiceChip>(chip).selected, isTrue);
expect(t.widget<ChoiceChip>(chip).showCheckmark, isFalse, reason: '그림 위에 체크가 겹치지 않게');
expect(find.descendant(of: chip, matching: find.byIcon(Icons.check)), findsOneWidget, reason: '✓ 만');
expect(find.descendant(of: chip, matching: find.byIcon(Icons.crop_portrait)), findsNothing);
await shot('1_adjust');
navKey.currentState!.popUntil((r) => r.isFirst);
await settle();
// ② 자막 편집 - 긴 파일 이름
final v = c.videos.single;
final s = SubtitleEntry.external(path: p.join(tmp.path, '아주 긴 이름의 시험용 동영상 파일 - 제1화 고화질 버전.ko.srt'), language: languageOf('ko'));
v.subtitles.add(s);
final editor = SubtitleEditorController(parseSrt('1\n00:00:01,000 --> 00:00:02,000\n안녕\n'), saveCharset: 'UTF-8');
unawaited(navKey.currentState!.push(MaterialPageRoute<void>(builder: (_) => SubtitleEditorPage(app: c, video: v, entry: s, editor: editor))));
await settle(12);
expect(find.text(tr('자막 편집')), findsOneWidget, reason: '제목은 "자막 편집" 만');
final name = find.textContaining('.srt');
expect(name, findsOneWidget, reason: '파일 이름은 아래 한 줄 - 끝 (.srt) 이 보임');
await shot('2_editor');
expect(t.widget<Text>(name).data!.endsWith('.srt'), isTrue, reason: '${t.widget<Text>(name).data}');
});
}
}
}

View File

@ -12,6 +12,7 @@ import '../core/text_codec.dart';
import '../services/preview_player.dart';
import 'app_actions.dart';
import 'key_hint.dart';
import 'path_label.dart' show middleEllipsis;
import 'theme.dart';
import 'timeline.dart';
import '../l10n/tr.dart';
@ -487,12 +488,21 @@ class _SubtitleEditorPageState extends State<SubtitleEditorPage> {
actions: const AppActions(),
middle: Row(
children: [
// 관리자 지시 (10/21, 28 찾음): 960 에서도 "자막 편집 · …" 로 잘려 파일 이름이 안 보였다 - 제목은 "자막 편집" 만,
// 파일 이름은 그 아래 한 줄 (가운데를 줄여 앞 · 끝 (.srt) 이 보이게, 길게 누르거나 마우스를 올리면 전체)
Expanded(
child: Text(
trf('자막 편집 · {0}', [_entry.displayName]),
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(tr('자막 편집'),
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
LayoutBuilder(builder: (context, box) {
const style = TextStyle(fontSize: 12, color: JjColors.textDim);
return Tooltip(
message: _entry.displayName,
child: Text(middleEllipsis(context, _entry.displayName, style, box.maxWidth),
maxLines: 1, overflow: TextOverflow.clip, style: style),
);
}),
]),
),
// 폭이 좁으면 (휴대폰) 도구 줄을 밀어서 본다 - 설정 · 종료 버튼은 늘 오른쪽 끝에
Flexible(
@ -581,16 +591,37 @@ class _HeaderRow extends StatelessWidget {
Widget build(BuildContext context) => Container(
color: JjColors.panel,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(children: [
SizedBox(width: 48, child: Text('#', style: _hdr)),
SizedBox(width: 130, child: Text(tr('시작'), style: _hdr)),
SizedBox(width: 130, child: Text(tr('끝'), style: _hdr)),
Expanded(child: Text(tr('내용'), style: _hdr)),
SizedBox(width: 96),
]),
child: LayoutBuilder(builder: (context, box) {
if (!_cueNarrow(box.maxWidth)) {
return Row(children: [
SizedBox(width: 48, child: Text('#', style: _hdr)),
SizedBox(width: 130, child: Text(tr('시작'), style: _hdr)),
SizedBox(width: 130, child: Text(tr('끝'), style: _hdr)),
Expanded(child: Text(tr('내용'), style: _hdr)),
SizedBox(width: 96),
]);
}
// F9: 좁을 때 - 위 # · 시작 · 끝, 아래 내용 (줄과 같은 두 단)
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
SizedBox(width: _narrowNo, child: Text('#', style: _hdr)),
SizedBox(width: 130, child: Text(tr('시작'), style: _hdr)),
SizedBox(width: 130, child: Text(tr('끝'), style: _hdr)),
]),
Padding(padding: const EdgeInsets.only(left: _narrowNo), child: Text(tr('내용'), style: _hdr)),
]);
}),
);
}
/// 관리자 지시 (10/21, 15 찾음 F9): 자막 편집 표는 # · 시작 · 끝 · 단추 칸 폭이 정해져 있어 (48 + 130 + 130 + 96) 폰 세로 (412) 에서
/// 오른쪽으로 넘쳤다 - 내용 칸까지 한 줄에 들지 않으면 두 단 (위: # · 시작 · 끝 · 단추, 아래: 내용). 시간 칸은 줄이지 않는다 (편집이 어려움)
bool _cueNarrow(double width) => width < 48 + 130 + 130 + 96 + 200;
/// 두 단일 때 # 칸 · 단추 하나의 폭 (위 단이 412 에 들게: 28 + 130 + 130 + 2 × 40 = 368)
const _narrowNo = 28.0;
const _narrowButton = 40.0;
const _hdr = TextStyle(fontSize: 12, color: JjColors.textDim);
class _CueRow extends StatefulWidget {
@ -666,11 +697,10 @@ class _CueRowState extends State<_CueRow> {
color: widget.active ? JjColors.accent : Colors.transparent, width: 3),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 48,
child: LayoutBuilder(builder: (context, box) {
final narrow = _cueNarrow(box.maxWidth);
final number = SizedBox(
width: narrow ? _narrowNo : 48,
child: InkWell(
onTap: widget.onSelect,
child: Padding(
@ -681,11 +711,10 @@ class _CueRowState extends State<_CueRow> {
color: widget.selected ? JjColors.accent : JjColors.textDim)),
),
),
),
SizedBox(width: 130, child: _timeField(_start, _startFocus, _startBad, true)),
SizedBox(width: 130, child: _timeField(_end, _endFocus, _endBad, false)),
Expanded(
child: TextField(
);
final start = SizedBox(width: 130, child: _timeField(_start, _startFocus, _startBad, true));
final end = SizedBox(width: 130, child: _timeField(_end, _endFocus, _endBad, false));
final text = TextField(
controller: _text,
minLines: 1,
maxLines: 4,
@ -693,27 +722,42 @@ class _CueRowState extends State<_CueRow> {
decoration: const InputDecoration(isDense: true, border: InputBorder.none),
onTap: () => e.select(widget.cue),
onChanged: (t) => e.setText(widget.cue, t),
),
);
// 좁으면 단추 누를 자리를 40 으로 (기본 48) - 위 단이 폭에 들게
final small = narrow ? const BoxConstraints(minWidth: _narrowButton, minHeight: _narrowButton) : null;
final buttons = [
IconButton(
tooltip: tr('아래에 줄 추가'),
iconSize: 18,
constraints: small,
padding: narrow ? EdgeInsets.zero : null,
icon: const Icon(Icons.add, color: JjColors.textDim),
onPressed: () => e.insertAfter(widget.index),
),
SizedBox(
width: 96,
child: Row(children: [
IconButton(
tooltip: tr('아래에 줄 추가'),
iconSize: 18,
icon: const Icon(Icons.add, color: JjColors.textDim),
onPressed: () => e.insertAfter(widget.index),
),
IconButton(
tooltip: tr('줄 삭제'),
iconSize: 18,
icon: const Icon(Icons.delete_outline, color: JjColors.textDim),
onPressed: () => e.removeAt(widget.index),
),
]),
IconButton(
tooltip: tr('줄 삭제'),
iconSize: 18,
constraints: small,
padding: narrow ? EdgeInsets.zero : null,
icon: const Icon(Icons.delete_outline, color: JjColors.textDim),
onPressed: () => e.removeAt(widget.index),
),
],
),
];
if (!narrow) {
return Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
number,
start,
end,
Expanded(child: text),
SizedBox(width: 96, child: Row(children: buttons)),
]);
}
// F9: 좁을 때 두 단 - 위 # · 시작 · 끝 · 단추, 아래 내용 (# 칸만큼 들여)
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [number, start, end, const Spacer(), ...buttons]),
Padding(padding: const EdgeInsets.only(left: _narrowNo), child: text),
]);
}),
);
}

View File

@ -156,7 +156,11 @@ class _VideoAdjustState extends State<_VideoAdjust> {
/// 뷰어: 조정 후 장면 (누르고 있으면 조정 전)
Widget _viewer() {
final img = _showBefore ? _before : _after;
return Container(
// 관리자 지시 (10/21, 15 찾음 F10): 좁으면 (폰 세로) 아래의 파일 이름 · 시각 글이 [누르고 있으면 조정 전] 위에 겹쳤다 -
// 좁을 때만 그 글을 미리보기 위쪽으로 (단추 자리는 그대로)
return LayoutBuilder(builder: (context, box) {
final narrow = box.maxWidth < 560;
return Container(
color: Colors.black,
child: Stack(children: [
Positioned.fill(
@ -193,13 +197,16 @@ class _VideoAdjustState extends State<_VideoAdjust> {
const Positioned(right: 12, top: 12, child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))),
if (v != null)
Positioned(
right: 8,
bottom: 8,
left: narrow ? 8 : null,
right: narrow ? 36 : 8, // 위 오른쪽의 돌아가는 표시 자리는 비운다
top: narrow ? 8 : null,
bottom: narrow ? null : 8,
child: Text('${v!.fileName} · ${_fmt(_at)}',
style: const TextStyle(fontSize: 11, color: Colors.white70)),
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11, color: Colors.white70)),
),
]),
);
});
}
static String _fmt(Duration d) =>
@ -225,7 +232,9 @@ class _VideoAdjustState extends State<_VideoAdjust> {
Wrap(spacing: 6, runSpacing: 6, children: [
for (final f in FrameChoice.values)
ChoiceChip(
avatar: Icon(frameIcons[f], size: 16),
// 관리자 지시 (10/21, 28 찾음): 고르면 체크 표가 그림 위에 겹쳐 그려졌다 - 다른 고른 칩처럼 ✓ 만 (그림 자리에)
avatar: Icon(s.frame == f ? Icons.check : frameIcons[f], size: 16),
showCheckmark: false,
label: Text(f.label),
selected: s.frame == f,
onSelected: (_) => _set(s.copyWith(frame: f)),

View File

@ -294,6 +294,25 @@ void main() {
addTearDown(() {
if (!finished) writeFail('끝나지 않음 (시간 초과 · 예외)');
});
// 관리자 지시 (10/21, 28 원문): 전체 시험 부하에서 시험 서버 (pyftpdlib) 가 처음 연결에 16초 동안 답하지 않아 실패했다 (앱의 "답하지 않음" 처리는 맞음).
// 처음 연결 (서버 준비) 만 한 번 다시 시도한다 - 맞추기 도중의 실패 · 비교 결과가 다른 것은 다시 시도하지 않는다
Future<void> warm() async {
final w = FtpRemote(srv(security: 'none'));
try {
await w.list('/');
} finally {
w.close();
}
}
try {
await warm();
} on FtpException catch (e) {
if (e.kind != FtpFailure.unreachable) rethrow;
progress.add('처음 연결 실패 (${sw.elapsedMilliseconds}ms, $e) - 다시 시도함');
await Future<void>.delayed(const Duration(seconds: 2));
await warm();
}
for (final opts in sceneOptions) {
final t0 = sw.elapsedMilliseconds;
progress.add('$opts - 시작 ${t0}ms');
@ -329,6 +348,8 @@ void main() {
}
finished = true;
if (bad.isNotEmpty) writeFail('결과가 rsync 와 다름 ${bad.length}개');
// 다시 시도해서 통과한 것도 기록에 남긴다 (서버 준비가 늦었던 때를 알 수 있게)
if (bad.isEmpty && progress.any((x) => x.contains('다시 시도함'))) writeFail('통과 (처음 연결을 다시 시도함)');
expect(bad, isEmpty, reason: bad.join('\n'));
}, skip: hasPy ? false : '시험용 파이썬 없음', timeout: const Timeout(Duration(minutes: 5)));