원어를 자동 감지했으면 끝난 뒤 감지한 언어 · [다른 언어로 다시] (198)

- 사용자 편의 (기기 확인): 영어 음성을 한국어로 감지해 영어 발음을 한글로 적은 자막 - 막지는 못하므로 알리고 고른 원어로 다시 만들 수 있게

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음5
RyuJongJin 2026-10-12 05:51:56 +09:00
parent 65c049d043
commit 36bbee615d
7 changed files with 98 additions and 2 deletions

View File

@ -59,6 +59,7 @@
"(현재 방식은 옵션 없음)": "(no options for the built-in method)",
"* 는 아무 글자, ? 는 한 글자. 여러 개는 ; 로 (예: *.srt; *.ass)": "* matches any text, ? one character. Separate several with ; (e.g. *.srt; *.ass)",
"+ 여기에 새 줄": "+ New line here",
"- 감지한 언어": "- detected",
"-1 은 아무렇게나": "-1 = random",
".{0} 재생 프로그램 (exe)": ".{0} player (exe)",
"0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = unlimited. Applies to every method: rsync --bwlimit, the built-in method throttles itself, robocopy approximates with /IPG. You can also put it in the options field (that takes priority)",
@ -442,6 +443,7 @@
"가져온 즐겨찾기": "Imported bookmarks",
"가져온 즐겨찾기 ({0})": "Imported bookmarks ({0})",
"가져올 즐겨찾기가 없습니다.": "No bookmarks to import.",
"감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "Detected language: {0} - if it is wrong, make them again with another language",
"같은 시리즈 (번호만 다른 파일)": "Same series (files that differ only by number)",
"같은 이름이 이미 있습니다": "An item with the same name already exists",
"같은 이름이 이미 있습니다: {0}": "Already exists with the same name: {0}",
@ -641,6 +643,7 @@
"다른 앱에서 동영상을 열 때": "When opening a video from another app",
"다른 앱에서 열기: {0}": "Opened from another app: {0}",
"다른 앱으로 열기": "Open with another app",
"다른 언어로 다시": "Redo with another language",
"다른 언어로 된 페이지를 화면 언어 ({0}) 로 번역합니다. 페이지의 글을 Google 번역으로 보냅니다. 주소창 옆 번역 버튼으로 원문 · 번역을 바꿀 수 있습니다.": "Translates pages in other languages into the display language ({0}). The page text is sent to Google Translate. Use the translate button next to the address bar to switch between original · translation.",
"다른 언어로 번역 (AI · 이 {0}에서)": "Translate to another language (AI · on this {0})",
"다른 이름으로 저장": "Save as",
@ -1415,6 +1418,7 @@
"원어({0})와 같은 언어로는 번역하지 않습니다.": "Will not translate into the same language as the source ({0}).",
"원어: {0} ({1})": "Source: {0} ({1})",
"원어: 자동 감지 (글자로 추정)": "Source: auto-detect (guessed from the text)",
"원어를 골라 다시 만들기": "Choose the source language and redo",
"원인: {0}": "Cause: {0}",
"월": "Mon",
"웹 브라우저": "Web browser",
@ -1849,6 +1853,7 @@
"처음 쓸 때 내려받기 (기본)": "Download on first use (default)",
"처음 아이콘 (파랑)": "Original icon (blue)",
"처음대로": "Reset to default",
"처음부터 정하려면 AI 자막 창의 \"원어 (영상 속 언어)\" 에서 고르세요.": "To set it from the start, choose it in \"Source language (spoken in the video)\" in the AI subtitles window.",
"처음으로: {0}": "Back to the first: {0}",
"첫 장입니다.": "This is the first page.",
"첫 줄 추가": "Add first line",

View File

