gallery_aves_0.98/lib/services/cast/cast_progressive_ts_optimizer.dart

1042 lines
30 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
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_video_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 _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, {
required int durationMillis,
}) async {
final existing = _runs[mediaId];
if (existing != null) {
CastProgressiveTsOptimizer._log('REUSE mediaId=$mediaId');
return existing.session;
}
final run = _ProgressiveTsRun(
mediaId: mediaId,
durationMillis: durationMillis,
channel: _channel,
onFinished: () => _runs.remove(mediaId),
);
_runs[mediaId] = run;
run.start();
return run.session;
}
Future<String> prepareUntilReady(
int mediaId, {
required int durationMillis,
}) 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,
durationMillis: durationMillis,
);
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;
/*
* Durata totale originale. Viene usata per costruire,
* dopo i primi tre segmenti, una playlist VOD anticipata.
*/
final int durationMillis;
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>[];
/*
* Durate congelate quando viene pubblicata per la prima volta
* la VOD anticipata. Non devono cambiare durante la sessione,
* perché Chromecast può mantenere in cache il primo manifest.
*/
List<double>? _anticipatedDurationsSeconds;
final Stopwatch _stopwatch = Stopwatch();
CastFmp4ProgressiveReader? _reader;
bool _stopRequested = false;
bool _conversionCompleted = false;
int _publishedSegments = 0;
int _publishedVideoSamples = 0;
int _publishedAudioSamples = 0;
// Media3 usa normalmente trackId=1 per l'audio e trackId=2 per il video.
// Negli output video-only, però, la sola traccia video riceve trackId=1.
bool _videoOnlyOutput = false;
int _videoContinuityCounter = 0;
int _audioContinuityCounter = 0;
int _patContinuityCounter = 0;
int _pmtContinuityCounter = 0;
late String _workingPath;
late String _playlistPath;
_ProgressiveTsRun({
required this.mediaId,
required this.durationMillis,
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',
);
}
final trackInspection = await channel.invokeMapMethod<String, dynamic>(
'inspectMediaTracks',
<String, dynamic>{
'inputPath': sourcePath,
},
);
final inspectionSucceeded = trackInspection?['success'] == true;
final sourceHasVideo = trackInspection?['hasVideo'] == true;
final sourceHasAudio = trackInspection?['hasAudio'] == true;
if (!inspectionSucceeded || !sourceHasVideo) {
throw CastProgressiveTsException(
'Ispezione tracce non valida '
'mediaId=$mediaId '
'result=$trackInspection',
);
}
_videoOnlyOutput = !sourceHasAudio;
CastProgressiveTsOptimizer._log(
'SOURCE TRACKS '
'mediaId=$mediaId '
'hasVideo=$sourceHasVideo '
'hasAudio=$sourceHasAudio '
'videoOnly=$_videoOnlyOutput '
'videoTracks=${trackInspection?['videoTrackCount'] ?? '-'} '
'audioTracks=${trackInspection?['audioTrackCount'] ?? '-'} '
'mimeTypes=${trackInspection?['mimeTypes'] ?? const <String>[]}',
);
_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',
);
}
final audioMimeType = conversion?['audioMimeType']?.toString().trim();
_videoOnlyOutput = _videoOnlyOutput || audioMimeType == null || audioMimeType.isEmpty;
if (_videoOnlyOutput && _pendingVideo.isEmpty && _pendingAudio.isNotEmpty) {
final remappedSampleCount = _pendingAudio.length;
_pendingVideo.addAll(_pendingAudio);
_pendingAudio.clear();
CastProgressiveTsOptimizer._log(
'VIDEO-ONLY TRACK REMAP '
'mediaId=$mediaId '
'remappedSamples=$remappedSampleCount '
'pendingVideo=${_pendingVideo.length} '
'audioMimeType=${audioMimeType ?? '-'}',
);
}
_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);
/*
* Tutti i segmenti HLS finali sono stati pubblicati.
* Conserviamo playlist, segmenti, sessione e complete.flag;
* eliminiamo soltanto i file intermedi e l'eventuale MP4
* ottimizzato dello stesso mediaId.
*/
await castHlsCache.removeCompletedWorkingFiles(
mediaId,
);
await castVideoCache.invalidate(mediaId);
if (!_readyCompleter.isCompleted) {
// Un video breve può terminare prima di raggiungere la normale
// soglia di buffer. A conversione completata verifica tutti i
// segmenti prodotti, fino alla soglia ordinaria di READY.
await _completeReady(
minimumSegments: math.min(
CastProgressiveTsOptimizer.minimumReadySegments,
_publishedSegments,
),
);
}
_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;
}
/*
* Un frammento fMP4, soprattutto quello finale, può contenere
* soltanto una delle due tracce.
*
* Non usare singleWhere(), perché l'assenza temporanea della
* traccia audio o video non è un errore.
*/
var audioTracks = 0;
var videoTracks = 0;
var addedAudioSamples = 0;
var addedVideoSamples = 0;
for (final track in fragment.tracks) {
final isVideoTrack = track.trackId == CastProgressiveTsOptimizer._videoTrackId || (_videoOnlyOutput && track.trackId == CastProgressiveTsOptimizer._audioTrackId);
if (isVideoTrack) {
videoTracks++;
addedVideoSamples += track.samples.length;
_pendingVideo.addAll(track.samples);
} else if (track.trackId == CastProgressiveTsOptimizer._audioTrackId) {
audioTracks++;
addedAudioSamples += track.samples.length;
_pendingAudio.addAll(track.samples);
}
}
CastProgressiveTsOptimizer._log(
'FRAGMENT '
'mediaId=$mediaId '
'audioTracks=$audioTracks '
'videoTracks=$videoTracks '
'audioSamples=$addedAudioSamples '
'videoSamples=$addedVideoSamples '
'pendingAudio=${_pendingAudio.length} '
'pendingVideo=${_pendingVideo.length}',
);
/*
* I sample della traccia mancante possono arrivare con un
* frammento successivo. Pubblica ciò che è già coperto da
* entrambe le timeline.
*/
await _publishAvailableSegments();
}
Future<void> _publishAvailableSegments() async {
while (!_stopRequested) {
final videoOnly = _videoOnlyOutput && _pendingAudio.isEmpty;
if (_pendingVideo.isEmpty || (!videoOnly && _pendingAudio.isEmpty)) {
return;
}
/*
* Ogni segmento MPEG-TS deve iniziare con un keyframe.
*
* Dopo la pubblicazione precedente, il primo sample video
* rimasto deve quindi essere il keyframe che apre il nuovo
* segmento.
*/
final firstVideoSample = _pendingVideo.first;
if (!firstVideoSample.isSync) {
/*
* Durante l'avvio possono essere presenti sample anteriori
* al primo keyframe utilizzabile. Li scartiamo soltanto prima
* del primo segmento.
*
* Per segmenti successivi questa condizione indica invece
* una discontinuità inattesa e non bisogna produrre un TS
* non indipendente.
*/
if (_publishedSegments == 0) {
final firstSyncIndex = _pendingVideo.indexWhere(
(sample) => sample.isSync,
);
if (firstSyncIndex < 0) {
return;
}
final firstSyncTime = _pendingVideo[firstSyncIndex].decodeTime;
_pendingVideo.removeWhere(
(sample) => sample.decodeTime < firstSyncTime,
);
final audioStartAtSync = (firstSyncTime * CastProgressiveTsOptimizer._audioTimescale) ~/ CastProgressiveTsOptimizer._videoTimescale;
_pendingAudio.removeWhere(
(sample) => sample.decodeTime + sample.duration <= audioStartAtSync,
);
continue;
}
throw CastProgressiveTsException(
'Il segmento $_publishedSegments non ha un keyframe iniziale',
);
}
final videoStart = firstVideoSample.decodeTime;
/*
* Il segmento deve durare almeno circa 3 secondi, ma il confine
* effettivo viene spostato al primo keyframe successivo.
*
* Quel keyframe non viene incluso nel segmento corrente:
* resterà in _pendingVideo e aprirà il segmento successivo.
*/
final targetVideoEnd = videoStart + CastProgressiveTsOptimizer._segmentDurationVideoTicks;
final nextSyncIndex = _pendingVideo.indexWhere(
(sample) => sample.isSync && sample.decodeTime >= targetVideoEnd,
1,
);
if (nextSyncIndex < 0) {
/*
* Il keyframe che deve aprire il prossimo segmento non è
* ancora arrivato dal reader fMP4.
*/
return;
}
final videoEnd = _pendingVideo[nextSyncIndex].decodeTime;
final audioStart = (videoStart * CastProgressiveTsOptimizer._audioTimescale) ~/ CastProgressiveTsOptimizer._videoTimescale;
final audioEnd = (videoEnd * CastProgressiveTsOptimizer._audioTimescale) ~/ CastProgressiveTsOptimizer._videoTimescale;
/*
* Aspetta finché l'audio copre interamente lo stesso intervallo
* temporale del segmento video.
*/
final audioCovered =
videoOnly ||
_pendingAudio.any(
(sample) => sample.decodeTime + sample.duration >= audioEnd,
);
if (!audioCovered) {
return;
}
await _publishSegment(
segmentIndex: _publishedSegments,
videoStart: videoStart,
videoEnd: videoEnd,
audioStart: audioStart,
audioEnd: audioEnd,
);
}
}
Future<void> _publishFinalPartialSegment() async {
if (_pendingVideo.isEmpty) {
return;
}
/*
* Alla fine non serve un keyframe successivo per determinare
* videoEnd, ma il segmento finale deve comunque iniziare con
* un keyframe.
*/
final firstVideoSample = _pendingVideo.first;
if (!firstVideoSample.isSync) {
throw CastProgressiveTsException(
'Il segmento finale $_publishedSegments '
'non inizia con keyframe',
);
}
final videoStart = firstVideoSample.decodeTime;
final videoEnd = _pendingVideo
.map(
(sample) => sample.decodeTime + sample.duration,
)
.reduce(
(a, b) => a > b ? a : b,
);
if (videoEnd <= videoStart) {
return;
}
final audioStart = (videoStart * CastProgressiveTsOptimizer._audioTimescale) ~/ CastProgressiveTsOptimizer._videoTimescale;
final audioEnd = (videoEnd * CastProgressiveTsOptimizer._audioTimescale) ~/ CastProgressiveTsOptimizer._videoTimescale;
final videoOnly = _videoOnlyOutput && _pendingAudio.isEmpty;
final hasAudio = _pendingAudio.any(
(sample) => sample.decodeTime >= audioStart && sample.decodeTime < audioEnd,
);
if (!videoOnly && !hasAudio) {
return;
}
await _publishSegment(
segmentIndex: _publishedSegments,
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) {
throw CastProgressiveTsException(
'Sample video 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({
int minimumSegments = CastProgressiveTsOptimizer.minimumReadySegments,
}) async {
if (minimumSegments < 1) {
throw ArgumentError.value(
minimumSegments,
'minimumSegments',
'Deve essere almeno 1',
);
}
final ready = await castHlsCache.isTransportStreamReadyForPlayback(
mediaId,
minimumSegments: minimumSegments,
);
if (!ready) {
throw CastProgressiveTsException(
'Segnale READY senza file sufficienti '
'mediaId=$mediaId '
'minimumSegments=$minimumSegments '
'publishedSegments=$_publishedSegments',
);
}
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 '
'minimumSegments=$minimumSegments '
'conversionCompleted=$_conversionCompleted '
'elapsedMs=${_stopwatch.elapsedMilliseconds} '
'url=${value.url}',
);
}
List<double> _buildAnticipatedDurations() {
if (_segmentDurationsSeconds.isEmpty) {
throw const CastProgressiveTsException(
'Impossibile stimare la VOD senza segmenti reali',
);
}
if (durationMillis <= 0) {
throw CastProgressiveTsException(
'Durata totale non valida per mediaId=$mediaId: '
'$durationMillis ms',
);
}
final observedCount = math.min(
CastProgressiveTsOptimizer.minimumReadySegments,
_segmentDurationsSeconds.length,
);
final observedDurations = _segmentDurationsSeconds.take(observedCount).toList(growable: false);
/*
* Il primo segmento può essere più corto per l'allineamento
* iniziale al primo keyframe.
*
* La mediana è più robusta della media e rappresenta meglio
* la durata normale dei segmenti successivi.
*/
final sortedObservedDurations = List<double>.of(
observedDurations,
)..sort();
final middleIndex = sortedObservedDurations.length ~/ 2;
final double typicalDuration;
if (sortedObservedDurations.length.isOdd) {
typicalDuration = sortedObservedDurations[middleIndex];
} else {
typicalDuration = (sortedObservedDurations[middleIndex - 1] + sortedObservedDurations[middleIndex]) / 2;
}
if (!typicalDuration.isFinite || typicalDuration <= 0) {
throw CastProgressiveTsException(
'Durata tipica segmento non valida: '
'$typicalDuration',
);
}
final totalDurationSeconds = durationMillis / 1000.0;
final predictedCount = math.max(
_publishedSegments,
(totalDurationSeconds / typicalDuration).ceil(),
);
final predicted = <double>[];
for (var index = 0; index < predictedCount; index++) {
if (index < _segmentDurationsSeconds.length) {
predicted.add(
_segmentDurationsSeconds[index],
);
} else {
predicted.add(typicalDuration);
}
}
/*
* Corregge l'ultimo EXTINF affinché la somma totale
* corrisponda alla durata originale dichiarata.
*/
final durationBeforeLast = predicted
.take(predicted.length - 1)
.fold<double>(
0,
(total, value) => total + value,
);
final lastDuration = totalDurationSeconds - durationBeforeLast;
if (lastDuration > 0) {
predicted[predicted.length - 1] = lastDuration;
}
CastProgressiveTsOptimizer._log(
'ANTICIPATED VOD ESTIMATE '
'mediaId=$mediaId '
'durationMs=$durationMillis '
'observed=$observedCount '
'published=$_publishedSegments '
'typicalSeconds=${typicalDuration.toStringAsFixed(6)} '
'predicted=${predicted.length}',
);
return List<double>.unmodifiable(
predicted,
);
}
Future<void> _writePlaylist({
required bool completed,
}) async {
/*
* Prima della soglia READY pubblica una normale EVENT
* progressiva contenente solo i segmenti reali.
*
* Al raggiungimento della soglia congela una VOD anticipata
* completa. Le richieste ai segmenti futuri saranno trattenute
* dal CastServer finché i file non verranno prodotti.
*
* Alla fine sostituisce la previsione con la VOD reale.
*/
final bool anticipated = !completed && _publishedSegments >= CastProgressiveTsOptimizer.minimumReadySegments;
final List<double> playlistDurations;
if (completed) {
playlistDurations = List<double>.of(
_segmentDurationsSeconds,
);
} else if (anticipated) {
_anticipatedDurationsSeconds ??= _buildAnticipatedDurations();
playlistDurations = _anticipatedDurationsSeconds!;
} else {
playlistDurations = List<double>.of(
_segmentDurationsSeconds,
);
}
final maximumDuration = playlistDurations.isEmpty
? 3.0
: playlistDurations.reduce(
math.max,
);
final targetDuration = math.max(
1,
maximumDuration.ceil(),
);
final buffer = StringBuffer()
..writeln('#EXTM3U')
..writeln('#EXT-X-VERSION:6')
..writeln(
'#EXT-X-TARGETDURATION:$targetDuration',
)
..writeln('#EXT-X-MEDIA-SEQUENCE:0');
if (anticipated || completed) {
buffer
..writeln(
'#EXT-X-START:TIME-OFFSET=0,PRECISE=YES',
)
..writeln(
'#EXT-X-PLAYLIST-TYPE:VOD',
);
} else {
buffer.writeln(
'#EXT-X-PLAYLIST-TYPE:EVENT',
);
}
buffer.writeln(
'#EXT-X-INDEPENDENT-SEGMENTS',
);
for (var index = 0; index < playlistDurations.length; index++) {
final duration = playlistDurations[index].toStringAsFixed(6);
buffer
..writeln('#EXTINF:$duration,')
..writeln(
'segment_${index.toString().padLeft(5, '0')}.ts',
);
}
if (anticipated || completed) {
buffer.writeln('#EXT-X-ENDLIST');
}
await castHlsCache.writePlaylistAtomically(
mediaId,
buffer.toString(),
);
CastProgressiveTsOptimizer._log(
'PLAYLIST '
'mediaId=$mediaId '
'mode=${completed
? 'final-vod'
: anticipated
? 'anticipated-vod'
: 'event'} '
'realSegments=$_publishedSegments '
'declaredSegments=${playlistDurations.length} '
'targetDuration=$targetDuration',
);
}
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;