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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
## 2.10.0
- **FEAT**(iOS, macOS): Add `VideoRenderData.trimToCommonTrackEnd` (default `false`). Ends each clip where both its video and audio track still have content, so an export no longer finishes on a stretch of missing audio — the seam a looping player replays every cycle. Leave it off when a clip is meant to outlast its own audio.

## 2.9.0
- **FEAT**(android): `RenderEncoderException` now carries `isTransient`. A render that failed because the device's codec resources were exhausted is flagged as retryable, so it can be told apart from a genuine format/encoder incompatibility, which is not. Covers a starved decoder as well as a starved encoder.
- **FIX**(android): The software-encoder fallback no longer silently re-runs the hardware encoder under a `software-encoder` label. It is skipped with an explicit log line when the device has no usable software encoder.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,8 @@ class RenderVideo {
videoClips: workingConfig.videoClips,
videoEffects: effectsConfig,
enableAudio: workingConfig.enableAudio,
audioTracks: workingConfig.audioTracks
audioTracks: workingConfig.audioTracks,
trimToCommonTrackEnd: workingConfig.trimToCommonTrackEnd
)
}
let (
Expand Down Expand Up @@ -507,8 +508,24 @@ class RenderVideo {
export.outputURL = outputURL
export.outputFileType = mapFormatToMimeType(format: config.outputFormat)
export.shouldOptimizeForNetworkUse = config.shouldOptimizeForNetworkUse
// Attached before the track loads below so a cancel arriving during them
// is still honoured; `export()` only starts in `monitorExportProgress`.
handle.attach(export: export)

// This fast path skips the composition entirely, so it has to apply the
// common-track-end trim itself — otherwise the flag would be a silent
// no-op for exactly the single untrimmed clip it targets. Passthrough
// honours `timeRange` frame-accurately (same as the split fast path), so
// the export stays lossless.
if config.trimToCommonTrackEnd, config.enableAudio,
let trimmed = await TrackEndTrimmer.trimmedAssetRange(of: asset, label: "Passthrough")
{
export.timeRange = trimmed
PluginLog.print(
" ✂️ Passthrough trimmed to common track end: "
+ "\(String(format: "%.3f", trimmed.duration.seconds))s")
}

do {
try await monitorExportProgress(export, onProgress: onProgress)
} catch {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,15 @@ func applyComposition(
videoClips: [VideoClip],
videoEffects: VideoCompositorConfig,
enableAudio: Bool,
audioTracks: [AudioTrackConfig]
audioTracks: [AudioTrackConfig],
trimToCommonTrackEnd: Bool = false
) async throws -> (
AVMutableComposition, VideoCompositionData, CGSize, AVAudioMix?, CMPersistentTrackID, [URL],
[FadeWindow]
) {
return try await CompositionBuilder(videoClips: videoClips, videoEffects: videoEffects)
.setEnableAudio(enableAudio)
.setTrimToCommonTrackEnd(trimToCommonTrackEnd)
.setAudioTracks(audioTracks)
.build()
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ internal class CompositionBuilder {
private let videoClips: [VideoClip]
private let videoEffects: VideoCompositorConfig
private var enableAudio: Bool = true
private var trimToCommonTrackEnd: Bool = false
private var audioTracks: [AudioTrackConfig] = []

/// Initializes builder with configuration.
Expand All @@ -33,6 +34,16 @@ internal class CompositionBuilder {
return self
}

/// Ends each clip where both of its tracks still have content.
///
/// - Parameter enabled: If true, a clip is cut back to the earlier of its
/// video and audio track ends instead of spanning the longer one
/// - Returns: Self for chaining
func setTrimToCommonTrackEnd(_ enabled: Bool) -> CompositionBuilder {
self.trimToCommonTrackEnd = enabled
return self
}

/// Sets the audio tracks for mixing.
///
/// - Parameter tracks: Array of audio track configurations
Expand Down Expand Up @@ -66,6 +77,7 @@ internal class CompositionBuilder {
// Build video sequence
let videoBuilder = VideoSequenceBuilder(videoClips: videoClips)
.setEnableAudio(enableAudio)
.setTrimToCommonTrackEnd(trimToCommonTrackEnd)

let videoResult = try await videoBuilder.build(in: composition)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import AVFoundation
import Foundation

/// Cuts a clip range back to where an asset's video and audio tracks both
/// still have content.
///
/// A source asset's two tracks routinely end tens of milliseconds apart,
/// because capture and export stop them independently. A range that spans the
/// longer track ends on missing audio — the seam a looping player replays
/// every cycle.
///
/// Both render paths that can honour `trimToCommonTrackEnd` go through here:
/// the composition path (`VideoSequenceBuilder`, per clip) and the lossless
/// bitrate-cap fast path (`RenderVideo.passthroughExport`, whole asset).
internal enum TrackEndTrimmer {

/// How far a clip's audio may fall short of its video before the gap counts
/// as content rather than a track-end mismatch.
///
/// Capture and export pipelines end the two tracks a fraction of a second
/// apart; a survey of published mp4s put the worst case at ~0.4 s. A larger
/// gap means the clip is genuinely meant to outlast its audio, and trimming
/// it would swallow content instead of a seam.
static let maxTrackEndMismatch = CMTime(value: 500, timescale: 1000)

/// The track's time range, loaded asynchronously where the OS supports it.
static func timeRange(of track: AVAssetTrack) async -> CMTimeRange {
#if os(iOS)
if #available(iOS 15.0, *) {
return (try? await track.load(.timeRange)) ?? track.timeRange
}
#elseif os(macOS)
if #available(macOS 13.0, *) {
return (try? await track.load(.timeRange)) ?? track.timeRange
}
#endif
return track.timeRange
}

/// Returns `range` cut back to where `asset`'s audio track also has content,
/// or `nil` when the range should be left as it is.
///
/// - Parameters:
/// - range: The clip range, already clamped to the video track
/// - asset: The source asset `range` refers to
/// - label: Identifies the clip in the log line for a skipped trim
/// - Returns: The trimmed range, or `nil` to keep `range` unchanged
static func trimmedRange(
_ range: CMTimeRange,
in asset: AVAsset,
label: String
) async -> CMTimeRange? {
guard let audioTrack = try? await MediaInfoExtractor.loadAudioTrack(from: asset) else {
return nil
}

// `CMTimeRangeGetIntersection` clamps both ends, but only the end moves in
// practice: AVFoundation folds a leading empty edit into the track's
// duration rather than into its start, so an audio track that begins late
// still reports `timeRange.start == 0` and the clip keeps its head.
let commonRange = CMTimeRangeGetIntersection(
range, otherRange: await timeRange(of: audioTrack))
let shortfall = CMTimeSubtract(range.duration, commonRange.duration)

if commonRange.duration > .zero, shortfall <= maxTrackEndMismatch {
return commonRange
}

if shortfall > maxTrackEndMismatch {
// Too large to be an encoder tail: the clip genuinely outlasts its own
// audio (stop motion held past a short sound, a source file with a
// broken audio track). Trimming here would swallow content, so keep the
// video and leave the tail alone.
PluginLog.print(
" ⚠️ \(label) audio ends \(String(format: "%.2f", shortfall.seconds))s early — too far to treat as a track-end mismatch, not trimming"
)
}
return nil
}

/// Returns the whole asset cut back to where both tracks still have content,
/// or `nil` when it should be exported as it is.
///
/// Mirrors what the composition path does for an untrimmed single clip:
/// clamp to the video track first, then to the audio track.
///
/// - Parameters:
/// - asset: The source asset to measure
/// - label: Identifies the asset in the log line for a skipped trim
/// - Returns: The trimmed range, or `nil` to export the asset unchanged
static func trimmedAssetRange(of asset: AVAsset, label: String) async -> CMTimeRange? {
guard let videoTrack = try? await MediaInfoExtractor.loadVideoTrack(from: asset) else {
return nil
}

let assetDuration: CMTime
if #available(iOS 15.0, macOS 13.0, *) {
assetDuration = (try? await asset.load(.duration)) ?? .zero
} else {
assetDuration = asset.duration
}

// Some MP4 files have a container duration slightly longer than the video
// track's decoded frames; clamp to the track before consulting the audio.
let rawRange = CMTimeRange(start: .zero, duration: assetDuration)
let clamped = CMTimeRangeGetIntersection(
rawRange, otherRange: await timeRange(of: videoTrack))
let videoRange = clamped.duration > .zero ? clamped : rawRange

return await trimmedRange(videoRange, in: asset, label: label)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ internal class VideoSequenceBuilder {

private let videoClips: [VideoClip]
private var enableAudio: Bool = true
private var trimToCommonTrackEnd: Bool = false

/// Initializes builder with video clips.
///
Expand All @@ -26,6 +27,16 @@ internal class VideoSequenceBuilder {
return self
}

/// Ends each clip where both of its tracks still have content.
///
/// - Parameter enabled: If true, a clip is cut back to the earlier of its
/// video and audio track ends instead of spanning the longer one
/// - Returns: Self for chaining
func setTrimToCommonTrackEnd(_ enabled: Bool) -> VideoSequenceBuilder {
self.trimToCommonTrackEnd = enabled
return self
}

/// Calculates total duration of all video clips combined.
///
/// - Returns: Total duration as CMTime
Expand Down Expand Up @@ -180,23 +191,22 @@ internal class VideoSequenceBuilder {
// the insert but the ClipInstruction keeps the longer duration, creating a gap
// where AVFoundation calls the compositor with no source frame available
// (sourceTrackIDs empty), causing a RENDER_ERROR crash.
let videoTrackTimeRange: CMTimeRange
#if os(iOS)
if #available(iOS 15.0, *) {
videoTrackTimeRange = (try? await videoTrack.load(.timeRange)) ?? videoTrack.timeRange
} else {
videoTrackTimeRange = videoTrack.timeRange
}
#elseif os(macOS)
if #available(macOS 13.0, *) {
videoTrackTimeRange = (try? await videoTrack.load(.timeRange)) ?? videoTrack.timeRange
} else {
videoTrackTimeRange = videoTrack.timeRange
}
#endif
let videoTrackTimeRange = await TrackEndTrimmer.timeRange(of: videoTrack)
let clampedRange = CMTimeRangeGetIntersection(
rawClipTimeRange, otherRange: videoTrackTimeRange)
let clipTimeRange = clampedRange.duration > .zero ? clampedRange : rawClipTimeRange
var clipTimeRange = clampedRange.duration > .zero ? clampedRange : rawClipTimeRange

// The video clamp above leaves a range the audio track cannot fill when
// the two tracks end apart, `insertTimeRange` silently inserts what
// exists, and the export ends on a stretch of missing audio — the seam a
// looping player replays every cycle. Cut the clip back to where both
// tracks still have content instead.
if trimToCommonTrackEnd, enableAudio,
let trimmed = await TrackEndTrimmer.trimmedRange(
clipTimeRange, in: asset, label: "Clip \(index)")
{
clipTimeRange = trimmed
}
let clipDuration = clipTimeRange.duration
let insertStart = totalDuration
let sourceRanges: [CMTimeRange]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,13 @@ struct RenderConfig: Sendable {
/// Whether to include audio in output
let enableAudio: Bool

/// Whether to end each clip where both of its tracks still have content
///
/// A source asset's audio and video tracks routinely end tens of
/// milliseconds apart. Spanning the longer one leaves a tail of missing
/// audio or a frozen frame, which a looping player replays as a seam.
let trimToCommonTrackEnd: Bool

/// Playback speed multiplier (e.g., 2.0 = 2x speed)
let playbackSpeed: Float?

Expand Down Expand Up @@ -392,6 +399,7 @@ struct RenderConfig: Sendable {
bitrate: self.bitrate,
maxFrameRate: self.maxFrameRate,
enableAudio: self.enableAudio,
trimToCommonTrackEnd: self.trimToCommonTrackEnd,
playbackSpeed: self.playbackSpeed,
colorFilters: self.colorFilters,
audioTracks: self.audioTracks,
Expand Down Expand Up @@ -461,6 +469,7 @@ struct RenderConfig: Sendable {
bitrate: args["bitrate"] as? Int,
maxFrameRate: (args["maxFrameRate"] as? NSNumber)?.intValue,
enableAudio: args["enableAudio"] as? Bool ?? true,
trimToCommonTrackEnd: args["trimToCommonTrackEnd"] as? Bool ?? false,
playbackSpeed: (args["playbackSpeed"] as? NSNumber)?.floatValue,
colorFilters: colorFilters,
audioTracks: audioTracks,
Expand Down
18 changes: 18 additions & 0 deletions example/lib/features/render/video_renderer_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,15 @@ class _VideoRendererPageState extends State<VideoRendererPage> {
await _renderVideo(data);
}

Future<void> _trimToCommonTrackEnd() async {
var data = VideoRenderData(
videoSegments: [VideoSegment(video: _video)],
trimToCommonTrackEnd: true,
);

await _renderVideo(data);
}

Future<File> _writeAssetAudioToFile(String assetPath) async {
final ByteData data = await rootBundle.load(assetPath);
final buffer = data.buffer;
Expand Down Expand Up @@ -2286,6 +2295,15 @@ class _VideoRendererPageState extends State<VideoRendererPage> {
leading: const Icon(Icons.volume_off_outlined),
title: const Text('Remove Audio'),
),
if (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
ListTile(
onTap: _trimToCommonTrackEnd,
leading: const Icon(Icons.content_cut_outlined),
title: const Text('Trim to Common Track End'),
subtitle: const Text(
'End the clip where both tracks still have content',
),
),
ListTile(
onTap: _customAudioReplace,
leading: const Icon(Icons.library_music_outlined),
Expand Down
Loading
Loading