JJ_MKVMaker/lib/ui/ai_dialog.dart

340 lines
16 KiB
Dart

import 'dart:io';
import 'package:flutter/material.dart';
import '../app/app_controller.dart';
import '../core/languages.dart';
import '../core/models.dart';
import '../services/model_store.dart';
import 'existing_output_dialog.dart';
import 'license_texts.dart' show NllbLicenseNote;
import 'theme.dart';
import '../l10n/tr.dart';
import 'notice.dart';
/// AI 자막 만들기 설정 창
/// [targets] 를 주면 그 동영상들만 (탐색기 "자막 만들기" 메뉴 · 여러 개 선택)
/// [thenBuild]: 자막을 만든 뒤 그 동영상들을 이어서 MKV 로 만든다
Future<void> showAiDialog(BuildContext context, AppController c, VideoItem current,
{List<VideoItem>? targets, bool thenBuild = false}) async {
final list = targets ?? [current];
// 198: 끝난 뒤 알림 · 다시 하기 창을 띄울 곳 (이 화면이 닫혀도 남는 것)
final messenger = ScaffoldMessenger.maybeOf(context);
final overlay = Navigator.maybeOf(context)?.overlay;
Future<void> run(List<VideoItem> v, AiOptions o) async {
// P0: 자막 → MKV 면 결과 MKV 가 이미 있을 때 시작 전에 묻는다
String? existing;
if (thenBuild) {
if (!context.mounted) return;
existing = await askExistingOutputs(context, c, v);
if (existing == null) return;
}
await (thenBuild ? c.aiThenBuild(v, o, onExisting: existing) : c.generateAiSubtitles(v, o));
if (o.source == undetermined && messenger != null) {
showDetectedLanguage(messenger, overlay, v, onRetry: (lang) => run(v, o.copyWith(source: lang)));
}
}
Future<(AiOptions, bool)?> full() => showDialog<(AiOptions, bool)>(
context: context,
builder: (_) => _AiDialog(c: c, videos: list, videoCount: targets == null ? c.videos.length : 1, thenBuild: thenBuild),
);
// 환경 설정에서 "매번 묻기" 를 끄면 사용자가 고른 대로 묻지 않는다 (마지막 설정으로).
// 138: 다만 [MKV 만들기] 와 헷갈려 잘못 누른 것을 되돌릴 수 있게 3초 알림 ([취소]) 뒤에 시작한다.
if (!c.settings.mkv.askAiOptions) {
final messenger = ScaffoldMessenger.maybeOf(context);
if (messenger == null) return run(list, c.aiOptions);
var cancelled = false;
// 시작까지 [취소] 를 받다가 시작하면 닫는다 (부르는 쪽이 닫음)
final bar = showNotice(messenger,
life: NoticeLife.untilClosed,
text: aiStartNotice(list, c.aiOptions, thenBuild: thenBuild),
action: (tr('취소'), () => cancelled = true));
await Future<void>.delayed(aiStartDelay);
bar?.close();
if (cancelled) return;
return run(list, c.aiOptions);
}
final result = await full();
if (result == null) return;
final (opts, all) = result;
await run(all ? List.of(c.videos) : list, opts);
}
/// 198: 원어를 자동 감지했으면 끝난 뒤 "감지한 언어: …" 와 [다른 언어로 다시] (언어 고르기 → 그 원어로 다시 만듦)
void showDetectedLanguage(ScaffoldMessengerState messenger, OverlayState? overlay, List<VideoItem> videos,
{required Future<void> Function(Language) onRetry}) {
final found = [for (final v in videos) if (v.aiDetected != null) (v, languageOf(v.aiDetected))];
if (found.isEmpty) return;
final names = found.length == 1
? found.single.$2.name
: found.map((x) => '${x.$2.name} (${x.$1.fileName})').join(' · ');
showNotice(
messenger,
// 248 과 같게: 버튼이 있는 안내는 사용자가 닫을 때까지 ([✕]). 나중에는 MKV 목록의 그 동영상 세부 정보에서도
life: NoticeLife.untilClosed,
text: trf('감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요', [names]),
action: overlay == null
? null
: (
tr('다른 언어로 다시'),
() async {
if (!overlay.mounted) return;
final lang = await pickSourceLanguage(overlay.context, found.first.$2);
if (lang != null) await onRetry(lang);
}
),
);
}
/// 198: 원어를 골라 다시 만들 때의 언어 고르기 (원어를 처음부터 정하는 곳도 알린다)
Future<Language?> pickSourceLanguage(BuildContext context, Language detected) => showDialog<Language>(
context: context,
builder: (ctx) => SimpleDialog(
title: Text(tr('원어를 골라 다시 만들기')),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
child: Text(tr('처음부터 정하려면 AI 자막 창의 "원어 (영상 속 언어)" 에서 고르세요.'),
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
),
for (final l in languages)
SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, l),
child: Text('${l.name} (${l.code})${l == detected ? ' ${tr('- 감지한 언어')}' : ''}'),
),
],
),
);
/// 138: "매번 묻기" 를 껐을 때 시작 전에 [취소] 할 수 있는 시간
const aiStartDelay = Duration(seconds: 3);
/// 138: "AI 자막 (동영상 n개 · 약 m분) 을 시작합니다" (길이를 모르면 시간 없이)
String aiStartNotice(List<VideoItem> videos, AiOptions o, {required bool thenBuild}) {
final known = videos.map((v) => v.info?.duration).whereType<Duration>().toList();
final est = known.length == videos.length ? aiEstimateMinutes(known.fold(Duration.zero, (a, b) => a + b), aiTranslateCount(o)) : null;
final what = [trf('동영상 {0}개', [videos.length]), if (est != null) aiEstimateText(est)].join(' · ');
return thenBuild ? trf('AI 자막 → MKV ({0}) 을 시작합니다', [what]) : trf('AI 자막 ({0}) 을 시작합니다', [what]);
}
/// 138: AI 작업 (자막 · 자막 & MKV) 의 색 - [MKV 만들기] (청록) 와 헷갈리지 않게
Color get aiColor => JjColors.isLight ? const Color(0xFF6A3FC8) : const Color(0xFFB388FF); // ⑦ 밝은 화면은 진한 보라 (대비 4.5:1)
/// 138: AI 작업 시간 어림 (분): 영상 1시간 ≈ 음성인식 15~30분 + 번역할 언어마다 10~20분. 길이를 모르면 null
(int, int)? aiEstimateMinutes(Duration total, int translateLanguages) {
if (total <= Duration.zero) return null;
final h = total.inSeconds / 3600;
return ((h * (15 + 10 * translateLanguages)).ceil(), (h * (30 + 20 * translateLanguages)).ceil());
}
/// 197: 예상 시간 글 - 짧은 동영상은 "약 1~1분" 처럼 같은 수가 두 번 보였다
String aiEstimateText((int, int) est) {
final (lo, hi) = est;
if (hi <= 1) return tr('1분 안');
if (lo == hi) return trf('약 {0}분', [lo]);
return trf('약 {0}~{1}분', [lo, hi]);
}
/// 번역할 언어 수 (원어와 같은 언어는 번역하지 않음, 원어 자동 감지면 하나는 원어로 본다)
int aiTranslateCount(AiOptions o) => o.source == undetermined
? (o.targets.length - 1).clamp(0, 99)
: o.targets.where((t) => t.code != o.source.code).length;
/// 138: 시작 전에 보여 줄 계획: 할 일 · 대상 수 · 자막 언어 · 예상 시간
class AiPlanSummary extends StatelessWidget {
final List<VideoItem> videos;
final AiOptions o;
final bool thenBuild;
const AiPlanSummary({super.key, required this.videos, required this.o, required this.thenBuild});
@override
Widget build(BuildContext context) {
final known = videos.map((v) => v.info?.duration).whereType<Duration>().toList();
final total = known.fold(Duration.zero, (a, b) => a + b);
final est = known.length == videos.length ? aiEstimateMinutes(total, aiTranslateCount(o)) : null;
String hm(Duration d) => d.inHours > 0 ? trf('{0}시간 {1}분', [d.inHours, d.inMinutes % 60]) : trf('{0}분', [d.inMinutes.clamp(1, 59)]);
final rows = [
(tr('할 일'), thenBuild ? tr('AI 자막 만들기 → 이어서 MKV 만들기') : tr('AI 자막 만들기 (MKV 는 만들지 않음)')),
(tr('대상'), trf('동영상 {0}개', [videos.length])),
(tr('자막 언어'), [for (final l in o.targets) l.name].join(' · ')),
(
tr('예상 시간'),
est == null
? tr('동영상 길이를 몰라 셀 수 없습니다 (영상 1시간 ≈ 음성인식 15~30분 + 언어당 번역 10~20분)')
: trf('{0} (동영상 {1})', [aiEstimateText(est), hm(total)]),
),
];
return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: aiColor.withValues(alpha: 0.08), border: Border.all(color: aiColor.withValues(alpha: 0.5)), borderRadius: BorderRadius.circular(8)),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
for (final (k, v) in rows)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
SizedBox(width: 84, child: Text(k, style: TextStyle(fontSize: 12, color: JjColors.textDim))),
Expanded(child: Text(v, style: const TextStyle(fontSize: 13))),
]),
),
]),
);
}
}
class _AiDialog extends StatefulWidget {
final AppController c;
final int videoCount;
/// 이번에 자막을 만들 동영상 · 이어서 MKV 도 만드는지 (제목 · 계획에 표시)
final List<VideoItem> videos;
final bool thenBuild;
const _AiDialog({required this.c, required this.videos, required this.videoCount, this.thenBuild = false});
int get targetCount => videos.length;
@override
State<_AiDialog> createState() => _AiDialogState();
}
class _AiDialogState extends State<_AiDialog> {
late AiOptions _o = widget.c.aiOptions;
bool _all = false;
final Map<String, bool> _installed = {};
@override
void initState() {
super.initState();
for (final m in [...whisperModels, nllbModel]) {
widget.c.services.models.isInstalled(m).then((ok) {
if (mounted) setState(() => _installed[m.id] = ok);
});
}
}
Widget _status(ModelSpec m) {
final ok = _installed[m.id];
if (ok == null) return const SizedBox();
return Text(ok ? tr('설치됨') : trf('처음 사용 시 내려받기 {0}', [m.sizeLabel]),
style: TextStyle(fontSize: 11, color: ok ? JjColors.success : JjColors.textDim));
}
/// 259: 번역이 없으면 받지 않는다 · 감지한 뒤에야 알 수 있으면 그때 받는다
Widget _nllbStatus() {
if (_installed[nllbModel.id] != false || _o.surelyNeedsTranslation) return _status(nllbModel);
final style = TextStyle(fontSize: 11, color: JjColors.textDim);
if (!_o.needsTranslation) return Text(tr('이번에는 쓰지 않음 (번역 없음)'), style: style);
return Text(trf('감지한 원어가 만들 언어와 다를 때만 내려받기 {0}', [nllbModel.sizeLabel]), style: style);
}
@override
Widget build(BuildContext context) {
final extra = languages.where((l) => !_o.targets.contains(l)).toList();
return AlertDialog(
title: Text(trf('AI 자막 만들기{0}' '{1}', [widget.thenBuild ? tr(' → MKV 만들기') : '', widget.targetCount > 1 ? trf(' (동영상 {0}개)', [widget.targetCount]) : ''])),
content: SizedBox(
width: 520,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(trf('영상의 음성을 인식하고 선택한 언어로 번역합니다. 모든 처리는 이 {0}에서 이루어집니다.', [Platform.isAndroid ? tr('기기') : 'PC']),
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 10),
// 138: 무엇을 할지 먼저 (바꾸면 따라 바뀐다)
AiPlanSummary(videos: _all ? widget.c.videos : widget.videos, o: _o, thenBuild: widget.thenBuild),
const SizedBox(height: 16),
_label(tr('원어 (영상 속 언어)')),
DropdownButton<Language>(
value: _o.source,
isExpanded: true,
items: [
DropdownMenuItem(value: undetermined, child: Text(tr('자동 감지'))),
for (final l in languages)
DropdownMenuItem(value: l, child: Text('${l.name} (${l.code})')),
],
onChanged: (l) => setState(() => _o = _o.copyWith(source: l)),
),
const SizedBox(height: 16),
_label(tr('만들 자막 언어 (파일명_언어코드.srt)')),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (final l in _o.targets)
InputChip(
label: Text('${l.name} (${l.code})'),
onDeleted: _o.targets.length > 1
? () => setState(() => _o = _o.copyWith(targets: {..._o.targets}..remove(l)))
: null,
),
PopupMenuButton<Language>(
tooltip: tr('언어 추가'),
itemBuilder: (_) => [
for (final l in extra)
PopupMenuItem(value: l, child: Text('${l.name} (${l.code})')),
],
onSelected: (l) => setState(() => _o = _o.copyWith(targets: {..._o.targets, l})),
child: Chip(avatar: Icon(Icons.add, size: 16), label: Text(tr('언어 추가'))),
),
],
),
const SizedBox(height: 16),
_label(tr('음성인식 모델')),
RadioGroup<String>(
groupValue: _o.whisper.id,
onChanged: (id) => setState(() => _o =
_o.copyWith(whisper: whisperModels.firstWhere((m) => m.id == id))),
child: Column(children: [
for (final m in whisperModels)
RadioListTile<String>(
dense: true,
contentPadding: EdgeInsets.zero,
value: m.id,
title: Text(m.label),
subtitle: _status(m),
),
]),
),
Row(children: [
Text(tr('번역 모델: NLLB-200 (로컬) '), style: TextStyle(fontSize: 13)),
Flexible(child: _nllbStatus()),
]),
const NllbLicenseNote(),
if (widget.videoCount > 1) ...[
const SizedBox(height: 8),
CheckboxListTile(
dense: true,
contentPadding: EdgeInsets.zero,
value: _all,
onChanged: (v) => setState(() => _all = v ?? false),
title: Text(trf('목록의 동영상 {0}개 모두', [widget.videoCount])),
),
],
const SizedBox(height: 8),
Text(tr('※ 시간이 오래 걸립니다 (영상 1시간 ≈ 음성인식 15~30분 + 언어당 번역 10~20분)'),
style: TextStyle(fontSize: 11, color: JjColors.textDim)),
],
),
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('취소'))),
FilledButton.icon(
style: FilledButton.styleFrom(backgroundColor: aiColor),
onPressed: () => Navigator.pop(context, (_o, _all)),
icon: const Icon(Icons.auto_awesome, size: 18),
label: Text(tr('시작')),
),
],
);
}
Widget _label(String t) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(t, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
);
}