1042 lines
30 KiB
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;
|