화면 · 색 보정 칩 ✓ 만 · 자막 편집 제목 / 파일 이름 두 줄 · 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
parent
73fcd97e90
commit
62ba78dfeb
|
|
@ -2253,7 +2253,7 @@
|
||||||
"자막 파일 선택": "Choose subtitle files",
|
"자막 파일 선택": "Choose subtitle files",
|
||||||
"자막 파일 추가": "Add subtitle files",
|
"자막 파일 추가": "Add subtitle files",
|
||||||
"자막 파일을 받을 수 없습니다 ({0})": "Cannot download the subtitle file ({0})",
|
"자막 파일을 받을 수 없습니다 ({0})": "Cannot download the subtitle file ({0})",
|
||||||
"자막 편집 · {0}": "Edit subtitles · {0}",
|
"자막 편집": "Edit subtitles",
|
||||||
"자막을 불러올 수 없습니다: {0}": "Cannot load subtitles: {0}",
|
"자막을 불러올 수 없습니다: {0}": "Cannot load subtitles: {0}",
|
||||||
"자막이 비어 있습니다.": "The subtitles are empty.",
|
"자막이 비어 있습니다.": "The subtitles are empty.",
|
||||||
"자막이 없습니다.": "No subtitles.",
|
"자막이 없습니다.": "No subtitles.",
|
||||||
|
|
|
||||||
|
|
@ -2253,7 +2253,7 @@
|
||||||
"자막 파일 선택": "字幕ファイルを選択",
|
"자막 파일 선택": "字幕ファイルを選択",
|
||||||
"자막 파일 추가": "字幕ファイルを追加",
|
"자막 파일 추가": "字幕ファイルを追加",
|
||||||
"자막 파일을 받을 수 없습니다 ({0})": "字幕ファイルをダウンロードできません({0})",
|
"자막 파일을 받을 수 없습니다 ({0})": "字幕ファイルをダウンロードできません({0})",
|
||||||
"자막 편집 · {0}": "字幕編集・{0}",
|
"자막 편집": "字幕編集",
|
||||||
"자막을 불러올 수 없습니다: {0}": "字幕を読み込めません: {0}",
|
"자막을 불러올 수 없습니다: {0}": "字幕を読み込めません: {0}",
|
||||||
"자막이 비어 있습니다.": "字幕が空です。",
|
"자막이 비어 있습니다.": "字幕が空です。",
|
||||||
"자막이 없습니다.": "字幕がありません。",
|
"자막이 없습니다.": "字幕がありません。",
|
||||||
|
|
|
||||||
|
|
@ -2253,7 +2253,7 @@
|
||||||
"자막 파일 선택": "选择字幕文件",
|
"자막 파일 선택": "选择字幕文件",
|
||||||
"자막 파일 추가": "添加字幕文件",
|
"자막 파일 추가": "添加字幕文件",
|
||||||
"자막 파일을 받을 수 없습니다 ({0})": "无法下载字幕文件({0})",
|
"자막 파일을 받을 수 없습니다 ({0})": "无法下载字幕文件({0})",
|
||||||
"자막 편집 · {0}": "编辑字幕 · {0}",
|
"자막 편집": "编辑字幕",
|
||||||
"자막을 불러올 수 없습니다: {0}": "无法加载字幕: {0}",
|
"자막을 불러올 수 없습니다: {0}": "无法加载字幕: {0}",
|
||||||
"자막이 비어 있습니다.": "字幕为空。",
|
"자막이 비어 있습니다.": "字幕为空。",
|
||||||
"자막이 없습니다.": "没有字幕。",
|
"자막이 없습니다.": "没有字幕。",
|
||||||
|
|
|
||||||
|
|
@ -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}');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ import '../core/text_codec.dart';
|
||||||
import '../services/preview_player.dart';
|
import '../services/preview_player.dart';
|
||||||
import 'app_actions.dart';
|
import 'app_actions.dart';
|
||||||
import 'key_hint.dart';
|
import 'key_hint.dart';
|
||||||
|
import 'path_label.dart' show middleEllipsis;
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
import 'timeline.dart';
|
import 'timeline.dart';
|
||||||
import '../l10n/tr.dart';
|
import '../l10n/tr.dart';
|
||||||
|
|
@ -487,12 +488,21 @@ class _SubtitleEditorPageState extends State<SubtitleEditorPage> {
|
||||||
actions: const AppActions(),
|
actions: const AppActions(),
|
||||||
middle: Row(
|
middle: Row(
|
||||||
children: [
|
children: [
|
||||||
|
// 관리자 지시 (10/21, 28 찾음): 960 에서도 "자막 편집 · …" 로 잘려 파일 이름이 안 보였다 - 제목은 "자막 편집" 만,
|
||||||
|
// 파일 이름은 그 아래 한 줄 (가운데를 줄여 앞 · 끝 (.srt) 이 보이게, 길게 누르거나 마우스를 올리면 전체)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
trf('자막 편집 · {0}', [_entry.displayName]),
|
Text(tr('자막 편집'),
|
||||||
overflow: TextOverflow.ellipsis,
|
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
||||||
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(
|
Flexible(
|
||||||
|
|
@ -581,16 +591,37 @@ class _HeaderRow extends StatelessWidget {
|
||||||
Widget build(BuildContext context) => Container(
|
Widget build(BuildContext context) => Container(
|
||||||
color: JjColors.panel,
|
color: JjColors.panel,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: Row(children: [
|
child: LayoutBuilder(builder: (context, box) {
|
||||||
SizedBox(width: 48, child: Text('#', style: _hdr)),
|
if (!_cueNarrow(box.maxWidth)) {
|
||||||
SizedBox(width: 130, child: Text(tr('시작'), style: _hdr)),
|
return Row(children: [
|
||||||
SizedBox(width: 130, child: Text(tr('끝'), style: _hdr)),
|
SizedBox(width: 48, child: Text('#', style: _hdr)),
|
||||||
Expanded(child: Text(tr('내용'), style: _hdr)),
|
SizedBox(width: 130, child: Text(tr('시작'), style: _hdr)),
|
||||||
SizedBox(width: 96),
|
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);
|
const _hdr = TextStyle(fontSize: 12, color: JjColors.textDim);
|
||||||
|
|
||||||
class _CueRow extends StatefulWidget {
|
class _CueRow extends StatefulWidget {
|
||||||
|
|
@ -666,11 +697,10 @@ class _CueRowState extends State<_CueRow> {
|
||||||
color: widget.active ? JjColors.accent : Colors.transparent, width: 3),
|
color: widget.active ? JjColors.accent : Colors.transparent, width: 3),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: LayoutBuilder(builder: (context, box) {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
final narrow = _cueNarrow(box.maxWidth);
|
||||||
children: [
|
final number = SizedBox(
|
||||||
SizedBox(
|
width: narrow ? _narrowNo : 48,
|
||||||
width: 48,
|
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: widget.onSelect,
|
onTap: widget.onSelect,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
|
@ -681,11 +711,10 @@ class _CueRowState extends State<_CueRow> {
|
||||||
color: widget.selected ? JjColors.accent : JjColors.textDim)),
|
color: widget.selected ? JjColors.accent : JjColors.textDim)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
SizedBox(width: 130, child: _timeField(_start, _startFocus, _startBad, true)),
|
final start = SizedBox(width: 130, child: _timeField(_start, _startFocus, _startBad, true));
|
||||||
SizedBox(width: 130, child: _timeField(_end, _endFocus, _endBad, false)),
|
final end = SizedBox(width: 130, child: _timeField(_end, _endFocus, _endBad, false));
|
||||||
Expanded(
|
final text = TextField(
|
||||||
child: TextField(
|
|
||||||
controller: _text,
|
controller: _text,
|
||||||
minLines: 1,
|
minLines: 1,
|
||||||
maxLines: 4,
|
maxLines: 4,
|
||||||
|
|
@ -693,27 +722,42 @@ class _CueRowState extends State<_CueRow> {
|
||||||
decoration: const InputDecoration(isDense: true, border: InputBorder.none),
|
decoration: const InputDecoration(isDense: true, border: InputBorder.none),
|
||||||
onTap: () => e.select(widget.cue),
|
onTap: () => e.select(widget.cue),
|
||||||
onChanged: (t) => e.setText(widget.cue, t),
|
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(
|
IconButton(
|
||||||
width: 96,
|
tooltip: tr('줄 삭제'),
|
||||||
child: Row(children: [
|
iconSize: 18,
|
||||||
IconButton(
|
constraints: small,
|
||||||
tooltip: tr('아래에 줄 추가'),
|
padding: narrow ? EdgeInsets.zero : null,
|
||||||
iconSize: 18,
|
icon: const Icon(Icons.delete_outline, color: JjColors.textDim),
|
||||||
icon: const Icon(Icons.add, color: JjColors.textDim),
|
onPressed: () => e.removeAt(widget.index),
|
||||||
onPressed: () => e.insertAfter(widget.index),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
tooltip: tr('줄 삭제'),
|
|
||||||
iconSize: 18,
|
|
||||||
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),
|
||||||
|
]);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -156,7 +156,11 @@ class _VideoAdjustState extends State<_VideoAdjust> {
|
||||||
/// 뷰어: 조정 후 장면 (누르고 있으면 조정 전)
|
/// 뷰어: 조정 후 장면 (누르고 있으면 조정 전)
|
||||||
Widget _viewer() {
|
Widget _viewer() {
|
||||||
final img = _showBefore ? _before : _after;
|
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,
|
color: Colors.black,
|
||||||
child: Stack(children: [
|
child: Stack(children: [
|
||||||
Positioned.fill(
|
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))),
|
const Positioned(right: 12, top: 12, child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))),
|
||||||
if (v != null)
|
if (v != null)
|
||||||
Positioned(
|
Positioned(
|
||||||
right: 8,
|
left: narrow ? 8 : null,
|
||||||
bottom: 8,
|
right: narrow ? 36 : 8, // 위 오른쪽의 돌아가는 표시 자리는 비운다
|
||||||
|
top: narrow ? 8 : null,
|
||||||
|
bottom: narrow ? null : 8,
|
||||||
child: Text('${v!.fileName} · ${_fmt(_at)}',
|
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) =>
|
static String _fmt(Duration d) =>
|
||||||
|
|
@ -225,7 +232,9 @@ class _VideoAdjustState extends State<_VideoAdjust> {
|
||||||
Wrap(spacing: 6, runSpacing: 6, children: [
|
Wrap(spacing: 6, runSpacing: 6, children: [
|
||||||
for (final f in FrameChoice.values)
|
for (final f in FrameChoice.values)
|
||||||
ChoiceChip(
|
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),
|
label: Text(f.label),
|
||||||
selected: s.frame == f,
|
selected: s.frame == f,
|
||||||
onSelected: (_) => _set(s.copyWith(frame: f)),
|
onSelected: (_) => _set(s.copyWith(frame: f)),
|
||||||
|
|
|
||||||
|
|
@ -294,6 +294,25 @@ void main() {
|
||||||
addTearDown(() {
|
addTearDown(() {
|
||||||
if (!finished) writeFail('끝나지 않음 (시간 초과 · 예외)');
|
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) {
|
for (final opts in sceneOptions) {
|
||||||
final t0 = sw.elapsedMilliseconds;
|
final t0 = sw.elapsedMilliseconds;
|
||||||
progress.add('$opts - 시작 ${t0}ms');
|
progress.add('$opts - 시작 ${t0}ms');
|
||||||
|
|
@ -329,6 +348,8 @@ void main() {
|
||||||
}
|
}
|
||||||
finished = true;
|
finished = true;
|
||||||
if (bad.isNotEmpty) writeFail('결과가 rsync 와 다름 ${bad.length}개');
|
if (bad.isNotEmpty) writeFail('결과가 rsync 와 다름 ${bad.length}개');
|
||||||
|
// 다시 시도해서 통과한 것도 기록에 남긴다 (서버 준비가 늦었던 때를 알 수 있게)
|
||||||
|
if (bad.isEmpty && progress.any((x) => x.contains('다시 시도함'))) writeFail('통과 (처음 연결을 다시 시도함)');
|
||||||
expect(bad, isEmpty, reason: bad.join('\n'));
|
expect(bad, isEmpty, reason: bad.join('\n'));
|
||||||
}, skip: hasPy ? false : '시험용 파이썬 없음', timeout: const Timeout(Duration(minutes: 5)));
|
}, skip: hasPy ? false : '시험용 파이썬 없음', timeout: const Timeout(Duration(minutes: 5)));
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue