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.7.0
- **FEAT**(android, iOS, macOS): Add `loopCustomAudio` option to `VideoRenderData`. When `false`, custom audio plays once instead of looping to match the video duration. Defaults to `true` for backward compatibility.

## 1.6.2
- **FIX**(iOS, macOS): Fixed crash when merging multiple MOV video clips on older devices (e.g., iPhone 7, iOS 15). The issue was caused by `AVMutableVideoCompositionInstruction` not properly deriving `requiredSourceTrackIDs` from layer instructions when using a custom video compositor. Introduced `CustomVideoCompositionInstruction` that explicitly provides source track IDs.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class AudioSequenceBuilder(
) {
private var volume: Float = 1.0f
private var needsNormalization: Boolean = false
private var loopAudio: Boolean = true

/**
* Sets the volume multiplier for the custom audio.
Expand All @@ -48,6 +49,16 @@ class AudioSequenceBuilder(
return this
}

/**
* Sets whether the audio should loop to match video duration.
*
* @param loop If true, audio repeats; if false, plays once
*/
fun setLoop(loop: Boolean): AudioSequenceBuilder {
this.loopAudio = loop
return this
}

/**
* Builds the audio sequence with looping to match video duration.
*
Expand All @@ -73,8 +84,12 @@ class AudioSequenceBuilder(
val audioProcessors = buildAudioProcessors()
val audioEffects = Effects(audioProcessors, emptyList())

// Create audio items with looping
val audioItems = createLoopedAudioItems(audioFile, audioDurationUs, audioEffects)
// Create audio items with looping or single play
val audioItems = if (loopAudio) {
createLoopedAudioItems(audioFile, audioDurationUs, audioEffects)
} else {
createSingleAudioItem(audioFile, audioDurationUs, audioEffects)
}

return EditedMediaItemSequence.Builder(audioItems).build()
}
Expand Down Expand Up @@ -191,6 +206,24 @@ class AudioSequenceBuilder(
return audioItems
}

/**
* Creates a single audio item (no looping). Trims if audio is longer than video.
*/
private fun createSingleAudioItem(
audioFile: File,
audioDurationUs: Long,
effects: Effects
): List<EditedMediaItem> {
val trimDurationUs = if (audioDurationUs > videoDurationUs && videoDurationUs > 0) {
Log.d(RENDER_TAG, "Trimming audio to ${videoDurationUs / 1000} ms (no loop)")
videoDurationUs
} else {
Log.d(RENDER_TAG, "Playing audio once (${audioDurationUs / 1000} ms, no loop)")
null
}
return listOf(createAudioItem(audioFile, trimDurationUs, effects))
}

/**
* Creates a single audio EditedMediaItem with optional trimming.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ class CompositionBuilder(
val audioSequence = AudioSequenceBuilder(config.customAudioPath!!, totalVideoDuration)
.setVolume(config.customAudioVolume ?: 1.0f)
.setNormalization(needsNormalization)
.setLoop(config.loopCustomAudio)
.build()

if (audioSequence != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,11 @@ data class RenderConfig(
/** Whether to apply cropping to the image overlay along with the video.
* When true, the image overlay is applied before cropping (cropped together with video).
* When false (default), the overlay is scaled to the final cropped size. */
val imageBytesWithCropping: Boolean = false
val imageBytesWithCropping: Boolean = false,
/** Whether to loop the custom audio if it is shorter than the video.
* When true (default), audio is repeated to match video duration.
* When false, audio plays once and silence fills the rest. */
val loopCustomAudio: Boolean = true
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
Expand Down Expand Up @@ -129,7 +133,8 @@ data class RenderConfig(
startUs = call.argument<Number?>("startUs")?.toLong(),
endUs = call.argument<Number?>("endUs")?.toLong(),
shouldOptimizeForNetworkUse = call.argument<Boolean>("shouldOptimizeForNetworkUse") ?: true,
imageBytesWithCropping = call.argument<Boolean>("imageBytesWithCropping") ?: false
imageBytesWithCropping = call.argument<Boolean>("imageBytesWithCropping") ?: false,
loopCustomAudio = call.argument<Boolean>("loopCustomAudio") ?: true
)
}
}
Expand Down
26 changes: 26 additions & 0 deletions example/lib/features/render/video_renderer_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,26 @@ class _VideoRendererPageState extends State<VideoRendererPage> {
await _renderVideo(data);
}

/// Play custom audio once without looping.
///
/// By default, custom audio loops to match the video duration.
/// Setting `loopCustomAudio: false` plays the audio only once,
/// with silence for the remaining video duration.
Future<void> _customAudioNoLoop() async {
final customAudioFile =
await _writeAssetAudioToFile(kVideoEditorExampleAudio1Path);

var data = VideoRenderData(
video: _video,
customAudioPath: customAudioFile.path,
originalAudioVolume: 0.0,
customAudioVolume: 1.0,
loopCustomAudio: false,
);

await _renderVideo(data);
}

