From 01562405dbe40d0440258ee51c9882722b456509 Mon Sep 17 00:00:00 2001 From: hm21 Date: Mon, 27 Jul 2026 12:01:45 +0200 Subject: [PATCH 1/4] feat: end a clip where both of its tracks still have content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A source asset's audio and video tracks routinely end tens of milliseconds apart, because capture and export stop them independently. VideoSequenceBuilder clamped the clip range to the video track's range — with an explicit comment about why that clamp is needed — and then reused that same range for the audio insert without intersecting the audio track. When the source audio is shorter, insertTimeRange silently inserts what exists and the export ends on a stretch of missing audio. A looping player replays that stretch every cycle as a seam. Measured across 622 published mp4s: 17% of the Apple-produced files were off by >=40ms, the worst by 379ms, and 192 of 225 non-zero deltas had the audio short rather than long — the signature of the missing clamp rather than of packaging. The new VideoRenderData.trimToCommonTrackEnd intersects the clip range with the audio track too, so both tracks carry content for the whole export. It stays off by default because it shortens the export by the mismatch, which is wrong for a clip meant to outlast its own audio. A shortfall beyond 500ms is treated as content rather than a track-end mismatch and left alone, so a stop-motion clip held past a short sound cannot be swallowed; the worst real artifact measured was 379ms. --- CHANGELOG.md | 3 + .../shared/features/render/RenderVideo.swift | 3 +- .../render/helpers/ApplyComposition.swift | 4 +- .../render/helpers/CompositionBuilder.swift | 12 +++ .../render/helpers/VideoSequenceBuilder.swift | 77 +++++++++++++++---- .../features/render/models/RenderConfig.swift | 9 +++ .../models/video/video_render_data_model.dart | 26 +++++++ pubspec.yaml | 2 +- .../video/video_render_data_model_test.dart | 47 +++++++++++ ...ideo_editor_method_channel_test.mocks.dart | 11 +++ 10 files changed, 176 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbaead27..eee4e276 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## 2.10.0 +- **FEAT**(iOS, macOS): Add `VideoRenderData.trimToCommonTrackEnd` (default `false`). A source asset's audio and video tracks routinely end tens of milliseconds apart, because capture and export stop them independently. The clip range was clamped to the video track only and then reused for the audio insert, so `insertTimeRange` silently truncated and the export ended on a stretch of missing audio — a seam a looping player replays every cycle. Measured across 622 published mp4s: 17% of Apple-produced files were off by >=40ms, worst case 379ms. Enabling this cuts each clip back to the earlier of its two track ends, so both tracks carry content for the whole export. It shortens the export by the mismatch, so leave it off when a clip is meant to outlast its own audio (stop motion held past a short sound); a gap beyond 500ms is treated as content rather than a track-end mismatch and is left alone. + ## 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. diff --git a/darwin/pro_video_editor/Sources/pro_video_editor/src/shared/features/render/RenderVideo.swift b/darwin/pro_video_editor/Sources/pro_video_editor/src/shared/features/render/RenderVideo.swift index 7a83c889..54e3b103 100644 --- a/darwin/pro_video_editor/Sources/pro_video_editor/src/shared/features/render/RenderVideo.swift +++ b/darwin/pro_video_editor/Sources/pro_video_editor/src/shared/features/render/RenderVideo.swift @@ -205,7 +205,8 @@ class RenderVideo { videoClips: workingConfig.videoClips, videoEffects: effectsConfig, enableAudio: workingConfig.enableAudio, - audioTracks: workingConfig.audioTracks + audioTracks: workingConfig.audioTracks, + trimToCommonTrackEnd: workingConfig.trimToCommonTrackEnd ) } let ( diff --git a/darwin/pro_video_editor/Sources/pro_video_editor/src/shared/features/render/helpers/ApplyComposition.swift b/darwin/pro_video_editor/Sources/pro_video_editor/src/shared/features/render/helpers/ApplyComposition.swift index cdec23b5..064d5918 100644 --- a/darwin/pro_video_editor/Sources/pro_video_editor/src/shared/features/render/helpers/ApplyComposition.swift +++ b/darwin/pro_video_editor/Sources/pro_video_editor/src/shared/features/render/helpers/ApplyComposition.swift @@ -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() } diff --git a/darwin/pro_video_editor/Sources/pro_video_editor/src/shared/features/render/helpers/CompositionBuilder.swift b/darwin/pro_video_editor/Sources/pro_video_editor/src/shared/features/render/helpers/CompositionBuilder.swift index 08b9ecf6..f7cb897a 100644 --- a/darwin/pro_video_editor/Sources/pro_video_editor/src/shared/features/render/helpers/CompositionBuilder.swift +++ b/darwin/pro_video_editor/Sources/pro_video_editor/src/shared/features/render/helpers/CompositionBuilder.swift @@ -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. @@ -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 @@ -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) diff --git a/darwin/pro_video_editor/Sources/pro_video_editor/src/shared/features/render/helpers/VideoSequenceBuilder.swift b/darwin/pro_video_editor/Sources/pro_video_editor/src/shared/features/render/helpers/VideoSequenceBuilder.swift index e825679b..e7b7f75f 100644 --- a/darwin/pro_video_editor/Sources/pro_video_editor/src/shared/features/render/helpers/VideoSequenceBuilder.swift +++ b/darwin/pro_video_editor/Sources/pro_video_editor/src/shared/features/render/helpers/VideoSequenceBuilder.swift @@ -9,6 +9,16 @@ internal class VideoSequenceBuilder { private let videoClips: [VideoClip] private var enableAudio: Bool = true + private var trimToCommonTrackEnd: Bool = false + + /// 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. + private static let maxTrackEndMismatch = CMTime(value: 500, timescale: 1000) /// Initializes builder with video clips. /// @@ -26,6 +36,30 @@ 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 + } + + /// The track's time range, loaded asynchronously where the OS supports it. + private 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 + } + /// Calculates total duration of all video clips combined. /// /// - Returns: Total duration as CMTime @@ -180,23 +214,36 @@ 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 Self.timeRange(of: videoTrack) let clampedRange = CMTimeRangeGetIntersection( rawClipTimeRange, otherRange: videoTrackTimeRange) - let clipTimeRange = clampedRange.duration > .zero ? clampedRange : rawClipTimeRange + var clipTimeRange = clampedRange.duration > .zero ? clampedRange : rawClipTimeRange + + // A source asset's audio and video tracks routinely end tens of + // milliseconds apart, because capture and export stop them + // independently. The video clamp above then leaves a range the audio + // track cannot fill, `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 audioTrack = try? await MediaInfoExtractor.loadAudioTrack(from: asset) + { + let commonRange = CMTimeRangeGetIntersection( + clipTimeRange, otherRange: await Self.timeRange(of: audioTrack)) + let shortfall = CMTimeSubtract(clipTimeRange.duration, commonRange.duration) + if commonRange.duration > .zero, shortfall <= Self.maxTrackEndMismatch { + clipTimeRange = commonRange + } else if shortfall > Self.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( + " ⚠️ Clip \(index) audio ends \(String(format: "%.2f", shortfall.seconds))s early — too far to treat as a track-end mismatch, not trimming" + ) + } + } let clipDuration = clipTimeRange.duration let insertStart = totalDuration let sourceRanges: [CMTimeRange] diff --git a/darwin/pro_video_editor/Sources/pro_video_editor/src/shared/features/render/models/RenderConfig.swift b/darwin/pro_video_editor/Sources/pro_video_editor/src/shared/features/render/models/RenderConfig.swift index 5a3f635b..6df6d979 100644 --- a/darwin/pro_video_editor/Sources/pro_video_editor/src/shared/features/render/models/RenderConfig.swift +++ b/darwin/pro_video_editor/Sources/pro_video_editor/src/shared/features/render/models/RenderConfig.swift @@ -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? @@ -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, @@ -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, diff --git a/lib/core/models/video/video_render_data_model.dart b/lib/core/models/video/video_render_data_model.dart index d9963ed1..13846315 100644 --- a/lib/core/models/video/video_render_data_model.dart +++ b/lib/core/models/video/video_render_data_model.dart @@ -30,6 +30,7 @@ class VideoRenderData { this.imageLayers, this.transform, this.enableAudio = true, + this.trimToCommonTrackEnd = false, this.startTime, this.endTime, this.colorFilters = const [], @@ -91,6 +92,7 @@ class VideoRenderData { List imageLayers = const [], ExportTransform? transform, bool enableAudio = true, + bool trimToCommonTrackEnd = false, Duration? startTime, Duration? endTime, double? blur, @@ -112,6 +114,7 @@ class VideoRenderData { imageLayers: imageLayers, transform: transform, enableAudio: enableAudio, + trimToCommonTrackEnd: trimToCommonTrackEnd, startTime: startTime, endTime: endTime, blur: blur, @@ -185,6 +188,21 @@ class VideoRenderData { /// **Default**: `true` final bool enableAudio; + /// 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, because capture and export stop them independently. + /// The export then spans the longer track and ends on missing audio or a + /// frozen frame — the seam a looping player replays every cycle. Enabling + /// this cuts each clip back to the earlier of the two ends. + /// + /// It shortens the export by the mismatch, so leave it off when a clip is + /// meant to outlast its own audio (stop motion held past a short sound). + /// Currently honoured on Apple platforms only. + /// + /// **Default**: `false` + final bool trimToCommonTrackEnd; + /// Optional start time for trimming the entire composition across all /// segments. final Duration? startTime; @@ -393,6 +411,7 @@ class VideoRenderData { 'colorFilters': colorFilterMaps, 'audioTracks': audioTrackMaps, 'enableAudio': enableAudio, + 'trimToCommonTrackEnd': trimToCommonTrackEnd, 'outputFormat': outputFormat.name, 'blur': blur, // Fall back to the quality config's bitrate when no explicit bitrate is @@ -422,6 +441,7 @@ class VideoRenderData { List? imageLayers, ExportTransform? transform, bool? enableAudio, + bool? trimToCommonTrackEnd, Duration? startTime, Duration? endTime, List? colorFilters, @@ -441,6 +461,7 @@ class VideoRenderData { imageLayers: imageLayers ?? this.imageLayers, transform: transform ?? this.transform, enableAudio: enableAudio ?? this.enableAudio, + trimToCommonTrackEnd: trimToCommonTrackEnd ?? this.trimToCommonTrackEnd, startTime: startTime ?? this.startTime, endTime: endTime ?? this.endTime, colorFilters: colorFilters ?? this.colorFilters, @@ -465,6 +486,7 @@ class VideoRenderData { 'imageLayers': imageLayers?.map((x) => x.toMap()).toList(), 'transform': transform?.toMap(), 'enableAudio': enableAudio, + 'trimToCommonTrackEnd': trimToCommonTrackEnd, 'startTime': startTime?.inMicroseconds, 'endTime': endTime?.inMicroseconds, 'colorFilters': colorFilters.map((x) => x.toMap()).toList(), @@ -509,6 +531,7 @@ class VideoRenderData { ? ExportTransform.fromMap(map['transform'] as Map) : null, enableAudio: map['enableAudio'] as bool, + trimToCommonTrackEnd: map['trimToCommonTrackEnd'] as bool? ?? false, startTime: map['startTime'] != null ? Duration(microseconds: safeParseInt(map['startTime'])) : null, @@ -550,6 +573,7 @@ class VideoRenderData { 'imageLayers: $imageLayers, ' 'transform: $transform, ' 'enableAudio: $enableAudio, ' + 'trimToCommonTrackEnd: $trimToCommonTrackEnd, ' 'startTime: $startTime, ' 'endTime: $endTime, ' 'colorFilters: $colorFilters, ' @@ -573,6 +597,7 @@ class VideoRenderData { listEquals(other.imageLayers, imageLayers) && other.transform == transform && other.enableAudio == enableAudio && + other.trimToCommonTrackEnd == trimToCommonTrackEnd && other.startTime == startTime && other.endTime == endTime && listEquals(other.colorFilters, colorFilters) && @@ -594,6 +619,7 @@ class VideoRenderData { imageLayers.hashCode ^ transform.hashCode ^ enableAudio.hashCode ^ + trimToCommonTrackEnd.hashCode ^ startTime.hashCode ^ endTime.hashCode ^ colorFilters.hashCode ^ diff --git a/pubspec.yaml b/pubspec.yaml index 82cd5cc2..d0b18d01 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: pro_video_editor description: "A Flutter video editor: Seamlessly enhance your videos with user-friendly editing features." -version: 2.9.0 +version: 2.10.0 homepage: https://github.com/hm21/pro_video_editor/ repository: https://github.com/hm21/pro_video_editor/ documentation: https://github.com/hm21/pro_video_editor/ diff --git a/test/core/models/video/video_render_data_model_test.dart b/test/core/models/video/video_render_data_model_test.dart index 28c4f389..5f21cee2 100644 --- a/test/core/models/video/video_render_data_model_test.dart +++ b/test/core/models/video/video_render_data_model_test.dart @@ -100,4 +100,51 @@ void main() { expect(map['bitrate'], 8000000); }); }); + + group('VideoRenderData trimToCommonTrackEnd', () { + VideoRenderData buildData({bool? trimToCommonTrackEnd}) { + return VideoRenderData( + id: 'test', + videoSegments: [VideoSegment(video: EditorVideo.file('test.mp4'))], + trimToCommonTrackEnd: trimToCommonTrackEnd ?? false, + ); + } + + test('defaults to off so exports keep their full length', () { + expect(buildData().trimToCommonTrackEnd, isFalse); + }); + + test('toMap serializes the value', () { + expect( + buildData(trimToCommonTrackEnd: true).toMap()['trimToCommonTrackEnd'], + isTrue, + ); + expect(buildData().toMap()['trimToCommonTrackEnd'], isFalse); + }); + + test('toMap / fromMap roundtrip preserves the value', () { + final restored = VideoRenderData.fromMap( + buildData(trimToCommonTrackEnd: true).toMap(), + ); + expect(restored.trimToCommonTrackEnd, isTrue); + }); + + test('fromMap defaults to off for payloads without the key', () { + final map = buildData(trimToCommonTrackEnd: true).toMap() + ..remove('trimToCommonTrackEnd'); + + expect(VideoRenderData.fromMap(map).trimToCommonTrackEnd, isFalse); + }); + + test('copyWith overrides the value', () { + expect( + buildData().copyWith(trimToCommonTrackEnd: true).trimToCommonTrackEnd, + isTrue, + ); + expect( + buildData(trimToCommonTrackEnd: true).copyWith().trimToCommonTrackEnd, + isTrue, + ); + }); + }); } diff --git a/test/pro_video_editor_method_channel_test.mocks.dart b/test/pro_video_editor_method_channel_test.mocks.dart index 5d24ad0d..9a3ce0be 100644 --- a/test/pro_video_editor_method_channel_test.mocks.dart +++ b/test/pro_video_editor_method_channel_test.mocks.dart @@ -344,6 +344,14 @@ class MockVideoRenderData extends _i1.Mock implements _i4.VideoRenderData { (super.noSuchMethod(Invocation.getter(#enableAudio), returnValue: false) as bool); + @override + bool get trimToCommonTrackEnd => + (super.noSuchMethod( + Invocation.getter(#trimToCommonTrackEnd), + returnValue: false, + ) + as bool); + @override List<_i4.ColorFilter> get colorFilters => (super.noSuchMethod( @@ -404,6 +412,7 @@ class MockVideoRenderData extends _i1.Mock implements _i4.VideoRenderData { List<_i4.ImageLayer>? imageLayers, _i4.ExportTransform? transform, bool? enableAudio, + bool? trimToCommonTrackEnd, Duration? startTime, Duration? endTime, List<_i4.ColorFilter>? colorFilters, @@ -424,6 +433,7 @@ class MockVideoRenderData extends _i1.Mock implements _i4.VideoRenderData { #imageLayers: imageLayers, #transform: transform, #enableAudio: enableAudio, + #trimToCommonTrackEnd: trimToCommonTrackEnd, #startTime: startTime, #endTime: endTime, #colorFilters: colorFilters, @@ -445,6 +455,7 @@ class MockVideoRenderData extends _i1.Mock implements _i4.VideoRenderData { #imageLayers: imageLayers, #transform: transform, #enableAudio: enableAudio, + #trimToCommonTrackEnd: trimToCommonTrackEnd, #startTime: startTime, #endTime: endTime, #colorFilters: colorFilters, From 4286c4a10b32fb96386907ba30ae12d75cb830ba Mon Sep 17 00:00:00 2001 From: hm21 Date: Mon, 27 Jul 2026 16:06:46 +0200 Subject: [PATCH 2/4] fix: honour trimToCommonTrackEnd on the passthrough fast path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bitrate-cap passthrough export skips the composition entirely, so `isPassthroughEligible` letting it through made the flag a silent no-op for exactly the single untrimmed clip it targets — and every `qualityConfig` caller sets a bitrate implicitly, so this was the common case rather than a corner. `passthroughExport` now sets `export.timeRange` to the common track range instead of disqualifying the fast path, so the export stays lossless. Measured on a 10.000s video / 9.880s audio source: both tracks end at 9.880s, gap 0.0, video codec unchanged. The trim arithmetic moves to `TrackEndTrimmer` so both paths share one implementation, and records why the two-sided `CMTimeRangeGetIntersection` is safe here: AVFoundation folds a leading empty edit into the track's duration rather than its start, so the clip keeps its head. Also names the layered-path gap in the public doc instead of only the platform caveat, asserts the flag on `toAsyncMap` (the map that actually reaches the native side, where the previous tests only covered `toMap`), and adds an Apple-guarded example entry. --- CHANGELOG.md | 2 +- .../shared/features/render/RenderVideo.swift | 16 +++ .../render/helpers/TrackEndTrimmer.swift | 112 ++++++++++++++++++ .../render/helpers/VideoSequenceBuilder.swift | 55 ++------- .../features/render/video_renderer_page.dart | 18 +++ .../models/video/video_render_data_model.dart | 8 +- .../video/video_render_data_model_test.dart | 14 +++ 7 files changed, 177 insertions(+), 48 deletions(-) create mode 100644 darwin/pro_video_editor/Sources/pro_video_editor/src/shared/features/render/helpers/TrackEndTrimmer.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index eee4e276..0b6ab044 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,5 @@ ## 2.10.0 -- **FEAT**(iOS, macOS): Add `VideoRenderData.trimToCommonTrackEnd` (default `false`). A source asset's audio and video tracks routinely end tens of milliseconds apart, because capture and export stop them independently. The clip range was clamped to the video track only and then reused for the audio insert, so `insertTimeRange` silently truncated and the export ended on a stretch of missing audio — a seam a looping player replays every cycle. Measured across 622 published mp4s: 17% of Apple-produced files were off by >=40ms, worst case 379ms. Enabling this cuts each clip back to the earlier of its two track ends, so both tracks carry content for the whole export. It shortens the export by the mismatch, so leave it off when a clip is meant to outlast its own audio (stop motion held past a short sound); a gap beyond 500ms is treated as content rather than a track-end mismatch and is left alone. +- **FEAT**(iOS, macOS): Add `VideoRenderData.trimToCommonTrackEnd` (default `false`). A source asset's audio and video tracks routinely end tens of milliseconds apart, because capture and export stop them independently. The clip range was clamped to the video track only and then reused for the audio insert, so `insertTimeRange` silently truncated and the export ended on a stretch of missing audio — a seam a looping player replays every cycle. Measured across 622 published mp4s: 17% of Apple-produced files were off by >=40ms, worst case 379ms. Enabling this cuts each clip back to the earlier of its two track ends, so both tracks carry content for the whole export. It shortens the export by the mismatch, so leave it off when a clip is meant to outlast its own audio (stop motion held past a short sound); a gap beyond 500ms is treated as content rather than a track-end mismatch and is left alone. Applies to the lossless bitrate-cap passthrough export as well, which stays lossless; ignored for `composition:` renders, where the layered path builds its own timeline. ## 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. diff --git a/darwin/pro_video_editor/Sources/pro_video_editor/src/shared/features/render/RenderVideo.swift b/darwin/pro_video_editor/Sources/pro_video_editor/src/shared/features/render/RenderVideo.swift index 54e3b103..2b91f47a 100644 --- a/darwin/pro_video_editor/Sources/pro_video_editor/src/shared/features/render/RenderVideo.swift +++ b/darwin/pro_video_editor/Sources/pro_video_editor/src/shared/features/render/RenderVideo.swift @@ -508,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 { diff --git a/darwin/pro_video_editor/Sources/pro_video_editor/src/shared/features/render/helpers/TrackEndTrimmer.swift b/darwin/pro_video_editor/Sources/pro_video_editor/src/shared/features/render/helpers/TrackEndTrimmer.swift new file mode 100644 index 00000000..ef1443d6 --- /dev/null +++ b/darwin/pro_video_editor/Sources/pro_video_editor/src/shared/features/render/helpers/TrackEndTrimmer.swift @@ -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) + } +} diff --git a/darwin/pro_video_editor/Sources/pro_video_editor/src/shared/features/render/helpers/VideoSequenceBuilder.swift b/darwin/pro_video_editor/Sources/pro_video_editor/src/shared/features/render/helpers/VideoSequenceBuilder.swift index e7b7f75f..b47124bc 100644 --- a/darwin/pro_video_editor/Sources/pro_video_editor/src/shared/features/render/helpers/VideoSequenceBuilder.swift +++ b/darwin/pro_video_editor/Sources/pro_video_editor/src/shared/features/render/helpers/VideoSequenceBuilder.swift @@ -11,15 +11,6 @@ internal class VideoSequenceBuilder { private var enableAudio: Bool = true private var trimToCommonTrackEnd: Bool = false - /// 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. - private static let maxTrackEndMismatch = CMTime(value: 500, timescale: 1000) - /// Initializes builder with video clips. /// /// - Parameter videoClips: Array of video clips to process @@ -46,20 +37,6 @@ internal class VideoSequenceBuilder { return self } - /// The track's time range, loaded asynchronously where the OS supports it. - private 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 - } - /// Calculates total duration of all video clips combined. /// /// - Returns: Total duration as CMTime @@ -214,35 +191,21 @@ 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 = await Self.timeRange(of: videoTrack) + let videoTrackTimeRange = await TrackEndTrimmer.timeRange(of: videoTrack) let clampedRange = CMTimeRangeGetIntersection( rawClipTimeRange, otherRange: videoTrackTimeRange) var clipTimeRange = clampedRange.duration > .zero ? clampedRange : rawClipTimeRange - // A source asset's audio and video tracks routinely end tens of - // milliseconds apart, because capture and export stop them - // independently. The video clamp above then leaves a range the audio - // track cannot fill, `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. + // 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 audioTrack = try? await MediaInfoExtractor.loadAudioTrack(from: asset) + let trimmed = await TrackEndTrimmer.trimmedRange( + clipTimeRange, in: asset, label: "Clip \(index)") { - let commonRange = CMTimeRangeGetIntersection( - clipTimeRange, otherRange: await Self.timeRange(of: audioTrack)) - let shortfall = CMTimeSubtract(clipTimeRange.duration, commonRange.duration) - if commonRange.duration > .zero, shortfall <= Self.maxTrackEndMismatch { - clipTimeRange = commonRange - } else if shortfall > Self.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( - " ⚠️ Clip \(index) audio ends \(String(format: "%.2f", shortfall.seconds))s early — too far to treat as a track-end mismatch, not trimming" - ) - } + clipTimeRange = trimmed } let clipDuration = clipTimeRange.duration let insertStart = totalDuration diff --git a/example/lib/features/render/video_renderer_page.dart b/example/lib/features/render/video_renderer_page.dart index ec06734d..e8da8822 100644 --- a/example/lib/features/render/video_renderer_page.dart +++ b/example/lib/features/render/video_renderer_page.dart @@ -758,6 +758,15 @@ class _VideoRendererPageState extends State { await _renderVideo(data); } + Future _trimToCommonTrackEnd() async { + var data = VideoRenderData( + videoSegments: [VideoSegment(video: _video)], + trimToCommonTrackEnd: true, + ); + + await _renderVideo(data); + } + Future _writeAssetAudioToFile(String assetPath) async { final ByteData data = await rootBundle.load(assetPath); final buffer = data.buffer; @@ -2286,6 +2295,15 @@ class _VideoRendererPageState extends State { 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), diff --git a/lib/core/models/video/video_render_data_model.dart b/lib/core/models/video/video_render_data_model.dart index 13846315..4d174d33 100644 --- a/lib/core/models/video/video_render_data_model.dart +++ b/lib/core/models/video/video_render_data_model.dart @@ -198,7 +198,13 @@ class VideoRenderData { /// /// It shortens the export by the mismatch, so leave it off when a clip is /// meant to outlast its own audio (stop motion held past a short sound). - /// Currently honoured on Apple platforms only. + /// A gap beyond 500ms is treated as content rather than a track-end + /// mismatch and is left alone. + /// + /// Honoured on Apple platforms only, and ignored when [composition] is set — + /// the layered path builds its own timeline, where shortening one clip would + /// shift every layer placed after it. Android decides its per-track output + /// lengths in `Media3 Transformer` and has no equivalent clamp. /// /// **Default**: `false` final bool trimToCommonTrackEnd; diff --git a/test/core/models/video/video_render_data_model_test.dart b/test/core/models/video/video_render_data_model_test.dart index 5f21cee2..d13baf48 100644 --- a/test/core/models/video/video_render_data_model_test.dart +++ b/test/core/models/video/video_render_data_model_test.dart @@ -122,6 +122,20 @@ void main() { expect(buildData().toMap()['trimToCommonTrackEnd'], isFalse); }); + test('toAsyncMap sends the value over the platform channel', () async { + // toMap() is the Dart-side JSON form; toAsyncMap() is what actually + // reaches RenderConfig.fromArgs, so the native key is asserted here. + expect( + (await buildData(trimToCommonTrackEnd: true) + .toAsyncMap())['trimToCommonTrackEnd'], + isTrue, + ); + expect( + (await buildData().toAsyncMap())['trimToCommonTrackEnd'], + isFalse, + ); + }); + test('toMap / fromMap roundtrip preserves the value', () { final restored = VideoRenderData.fromMap( buildData(trimToCommonTrackEnd: true).toMap(), From 49747ca6e2f5f7820394d80ee3db4393ab6c703f Mon Sep 17 00:00:00 2001 From: hm21 Date: Mon, 27 Jul 2026 16:09:19 +0200 Subject: [PATCH 3/4] style: satisfy dart format in the render-data test --- .../models/video/video_render_data_model_test.dart | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/test/core/models/video/video_render_data_model_test.dart b/test/core/models/video/video_render_data_model_test.dart index d13baf48..315d5ad8 100644 --- a/test/core/models/video/video_render_data_model_test.dart +++ b/test/core/models/video/video_render_data_model_test.dart @@ -126,14 +126,12 @@ void main() { // toMap() is the Dart-side JSON form; toAsyncMap() is what actually // reaches RenderConfig.fromArgs, so the native key is asserted here. expect( - (await buildData(trimToCommonTrackEnd: true) - .toAsyncMap())['trimToCommonTrackEnd'], + (await buildData( + trimToCommonTrackEnd: true, + ).toAsyncMap())['trimToCommonTrackEnd'], isTrue, ); - expect( - (await buildData().toAsyncMap())['trimToCommonTrackEnd'], - isFalse, - ); + expect((await buildData().toAsyncMap())['trimToCommonTrackEnd'], isFalse); }); test('toMap / fromMap roundtrip preserves the value', () { From 035ad5c1df21b213131ece3d8116c4aa717eae36 Mon Sep 17 00:00:00 2001 From: hm21 Date: Mon, 27 Jul 2026 16:21:32 +0200 Subject: [PATCH 4/4] docs: shorten the 2.10.0 changelog entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6ab044..76e3f7fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,5 @@ ## 2.10.0 -- **FEAT**(iOS, macOS): Add `VideoRenderData.trimToCommonTrackEnd` (default `false`). A source asset's audio and video tracks routinely end tens of milliseconds apart, because capture and export stop them independently. The clip range was clamped to the video track only and then reused for the audio insert, so `insertTimeRange` silently truncated and the export ended on a stretch of missing audio — a seam a looping player replays every cycle. Measured across 622 published mp4s: 17% of Apple-produced files were off by >=40ms, worst case 379ms. Enabling this cuts each clip back to the earlier of its two track ends, so both tracks carry content for the whole export. It shortens the export by the mismatch, so leave it off when a clip is meant to outlast its own audio (stop motion held past a short sound); a gap beyond 500ms is treated as content rather than a track-end mismatch and is left alone. Applies to the lossless bitrate-cap passthrough export as well, which stays lossless; ignored for `composition:` renders, where the layered path builds its own timeline. +- **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.