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 @@
## 1.16.1
- **FIX**(iOS, macOS): Fix crash `No source tracks available for compositing` when rendering H.264 MP4 files whose container duration slightly exceeds the video track's actual frame duration. The compositor time range is now clamped to the video track's real `timeRange` before inserting into the composition, preventing AVFoundation from requesting frames that have no pixel buffer. A black-frame fallback was also added as a safety net.

## 1.16.0
- **FEAT**(android, iOS, macOS): Add per-clip `playbackSpeed` to `VideoSegment`, allowing each segment in a multi-clip composition to have its own playback speed (e.g. `2.0` for fast-forward, `0.5` for slow-motion).
- **DEPRECATED**: `VideoRenderData.playbackSpeed` is deprecated. Use `VideoSegment.playbackSpeed` instead.
Expand Down
Binary file added example/assets/tests/divine_transcribed.mp4
Binary file not shown.
78 changes: 78 additions & 0 deletions example/integration_test/video_render_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1800,4 +1800,82 @@ void main() {
);
}, skip: true);
});

// ===========================================================================
// Regression Tests
// ===========================================================================
group('Regression tests', () {
// Issue #131 – reported by @rabble
// Certain H.264 MP4 files have a container duration that is slightly longer
// than the video track's actual decoded frames. This caused AVFoundation to
// call the custom compositor for a time slot where no pixel buffer was
// available (sourceTrackIDs empty), resulting in the crash:
// PlatformException(RENDER_ERROR,
// No source tracks available for compositing
// (sourceTrackIDs: 0, configTrackID: 1))
// Fix: clamp ClipInstruction.timeRange to the video track's actual
// timeRange in VideoSequenceBuilder before inserting into the composition.
group('#131 – H.264 MP4 with container duration > track duration', () {
final divineVideo = EditorVideo.asset(kVideoEditorExampleDivinePath);

testWidgets('plain export succeeds (no effects)', (_) async {
final result = await ProVideoEditor.instance.renderVideo(
VideoRenderData(
videoSegments: [VideoSegment(video: divineVideo)],
outputFormat: VideoOutputFormat.mp4,
shouldOptimizeForNetworkUse: true,
),
);
expect(result.lengthInBytes, greaterThan(10000));
}, skip: !isIOS && !isMacOS);

testWidgets(
'export with image layer (original crash scenario)',
(_) async {
final watermark = await createTestOverlayImage(
width: 200,
height: 100,
);
final result = await ProVideoEditor.instance.renderVideo(
VideoRenderData(
videoSegments: [VideoSegment(video: divineVideo)],
outputFormat: VideoOutputFormat.mp4,
shouldOptimizeForNetworkUse: true,
imageLayers: [
ImageLayer(image: EditorLayerImage.memory(watermark)),
],
),
);
expect(result.lengthInBytes, greaterThan(10000));
},
skip: !isIOS && !isMacOS,
);

testWidgets('export with color filter + image layer', (_) async {
final watermark = await createTestOverlayImage(width: 200, height: 100);
final result = await ProVideoEditor.instance.renderVideo(
VideoRenderData(
videoSegments: [VideoSegment(video: divineVideo)],
outputFormat: VideoOutputFormat.mp4,
colorFilters: kBasicFilterMatrix,
imageLayers: [
ImageLayer(image: EditorLayerImage.memory(watermark)),
],
),
);
expect(result.lengthInBytes, greaterThan(10000));
}, skip: !isIOS && !isMacOS);

testWidgets('export with blur', (_) async {
final result = await ProVideoEditor.instance.renderVideo(
VideoRenderData(
videoSegments: [VideoSegment(video: divineVideo)],
outputFormat: VideoOutputFormat.mp4,
blur: 3,
),
);
expect(result.lengthInBytes, greaterThan(10000));
}, skip: !isIOS && !isMacOS);
});
});
}
6 changes: 6 additions & 0 deletions example/lib/core/constants/example_constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ const String kVideoEditorExampleH264Path = 'assets/demo.mp4';
/// examples.
const String kVideoEditorExampleAssetWorldPath = 'assets/demo_world.mp4';

/// A local path to a H.264 MP4 where the container duration slightly exceeds
/// the video track's actual frame duration. Used to reproduce issue #131
/// (compositor crash: "No source tracks available") reported by @rabble.
const String kVideoEditorExampleDivinePath =
'assets/tests/divine_transcribed.mp4';

/// A local path to the first example audio track used in video editor demos.
const String kVideoEditorExampleAudio1Path = 'assets/audio1.mp3';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,22 @@ internal class VideoSequenceBuilder {
}

// Calculate time range for this clip
let clipTimeRange = await calculateTimeRange(for: clip, from: asset)
let rawClipTimeRange = await calculateTimeRange(for: clip, from: asset)

// Clamp clip range to the video track's actual available range.
// Some MP4 files have a container duration slightly longer than the video
// track's decoded frames. Without clamping, insertTimeRange silently truncates
// 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 #available(iOS 15.0, *) {
videoTrackTimeRange = (try? await videoTrack.load(.timeRange)) ?? videoTrack.timeRange
} else {
videoTrackTimeRange = videoTrack.timeRange
}
let clampedRange = CMTimeRangeGetIntersection(rawClipTimeRange, otherRange: videoTrackTimeRange)
let clipTimeRange = clampedRange.duration > .zero ? clampedRange : rawClipTimeRange
let clipDuration = clipTimeRange.duration
let insertStart = totalDuration

