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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 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.

## 1.15.2
- **FIX**(example): Replace deprecated `CompleteParameters.customAudioTrack` usage with `CompleteParameters.audioTracks` in the basic video editor example.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,8 @@ class RenderVideo(private val context: Context) {
val updatedClips = config.videoClips.map { clip ->
val newPath = transcodeMap[clip.inputPath] ?: clip.inputPath
if (newPath != clip.inputPath) {
// If transcoded, use the new path but keep trim times and volume
VideoClip(newPath, clip.startUs, clip.endUs, clip.volume)
// If transcoded, use the new path but keep trim times, volume and speed
VideoClip(newPath, clip.startUs, clip.endUs, clip.volume, clip.playbackSpeed)
} else {
clip
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import androidx.media3.common.MediaItem
import androidx.media3.common.audio.AudioProcessor
import androidx.media3.common.audio.ChannelMixingAudioProcessor
import androidx.media3.common.audio.ChannelMixingMatrix
import androidx.media3.common.audio.SonicAudioProcessor
import androidx.media3.common.util.UnstableApi
import androidx.media3.effect.SpeedChangeEffect
import androidx.media3.transformer.EditedMediaItem
import androidx.media3.transformer.EditedMediaItemSequence
import androidx.media3.transformer.Effects
Expand Down Expand Up @@ -489,18 +491,30 @@ class VideoSequenceBuilder(
// - With custom audio: AudioProcessors don't work with parallel sequences,
// so per-clip volume is best-effort (applied via VolumeControlAudioMixer globally)
val clipVolume = clip.volume
val finalAudioEffects = if (!hasCustomAudio && clipVolume != null && clipVolume != 1.0f) {
Log.d(
RENDER_TAG,
"Clip $index volume: ${clipVolume}x (applied via VolumeAudioProcessor)"
)
val volumeProcessor = VolumeAudioProcessor(clipVolume)
mutableListOf<AudioProcessor>().apply {
addAll(normalizedAudioEffects)
add(volumeProcessor)
val perClipAudioProcessors = mutableListOf<AudioProcessor>().apply {
addAll(normalizedAudioEffects)
if (!hasCustomAudio && clipVolume != null && clipVolume != 1.0f) {
Log.d(
RENDER_TAG,
"Clip $index volume: ${clipVolume}x (applied via VolumeAudioProcessor)"
)
add(VolumeAudioProcessor(clipVolume))
}
}

// Per-clip playback speed:
// - Video: SpeedChangeEffect on the EditedMediaItem
// - Audio: SonicAudioProcessor (only effective without custom audio /
// parallel sequences; otherwise best-effort)
val clipSpeed = clip.playbackSpeed
val finalAudioEffects: List<AudioProcessor> = if (clipSpeed != null && clipSpeed > 0f && clipSpeed != 1.0f) {
Log.d(RENDER_TAG, "Clip $index playback speed: ${clipSpeed}x")
clipVideoEffects += SpeedChangeEffect(clipSpeed)
perClipAudioProcessors.apply {
add(SonicAudioProcessor().apply { setSpeed(clipSpeed) })
}
} else {
normalizedAudioEffects
perClipAudioProcessors
}

val effects = Effects(finalAudioEffects, clipVideoEffects)
Expand Down Expand Up @@ -599,7 +613,8 @@ class VideoSequenceBuilder(
inputPath = clip.inputPath,
startUs = newStartInSource,
endUs = newEndInSource,
volume = clip.volume
volume = clip.volume,
playbackSpeed = clip.playbackSpeed
)
)
val trimmedDuration = newEndInSource - newStartInSource
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ import io.flutter.plugin.common.MethodCall
* @property startUs Start time in microseconds (null = from beginning)
* @property endUs End time in microseconds (null = until end)
* @property volume Volume multiplier for this clip (null = unchanged, 0.0=mute, 1.0=original)
* @property playbackSpeed Speed multiplier for this clip (null = unchanged, 0.5=half, 2.0=double)
*/
data class VideoClip(
val inputPath: String,
val startUs: Long?,
val endUs: Long?,
val volume: Float? = null
val volume: Float? = null,
val playbackSpeed: Float? = null
)

/**
Expand Down Expand Up @@ -231,11 +233,12 @@ data class RenderConfig(
inputPath = clipMap["inputPath"] as String,
startUs = (clipMap["startUs"] as? Number)?.toLong(),
endUs = (clipMap["endUs"] as? Number)?.toLong(),
volume = (clipMap["volume"] as? Number)?.toFloat()
volume = (clipMap["volume"] as? Number)?.toFloat(),
playbackSpeed = (clipMap["playbackSpeed"] as? Number)?.toFloat()
)
Log.d(
PACKAGE_TAG,
"Clip $index: path=${clip.inputPath}, start=${clip.startUs}, end=${clip.endUs}, volume=${clip.volume}"
"Clip $index: path=${clip.inputPath}, start=${clip.startUs}, end=${clip.endUs}, volume=${clip.volume}, speed=${clip.playbackSpeed}"
)
clip
}
Expand Down
163 changes: 163 additions & 0 deletions example/integration_test/video_render_test.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// ignore_for_file: deprecated_member_use

import 'dart:io';
import 'dart:ui' as ui;

Expand Down Expand Up @@ -230,6 +232,59 @@ void main() {
await testSpeed(0.8); // Slow down
});

testWidgets('per-clip speed: 2x on single segment', (tester) async {
final originalMeta = await ProVideoEditor.instance.getMetadata(inputVideo);
const speedFactor = 2.0;

final meta = await testRender(
description: 'Per-clip speed x$speedFactor',
renderModel: VideoRenderData(
outputFormat: VideoOutputFormat.mp4,
videoSegments: [
VideoSegment(video: inputVideo, playbackSpeed: speedFactor),
],
),
);

expect(
meta.duration.inSeconds,
closeTo(originalMeta.duration.inSeconds / speedFactor, 1),
reason: 'Duration should be halved with per-clip speed x$speedFactor',
);
});

testWidgets('per-clip speed: different speeds per segment', (tester) async {
// Use two non-overlapping trim windows so we can predict output duration.
// segment A: 3 s at 2× → contributes ~1.5 s
// segment B: 3 s at 0.5× → contributes ~6 s → total ~7.5 s
final meta = await testRender(
description: 'Per-clip mixed speeds (2× + 0.5×)',
renderModel: VideoRenderData(
outputFormat: VideoOutputFormat.mp4,
videoSegments: [
VideoSegment(
video: inputVideo,
startTime: const Duration(seconds: 0),
endTime: const Duration(seconds: 3),
playbackSpeed: 2.0,
),
VideoSegment(
video: inputVideo,
startTime: const Duration(seconds: 3),
endTime: const Duration(seconds: 6),
playbackSpeed: 0.5,
),
],
),
);

expect(
meta.duration.inSeconds,
closeTo(7, 2),
reason: 'Total duration should be ~7.5 s (1.5 s + 6 s)',
);
});

testWidgets('remove audio', (tester) async {
await testRender(
description: 'Audio removed',
Expand Down Expand Up @@ -698,6 +753,60 @@ void main() {
expect(result.lengthInBytes, greaterThan(50000));
});

testWidgets('per-clip speed 2x', (_) async {
final result = await ProVideoEditor.instance.renderVideo(
VideoRenderData(
outputFormat: VideoOutputFormat.mp4,
videoSegments: [
VideoSegment(
video: hevcVideo,
startTime: Duration.zero,
endTime: const Duration(seconds: 2),
playbackSpeed: 2.0,
),
],
),
);
expect(result, isNotNull, reason: 'HEVC per-clip speed 2x failed');
expect(result.lengthInBytes, greaterThan(10000));

final meta = await ProVideoEditor.instance.getMetadata(
EditorVideo.memory(result),
);
expect(
meta.duration.inSeconds,
closeTo(1, 1),
reason: 'HEVC per-clip speed 2x: 2s input → ~1s output',
);
});

testWidgets('per-clip speed 0.5x', (_) async {
final result = await ProVideoEditor.instance.renderVideo(
VideoRenderData(
outputFormat: VideoOutputFormat.mp4,
videoSegments: [
VideoSegment(
video: hevcVideo,
startTime: Duration.zero,
endTime: const Duration(seconds: 1),
playbackSpeed: 0.5,
),
],
),
);
expect(result, isNotNull, reason: 'HEVC per-clip speed 0.5x failed');
expect(result.lengthInBytes, greaterThan(10000));

final meta = await ProVideoEditor.instance.getMetadata(
EditorVideo.memory(result),
);
expect(
meta.duration.inSeconds,
closeTo(2, 1),
reason: 'HEVC per-clip speed 0.5x: 1s input → ~2s output',
);
});

// Note: hevc.mp4 is only ~2.5s, so use 0-1s and 1-2s segments
testWidgets('merge two HEVC videos', (_) async {
final result = await ProVideoEditor.instance.renderVideo(
Expand Down Expand Up @@ -955,6 +1064,60 @@ void main() {
expect(result.lengthInBytes, greaterThan(50000));
});

testWidgets('per-clip speed 2x', (_) async {
final result = await ProVideoEditor.instance.renderVideo(
VideoRenderData(
outputFormat: VideoOutputFormat.mp4,
videoSegments: [
VideoSegment(
video: h264Video,
startTime: const Duration(seconds: 1),
endTime: const Duration(seconds: 5),
playbackSpeed: 2.0,
),
],
),
);
expect(result, isNotNull, reason: 'H.264 per-clip speed 2x failed');
expect(result.lengthInBytes, greaterThan(50000));

final meta = await ProVideoEditor.instance.getMetadata(
EditorVideo.memory(result),
);
expect(
meta.duration.inSeconds,
closeTo(2, 1),
reason: 'H.264 per-clip speed 2x: 4s input → ~2s output',
);
});

testWidgets('per-clip speed 0.5x', (_) async {
final result = await ProVideoEditor.instance.renderVideo(
VideoRenderData(
outputFormat: VideoOutputFormat.mp4,
videoSegments: [
VideoSegment(
video: h264Video,
startTime: const Duration(seconds: 1),
endTime: const Duration(seconds: 3),
playbackSpeed: 0.5,
),
],
),
);
expect(result, isNotNull, reason: 'H.264 per-clip speed 0.5x failed');
expect(result.lengthInBytes, greaterThan(50000));

final meta = await ProVideoEditor.instance.getMetadata(
EditorVideo.memory(result),
);
expect(
meta.duration.inSeconds,
closeTo(4, 1),
reason: 'H.264 per-clip speed 0.5x: 2s input → ~4s output',
);
});

testWidgets('merge two H.264 videos', (_) async {
final result = await ProVideoEditor.instance.renderVideo(
VideoRenderData(
Expand Down
36 changes: 36 additions & 0 deletions example/lib/features/render/video_renderer_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -127,12 +127,40 @@ class _VideoRendererPageState extends State<VideoRendererPage> {
Future<void> _changeSpeed() async {
var data = VideoRenderData(
videoSegments: [VideoSegment(video: _video)],
// ignore: deprecated_member_use
playbackSpeed: .5,
);

await _renderVideo(data);
}

/// Different playback speeds per video segment.
///
/// This example demonstrates per-clip speed control when concatenating
/// multiple video clips:
/// - Clip 1: 2× speed (fast-forward)
/// - Clip 2: 0.5× speed (slow-motion)
Future<void> _perClipSpeed() async {
var data = VideoRenderData(
videoSegments: [
VideoSegment(
video: _video,
startTime: const Duration(seconds: 0),
endTime: const Duration(seconds: 5),
playbackSpeed: 2.0, // Fast-forward
),
VideoSegment(
video: _video,
startTime: const Duration(seconds: 5),
endTime: const Duration(seconds: 10),
playbackSpeed: 0.5, // Slow-motion
),
],
);

await _renderVideo(data);
}

Future<void> _removeAudio() async {
var data = VideoRenderData(
videoSegments: [VideoSegment(video: _video)],
Expand Down Expand Up @@ -1179,6 +1207,14 @@ class _VideoRendererPageState extends State<VideoRendererPage> {
leading: const Icon(Icons.speed_outlined),
title: const Text('Change playback speed'),
),
ListTile(
onTap: _perClipSpeed,
leading: const Icon(Icons.speed_rounded),
title: const Text('Per-clip playback speed'),
subtitle: const Text(
'Clip 1: 2× fast-forward · Clip 2: 0.5× slow-motion',
),
),
ListTile(
onTap: _layers,
leading: const Icon(Icons.layers_outlined),
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 @@ -74,7 +74,8 @@ class RenderVideo {
inputPath: newPath,
startUs: clip.startUs,
endUs: clip.endUs,
volume: clip.volume
volume: clip.volume,
playbackSpeed: clip.playbackSpeed
)
}
return clip
Expand Down
Loading
Loading