@ -59,6 +59,7 @@
"(현재 방식은 옵션 없음)": "(現在の方式にはオプションなし)",
"* 는 아무 글자, ? 는 한 글자. 여러 개는 ; 로 (예: *.srt; *.ass)": "* は任意の文字列、? は 1 文字。複数は ; で区切る (例: *.srt; *.ass)",
"+ 여기에 새 줄": "+ ここに新しい行",
"- 감지한 언어": "- 検出した言語",
"-1 은 아무렇게나": "-1 はランダム",
".{0} 재생 프로그램 (exe)": ".{0} 再生プログラム(exe)",
"0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 制限なし。すべての方法に適用: rsync は --bwlimit、現在の方式はアプリが調整、robocopy は /IPG で近似。オプション欄に直接入れても構いません (そちらが優先)",
@ -442,6 +443,7 @@
"가져온 즐겨찾기": "取り込んだブックマーク",
"가져온 즐겨찾기 ({0})": "取り込んだブックマーク({0})",
"가져올 즐겨찾기가 없습니다.": "取り込むブックマークがありません。",
"감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "検出した言語: {0} - 違っていれば別の言語で作り直してください",
"같은 시리즈 (번호만 다른 파일)": "同じシリーズ(番号だけ違うファイル)",
"같은 이름이 이미 있습니다": "同じ名前のものが既にあります",
"같은 이름이 이미 있습니다: {0}": "同じ名前が既にあります: {0}",
@ -641,6 +643,7 @@
"다른 앱에서 동영상을 열 때": "他のアプリで動画を開いたとき",
"다른 앱에서 열기: {0}": "他のアプリから開く: {0}",
"다른 앱으로 열기": "別のアプリで開く",
"다른 언어로 다시": "別の言語でやり直す",
"다른 언어로 된 페이지를 화면 언어 ({0}) 로 번역합니다. 페이지의 글을 Google 번역으로 보냅니다. 주소창 옆 번역 버튼으로 원문 · 번역을 바꿀 수 있습니다.": "ほかの言語のページを表示言語 ({0}) に翻訳します。ページの文章は Google 翻訳に送られます。アドレスバー横の翻訳ボタンで原文 · 翻訳を切り替えられます。",
"다른 언어로 번역 (AI · 이 {0}에서)": "他の言語に翻訳(AI・この{0}で)",
"다른 이름으로 저장": "名前を付けて保存",
@ -1415,6 +1418,7 @@
"원어({0})와 같은 언어로는 번역하지 않습니다.": "原語({0})と同じ言語には翻訳しません。",
"원어: {0} ({1})": "原語: {0}({1})",
"원어: 자동 감지 (글자로 추정)": "原語: 自動検出(文字から推定)",
"원어를 골라 다시 만들기": "原語を選んで作り直す",
"원인: {0}": "原因: {0}",
"월": "月",
"웹 브라우저": "ウェブブラウザー",
@ -1849,6 +1853,7 @@
"처음 쓸 때 내려받기 (기본)": "初めて使うときにダウンロード (既定)",
"처음 아이콘 (파랑)": "最初のアイコン(青)",
"처음대로": "既定に戻す",
"처음부터 정하려면 AI 자막 창의 \"원어 (영상 속 언어)\" 에서 고르세요.": "最初から決めるには、AI 字幕ウィンドウの「原語(動画内の言語)」で選んでください。",
"처음으로: {0}": "最初へ: {0}",
"첫 장입니다.": "最初のページです。",
"첫 줄 추가": "最初の行を追加",

View File

@ -59,6 +59,7 @@
"(현재 방식은 옵션 없음)": "(当前方式没有选项)",
"* 는 아무 글자, ? 는 한 글자. 여러 개는 ; 로 (예: *.srt; *.ass)": "* 表示任意字符,? 表示一个字符。多个用 ; 分隔(例:*.srt; *.ass)",
"+ 여기에 새 줄": "+ 在此新增一行",
"- 감지한 언어": "- 检测到的语言",
"-1 은 아무렇게나": "-1 为随机",
".{0} 재생 프로그램 (exe)": ".{0} 播放程序(exe)",
"0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 不限制。适用于所有方式: rsync 用 --bwlimit,当前方式由应用调节,robocopy 用 /IPG 近似。也可直接写在选项栏中 (优先)",
@ -442,6 +443,7 @@
"가져온 즐겨찾기": "导入的书签",
"가져온 즐겨찾기 ({0})": "导入的书签({0})",
"가져올 즐겨찾기가 없습니다.": "没有可导入的书签。",
"감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "检测到的语言:{0} - 如有误,请用其他语言重新生成",
"같은 시리즈 (번호만 다른 파일)": "同一系列(仅编号不同的文件)",
"같은 이름이 이미 있습니다": "已存在同名项目",
"같은 이름이 이미 있습니다: {0}": "已存在同名项目: {0}",
@ -641,6 +643,7 @@
"다른 앱에서 동영상을 열 때": "从其他应用打开视频时",
"다른 앱에서 열기: {0}": "从其他应用打开: {0}",
"다른 앱으로 열기": "用其他应用打开",
"다른 언어로 다시": "用其他语言重做",
"다른 언어로 된 페이지를 화면 언어 ({0}) 로 번역합니다. 페이지의 글을 Google 번역으로 보냅니다. 주소창 옆 번역 버튼으로 원문 · 번역을 바꿀 수 있습니다.": "将其他语言的页面翻译为界面语言 ({0})。页面文字会发送到 Google 翻译。可用地址栏旁的翻译按钮切换原文 · 译文。",
"다른 언어로 번역 (AI · 이 {0}에서)": "翻译为其他语言(AI · 在此{0}上)",
"다른 이름으로 저장": "另存为",
@ -1415,6 +1418,7 @@
"원어({0})와 같은 언어로는 번역하지 않습니다.": "不会翻译成与原语言({0})相同的语言。",
"원어: {0} ({1})": "原语言: {0}({1})",
"원어: 자동 감지 (글자로 추정)": "原语言: 自动检测(根据文字推测)",
"원어를 골라 다시 만들기": "选择原语言并重新生成",
"원인: {0}": "原因: {0}",
"월": "一",
"웹 브라우저": "网页浏览器",
@ -1849,6 +1853,7 @@
"처음 쓸 때 내려받기 (기본)": "首次使用时下载 (默认)",
"처음 아이콘 (파랑)": "最初的图标(蓝色)",
"처음대로": "恢复默认",
"처음부터 정하려면 AI 자막 창의 \"원어 (영상 속 언어)\" 에서 고르세요.": "如需一开始就指定,请在 AI 字幕窗口的“原语言(视频中的语言)”中选择。",
"처음으로: {0}": "回到第一个: {0}",
"첫 장입니다.": "已是第一页。",
"첫 줄 추가": "添加第一行",

