611 lines
18 KiB
Dart
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;
|