From cc6d57a60089b987d05500435edf35648f7fc212 Mon Sep 17 00:00:00 2001 From: hm21 Date: Sat, 9 May 2026 17:24:35 +0700 Subject: [PATCH 1/2] feat: add per-clip playback speed control for video segments --- CHANGELOG.md | 4 + .../src/features/render/RenderVideo.kt | 4 +- .../render/helpers/VideoSequenceBuilder.kt | 37 ++-- .../features/render/models/RenderConfig.kt | 9 +- .../integration_test/video_render_test.dart | 161 ++++++++++++++++++ .../features/render/video_renderer_page.dart | 36 ++++ .../src/features/render/RenderVideo.swift | 3 +- .../render/helpers/VideoSequenceBuilder.swift | 32 +++- .../features/render/models/RenderConfig.swift | 3 +- .../features/render/models/VideoClip.swift | 12 +- .../models/video/video_render_data_model.dart | 5 +- .../models/video/video_segment_model.dart | 26 ++- .../src/features/render/RenderVideo.swift | 3 +- .../render/helpers/VideoSequenceBuilder.swift | 32 +++- .../features/render/models/RenderConfig.swift | 3 +- .../features/render/models/VideoClip.swift | 12 +- pubspec.yaml | 2 +- 17 files changed, 345 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c460306..72fb12f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/android/src/main/kotlin/ch/waio/pro_video_editor/src/features/render/RenderVideo.kt b/android/src/main/kotlin/ch/waio/pro_video_editor/src/features/render/RenderVideo.kt index 4051de8..7335ec2 100644 --- a/android/src/main/kotlin/ch/waio/pro_video_editor/src/features/render/RenderVideo.kt +++ b/android/src/main/kotlin/ch/waio/pro_video_editor/src/features/render/RenderVideo.kt @@ -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 } diff --git a/android/src/main/kotlin/ch/waio/pro_video_editor/src/features/render/helpers/VideoSequenceBuilder.kt b/android/src/main/kotlin/ch/waio/pro_video_editor/src/features/render/helpers/VideoSequenceBuilder.kt index 8193e0c..9391dfb 100644 --- a/android/src/main/kotlin/ch/waio/pro_video_editor/src/features/render/helpers/VideoSequenceBuilder.kt +++ b/android/src/main/kotlin/ch/waio/pro_video_editor/src/features/render/helpers/VideoSequenceBuilder.kt @@ -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 @@ -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().apply { - addAll(normalizedAudioEffects) - add(volumeProcessor) + val perClipAudioProcessors = mutableListOf().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 = 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) @@ -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 diff --git a/android/src/main/kotlin/ch/waio/pro_video_editor/src/features/render/models/RenderConfig.kt b/android/src/main/kotlin/ch/waio/pro_video_editor/src/features/render/models/RenderConfig.kt index bb71bc3..ca600e1 100644 --- a/android/src/main/kotlin/ch/waio/pro_video_editor/src/features/render/models/RenderConfig.kt +++ b/android/src/main/kotlin/ch/waio/pro_video_editor/src/features/render/models/RenderConfig.kt @@ -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 ) /** @@ -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 } diff --git a/example/integration_test/video_render_test.dart b/example/integration_test/video_render_test.dart index fc91918..505a538 100644 --- a/example/integration_test/video_render_test.dart +++ b/example/integration_test/video_render_test.dart @@ -230,6 +230,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', @@ -698,6 +751,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( @@ -955,6 +1062,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( diff --git a/example/lib/features/render/video_renderer_page.dart b/example/lib/features/render/video_renderer_page.dart index 42ca328..b9880b1 100644 --- a/example/lib/features/render/video_renderer_page.dart +++ b/example/lib/features/render/video_renderer_page.dart @@ -127,12 +127,40 @@ class _VideoRendererPageState extends State { Future _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 _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 _removeAudio() async { var data = VideoRenderData( videoSegments: [VideoSegment(video: _video)], @@ -1179,6 +1207,14 @@ class _VideoRendererPageState extends State { 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), diff --git a/ios/Classes/src/features/render/RenderVideo.swift b/ios/Classes/src/features/render/RenderVideo.swift index 5a78609..c06f243 100644 --- a/ios/Classes/src/features/render/RenderVideo.swift +++ b/ios/Classes/src/features/render/RenderVideo.swift @@ -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 diff --git a/ios/Classes/src/features/render/helpers/VideoSequenceBuilder.swift b/ios/Classes/src/features/render/helpers/VideoSequenceBuilder.swift index 56f93b3..1df1c80 100644 --- a/ios/Classes/src/features/render/helpers/VideoSequenceBuilder.swift +++ b/ios/Classes/src/features/render/helpers/VideoSequenceBuilder.swift @@ -175,18 +175,35 @@ internal class VideoSequenceBuilder { // Calculate time range for this clip let clipTimeRange = await calculateTimeRange(for: clip, from: asset) let clipDuration = clipTimeRange.duration + let insertStart = totalDuration // Insert video clip into the composition track try compositionVideoTrack.insertTimeRange( clipTimeRange, of: videoTrack, - at: totalDuration + at: insertStart ) + // Apply per-clip playback speed by scaling the inserted segment. + // The global config.playbackSpeed is still applied via + // composition instructions in `applyPlaybackSpeed` and is multiplicative. + let effectiveDuration: CMTime + if let speed = clip.playbackSpeed, speed > 0, speed != 1.0 { + let scaled = CMTimeMultiplyByFloat64(clipDuration, multiplier: 1.0 / Float64(speed)) + let insertedRange = CMTimeRange(start: insertStart, duration: clipDuration) + compositionVideoTrack.scaleTimeRange(insertedRange, toDuration: scaled) + effectiveDuration = scaled + PluginLog.print( + "⏩ Clip \(index) playback speed: \(speed)× (duration \(String(format: "%.2f", clipDuration.seconds))s → \(String(format: "%.2f", scaled.seconds))s)" + ) + } else { + effectiveDuration = clipDuration + } + // Store instruction for this clip segment clipInstructions.append( ClipInstruction( - timeRange: CMTimeRange(start: totalDuration, duration: clipDuration), + timeRange: CMTimeRange(start: insertStart, duration: effectiveDuration), transform: preferredTransform, naturalSize: naturalSize, renderSize: correctedSize @@ -209,8 +226,13 @@ internal class VideoSequenceBuilder { try sharedAudioTrack.insertTimeRange( clipTimeRange, of: audioTrack, - at: totalDuration + at: insertStart ) + // Apply per-clip playback speed to audio segment to keep A/V in sync. + if let speed = clip.playbackSpeed, speed > 0, speed != 1.0 { + let insertedAudioRange = CMTimeRange(start: insertStart, duration: clipDuration) + sharedAudioTrack.scaleTimeRange(insertedAudioRange, toDuration: effectiveDuration) + } PluginLog.print(" ✅ Audio inserted into SHARED track!") PluginLog.print( " Source time range: \(String(format: "%.2f", clipTimeRange.start.seconds))s - \(String(format: "%.2f", (clipTimeRange.start + clipTimeRange.duration).seconds))s" @@ -227,9 +249,9 @@ internal class VideoSequenceBuilder { } } - totalDuration = CMTimeAdd(totalDuration, clipDuration) + totalDuration = CMTimeAdd(totalDuration, effectiveDuration) PluginLog.print("✅ Clip \(index) added successfully") - PluginLog.print(" - Duration: \(String(format: "%.2f", clipDuration.seconds))s") + PluginLog.print(" - Duration: \(String(format: "%.2f", effectiveDuration.seconds))s") PluginLog.print( " - Time range in composition: \(String(format: "%.2f", totalDuration.seconds - clipDuration.seconds))s - \(String(format: "%.2f", totalDuration.seconds))s" ) diff --git a/ios/Classes/src/features/render/models/RenderConfig.swift b/ios/Classes/src/features/render/models/RenderConfig.swift index 2e8b12d..7537280 100644 --- a/ios/Classes/src/features/render/models/RenderConfig.swift +++ b/ios/Classes/src/features/render/models/RenderConfig.swift @@ -269,7 +269,8 @@ struct RenderConfig { inputPath: inputPath, startUs: (clipMap["startUs"] as? NSNumber)?.int64Value, endUs: (clipMap["endUs"] as? NSNumber)?.int64Value, - volume: (clipMap["volume"] as? NSNumber)?.floatValue + volume: (clipMap["volume"] as? NSNumber)?.floatValue, + playbackSpeed: (clipMap["playbackSpeed"] as? NSNumber)?.floatValue ) } } diff --git a/ios/Classes/src/features/render/models/VideoClip.swift b/ios/Classes/src/features/render/models/VideoClip.swift index 6241247..87369a5 100644 --- a/ios/Classes/src/features/render/models/VideoClip.swift +++ b/ios/Classes/src/features/render/models/VideoClip.swift @@ -1,16 +1,24 @@ import Foundation -/// Represents a video clip with optional trimming and volume control +/// Represents a video clip with optional trimming, volume and playback speed control internal struct VideoClip { let inputPath: String let startUs: Int64? let endUs: Int64? let volume: Float? + let playbackSpeed: Float? - init(inputPath: String, startUs: Int64? = nil, endUs: Int64? = nil, volume: Float? = nil) { + init( + inputPath: String, + startUs: Int64? = nil, + endUs: Int64? = nil, + volume: Float? = nil, + playbackSpeed: Float? = nil + ) { self.inputPath = inputPath self.startUs = startUs self.endUs = endUs self.volume = volume + self.playbackSpeed = playbackSpeed } } diff --git a/lib/core/models/video/video_render_data_model.dart b/lib/core/models/video/video_render_data_model.dart index 7399245..82ab0cf 100644 --- a/lib/core/models/video/video_render_data_model.dart +++ b/lib/core/models/video/video_render_data_model.dart @@ -30,7 +30,7 @@ class VideoRenderData { this.imageLayers, this.transform, this.enableAudio = true, - this.playbackSpeed, + @Deprecated('Use VideoSegment.playbackSpeed instead.') this.playbackSpeed, this.startTime, this.endTime, @Deprecated('Use colorFilters instead.') this.colorMatrixList = const [], @@ -124,6 +124,7 @@ class VideoRenderData { List imageLayers = const [], ExportTransform? transform, bool enableAudio = true, + @Deprecated('Use VideoSegment.playbackSpeed instead.') double? playbackSpeed, Duration? startTime, Duration? endTime, @@ -153,6 +154,7 @@ class VideoRenderData { imageLayers: imageLayers, transform: transform, enableAudio: enableAudio, + // ignore: deprecated_member_use_from_same_package playbackSpeed: playbackSpeed, startTime: startTime, endTime: endTime, @@ -248,6 +250,7 @@ class VideoRenderData { /// Playback speed of the exported video. /// /// For example, `0.5` for half speed, `2.0` for double speed. + @Deprecated('Use VideoSegment.playbackSpeed instead.') final double? playbackSpeed; /// Optional start time for trimming the entire composition across all diff --git a/lib/core/models/video/video_segment_model.dart b/lib/core/models/video/video_segment_model.dart index c927e0d..7646d95 100644 --- a/lib/core/models/video/video_segment_model.dart +++ b/lib/core/models/video/video_segment_model.dart @@ -16,6 +16,7 @@ class VideoSegment { this.startTime, this.endTime, this.volume, + this.playbackSpeed, }) : assert( startTime == null || endTime == null || startTime < endTime, 'startTime must be before endTime', @@ -23,6 +24,10 @@ class VideoSegment { assert( volume == null || volume >= 0, '[volume] must be greater than or equal to 0', + ), + assert( + playbackSpeed == null || playbackSpeed > 0, + '[playbackSpeed] must be greater than 0', ); /// The video source for this clip. @@ -50,6 +55,13 @@ class VideoSegment { /// If null, the original volume is used. final double? volume; + /// Playback speed of this segment. + /// + /// For example, `0.5` for half speed, `2.0` for double speed. + /// + /// If null, the original speed is used. + final double? playbackSpeed; + /// Converts this clip to a map for platform channel communication. Future> toAsyncMap() async { final inputPath = await video.safeFilePath(); @@ -59,6 +71,7 @@ class VideoSegment { 'startUs': startTime?.inMicroseconds, 'endUs': endTime?.inMicroseconds, 'volume': volume, + 'playbackSpeed': playbackSpeed, }; } @@ -68,12 +81,14 @@ class VideoSegment { Duration? startTime, Duration? endTime, double? volume, + double? playbackSpeed, }) { return VideoSegment( video: video ?? this.video, startTime: startTime ?? this.startTime, endTime: endTime ?? this.endTime, volume: volume ?? this.volume, + playbackSpeed: playbackSpeed ?? this.playbackSpeed, ); } @@ -84,7 +99,8 @@ class VideoSegment { return other.video == video && other.startTime == startTime && other.endTime == endTime && - other.volume == volume; + other.volume == volume && + other.playbackSpeed == playbackSpeed; } @override @@ -92,7 +108,8 @@ class VideoSegment { return video.hashCode ^ startTime.hashCode ^ endTime.hashCode ^ - volume.hashCode; + volume.hashCode ^ + playbackSpeed.hashCode; } @override @@ -100,7 +117,8 @@ class VideoSegment { return 'VideoSegment(video: $video, ' 'startTime: $startTime, ' 'endTime: $endTime, ' - 'volume: $volume)'; + 'volume: $volume, ' + 'playbackSpeed: $playbackSpeed)'; } Map toMap() { @@ -109,6 +127,7 @@ class VideoSegment { 'startTime': startTime?.inMicroseconds, 'endTime': endTime?.inMicroseconds, 'volume': volume, + 'playbackSpeed': playbackSpeed, }; } @@ -122,6 +141,7 @@ class VideoSegment { ? Duration(microseconds: safeParseInt(map['endTime'])) : null, volume: tryParseDouble(map['volume']), + playbackSpeed: tryParseDouble(map['playbackSpeed']), ); } diff --git a/macos/Classes/src/features/render/RenderVideo.swift b/macos/Classes/src/features/render/RenderVideo.swift index 4c51432..30f37fb 100644 --- a/macos/Classes/src/features/render/RenderVideo.swift +++ b/macos/Classes/src/features/render/RenderVideo.swift @@ -75,7 +75,8 @@ class RenderVideo { inputPath: newPath, startUs: clip.startUs, endUs: clip.endUs, - volume: clip.volume + volume: clip.volume, + playbackSpeed: clip.playbackSpeed ) } return clip diff --git a/macos/Classes/src/features/render/helpers/VideoSequenceBuilder.swift b/macos/Classes/src/features/render/helpers/VideoSequenceBuilder.swift index 8fdc973..c2d42a2 100644 --- a/macos/Classes/src/features/render/helpers/VideoSequenceBuilder.swift +++ b/macos/Classes/src/features/render/helpers/VideoSequenceBuilder.swift @@ -175,18 +175,35 @@ internal class VideoSequenceBuilder { // Calculate time range for this clip let clipTimeRange = await calculateTimeRange(for: clip, from: asset) let clipDuration = clipTimeRange.duration + let insertStart = totalDuration // Insert video clip into the composition track try compositionVideoTrack.insertTimeRange( clipTimeRange, of: videoTrack, - at: totalDuration + at: insertStart ) + // Apply per-clip playback speed by scaling the inserted segment. + // The global config.playbackSpeed is still applied via + // composition instructions in `applyPlaybackSpeed` and is multiplicative. + let effectiveDuration: CMTime + if let speed = clip.playbackSpeed, speed > 0, speed != 1.0 { + let scaled = CMTimeMultiplyByFloat64(clipDuration, multiplier: 1.0 / Float64(speed)) + let insertedRange = CMTimeRange(start: insertStart, duration: clipDuration) + compositionVideoTrack.scaleTimeRange(insertedRange, toDuration: scaled) + effectiveDuration = scaled + PluginLog.print( + "⏩ Clip \(index) playback speed: \(speed)× (duration \(String(format: "%.2f", clipDuration.seconds))s → \(String(format: "%.2f", scaled.seconds))s)" + ) + } else { + effectiveDuration = clipDuration + } + // Store instruction for this clip segment clipInstructions.append( ClipInstruction( - timeRange: CMTimeRange(start: totalDuration, duration: clipDuration), + timeRange: CMTimeRange(start: insertStart, duration: effectiveDuration), transform: preferredTransform, naturalSize: naturalSize, renderSize: correctedSize @@ -209,8 +226,13 @@ internal class VideoSequenceBuilder { try sharedAudioTrack.insertTimeRange( clipTimeRange, of: audioTrack, - at: totalDuration + at: insertStart ) + // Apply per-clip playback speed to audio segment to keep A/V in sync. + if let speed = clip.playbackSpeed, speed > 0, speed != 1.0 { + let insertedAudioRange = CMTimeRange(start: insertStart, duration: clipDuration) + sharedAudioTrack.scaleTimeRange(insertedAudioRange, toDuration: effectiveDuration) + } PluginLog.print(" ✅ Audio inserted into SHARED track!") PluginLog.print( " Source time range: \(String(format: "%.2f", clipTimeRange.start.seconds))s - \(String(format: "%.2f", (clipTimeRange.start + clipTimeRange.duration).seconds))s" @@ -227,9 +249,9 @@ internal class VideoSequenceBuilder { } } - totalDuration = CMTimeAdd(totalDuration, clipDuration) + totalDuration = CMTimeAdd(totalDuration, effectiveDuration) PluginLog.print("✅ Clip \(index) added successfully") - PluginLog.print(" - Duration: \(String(format: "%.2f", clipDuration.seconds))s") + PluginLog.print(" - Duration: \(String(format: "%.2f", effectiveDuration.seconds))s") PluginLog.print( " - Time range in composition: \(String(format: "%.2f", totalDuration.seconds - clipDuration.seconds))s - \(String(format: "%.2f", totalDuration.seconds))s" ) diff --git a/macos/Classes/src/features/render/models/RenderConfig.swift b/macos/Classes/src/features/render/models/RenderConfig.swift index 36ede4a..e967664 100644 --- a/macos/Classes/src/features/render/models/RenderConfig.swift +++ b/macos/Classes/src/features/render/models/RenderConfig.swift @@ -269,7 +269,8 @@ struct RenderConfig { inputPath: inputPath, startUs: (clipMap["startUs"] as? NSNumber)?.int64Value, endUs: (clipMap["endUs"] as? NSNumber)?.int64Value, - volume: (clipMap["volume"] as? NSNumber)?.floatValue + volume: (clipMap["volume"] as? NSNumber)?.floatValue, + playbackSpeed: (clipMap["playbackSpeed"] as? NSNumber)?.floatValue ) } } diff --git a/macos/Classes/src/features/render/models/VideoClip.swift b/macos/Classes/src/features/render/models/VideoClip.swift index 6241247..87369a5 100644 --- a/macos/Classes/src/features/render/models/VideoClip.swift +++ b/macos/Classes/src/features/render/models/VideoClip.swift @@ -1,16 +1,24 @@ import Foundation -/// Represents a video clip with optional trimming and volume control +/// Represents a video clip with optional trimming, volume and playback speed control internal struct VideoClip { let inputPath: String let startUs: Int64? let endUs: Int64? let volume: Float? + let playbackSpeed: Float? - init(inputPath: String, startUs: Int64? = nil, endUs: Int64? = nil, volume: Float? = nil) { + init( + inputPath: String, + startUs: Int64? = nil, + endUs: Int64? = nil, + volume: Float? = nil, + playbackSpeed: Float? = nil + ) { self.inputPath = inputPath self.startUs = startUs self.endUs = endUs self.volume = volume + self.playbackSpeed = playbackSpeed } } diff --git a/pubspec.yaml b/pubspec.yaml index fd2f24b..36f95e3 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: 1.15.2 +version: 1.16.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/ From a0084d016539ce450ec8677c5f68af9130db2166 Mon Sep 17 00:00:00 2001 From: hm21 Date: Sat, 9 May 2026 17:28:40 +0700 Subject: [PATCH 2/2] fix: remove deprecated member use warning in video render test --- example/integration_test/video_render_test.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/example/integration_test/video_render_test.dart b/example/integration_test/video_render_test.dart index 505a538..1e1e18b 100644 --- a/example/integration_test/video_render_test.dart +++ b/example/integration_test/video_render_test.dart @@ -1,3 +1,5 @@ +// ignore_for_file: deprecated_member_use + import 'dart:io'; import 'dart:ui' as ui;