Expand Down
33 changes: 25 additions & 8 deletions ios/Classes/src/features/render/utils/VideoCompositor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,11 @@ class VideoCompositor: NSObject, AVVideoCompositing {
kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32BGRA)
]

func renderContextChanged(_ newRenderContext: AVVideoCompositionRenderContext) {}
private var renderContext: AVVideoCompositionRenderContext?

func renderContextChanged(_ newRenderContext: AVVideoCompositionRenderContext) {
renderContext = newRenderContext
}

func startRequest(_ request: AVAsynchronousVideoCompositionRequest) {
// Try to get source buffer from the first available track
Expand Down Expand Up @@ -205,13 +209,26 @@ class VideoCompositor: NSObject, AVVideoCompositing {
}

guard let sourceBuffer = sourceBuffer else {
request.finish(
with: NSError(
domain: "VideoCompositor", code: 0,
userInfo: [
NSLocalizedDescriptionKey:
"No source tracks available for compositing (sourceTrackIDs: \(request.sourceTrackIDs.count), configTrackID: \(sourceTrackID))"
]))
// Last-resort fallback: output a black frame rather than aborting the entire render.
// This can happen for certain MP4 files where the container duration slightly exceeds
// the video track's actual decoded frames, causing AVFoundation to call the compositor
// for a time slot where no pixel buffer is available.
if let ctx = renderContext, let blackBuffer = ctx.newPixelBuffer() {
CVPixelBufferLockBaseAddress(blackBuffer, [])
if let addr = CVPixelBufferGetBaseAddress(blackBuffer) {
memset(addr, 0, CVPixelBufferGetDataSize(blackBuffer))
}
CVPixelBufferUnlockBaseAddress(blackBuffer, [])
request.finish(withComposedVideoFrame: blackBuffer)
} else {
request.finish(
with: NSError(
domain: "VideoCompositor", code: 0,
userInfo: [
NSLocalizedDescriptionKey:
"No source tracks available for compositing (sourceTrackIDs: \(request.sourceTrackIDs.count), configTrackID: \(sourceTrackID))"
]))
}
return
}
var outputImage = CIImage(cvPixelBuffer: sourceBuffer)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,17 @@ internal class VideoSequenceBuilder {
}

// Calculate time range for this clip
let clipTimeRange = await calculateTimeRange(for: clip, from: asset)
let rawClipTimeRange = await calculateTimeRange(for: clip, from: asset)

// Clamp clip range to the video track's actual available range.
// Some MP4 files have a container duration slightly longer than the video
// track's decoded frames. Without clamping, insertTimeRange silently truncates
// 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 = videoTrack.timeRange
let clampedRange = CMTimeRangeGetIntersection(rawClipTimeRange, otherRange: videoTrackTimeRange)
let clipTimeRange = clampedRange.duration > .zero ? clampedRange : rawClipTimeRange
let clipDuration = clipTimeRange.duration
let insertStart = totalDuration

Expand Down
33 changes: 25 additions & 8 deletions macos/Classes/src/features/render/utils/VideoCompositor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,11 @@ class VideoCompositor: NSObject, AVVideoCompositing {
kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32BGRA)
]

func renderContextChanged(_ newRenderContext: AVVideoCompositionRenderContext) {}
private var renderContext: AVVideoCompositionRenderContext?

func renderContextChanged(_ newRenderContext: AVVideoCompositionRenderContext) {
renderContext = newRenderContext
}

func startRequest(_ request: AVAsynchronousVideoCompositionRequest) {
// Try to get source buffer from the first available track
Expand Down Expand Up @@ -203,13 +207,26 @@ class VideoCompositor: NSObject, AVVideoCompositing {
}

guard let sourceBuffer = sourceBuffer else {
request.finish(
with: NSError(
domain: "VideoCompositor", code: 0,
userInfo: [
NSLocalizedDescriptionKey:
"No source tracks available for compositing (sourceTrackIDs: \(request.sourceTrackIDs.count), configTrackID: \(sourceTrackID))"
]))
// Last-resort fallback: output a black frame rather than aborting the entire render.
// This can happen for certain MP4 files where the container duration slightly exceeds
// the video track's actual decoded frames, causing AVFoundation to call the compositor
// for a time slot where no pixel buffer is available.
if let ctx = renderContext, let blackBuffer = ctx.newPixelBuffer() {
CVPixelBufferLockBaseAddress(blackBuffer, [])
if let addr = CVPixelBufferGetBaseAddress(blackBuffer) {
memset(addr, 0, CVPixelBufferGetDataSize(blackBuffer))
}
CVPixelBufferUnlockBaseAddress(blackBuffer, [])
request.finish(withComposedVideoFrame: blackBuffer)
} else {
request.finish(
with: NSError(
domain: "VideoCompositor", code: 0,
userInfo: [
NSLocalizedDescriptionKey:
"No source tracks available for compositing (sourceTrackIDs: \(request.sourceTrackIDs.count), configTrackID: \(sourceTrackID))"
]))
}
return
}
var outputImage = CIImage(cvPixelBuffer: sourceBuffer)
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: pro_video_editor
description: "A Flutter video editor: Seamlessly enhance your videos with user-friendly editing features."
version: 1.16.0
version: 1.16.1
homepage: https://github.com/hm21/pro_video_editor/
repository: https://github.com/hm21/pro_video_editor/
documentation: https://github.com/hm21/pro_video_editor/
Expand Down
Loading