View File

@ -943,6 +943,8 @@ class AppController extends ChangeNotifier {
? opts.source
: detectLanguage(cues.map((c) => c.text).join(' '));
_log(trf('음성인식 완료: {0}줄, 원어 {1}({2})', [cues.length, src.name, src.code]));
// 198: 자동 감지가 틀릴 수 있다 (영어를 한국어로 잡아 영어 발음을 한글로 적음) - 끝난 뒤 알리고 다른 언어로 다시 할 수 있게
v.aiDetected = opts.source == undetermined ? src.code : null;
// 5. 파일명_AI.srt (원본)
await storage.ensureDirectory(outputDirFor(v.path));

View File

@ -99,6 +99,9 @@ class VideoItem {
String? message;
String? outputPath;
/// 198: 마지막 AI 자막에서 원어를 자동 감지했으면 그 언어 코드 (원어를 직접 골랐으면 null)
String? aiDetected;
VideoItem(this.path);
String get fileName => p.basename(path);

View File

@ -16,8 +16,16 @@ import '../l10n/tr.dart';
Future<void> showAiDialog(BuildContext context, AppController c, VideoItem current,
{List<VideoItem>? targets, bool thenBuild = false}) async {
final list = targets ?? [current];
Future<void> run(List<VideoItem> v, AiOptions o) =>
thenBuild ? c.aiThenBuild(v, o) : c.generateAiSubtitles(v, o);
// 198: 끝난 뒤 알림 · 다시 하기 창을 띄울 곳 (이 화면이 닫혀도 남는 것)
final messenger = ScaffoldMessenger.maybeOf(context);
final overlay = Navigator.maybeOf(context)?.overlay;
Future<void> run(List<VideoItem> v, AiOptions o) async {
await (thenBuild ? c.aiThenBuild(v, o) : 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),
@ -43,6 +51,50 @@ Future<void> showAiDialog(BuildContext context, AppController c, VideoItem curre
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(' · ');
messenger.showSnackBar(SnackBar(
duration: const Duration(seconds: 12),
content: Text(trf('감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요', [names])),
action: overlay == null
? null
: SnackBarAction(
label: tr('다른 언어로 다시'),
onPressed: () 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: const 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);

View File

@ -79,4 +79,28 @@ void main() {
await done;
expect(c.busy, isFalse);
});
testWidgets('198: 원어를 자동 감지했으면 끝난 뒤 "감지한 언어" 와 [다른 언어로 다시] (고른 원어로 다시)', (tester) async {
final key = GlobalKey<NavigatorState>();
await tester.pumpWidget(MaterialApp(navigatorKey: key, home: const Scaffold(body: SizedBox())));
final v = VideoItem(r'D:\v\a.mp4')..aiDetected = 'ko';
Language? again;
showDetectedLanguage(tester.state<ScaffoldMessengerState>(find.byType(ScaffoldMessenger)), key.currentState!.overlay, [v],
onRetry: (l) async => again = l);
await tester.pump();
await tester.pump(const Duration(milliseconds: 800)); // 알림이 다 올라올 때까지
expect(find.text('감지한 언어: 한국어 - 틀렸으면 다른 언어로 다시 만드세요'), findsOneWidget);
await tester.tap(find.text('다른 언어로 다시'));
await tester.pumpAndSettle();
expect(find.text('원어를 골라 다시 만들기'), findsOneWidget);
expect(find.textContaining('- 감지한 언어'), findsOneWidget);
await tester.tap(find.textContaining('영어 (en)'));
await tester.pumpAndSettle();
expect(again?.code, 'en');
// 원어를 직접 고른 동영상 (감지 안 함) 만 있으면 알리지 않는다
final w = VideoItem(r'D:\v\b.mp4');
showDetectedLanguage(tester.state<ScaffoldMessengerState>(find.byType(ScaffoldMessenger)), key.currentState!.overlay, [w],
onRetry: (_) async {});
await tester.pump(const Duration(seconds: 13));
expect(find.textContaining('감지한 언어:'), findsNothing);
});
}