Aves_mio0.88/lib/services/cast/cast_fmp4_segmenter.dart
2026-10-03 12:51:15 +02:00

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();
}
}