664 lines
14 KiB
Dart
664 lines
14 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import 'package:aves/services/cast/cast_hls_cache.dart';
|
|
|
|
class CastFmp4SegmenterException implements Exception {
|
|
final String message;
|
|
|
|
const CastFmp4SegmenterException(this.message);
|
|
|
|
@override
|
|
String toString() {
|
|
return 'CastFmp4SegmenterException: $message';
|
|
}
|
|
}
|
|
|
|
class _Mp4BoxHeader {
|
|
final int offset;
|
|
final int size;
|
|
final int headerSize;
|
|
final String type;
|
|
|
|
const _Mp4BoxHeader({
|
|
required this.offset,
|
|
required this.size,
|
|
required this.headerSize,
|
|
required this.type,
|
|
});
|
|
|
|
int get end {
|
|
return offset + size;
|
|
}
|
|
}
|
|
|
|
class _PendingMediaFragment {
|
|
final int offset;
|
|
final int moofSize;
|
|
|
|
const _PendingMediaFragment({
|
|
required this.offset,
|
|
required this.moofSize,
|
|
});
|
|
}
|
|
|
|
class CastFmp4Segmenter {
|
|
final int mediaId;
|
|
final String sourcePath;
|
|
final int minimumReadySegments;
|
|
final Duration pollingInterval;
|
|
final VoidCallback? onReady;
|
|
|
|
static const int _targetDurationSeconds = 3;
|
|
static const double _defaultSegmentDurationSeconds = 3.0;
|
|
|
|
CastFmp4Segmenter({
|
|
required this.mediaId,
|
|
required this.sourcePath,
|
|
this.minimumReadySegments = 3,
|
|
this.pollingInterval = const Duration(milliseconds: 150),
|
|
this.onReady,
|
|
});
|
|
|
|
final Completer<void> _readyCompleter = Completer<void>();
|
|
|
|
RandomAccessFile? _source;
|
|
bool _running = false;
|
|
bool _stopRequested = false;
|
|
bool _conversionCompleted = false;
|
|
|
|
int _nextOffset = 0;
|
|
int _publishedSegments = 0;
|
|
|
|
_PendingMediaFragment? _pendingFragment;
|
|
|
|
final BytesBuilder _initializationBytes = BytesBuilder(
|
|
copy: false,
|
|
);
|
|
|
|
Future<void> get ready {
|
|
return _readyCompleter.future;
|
|
}
|
|
|
|
bool get isReady {
|
|
return _readyCompleter.isCompleted;
|
|
}
|
|
|
|
bool get isRunning {
|
|
return _running;
|
|
}
|
|
|
|
int get publishedSegments {
|
|
return _publishedSegments;
|
|
}
|
|
|
|
static void _log(String message) {
|
|
debugPrint('[CAST FMP4 SEG] $message');
|
|
}
|
|
|
|
Future<void> start() async {
|
|
if (_running) {
|
|
return;
|
|
}
|
|
|
|
_running = true;
|
|
_stopRequested = false;
|
|
|
|
_log(
|
|
'START '
|
|
'mediaId=$mediaId '
|
|
'sourcePath=$sourcePath '
|
|
'minimumReadySegments=$minimumReadySegments '
|
|
'pollingMs=${pollingInterval.inMilliseconds}',
|
|
);
|
|
|
|
try {
|
|
await _run();
|
|
} catch (error, stackTrace) {
|
|
_log(
|
|
'ERROR '
|
|
'mediaId=$mediaId '
|
|
'offset=$_nextOffset '
|
|
'segments=$_publishedSegments '
|
|
'error=$error',
|
|
);
|
|
|
|
debugPrint(
|
|
'[CAST FMP4 SEG] STACK '
|
|
'mediaId=$mediaId\n'
|
|
'$stackTrace',
|
|
);
|
|
|
|
if (!_readyCompleter.isCompleted) {
|
|
_readyCompleter.completeError(
|
|
error,
|
|
stackTrace,
|
|
);
|
|
}
|
|
|
|
rethrow;
|
|
} finally {
|
|
await _closeSource();
|
|
|
|
_running = false;
|
|
|
|
_log(
|
|
'STOP '
|
|
'mediaId=$mediaId '
|
|
'offset=$_nextOffset '
|
|
'segments=$_publishedSegments '
|
|
'conversionCompleted=$_conversionCompleted',
|
|
);
|
|
}
|
|
}
|
|
|
|
void notifyConversionCompleted() {
|
|
_conversionCompleted = true;
|
|
|
|
_log(
|
|
'CONVERSION COMPLETED '
|
|
'mediaId=$mediaId '
|
|
'offset=$_nextOffset '
|
|
'segments=$_publishedSegments',
|
|
);
|
|
}
|
|
|
|
void requestStop() {
|
|
_stopRequested = true;
|
|
|
|
_log(
|
|
'STOP REQUESTED '
|
|
'mediaId=$mediaId',
|
|
);
|
|
}
|
|
|
|
Future<void> _run() async {
|
|
final sourceFile = File(sourcePath);
|
|
|
|
while (!_stopRequested) {
|
|
if (!await sourceFile.exists()) {
|
|
if (_conversionCompleted) {
|
|
throw CastFmp4SegmenterException(
|
|
'File fragmented MP4 non trovato: $sourcePath',
|
|
);
|
|
}
|
|
|
|
await Future<void>.delayed(pollingInterval);
|
|
continue;
|
|
}
|
|
|
|
_source ??= await sourceFile.open(
|
|
mode: FileMode.read,
|
|
);
|
|
|
|
final progressed = await _consumeAvailableBoxes(
|
|
sourceFile,
|
|
);
|
|
|
|
if (!progressed) {
|
|
if (_conversionCompleted) {
|
|
await _validateCompletedFile(sourceFile);
|
|
|
|
if (!_readyCompleter.isCompleted) {
|
|
throw CastFmp4SegmenterException(
|
|
'Conversione terminata con soli '
|
|
'$_publishedSegments segmenti pronti',
|
|
);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
await Future<void>.delayed(pollingInterval);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<bool> _consumeAvailableBoxes(
|
|
File sourceFile,
|
|
) async {
|
|
var progressed = false;
|
|
|
|
while (!_stopRequested) {
|
|
final fileLength = await sourceFile.length();
|
|
|
|
final header = await _readBoxHeader(
|
|
fileLength,
|
|
);
|
|
|
|
if (header == null) {
|
|
break;
|
|
}
|
|
|
|
if (header.end > fileLength) {
|
|
break;
|
|
}
|
|
|
|
await _consumeBox(header);
|
|
|
|
_nextOffset = header.end;
|
|
progressed = true;
|
|
}
|
|
|
|
return progressed;
|
|
}
|
|
|
|
Future<_Mp4BoxHeader?> _readBoxHeader(
|
|
int fileLength,
|
|
) async {
|
|
const normalHeaderSize = 8;
|
|
|
|
if (fileLength - _nextOffset < normalHeaderSize) {
|
|
return null;
|
|
}
|
|
|
|
final source = _source;
|
|
|
|
if (source == null) {
|
|
return null;
|
|
}
|
|
|
|
await source.setPosition(_nextOffset);
|
|
|
|
final normalHeader = await source.read(
|
|
normalHeaderSize,
|
|
);
|
|
|
|
if (normalHeader.length < normalHeaderSize) {
|
|
return null;
|
|
}
|
|
|
|
final normalData = ByteData.sublistView(
|
|
Uint8List.fromList(normalHeader),
|
|
);
|
|
|
|
final size32 = normalData.getUint32(
|
|
0,
|
|
Endian.big,
|
|
);
|
|
|
|
final type = String.fromCharCodes(
|
|
normalHeader.sublist(4, 8),
|
|
);
|
|
|
|
var headerSize = normalHeaderSize;
|
|
int boxSize;
|
|
|
|
if (size32 == 1) {
|
|
const extendedSizeLength = 8;
|
|
|
|
if (fileLength - _nextOffset < normalHeaderSize + extendedSizeLength) {
|
|
return null;
|
|
}
|
|
|
|
final extendedBytes = await source.read(
|
|
extendedSizeLength,
|
|
);
|
|
|
|
if (extendedBytes.length < extendedSizeLength) {
|
|
return null;
|
|
}
|
|
|
|
final extendedData = ByteData.sublistView(
|
|
Uint8List.fromList(extendedBytes),
|
|
);
|
|
|
|
boxSize = extendedData.getUint64(
|
|
0,
|
|
Endian.big,
|
|
);
|
|
|
|
headerSize = normalHeaderSize + extendedSizeLength;
|
|
} else if (size32 == 0) {
|
|
if (!_conversionCompleted) {
|
|
return null;
|
|
}
|
|
|
|
boxSize = fileLength - _nextOffset;
|
|
} else {
|
|
boxSize = size32;
|
|
}
|
|
|
|
if (boxSize < headerSize) {
|
|
throw CastFmp4SegmenterException(
|
|
'Box MP4 non valido '
|
|
'offset=$_nextOffset '
|
|
'type=$type '
|
|
'size=$boxSize '
|
|
'headerSize=$headerSize',
|
|
);
|
|
}
|
|
|
|
return _Mp4BoxHeader(
|
|
offset: _nextOffset,
|
|
size: boxSize,
|
|
headerSize: headerSize,
|
|
type: type,
|
|
);
|
|
}
|
|
|
|
Future<void> _consumeBox(
|
|
_Mp4BoxHeader header,
|
|
) async {
|
|
switch (header.type) {
|
|
case 'ftyp':
|
|
case 'moov':
|
|
await _consumeInitializationBox(header);
|
|
break;
|
|
|
|
case 'moof':
|
|
_consumeMoof(header);
|
|
break;
|
|
|
|
case 'mdat':
|
|
await _consumeMdat(header);
|
|
break;
|
|
|
|
default:
|
|
_log(
|
|
'BOX SKIP '
|
|
'mediaId=$mediaId '
|
|
'offset=${header.offset} '
|
|
'type=${header.type} '
|
|
'size=${header.size}',
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
|
|
Future<void> _consumeInitializationBox(
|
|
_Mp4BoxHeader header,
|
|
) async {
|
|
if (_publishedSegments > 0 || _pendingFragment != null || await castHlsCache.hasInitializationSegment(mediaId)) {
|
|
throw CastFmp4SegmenterException(
|
|
'Box di inizializzazione inatteso '
|
|
'type=${header.type} '
|
|
'offset=${header.offset}',
|
|
);
|
|
}
|
|
|
|
final bytes = await _readRange(
|
|
header.offset,
|
|
header.size,
|
|
);
|
|
|
|
_initializationBytes.add(bytes);
|
|
|
|
_log(
|
|
'INIT BOX '
|
|
'mediaId=$mediaId '
|
|
'type=${header.type} '
|
|
'offset=${header.offset} '
|
|
'size=${header.size}',
|
|
);
|
|
|
|
if (header.type == 'moov') {
|
|
final temporaryPath = await castHlsCache.getInitTemporaryPath(mediaId);
|
|
|
|
final temporaryFile = File(temporaryPath);
|
|
|
|
await temporaryFile.writeAsBytes(
|
|
_initializationBytes.takeBytes(),
|
|
flush: true,
|
|
);
|
|
|
|
await castHlsCache.publishInitializationSegment(
|
|
mediaId,
|
|
);
|
|
|
|
await _writeSession(
|
|
state: 'initialization-ready',
|
|
);
|
|
}
|
|
}
|
|
|
|
void _consumeMoof(
|
|
_Mp4BoxHeader header,
|
|
) {
|
|
if (_pendingFragment != null) {
|
|
throw CastFmp4SegmenterException(
|
|
'Nuovo moof prima del relativo mdat '
|
|
'offset=${header.offset}',
|
|
);
|
|
}
|
|
|
|
_pendingFragment = _PendingMediaFragment(
|
|
offset: header.offset,
|
|
moofSize: header.size,
|
|
);
|
|
|
|
_log(
|
|
'MOOF READY '
|
|
'mediaId=$mediaId '
|
|
'offset=${header.offset} '
|
|
'size=${header.size}',
|
|
);
|
|
}
|
|
|
|
Future<void> _consumeMdat(
|
|
_Mp4BoxHeader header,
|
|
) async {
|
|
final pending = _pendingFragment;
|
|
|
|
if (pending == null) {
|
|
throw CastFmp4SegmenterException(
|
|
'mdat senza moof '
|
|
'offset=${header.offset} '
|
|
'size=${header.size}',
|
|
);
|
|
}
|
|
|
|
final expectedMdatOffset = pending.offset + pending.moofSize;
|
|
|
|
if (header.offset != expectedMdatOffset) {
|
|
throw CastFmp4SegmenterException(
|
|
'moof e mdat non adiacenti '
|
|
'moofOffset=${pending.offset} '
|
|
'moofSize=${pending.moofSize} '
|
|
'mdatOffset=${header.offset}',
|
|
);
|
|
}
|
|
|
|
final segmentSize = pending.moofSize + header.size;
|
|
|
|
final segmentBytes = await _readRange(
|
|
pending.offset,
|
|
segmentSize,
|
|
);
|
|
|
|
final temporaryPath = await castHlsCache.getSegmentTemporaryPath(
|
|
mediaId,
|
|
_publishedSegments,
|
|
);
|
|
|
|
final temporaryFile = File(temporaryPath);
|
|
|
|
await temporaryFile.writeAsBytes(
|
|
segmentBytes,
|
|
flush: true,
|
|
);
|
|
|
|
await castHlsCache.publishSegment(
|
|
mediaId,
|
|
_publishedSegments,
|
|
);
|
|
|
|
_publishedSegments++;
|
|
_pendingFragment = null;
|
|
|
|
await _writePlaylist(
|
|
completed: false,
|
|
);
|
|
|
|
await _writeSession(
|
|
state: 'segment-published',
|
|
);
|
|
|
|
if (_publishedSegments >= minimumReadySegments && !_readyCompleter.isCompleted) {
|
|
_readyCompleter.complete();
|
|
|
|
onReady?.call();
|
|
|
|
_log(
|
|
'READY '
|
|
'mediaId=$mediaId '
|
|
'segments=$_publishedSegments '
|
|
'minimumSegments=$minimumReadySegments',
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<Uint8List> _readRange(
|
|
int offset,
|
|
int length,
|
|
) async {
|
|
final source = _source;
|
|
|
|
if (source == null) {
|
|
throw const CastFmp4SegmenterException(
|
|
'File fragmented MP4 non aperto',
|
|
);
|
|
}
|
|
|
|
await source.setPosition(offset);
|
|
|
|
final output = Uint8List(length);
|
|
var written = 0;
|
|
|
|
while (written < length) {
|
|
final chunk = await source.read(
|
|
length - written,
|
|
);
|
|
|
|
if (chunk.isEmpty) {
|
|
throw CastFmp4SegmenterException(
|
|
'Lettura fMP4 interrotta '
|
|
'offset=$offset '
|
|
'expected=$length '
|
|
'read=$written',
|
|
);
|
|
}
|
|
|
|
output.setRange(
|
|
written,
|
|
written + chunk.length,
|
|
chunk,
|
|
);
|
|
|
|
written += chunk.length;
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
Future<void> _writePlaylist({
|
|
required bool completed,
|
|
}) async {
|
|
final playlistType = completed ? 'VOD' : 'EVENT';
|
|
|
|
final buffer = StringBuffer()
|
|
..writeln('#EXTM3U')
|
|
..writeln('#EXT-X-VERSION:7')
|
|
..writeln('#EXT-X-TARGETDURATION:$_targetDurationSeconds')
|
|
..writeln('#EXT-X-MEDIA-SEQUENCE:0')
|
|
..writeln('#EXT-X-PLAYLIST-TYPE:$playlistType')
|
|
..writeln('#EXT-X-INDEPENDENT-SEGMENTS')
|
|
..writeln('#EXT-X-MAP:URI="init.mp4"');
|
|
|
|
for (var index = 0; index < _publishedSegments; index++) {
|
|
final number = index.toString().padLeft(5, '0');
|
|
|
|
buffer
|
|
..writeln(
|
|
'#EXTINF:${_defaultSegmentDurationSeconds.toStringAsFixed(3)},',
|
|
)
|
|
..writeln('segment_$number.m4s');
|
|
}
|
|
|
|
if (completed) {
|
|
buffer.writeln('#EXT-X-ENDLIST');
|
|
}
|
|
|
|
await castHlsCache.writePlaylistAtomically(
|
|
mediaId,
|
|
buffer.toString(),
|
|
);
|
|
|
|
_log(
|
|
'PLAYLIST UPDATED '
|
|
'mediaId=$mediaId '
|
|
'segments=$_publishedSegments '
|
|
'completed=$completed',
|
|
);
|
|
}
|
|
|
|
Future<void> _writeSession({
|
|
required String state,
|
|
}) async {
|
|
await castHlsCache.writeSessionAtomically(
|
|
mediaId,
|
|
<String, dynamic>{
|
|
'mediaId': mediaId,
|
|
'state': state,
|
|
'sourcePath': sourcePath,
|
|
'nextOffset': _nextOffset,
|
|
'publishedSegments': _publishedSegments,
|
|
'minimumReadySegments': minimumReadySegments,
|
|
'ready': isReady,
|
|
'conversionCompleted': _conversionCompleted,
|
|
'updatedAt': DateTime.now().toIso8601String(),
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _validateCompletedFile(
|
|
File sourceFile,
|
|
) async {
|
|
final fileLength = await sourceFile.length();
|
|
|
|
if (_pendingFragment != null) {
|
|
throw const CastFmp4SegmenterException(
|
|
'Conversione completata con un moof senza mdat',
|
|
);
|
|
}
|
|
|
|
if (_nextOffset != fileLength) {
|
|
throw CastFmp4SegmenterException(
|
|
'File fragmented MP4 non consumato completamente '
|
|
'parsedBytes=$_nextOffset '
|
|
'fileBytes=$fileLength',
|
|
);
|
|
}
|
|
|
|
await _writePlaylist(
|
|
completed: true,
|
|
);
|
|
|
|
await castHlsCache.markComplete(mediaId);
|
|
|
|
await _writeSession(
|
|
state: 'segmentation-complete',
|
|
);
|
|
|
|
_log(
|
|
'COMPLETE '
|
|
'mediaId=$mediaId '
|
|
'fileBytes=$fileLength '
|
|
'segments=$_publishedSegments',
|
|
);
|
|
}
|
|
|
|
Future<void> _closeSource() async {
|
|
final source = _source;
|
|
_source = null;
|
|
|
|
if (source == null) {
|
|
return;
|
|
}
|
|
|
|
await source.close();
|
|
}
|
|
}
|