Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.divinevideo.divine_video_player

import android.content.Context
import android.media.MediaExtractor
import android.media.MediaFormat
import android.net.Uri
import android.os.Handler
import android.os.Looper
Expand Down Expand Up @@ -415,6 +417,18 @@ internal class DivineVideoPlayerInstance(
}
val startMs = (map["startMs"] as? Number)?.toLong() ?: 0L
val endMs = (map["endMs"] as? Number)?.toLong()
val trimToCommonTrackEnd = map["trimToCommonTrackEnd"] as? Boolean ?: false
// The container duration is the *longest* track, so ending there
// leaves a stretch where the shorter track has already run out —
// silence, or a frozen frame. On a looping player that stretch is
// the seam. Clamping may only ever shorten: an earlier explicit
// trim still wins.
val commonEndMs = if (trimToCommonTrackEnd) {
boundedCommonTrackEndMs(uri, startMs, endMs)
} else {
null
}
val effectiveEndMs = listOfNotNull(endMs, commonEndMs).minOrNull()
val clipVol = (map["volume"] as? Number)?.toFloat() ?: 1.0f
val clipSpeed = ((map["playbackSpeed"] as? Number)?.toFloat() ?: 1.0f)
.coerceAtLeast(MIN_PLAYBACK_SPEED)
Expand All @@ -436,7 +450,7 @@ internal class DivineVideoPlayerInstance(
MediaItem.ClippingConfiguration.Builder()
.setStartPositionMs(startMs)
.apply {
if (endMs != null) setEndPositionMs(endMs)
if (effectiveEndMs != null) setEndPositionMs(effectiveEndMs)
}
.build(),
)
Expand All @@ -450,8 +464,8 @@ internal class DivineVideoPlayerInstance(
// Offsets accumulate in playback time so the global timeline
// matches what the editor UI shows (slow clips occupy more
// space, fast clips less).
if (endMs != null) {
accumulated += sourceToPlaybackMs(endMs - startMs, clipSpeed)
if (effectiveEndMs != null) {
accumulated += sourceToPlaybackMs(effectiveEndMs - startMs, clipSpeed)
}
}

Expand Down Expand Up @@ -529,6 +543,83 @@ internal class DivineVideoPlayerInstance(
mainHandler.postDelayed(setClipsTimeoutRunnable, SET_CLIPS_TIMEOUT_MS)
}

/**
* The point up to which *every* track of [uri] still has content, in
* milliseconds, or `null` when it cannot be determined without I/O that
* would delay playback.
*
* Only local files are probed. Reading a remote source's metadata means a
* network round trip on the platform thread before playback can start, so
* remote clips keep the container duration — and the seam — instead.
*/
private fun boundedCommonTrackEndMs(
uri: String,
startMs: Long,
requestedEndMs: Long?,
): Long? {
val path = when {
uri.startsWith("/") -> uri
uri.startsWith("file://") -> Uri.parse(uri).path
else -> null
} ?: return null

val extractor = MediaExtractor()
return try {
extractor.setDataSource(path)
var videoUs = -1L
var audioUs = -1L
for (i in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(i)
val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
if (!format.containsKey(MediaFormat.KEY_DURATION)) continue
val durationUs = format.getLong(MediaFormat.KEY_DURATION)
when {
mime.startsWith("video/") && videoUs < 0 -> videoUs = durationUs
mime.startsWith("audio/") && audioUs < 0 -> audioUs = durationUs
}
}
// A clip without both track types has no mismatch to trim.
if (videoUs <= 0 || audioUs <= 0) {
null
} else {
boundedCommonTrackEndMs(
startMs = startMs,
requestedEndMs = requestedEndMs,
videoEndMs = videoUs / 1000,
audioEndMs = audioUs / 1000,
)
}
} catch (e: Exception) {
DivineVideoPlayerLog.warning(
"Player $playerId could not read track durations: $e",
name = "DivineVideoPlayer.Load",
)
null
} finally {
extractor.release()
}
}

private fun boundedCommonTrackEndMs(
startMs: Long,
requestedEndMs: Long?,
videoEndMs: Long,
audioEndMs: Long,
): Long? {
val containerEndMs = maxOf(videoEndMs, audioEndMs)
val playbackEndMs = minOf(requestedEndMs ?: containerEndMs, containerEndMs)
val commonEndMs = minOf(videoEndMs, audioEndMs)
val trimMs = playbackEndMs - commonEndMs
if (commonEndMs <= startMs || trimMs <= 0) return null

val playableDurationMs = playbackEndMs - startMs
if (playableDurationMs <= 0) return null

val relativeLimitMs = (playableDurationMs * MAX_COMMON_TRACK_END_TRIM_RATIO).toLong()
val trimLimitMs = minOf(MAX_COMMON_TRACK_END_TRIM_MS, relativeLimitMs)
return commonEndMs.takeIf { trimMs <= trimLimitMs }
}

private fun handleSeekTo(call: MethodCall, result: MethodChannel.Result) {
val globalMs = (call.argument<Number>("positionMs"))?.toLong() ?: 0L
val exoPlayer = ensurePlayer()
Expand Down Expand Up @@ -1236,6 +1327,10 @@ internal class DivineVideoPlayerInstance(
*/
private const val MIN_PLAYBACK_SPEED = 0.001f

/** Maximum tail considered an encoder/export track-end mismatch. */
private const val MAX_COMMON_TRACK_END_TRIM_MS = 500L
private const val MAX_COMMON_TRACK_END_TRIM_RATIO = 0.10

/**
* How many times an editing/preview player re-prepares after a
* transient decoder error before giving up. Small: a couple of
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ final class DivineVideoPlayerInstance: NSObject, FlutterStreamHandler {

private static let setClipsTimeoutMs = 10_000
private static let bufferingStallMs = 8_000
private static let maxCommonTrackEndTrimMs = 500.0
private static let maxCommonTrackEndTrimRatio = 0.10

/// AVFoundation's asset-option key for per-asset HTTP request headers.
///
Expand Down Expand Up @@ -342,6 +344,8 @@ final class DivineVideoPlayerInstance: NSObject, FlutterStreamHandler {
let clipVol = (clipMap["volume"] as? NSNumber)?.floatValue ?? 1.0
let clipSpeed = (clipMap["playbackSpeed"] as? NSNumber)?.doubleValue ?? 1.0
let httpHeaders = clipMap["httpHeaders"] as? [String: String]
let trimToCommonTrackEnd =
(clipMap["trimToCommonTrackEnd"] as? NSNumber)?.boolValue ?? false

let url: URL
if uri.hasPrefix("/") {
Expand Down Expand Up @@ -387,7 +391,7 @@ final class DivineVideoPlayerInstance: NSObject, FlutterStreamHandler {
let standardizedTransform = transform.standardized(for: naturalSize)

let startTime = CMTime(value: startMs, timescale: 1000)
let endTime: CMTime
var endTime: CMTime
if let endMs {
// Clamp to the asset: insertTimeRange silently inserts only the
// media that exists, so an endMs past the source would leave
Expand All @@ -404,6 +408,30 @@ final class DivineVideoPlayerInstance: NSObject, FlutterStreamHandler {
} else {
endTime = assetDuration
}
// The asset duration is the *longest* track, so ending there leaves
// a stretch where the shorter track has already run out — silence,
// or a frozen frame. On a looping player that stretch is the seam.
// Clamping may only ever shorten: an earlier explicit trim wins.
if trimToCommonTrackEnd, let sourceAudioTrack = assetAudioTracks.first {
do {
let videoRange = try await sourceVideoTrack.load(.timeRange)
let audioRange = try await sourceAudioTrack.load(.timeRange)
if let commonEnd = boundedCommonTrackEnd(
startTime: startTime,
requestedEnd: endTime,
videoEnd: videoRange.end,
audioEnd: audioRange.end
) {
endTime = CMTimeMinimum(endTime, commonEnd)
}
} catch {
DivineVideoPlayerLog.shared.warning(
"Player \(playerId) could not read track durations: "
+ "\(error.localizedDescription)",
name: "DivineVideoPlayer.Load"
)
}
}
let timeRange = CMTimeRange(start: startTime, end: endTime)
let clipDuration = CMTimeSubtract(endTime, startTime)
guard CMTimeCompare(clipDuration, .zero) > 0 else {
Expand Down Expand Up @@ -727,6 +755,39 @@ final class DivineVideoPlayerInstance: NSObject, FlutterStreamHandler {
}
}

private func boundedCommonTrackEnd(
startTime: CMTime,
requestedEnd: CMTime,
videoEnd: CMTime,
audioEnd: CMTime
) -> CMTime? {
let containerEnd = CMTimeMaximum(videoEnd, audioEnd)
let playbackEnd = CMTimeMinimum(requestedEnd, containerEnd)
let commonEnd = CMTimeMinimum(videoEnd, audioEnd)
guard commonEnd.isNumeric,
playbackEnd.isNumeric,
CMTimeCompare(commonEnd, startTime) > 0,
CMTimeCompare(playbackEnd, commonEnd) > 0
else {
return nil
}

let trimMs = CMTimeSubtract(playbackEnd, commonEnd).seconds * 1000
let playableDurationMs = CMTimeSubtract(playbackEnd, startTime).seconds * 1000
guard trimMs.isFinite,
playableDurationMs.isFinite,
playableDurationMs > 0
else {
return nil
}

let trimLimitMs = min(
Self.maxCommonTrackEndTrimMs,
playableDurationMs * Self.maxCommonTrackEndTrimRatio
)
return trimMs <= trimLimitMs ? commonEnd : nil
}

/// Calls `AVPlayer.preroll(atRate:)` only when the player is ready;
/// otherwise defers via a one-shot KVO on `status`. No-op while
/// `player.rate != 0` (preroll is only useful when paused).
Expand Down
30 changes: 30 additions & 0 deletions mobile/packages/divine_video_player/lib/src/video_clip.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class VideoClip {
this.volume = 1.0,
this.playbackSpeed = 1.0,
this.httpHeaders = const {},
this.trimToCommonTrackEnd = false,
});

/// Creates a [VideoClip] from a local file path.
Expand All @@ -32,6 +33,7 @@ class VideoClip {
this.volume = 1.0,
this.playbackSpeed = 1.0,
this.httpHeaders = const {},
this.trimToCommonTrackEnd = false,
}) : uri = path;

/// Creates a [VideoClip] from a network URL.
Expand All @@ -42,6 +44,7 @@ class VideoClip {
this.volume = 1.0,
this.playbackSpeed = 1.0,
this.httpHeaders = const {},
this.trimToCommonTrackEnd = false,
}) : uri = url;

/// Creates a [VideoClip] from a Flutter asset.
Expand All @@ -55,6 +58,7 @@ class VideoClip {
double volume = 1.0,
double playbackSpeed = 1.0,
AssetBundle? bundle,
bool trimToCommonTrackEnd = false,
}) async {
final (data, dir) = await (
(bundle ?? rootBundle).load(assetPath),
Expand All @@ -70,6 +74,7 @@ class VideoClip {
end: end,
volume: volume,
playbackSpeed: playbackSpeed,
trimToCommonTrackEnd: trimToCommonTrackEnd,
);
}

Expand All @@ -84,6 +89,7 @@ class VideoClip {
Duration? end,
double volume = 1.0,
double playbackSpeed = 1.0,
bool trimToCommonTrackEnd = false,
}) async {
final dir = await getTemporaryDirectory();
final file = File('${dir.path}/divine_player_memory/$fileName');
Expand All @@ -95,6 +101,7 @@ class VideoClip {
end: end,
volume: volume,
playbackSpeed: playbackSpeed,
trimToCommonTrackEnd: trimToCommonTrackEnd,
);
}

Expand Down Expand Up @@ -122,6 +129,28 @@ class VideoClip {
/// HTTP headers to attach when [uri] resolves to a network source.
final Map<String, String> httpHeaders;

/// Whether to end the clip where *all* of the source's tracks still have
/// content, instead of at the container duration.
///
/// An mp4's declared duration is the longest of its tracks, and capture and
/// export pipelines routinely let the audio and video tracks end tens of
/// milliseconds apart. Playing to the container duration therefore ends on a
/// stretch where one track has already run out — silence, or a frozen last
/// frame. On a looping player that stretch is the loop seam.
///
/// Set this for looping playback of a single clip. It shortens the clip only
/// when the track mismatch is small (currently at most 500 ms and at most
/// 10% of the playable duration), so obviously malformed assets keep their
/// container duration instead of collapsing into a tiny loop. It is wrong for
/// a clip in the middle of a multi-clip timeline, where it would cut content
/// rather than a seam. Clamping only ever shortens: [end], when set, still
/// wins if it is earlier.
///
/// Support is per-platform: Apple always honours it, Android honours it for
/// local files only (a remote source would need a blocking metadata read
/// before playback could start), and the web and Linux backends ignore it.
final bool trimToCommonTrackEnd;

/// Serializes this clip for platform channel transport.
Map<String, dynamic> toMap() {
return {
Expand All @@ -131,6 +160,7 @@ class VideoClip {
'volume': volume,
'playbackSpeed': playbackSpeed,
if (httpHeaders.isNotEmpty) 'httpHeaders': httpHeaders,
if (trimToCommonTrackEnd) 'trimToCommonTrackEnd': true,
};
}
}
Loading
Loading