[verified] Publish ReelBox source under GPLv3
This commit is contained in:
commit
db2b505ca3
445 files changed
+105274
No files matched your search
@@ -0,0 +1,174 @@
|
||||
/// Converts already-decoded SRT, SSA/ASS or WebVTT text to plain-cue WebVTT.
|
||||
/// Deliberately drops styling, attachments, identifiers and positioning rather
|
||||
/// than allowing receiver-side fetches or pretending to preserve ASS rendering.
|
||||
class SubtitleWebVtt {
|
||||
static String convert(String source) {
|
||||
final text = source
|
||||
.replaceFirst(RegExp(r'^\uFEFF'), '')
|
||||
.replaceAll('\r\n', '\n')
|
||||
.replaceAll('\r', '\n')
|
||||
.trim();
|
||||
final cues = <_Cue>[];
|
||||
if (RegExp(r'^\[Script Info\]', caseSensitive: false).hasMatch(text)) {
|
||||
_ass(text, cues);
|
||||
} else {
|
||||
final blocks = text.split(RegExp(r'\n[ \t]*\n'));
|
||||
final vtt = RegExp(r'^WEBVTT(?:[ \t]|$)')
|
||||
.hasMatch(blocks.first.split('\n').first);
|
||||
if (vtt) blocks.removeAt(0); // Header metadata is never propagated.
|
||||
for (final block in blocks) {
|
||||
final lines = block.trim().split('\n');
|
||||
if (vtt &&
|
||||
RegExp(r'^(?:STYLE|REGION|NOTE)(?:[ \t]|$)')
|
||||
.hasMatch(lines.first)) {
|
||||
continue;
|
||||
}
|
||||
if (lines.length > 1 && !lines.first.contains('-->')) {
|
||||
if (!vtt && !RegExp(r'^\d+$').hasMatch(lines.first)) {
|
||||
throw const FormatException(
|
||||
'Unsupported subtitle format; use SRT, SSA/ASS or WebVTT.',
|
||||
);
|
||||
}
|
||||
lines.removeAt(0);
|
||||
}
|
||||
if (lines.length < 2) {
|
||||
throw const FormatException('Invalid or empty subtitle cue.');
|
||||
}
|
||||
final timing = RegExp(r'^(\S+)[ \t]+-->[ \t]+(\S+)(?:[ \t]+.*)?$')
|
||||
.firstMatch(lines.first);
|
||||
if (timing == null) {
|
||||
throw const FormatException(
|
||||
'Unsupported subtitle format; use SRT, SSA/ASS or WebVTT.',
|
||||
);
|
||||
}
|
||||
cues.add(
|
||||
_Cue(
|
||||
_time(timing[1]!, short: vtt),
|
||||
_time(timing[2]!, short: vtt),
|
||||
_plain(lines.skip(1).join('\n')),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (cues.isEmpty) {
|
||||
throw const FormatException('No supported subtitle cues.');
|
||||
}
|
||||
var previous = -1;
|
||||
for (final cue in cues) {
|
||||
if (cue.end <= cue.start || cue.start < previous || cue.body.isEmpty) {
|
||||
throw const FormatException(
|
||||
'Invalid subtitle cue timing/order or empty text.',
|
||||
);
|
||||
}
|
||||
previous = cue.start;
|
||||
}
|
||||
return 'WEBVTT\n\n${cues.map((c) => '${_stamp(c.start)} --> ${_stamp(c.end)}\n${c.body}').join('\n\n')}\n\n';
|
||||
}
|
||||
|
||||
static int _time(String value, {bool short = false, bool ass = false}) {
|
||||
final expression = ass
|
||||
? r'^(\d{1,6}):(\d{2}):(\d{2})\.(\d{2})$'
|
||||
: r'^(\d{2,6}):(\d{2}):(\d{2})[.,](\d{3})$';
|
||||
var match = RegExp(expression).firstMatch(value);
|
||||
if (match == null && short) {
|
||||
match = RegExp(r'^(\d{2}):(\d{2})\.(\d{3})$').firstMatch(value);
|
||||
if (match != null) {
|
||||
return _time('00:$value');
|
||||
}
|
||||
}
|
||||
if (match == null) {
|
||||
throw const FormatException('Invalid subtitle timestamp.');
|
||||
}
|
||||
final h = int.parse(match[1]!);
|
||||
final m = int.parse(match[2]!);
|
||||
final s = int.parse(match[3]!);
|
||||
if (m > 59 || s > 59) {
|
||||
throw const FormatException('Invalid subtitle timestamp range.');
|
||||
}
|
||||
return ((h * 60 + m) * 60 + s) * 1000 +
|
||||
int.parse(match[4]!) * (ass ? 10 : 1);
|
||||
}
|
||||
|
||||
static String _stamp(int ms) {
|
||||
String pad(int value, int width) => '$value'.padLeft(width, '0');
|
||||
return '${pad(ms ~/ 3600000, 2)}:${pad(ms ~/ 60000 % 60, 2)}:${pad(ms ~/ 1000 % 60, 2)}.${pad(ms % 1000, 3)}';
|
||||
}
|
||||
|
||||
static void _ass(String text, List<_Cue> cues) {
|
||||
var events = false;
|
||||
List<String>? fields;
|
||||
for (final raw in text.split('\n')) {
|
||||
final line = raw.trim();
|
||||
if (line.startsWith('[')) {
|
||||
events = line.toLowerCase() == '[events]';
|
||||
continue;
|
||||
}
|
||||
if (!events) continue;
|
||||
if (line.toLowerCase().startsWith('format:')) {
|
||||
fields = line
|
||||
.substring(line.indexOf(':') + 1)
|
||||
.split(',')
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.toList();
|
||||
} else if (line.toLowerCase().startsWith('dialogue:')) {
|
||||
if (fields == null ||
|
||||
fields.last != 'text' ||
|
||||
!fields.contains('start') ||
|
||||
!fields.contains('end') ||
|
||||
fields.toSet().length != fields.length) {
|
||||
throw const FormatException('Invalid SSA/ASS Events format.');
|
||||
}
|
||||
final parts = line.substring(line.indexOf(':') + 1).split(',');
|
||||
if (parts.length < fields.length) {
|
||||
throw const FormatException('Incomplete SSA/ASS dialogue.');
|
||||
}
|
||||
final body = parts.skip(fields.length - 1).join(',');
|
||||
if (RegExp(r'\\p[1-9]').hasMatch(body)) {
|
||||
throw UnsupportedError('ASS drawing subtitles are not supported.');
|
||||
}
|
||||
cues.add(
|
||||
_Cue(
|
||||
_time(parts[fields.indexOf('start')].trim(), ass: true),
|
||||
_time(parts[fields.indexOf('end')].trim(), ass: true),
|
||||
_plain(
|
||||
body
|
||||
.replaceAll(r'\N', '\n')
|
||||
.replaceAll(r'\n', '\n')
|
||||
.replaceAll(r'\h', ' '),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static String _plain(String body) {
|
||||
// Plain escaped text is a strict subset of WebVTT: no HTML resources,
|
||||
// CSS, voice/class attributes, ASS overrides, or embedded timing tags.
|
||||
return body
|
||||
.replaceAll(
|
||||
RegExp(
|
||||
r'<(script|style)\b[^>]*>.*?</\1\s*>',
|
||||
caseSensitive: false,
|
||||
dotAll: true,
|
||||
),
|
||||
'',
|
||||
)
|
||||
.replaceAll(RegExp(r'\{[^}]*\}'), '')
|
||||
.replaceAll(RegExp(r'<[^>]*>', dotAll: true), '')
|
||||
.replaceAll(RegExp(r'[\x00-\x08\x0B\x0C\x0E-\x1F]'), '')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
|
||||
class _Cue {
|
||||
const _Cue(this.start, this.end, this.body);
|
||||
final int start, end;
|
||||
final String body;
|
||||
}
|
||||
Reference in new issue
Block a user