Future<void> _layers() async {
final imageBytes = await _captureLayerContent();
var data = VideoRenderData(
Expand Down Expand Up @@ -716,6 +736,12 @@ class _VideoRendererPageState extends State<VideoRendererPage> {
title: const Text('Adjust Original Volume'),
subtitle: const Text('Reduce to 20%'),
),
ListTile(
onTap: _customAudioNoLoop,
leading: const Icon(Icons.music_off_outlined),
title: const Text('Custom Audio Without Loop'),
subtitle: const Text('Plays once, then silence'),
),
..._buildSectionTitle('Quality'),
ListTile(
onTap: _qualityPreset1080p,
Expand Down
3 changes: 2 additions & 1 deletion ios/Classes/src/features/render/RenderVideo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ class RenderVideo {
enableAudio: config.enableAudio,
customAudioPath: config.customAudioPath,
originalAudioVolume: config.originalAudioVolume,
customAudioVolume: config.customAudioVolume
customAudioVolume: config.customAudioVolume,
loopCustomAudio: config.loopCustomAudio
)

// Set source track ID for fallback on older iOS versions (e.g., iPhone 7)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,14 @@ func applyComposition(
enableAudio: Bool,
customAudioPath: String?,
originalAudioVolume: Float?,
customAudioVolume: Float?
customAudioVolume: Float?,
loopCustomAudio: Bool
) async throws -> (AVMutableComposition, AVMutableVideoComposition, CGSize, AVAudioMix?, CMPersistentTrackID) {
return try await CompositionBuilder(videoClips: videoClips, videoEffects: videoEffects)
.setEnableAudio(enableAudio)
.setCustomAudioPath(customAudioPath)
.setOriginalAudioVolume(originalAudioVolume)
.setCustomAudioVolume(customAudioVolume)
.setLoopCustomAudio(loopCustomAudio)
.build()
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ internal class AudioSequenceBuilder {
private let audioPath: String
private let targetDuration: CMTime
private var volume: Float = 1.0
private var loopAudio: Bool = true

/// Initializes builder with audio path and target duration.
///
Expand All @@ -30,6 +31,15 @@ internal class AudioSequenceBuilder {
return self
}

/// Sets whether the audio should loop to match video duration.
///
/// - Parameter loop: If true, audio repeats; if false, plays once
/// - Returns: Self for chaining
func setLoop(_ loop: Bool) -> AudioSequenceBuilder {
self.loopAudio = loop
return self
}

/// Builds custom audio track and adds it to composition.
///
/// Trims or loops the audio to match target duration and applies volume.
Expand Down Expand Up @@ -68,7 +78,7 @@ internal class AudioSequenceBuilder {
let timeRange = CMTimeRange(start: .zero, duration: targetDuration)
try compositionAudioTrack.insertTimeRange(timeRange, of: audioTrack, at: .zero)
print("✂️ Custom audio trimmed to \(targetDuration.seconds)s")
} else {
} else if loopAudio {
// Loop audio to match video duration
var currentTime = CMTime.zero
var loopCount = 0
Expand All @@ -84,6 +94,11 @@ internal class AudioSequenceBuilder {
}

print("🔄 Custom audio looped \(loopCount) times to match \(targetDuration.seconds)s duration")
} else {
// Play audio once without looping
let timeRange = CMTimeRange(start: .zero, duration: audioDuration)
try compositionAudioTrack.insertTimeRange(timeRange, of: audioTrack, at: .zero)
print("▶️ Custom audio plays once (\(audioDuration.seconds)s, no loop)")
}

if volume != 1.0 {
Expand Down
11 changes: 11 additions & 0 deletions ios/Classes/src/features/render/helpers/CompositionBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ internal class CompositionBuilder {
private var customAudioPath: String?
private var originalAudioVolume: Float = 1.0
private var customAudioVolume: Float = 1.0
private var loopCustomAudio: Bool = true

/// Initializes builder with configuration.
///
Expand Down Expand Up @@ -61,6 +62,15 @@ internal class CompositionBuilder {
return self
}

/// Sets whether custom audio should loop.
///
/// - Parameter loop: If true, audio repeats to match video duration
/// - Returns: Self for chaining
func setLoopCustomAudio(_ loop: Bool) -> CompositionBuilder {
self.loopCustomAudio = loop
return self
}

/// Builds the complete composition.
///
/// - Returns: Tuple containing composition, video composition, render size, audio mix, and source track ID
Expand Down Expand Up @@ -100,6 +110,7 @@ internal class CompositionBuilder {
audioPath: customPath,
targetDuration: videoResult.totalDuration
).setVolume(customAudioVolume)
.setLoop(loopCustomAudio)

customAudioTrack = try await audioBuilder.build(in: composition)
}
Expand Down
8 changes: 7 additions & 1 deletion ios/Classes/src/features/render/models/RenderConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ struct RenderConfig {
/// When true, the image overlay is cropped together with the video.
/// When false (default), the overlay is scaled to the final cropped size.
let imageBytesWithCropping: Bool

/// Whether to loop the custom audio if it is shorter than the video.
/// When true (default), audio is repeated to match video duration.
/// When false, audio plays once and silence fills the rest.
let loopCustomAudio: Bool
static func fromArguments(_ arguments: [String: Any]?) -> RenderConfig? {
guard let args = arguments else {
return nil
Expand Down Expand Up @@ -154,7 +159,8 @@ struct RenderConfig {
startUs: (args["startUs"] as? NSNumber)?.int64Value,
endUs: (args["endUs"] as? NSNumber)?.int64Value,
shouldOptimizeForNetworkUse: args["shouldOptimizeForNetworkUse"] as? Bool ?? true,
imageBytesWithCropping: args["imageBytesWithCropping"] as? Bool ?? false
imageBytesWithCropping: args["imageBytesWithCropping"] as? Bool ?? false,
loopCustomAudio: args["loopCustomAudio"] as? Bool ?? true
)
}
}
17 changes: 17 additions & 0 deletions lib/core/models/video/video_render_data_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class VideoRenderData {
this.customAudioVolume,
this.shouldOptimizeForNetworkUse = false,
this.imageBytesWithCropping = false,
this.loopCustomAudio = true,
String? id,
}) : id = id ?? DateTime.now().microsecondsSinceEpoch.toString(),
assert(
Expand Down Expand Up @@ -104,6 +105,7 @@ class VideoRenderData {
double? customAudioVolume,
bool shouldOptimizeForNetworkUse = false,
bool imageBytesWithCropping = false,
bool loopCustomAudio = true,
String? id,
}) {
final qualityConfig = VideoQualityConfig.fromPreset(qualityPreset);
Expand All @@ -127,6 +129,7 @@ class VideoRenderData {
customAudioVolume: customAudioVolume,
shouldOptimizeForNetworkUse: shouldOptimizeForNetworkUse,
imageBytesWithCropping: imageBytesWithCropping,
loopCustomAudio: loopCustomAudio,
);
}

Expand Down Expand Up @@ -298,6 +301,17 @@ class VideoRenderData {
/// - `true`: Overlay is cropped together with the video
final bool imageBytesWithCropping;

/// Whether to loop the custom audio track if it is shorter than the video.
///
/// When `true` (default), the custom audio will be repeated until it
/// matches the video duration. When `false`, the audio plays once and
/// silence fills the remaining duration.
///
/// This parameter is only effective when [customAudioPath] is provided.
///
/// **Default**: `true`
final bool loopCustomAudio;

/// Returns a [Stream] of [ProgressModel] objects that provides updates on
/// the progress of the video rendering process associated with this model's
/// [id].
Expand Down Expand Up @@ -367,6 +381,7 @@ class VideoRenderData {
'endUs': videoSegments != null ? endTime?.inMicroseconds : null,
'shouldOptimizeForNetworkUse': shouldOptimizeForNetworkUse,
'imageBytesWithCropping': imageBytesWithCropping,
'loopCustomAudio': loopCustomAudio,
};
}

Expand All @@ -391,6 +406,7 @@ class VideoRenderData {
double? customAudioVolume,
bool? shouldOptimizeForNetworkUse,
bool? imageBytesWithCropping,
bool? loopCustomAudio,
}) {
return VideoRenderData(
id: id ?? this.id,
Expand All @@ -414,6 +430,7 @@ class VideoRenderData {
shouldOptimizeForNetworkUse ?? this.shouldOptimizeForNetworkUse,
imageBytesWithCropping:
imageBytesWithCropping ?? this.imageBytesWithCropping,
loopCustomAudio: loopCustomAudio ?? this.loopCustomAudio,
);
}
}
Expand Down
3 changes: 2 additions & 1 deletion macos/Classes/src/features/render/RenderVideo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,8 @@ class RenderVideo {
enableAudio: config.enableAudio,
customAudioPath: config.customAudioPath,
originalAudioVolume: config.originalAudioVolume,
customAudioVolume: config.customAudioVolume
customAudioVolume: config.customAudioVolume,
loopCustomAudio: config.loopCustomAudio
)

// Set source track ID for fallback on older macOS versions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,14 @@ func applyComposition(
enableAudio: Bool,
customAudioPath: String?,
originalAudioVolume: Float?,
customAudioVolume: Float?
customAudioVolume: Float?,
loopCustomAudio: Bool
) async throws -> (AVMutableComposition, AVMutableVideoComposition, CGSize, AVAudioMix?, CMPersistentTrackID) {
return try await CompositionBuilder(videoClips: videoClips, videoEffects: videoEffects)
.setEnableAudio(enableAudio)
.setCustomAudioPath(customAudioPath)
.setOriginalAudioVolume(originalAudioVolume)
.setCustomAudioVolume(customAudioVolume)
.setLoopCustomAudio(loopCustomAudio)
.build()
}
Loading