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

611 lines
18 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:aves/services/cast/cast_fmp4_fragment_parser.dart';
import 'package:aves/services/cast/cast_fmp4_progressive_reader.dart';
import 'package:aves/services/cast/cast_hls_cache.dart';
import 'package:aves/services/cast/cast_server.dart';
class CastProgressiveTsException implements Exception {
final String message;
const CastProgressiveTsException(this.message);
@override
String toString() => 'CastProgressiveTsException: $message';
}
class CastProgressiveTsReady {
final int mediaId;
final String playlistPath;
final String url;
final int publishedSegmentCount;
final Duration elapsed;
const CastProgressiveTsReady({
required this.mediaId,
required this.playlistPath,
required this.url,
required this.publishedSegmentCount,
required this.elapsed,
});
}
class CastProgressiveTsResult {
final int mediaId;
final String playlistPath;
final String workingFmp4Path;
final int publishedSegmentCount;
final int videoSampleCount;
final int audioSampleCount;
final Duration elapsed;
const CastProgressiveTsResult({
required this.mediaId,
required this.playlistPath,
required this.workingFmp4Path,
required this.publishedSegmentCount,
required this.videoSampleCount,
required this.audioSampleCount,
required this.elapsed,
});
}
class CastProgressiveTsSession {
final int mediaId;
final Future<CastProgressiveTsReady> ready;
final Future<CastProgressiveTsResult> completed;
final void Function() _requestStop;
const CastProgressiveTsSession._({
required this.mediaId,
required this.ready,
required this.completed,
required void Function() requestStop,
}) : _requestStop = requestStop;
void requestStop() => _requestStop();
}
class CastProgressiveTsOptimizer {
CastProgressiveTsOptimizer._();
static final CastProgressiveTsOptimizer instance = CastProgressiveTsOptimizer._();
static const MethodChannel _channel = MethodChannel(
'aves/cast_video_optimizer',
);
static const int maximumWidth = 1920;
static const int maximumHeight = 1080;
static const int targetVideoBitrate = 5000000;
static const int maximumVideoBitrate = 6000000;
static const int targetAudioBitrate = 160000;
static const int fragmentDurationMs = 2000;
static const int minimumReadySegments = 3;
static const int _audioTrackId = 1;
static const int _videoTrackId = 2;
static const int _audioTimescale = 48000;
static const int _videoTimescale = 90000;
static const int _segmentDurationAudioTicks = 144000;
static const int _segmentDurationVideoTicks = 270000;
final Map<int, _ProgressiveTsRun> _runs = <int, _ProgressiveTsRun>{};
static void _log(String message) {
debugPrint('[CAST PROGRESSIVE TS] $message');
}
bool isPending(int mediaId) => _runs.containsKey(mediaId);
int get pendingCount => _runs.length;
Future<CastProgressiveTsSession> start(int mediaId) async {
final existing = _runs[mediaId];
if (existing != null) {
CastProgressiveTsOptimizer._log('REUSE mediaId=$mediaId');
return existing.session;
}
final run = _ProgressiveTsRun(
mediaId: mediaId,
channel: _channel,
onFinished: () => _runs.remove(mediaId),
);
_runs[mediaId] = run;
run.start();
return run.session;
}
Future<String> prepareUntilReady(int mediaId) async {
final alreadyComplete = await castHlsCache.isComplete(
mediaId,
);
final alreadyReady =
alreadyComplete &&
await castHlsCache.isTransportStreamReadyForPlayback(
mediaId,
minimumSegments: minimumReadySegments,
);
if (alreadyReady) {
await castServer.ensureStarted();
final url = castServer.buildHlsUrl(
mediaId,
);
_log(
'CACHE HIT '
'mediaId=$mediaId '
'url=$url',
);
return url;
}
final session = await start(
mediaId,
);
final ready = await session.ready;
return ready.url;
}
Future<void> reset(int mediaId) async {
final run = _runs[mediaId];
run?.requestStop();
if (run != null) {
try {
await run.completed;
} catch (_) {
// The session is being explicitly discarded.
}
}
await castHlsCache.resetSession(mediaId);
}
}
class _ProgressiveTsRun {
final int mediaId;
final MethodChannel channel;
final VoidCallback _onFinished;
final Completer<CastProgressiveTsReady> _readyCompleter = Completer<CastProgressiveTsReady>();
final Completer<CastProgressiveTsResult> _completedCompleter = Completer<CastProgressiveTsResult>();
final List<Fmp4SampleInfo> _pendingAudio = <Fmp4SampleInfo>[];
final List<Fmp4SampleInfo> _pendingVideo = <Fmp4SampleInfo>[];
final List<double> _segmentDurationsSeconds = <double>[];
final Stopwatch _stopwatch = Stopwatch();
CastFmp4ProgressiveReader? _reader;
bool _stopRequested = false;
bool _conversionCompleted = false;
int _publishedSegments = 0;
int _publishedVideoSamples = 0;
int _publishedAudioSamples = 0;
int _videoContinuityCounter = 0;
int _audioContinuityCounter = 0;
int _patContinuityCounter = 0;
int _pmtContinuityCounter = 0;
late String _workingPath;
late String _playlistPath;
_ProgressiveTsRun({
required this.mediaId,
required this.channel,
required VoidCallback onFinished,
}) : _onFinished = onFinished;
late final CastProgressiveTsSession session = CastProgressiveTsSession._(
mediaId: mediaId,
ready: _readyCompleter.future,
completed: _completedCompleter.future,
requestStop: requestStop,
);
Future<CastProgressiveTsResult> get completed => _completedCompleter.future;
void start() {
unawaited(_run());
}
void requestStop() {
_stopRequested = true;
_reader?.requestStop();
}
Future<void> _run() async {
_stopwatch.start();
Future<void>? readerFuture;
try {
await castHlsCache.resetSession(mediaId);
await castServer.ensureStarted();
final sourcePath = await castServer.resolveFilePathForMediaId(mediaId);
if (sourcePath == null || sourcePath.isEmpty) {
throw CastProgressiveTsException(
'Percorso originale non disponibile per mediaId=$mediaId',
);
}
final sourceFile = File(sourcePath);
if (!await sourceFile.exists() || await sourceFile.length() <= 0) {
throw CastProgressiveTsException(
'Video originale non valido: $sourcePath',
);
}
_workingPath = await castHlsCache.getWorkingMp4Path(mediaId);
_playlistPath = await castHlsCache.getPlaylistPath(mediaId);
_reader = CastFmp4ProgressiveReader(
sourcePath: _workingPath,
pollingInterval: const Duration(milliseconds: 50),
onFragment: _onFragment,
);
readerFuture = _reader!.start();
readerFuture.ignore();
CastProgressiveTsOptimizer._log(
'START mediaId=$mediaId source=$sourcePath working=$_workingPath',
);
final conversion = await channel.invokeMapMethod<String, dynamic>(
'convertToMp4',
<String, dynamic>{
'mediaId': mediaId,
'inputPath': sourcePath,
'outputPath': _workingPath,
'container': 'mp4',
'videoCodec': 'h264',
'audioCodec': 'aac',
'maximumWidth': CastProgressiveTsOptimizer.maximumWidth,
'maximumHeight': CastProgressiveTsOptimizer.maximumHeight,
'targetVideoBitrate': CastProgressiveTsOptimizer.targetVideoBitrate,
'maximumVideoBitrate': CastProgressiveTsOptimizer.maximumVideoBitrate,
'targetAudioBitrate': CastProgressiveTsOptimizer.targetAudioBitrate,
'fastStart': false,
'fragmentedMp4': true,
'fragmentDurationMs': CastProgressiveTsOptimizer.fragmentDurationMs,
},
);
if (conversion?['success'] != true) {
throw CastProgressiveTsException(
'Conversione Media3 non riuscita: $conversion',
);
}
_conversionCompleted = true;
_reader!.notifyConversionCompleted();
await readerFuture;
if (_stopRequested) {
throw const CastProgressiveTsException('Sessione interrotta');
}
await _publishAvailableSegments();
if (_pendingVideo.isNotEmpty) {
await _publishFinalPartialSegment();
}
if (_publishedSegments == 0) {
throw const CastProgressiveTsException(
'Nessun segmento MPEG-TS prodotto',
);
}
await _writePlaylist(completed: true);
await castHlsCache.markComplete(mediaId);
if (!_readyCompleter.isCompleted) {
await _completeReady();
}
_stopwatch.stop();
final result = CastProgressiveTsResult(
mediaId: mediaId,
playlistPath: _playlistPath,
workingFmp4Path: _workingPath,
publishedSegmentCount: _publishedSegments,
videoSampleCount: _publishedVideoSamples,
audioSampleCount: _publishedAudioSamples,
elapsed: _stopwatch.elapsed,
);
_completedCompleter.complete(result);
CastProgressiveTsOptimizer._log(
'END mediaId=$mediaId result=success segments=$_publishedSegments '
'videoSamples=$_publishedVideoSamples '
'audioSamples=$_publishedAudioSamples '
'elapsedMs=${_stopwatch.elapsedMilliseconds}',
);
} catch (error, stackTrace) {
if (_stopwatch.isRunning) {
_stopwatch.stop();
}
_reader?.requestStop();
final wrapped = error is CastProgressiveTsException ? error : CastProgressiveTsException('$error');
if (!_readyCompleter.isCompleted) {
_readyCompleter.completeError(wrapped, stackTrace);
}
if (!_completedCompleter.isCompleted) {
_completedCompleter.completeError(wrapped, stackTrace);
}
CastProgressiveTsOptimizer._log(
'END mediaId=$mediaId result=error '
'elapsedMs=${_stopwatch.elapsedMilliseconds} error=$wrapped',
);
} finally {
_reader?.requestStop();
if (readerFuture != null) {
try {
await readerFuture;
} catch (_) {
// The main error has already been propagated.
}
}
_onFinished();
}
}
Future<void> _onFragment(Fmp4FragmentInfo fragment) async {
if (_stopRequested) {
return;
}
final audio = fragment.tracks.singleWhere(
(track) => track.trackId == CastProgressiveTsOptimizer._audioTrackId,
);
final video = fragment.tracks.singleWhere(
(track) => track.trackId == CastProgressiveTsOptimizer._videoTrackId,
);
_pendingAudio.addAll(audio.samples);
_pendingVideo.addAll(video.samples);
await _publishAvailableSegments();
}
Future<void> _publishAvailableSegments() async {
while (!_stopRequested) {
final segmentIndex = _publishedSegments;
final videoStart = segmentIndex * CastProgressiveTsOptimizer._segmentDurationVideoTicks;
final videoEnd = (segmentIndex + 1) * CastProgressiveTsOptimizer._segmentDurationVideoTicks;
final audioStart = segmentIndex * CastProgressiveTsOptimizer._segmentDurationAudioTicks;
final audioEnd = (segmentIndex + 1) * CastProgressiveTsOptimizer._segmentDurationAudioTicks;
final videoCovered = _pendingVideo.any(
(sample) => sample.decodeTime + sample.duration >= videoEnd,
);
final audioCovered = _pendingAudio.any(
(sample) => sample.decodeTime + sample.duration >= audioEnd,
);
if (!videoCovered || !audioCovered) {
return;
}
await _publishSegment(
segmentIndex: segmentIndex,
videoStart: videoStart,
videoEnd: videoEnd,
audioStart: audioStart,
audioEnd: audioEnd,
);
}
}
Future<void> _publishFinalPartialSegment() async {
final segmentIndex = _publishedSegments;
final videoStart = segmentIndex * CastProgressiveTsOptimizer._segmentDurationVideoTicks;
final audioStart = segmentIndex * CastProgressiveTsOptimizer._segmentDurationAudioTicks;
final remainingVideo = _pendingVideo.where((sample) => sample.decodeTime >= videoStart).toList(growable: false);
if (remainingVideo.isEmpty) {
return;
}
final videoEnd = remainingVideo.map((sample) => sample.decodeTime + sample.duration).reduce((a, b) => a > b ? a : b);
final durationVideoTicks = videoEnd - videoStart;
final audioEnd = audioStart + ((durationVideoTicks * CastProgressiveTsOptimizer._audioTimescale) ~/ CastProgressiveTsOptimizer._videoTimescale);
if (!_pendingAudio.any(
(sample) => sample.decodeTime >= audioStart && sample.decodeTime < audioEnd,
)) {
return;
}
await _publishSegment(
segmentIndex: segmentIndex,
videoStart: videoStart,
videoEnd: videoEnd,
audioStart: audioStart,
audioEnd: audioEnd,
);
}
Future<void> _publishSegment({
required int segmentIndex,
required int videoStart,
required int videoEnd,
required int audioStart,
required int audioEnd,
}) async {
final videoSamples = _pendingVideo
.where(
(sample) => sample.decodeTime >= videoStart && sample.decodeTime < videoEnd,
)
.toList(growable: false);
final audioSamples = _pendingAudio
.where(
(sample) => sample.decodeTime >= audioStart && sample.decodeTime < audioEnd,
)
.toList(growable: false);
if (videoSamples.isEmpty || audioSamples.isEmpty) {
throw CastProgressiveTsException(
'Sample insufficienti per il segmento $segmentIndex',
);
}
if (!videoSamples.first.isSync) {
throw CastProgressiveTsException(
'Il segmento $segmentIndex non inizia con keyframe',
);
}
final outputPath = await castHlsCache.getTransportStreamSegmentPath(
mediaId,
segmentIndex,
);
final report = await channel.invokeMapMethod<String, dynamic>(
'writeFmp4FirstTsSegmentTest',
<String, dynamic>{
'inputPath': _workingPath,
'outputPath': outputPath,
'segmentIndex': segmentIndex,
'videoStartDts': videoStart,
'videoEndDts': videoEnd,
'audioStartDts': audioStart,
'audioEndDts': audioEnd,
'initialVideoContinuityCounter': _videoContinuityCounter,
'initialAudioContinuityCounter': _audioContinuityCounter,
'patContinuityCounter': _patContinuityCounter,
'pmtContinuityCounter': _pmtContinuityCounter,
'audioSamples': audioSamples.map(_sampleToMap).toList(growable: false),
'videoSamples': videoSamples.map(_sampleToMap).toList(growable: false),
},
);
if (report?['success'] != true) {
throw CastProgressiveTsException(
'Writer TS fallito per segmento $segmentIndex: $report',
);
}
_videoContinuityCounter = (report!['nextVideoContinuityCounter'] as num).toInt();
_audioContinuityCounter = (report['nextAudioContinuityCounter'] as num).toInt();
_patContinuityCounter = (_patContinuityCounter + 1) & 0x0f;
_pmtContinuityCounter = (_pmtContinuityCounter + 1) & 0x0f;
_publishedVideoSamples += videoSamples.length;
_publishedAudioSamples += audioSamples.length;
_segmentDurationsSeconds.add(
(videoEnd - videoStart) / CastProgressiveTsOptimizer._videoTimescale,
);
_publishedSegments++;
_pendingVideo.removeWhere((sample) => sample.decodeTime < videoEnd);
_pendingAudio.removeWhere((sample) => sample.decodeTime < audioEnd);
await _writePlaylist(completed: false);
await _writeSession();
CastProgressiveTsOptimizer._log(
'SEGMENT mediaId=$mediaId index=$segmentIndex '
'video=${videoSamples.length} audio=${audioSamples.length} '
'bytes=${report['outputBytes']} '
'published=$_publishedSegments',
);
if (_publishedSegments >= CastProgressiveTsOptimizer.minimumReadySegments && !_readyCompleter.isCompleted) {
await _completeReady();
}
}
Future<void> _completeReady() async {
final ready = await castHlsCache.isTransportStreamReadyForPlayback(
mediaId,
minimumSegments: CastProgressiveTsOptimizer.minimumReadySegments,
);
if (!ready) {
throw CastProgressiveTsException(
'Segnale READY senza file sufficienti mediaId=$mediaId',
);
}
final value = CastProgressiveTsReady(
mediaId: mediaId,
playlistPath: _playlistPath,
url: castServer.buildHlsUrl(mediaId),
publishedSegmentCount: _publishedSegments,
elapsed: _stopwatch.elapsed,
);
_readyCompleter.complete(value);
CastProgressiveTsOptimizer._log(
'READY mediaId=$mediaId segments=$_publishedSegments '
'elapsedMs=${_stopwatch.elapsedMilliseconds} url=${value.url}',
);
}
Future<void> _writePlaylist({required bool completed}) async {
final buffer = StringBuffer()
..writeln('#EXTM3U')
..writeln('#EXT-X-VERSION:3')
..writeln('#EXT-X-TARGETDURATION:3')
..writeln('#EXT-X-MEDIA-SEQUENCE:0')
..writeln(
completed
? '#EXT-X-PLAYLIST-TYPE:VOD'
: '#EXT-X-PLAYLIST-TYPE:EVENT',
)
..writeln('#EXT-X-INDEPENDENT-SEGMENTS');
for (var index = 0; index < _publishedSegments; index++) {
final duration = _segmentDurationsSeconds[index].toStringAsFixed(6);
buffer
..writeln('#EXTINF:$duration,')
..writeln('segment_${index.toString().padLeft(5, '0')}.ts');
}
if (completed) {
buffer.writeln('#EXT-X-ENDLIST');
}
await castHlsCache.writePlaylistAtomically(
mediaId,
buffer.toString(),
);
}
Future<void> _writeSession() async {
await castHlsCache.writeSessionAtomically(
mediaId,
<String, dynamic>{
'format': 'mpeg-ts',
'mediaId': mediaId,
'publishedSegmentCount': _publishedSegments,
'videoSampleCount': _publishedVideoSamples,
'audioSampleCount': _publishedAudioSamples,
'ready': _readyCompleter.isCompleted,
'conversionCompleted': _conversionCompleted,
'updatedAt': DateTime.now().toIso8601String(),
},
);
}
static Map<String, dynamic> _sampleToMap(Fmp4SampleInfo sample) {
return <String, dynamic>{
'offset': sample.absoluteOffset,
'size': sample.size,
'dts': sample.decodeTime,
'pts': sample.presentationTime,
'duration': sample.duration,
'sync': sample.isSync,
};
}
}
final castProgressiveTsOptimizer = CastProgressiveTsOptimizer.instance;