diff --git a/CHANGELOG.md b/CHANGELOG.md index 303e9db6..0b5ac834 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## 1.17.0 +- **FEAT**(android, iOS, macOS): Add `reverseVideo` flag to `VideoSegment` to play a clip backwards. When `true`, the segment renders from its trimmed end back to its trimmed start while other segments keep their own direction. + ## 1.16.3 - **FIX**(linux): Fix Linux build by replacing the unavailable C++ Flutter embedder API (`flutter::EncodableValue`) with the GLib-based `FlValue` API. Update CMakeLists to use GStreamer (`gstreamer-1.0`, `gstreamer-pbutils-1.0`) instead of the unused libavformat/libavcodec/libavutil pkg-config targets. Fix source file extensions (`.cc` instead of `.cpp`). diff --git a/README.md b/README.md index 9b4f65ac..03050444 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,7 @@ The ProVideoEditor is a Flutter widget designed for video editing within your ap - โœ‚๏ธ **Trim**: Cut the video to a specified start and end time. - ๐Ÿ”— **Merge Videos**: Concatenate multiple video clips into a single output. - โฉ **Playback Speed**: Adjust the playback speed of the video. +- โช **Reverse Video**: Play a video segment backwards. - ๐Ÿ”‡ **Mute Audio**: Remove or mute the audio track from the video. - ๐Ÿ“Š **Waveform**: Generate audio waveform data for visualization, with support for streaming mode. @@ -269,6 +270,23 @@ Uint8List result = await ProVideoEditor.instance.renderVideo(data); /// but not both. The clips will be joined in the order they appear in the list. ``` +#### Reverse Video Example +```dart +/// Render a segment backwards by setting reverseVideo to true. +/// Other segments keep their original direction. +var data = VideoRenderData( + videoSegments: [ + VideoSegment( + video: EditorVideo.file(File('/path/to/clip.mp4')), + reverseVideo: true, + ), + ], + outputFormat: VideoOutputFormat.mp4, +); + +Uint8List result = await ProVideoEditor.instance.renderVideo(data); +``` + #### Extract Audio Example Extract audio track from a video. 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 ba7f7f08..69577143 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 @@ -139,7 +139,14 @@ class RenderVideo(private val context: Context) { val newPath = transcodeMap[clip.inputPath] ?: clip.inputPath if (newPath != clip.inputPath) { // If transcoded, use the new path but keep trim times, volume and speed - VideoClip(newPath, clip.startUs, clip.endUs, clip.volume, clip.playbackSpeed) + VideoClip( + newPath, + clip.startUs, + clip.endUs, + clip.volume, + clip.playbackSpeed, + clip.reverseVideo + ) } else { clip } diff --git a/android/src/main/kotlin/ch/waio/pro_video_editor/src/features/render/helpers/MediaInfoExtractor.kt b/android/src/main/kotlin/ch/waio/pro_video_editor/src/features/render/helpers/MediaInfoExtractor.kt index 9c885891..c9df7475 100644 --- a/android/src/main/kotlin/ch/waio/pro_video_editor/src/features/render/helpers/MediaInfoExtractor.kt +++ b/android/src/main/kotlin/ch/waio/pro_video_editor/src/features/render/helpers/MediaInfoExtractor.kt @@ -44,6 +44,37 @@ object MediaInfoExtractor { } } + /** + * Retrieves video frame rate from file. + * + * @param videoPath Absolute path to video file + * @return Frame rate, or null if unavailable + */ + fun getVideoFrameRate(videoPath: String): Float? { + return try { + val extractor = MediaExtractor() + extractor.setDataSource(videoPath) + var frameRate: Float? = null + + for (i in 0 until extractor.trackCount) { + val format = extractor.getTrackFormat(i) + val mime = format.getString(MediaFormat.KEY_MIME) ?: "" + if (mime.startsWith("video/")) { + if (format.containsKey(MediaFormat.KEY_FRAME_RATE)) { + frameRate = format.getInteger(MediaFormat.KEY_FRAME_RATE).toFloat() + } + break + } + } + + extractor.release() + frameRate + } catch (e: Exception) { + Log.e(RENDER_TAG, "Failed to get video frame rate for $videoPath: ${e.message}") + null + } + } + /** * Retrieves audio duration from file. * 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 9391dfb1..ca4d462c 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 @@ -46,6 +46,7 @@ class VideoSequenceBuilder( private var hasCustomAudio: Boolean = false private var scaleX: Float? = null private var scaleY: Float? = null + private val rotatedDimensionsCache = mutableMapOf>() data class CropConfig( val width: Int?, @@ -249,6 +250,8 @@ class VideoSequenceBuilder( // Apply global trim to clips if set val trimmedClips = applyGlobalTrim(videoClips) Log.d(RENDER_TAG, "After global trim: ${trimmedClips.size} clips (was ${videoClips.size})") + val timelineClips = expandReversedClips(trimmedClips) + Log.d(RENDER_TAG, "After reverse expansion: ${timelineClips.size} timeline clips") // Prepare normalized audio effects with channel mixing if needed val normalizedAudioEffects = if (needsAudioNormalization) { @@ -259,7 +262,7 @@ class VideoSequenceBuilder( } // Build EditedMediaItems for each clip - val editedMediaItems = trimmedClips.mapIndexed { index, clip -> + val editedMediaItems = timelineClips.mapIndexed { index, clip -> buildEditedMediaItem(index, clip, normalizedAudioEffects) } @@ -399,8 +402,8 @@ class VideoSequenceBuilder( val mediaItemBuilder = MediaItem.Builder().setUri(Uri.fromFile(inputFile)) if (clip.startUs != null || clip.endUs != null) { - val startMs = (clip.startUs ?: 0L) / 1000 - val endMs = clip.endUs?.div(1000) ?: C.TIME_END_OF_SOURCE + val startUs = clip.startUs ?: 0L + val endUs = clip.endUs ?: C.TIME_END_OF_SOURCE val expectedDurationMs = if (clip.endUs != null && clip.startUs != null) { (clip.endUs - clip.startUs) / 1000 } else if (clip.endUs != null) { @@ -411,13 +414,17 @@ class VideoSequenceBuilder( Log.d( RENDER_TAG, - "Applying trim to clip ${clip.inputPath}: start=$startMs ms, end=$endMs ms, expectedDuration=$expectedDurationMs ms" + "Applying trim to clip ${clip.inputPath}: start=${startUs / 1000} ms, end=${if (endUs == C.TIME_END_OF_SOURCE) "source end" else "${endUs / 1000} ms"}, expectedDuration=$expectedDurationMs ms" ) - val clippingConfig = MediaItem.ClippingConfiguration.Builder() - .setStartPositionMs(startMs) - .setEndPositionMs(endMs) - .build() + val clippingConfigBuilder = MediaItem.ClippingConfiguration.Builder() + .setStartPositionUs(startUs) + if (clip.endUs != null) { + clippingConfigBuilder.setEndPositionUs(clip.endUs) + } else { + clippingConfigBuilder.setEndPositionMs(C.TIME_END_OF_SOURCE) + } + val clippingConfig = clippingConfigBuilder.build() mediaItemBuilder.setClippingConfiguration(clippingConfig) } @@ -430,10 +437,13 @@ class VideoSequenceBuilder( // Calculate video dimensions for image layer positioning // This must be done before applying any effects - var (videoWidth, videoHeight, videoRotation) = getRotatedVideoDimensions( - inputFile, - rotationDegrees - ) + val dimensionsKey = "${inputFile.absolutePath}|$rotationDegrees" + val dimensions = rotatedDimensionsCache.getOrPut(dimensionsKey) { + getRotatedVideoDimensions(inputFile, rotationDegrees) + } + var videoWidth = dimensions.first + var videoHeight = dimensions.second + val videoRotation = dimensions.third // Adjust dimensions based on rotation val isRotated90Deg = videoRotation == 90 || videoRotation == 270 @@ -582,28 +592,55 @@ class VideoSequenceBuilder( // Clip overlaps with global trim range - adjust boundaries var newStartInSource = clipStartInSource var newEndInSource = clipEndInSource + val startTrimOffsetUs = if (clipStartInComposition < globalStart) { + globalStart - clipStartInComposition + } else { + 0L + } + val endTrimOffsetUs = if (clipEndInComposition > globalEnd) { + clipEndInComposition - globalEnd + } else { + 0L + } + val frameCompensationUs = 33333L // ~33ms = 1 frame at 30fps // Adjust start if global start cuts into this clip - if (clipStartInComposition < globalStart) { - val offsetUs = globalStart - clipStartInComposition - newStartInSource = clipStartInSource + offsetUs - Log.d(RENDER_TAG, "Adjusting clip start by ${offsetUs / 1000}ms") + if (startTrimOffsetUs > 0L) { + if (clip.reverseVideo) { + newEndInSource = clipEndInSource - startTrimOffsetUs + Log.d( + RENDER_TAG, + "Adjusting reversed clip source end by ${startTrimOffsetUs / 1000}ms" + ) + } else { + newStartInSource = clipStartInSource + startTrimOffsetUs + Log.d(RENDER_TAG, "Adjusting clip start by ${startTrimOffsetUs / 1000}ms") + } } // Adjust end if global end cuts into this clip - if (clipEndInComposition > globalEnd) { - val offsetUs = clipEndInComposition - globalEnd - newEndInSource = clipEndInSource - offsetUs - - // Subtract ~1 frame (33ms for 30fps) to ensure encoder doesn't overshoot - // This compensates for encoder rounding to next frame/audio sample boundary - val frameCompensationUs = 33333L // ~33ms = 1 frame at 30fps - newEndInSource = maxOf(newStartInSource, newEndInSource - frameCompensationUs) - - Log.d( - RENDER_TAG, - "Adjusting clip end by ${offsetUs / 1000}ms (with frame compensation)" - ) + if (endTrimOffsetUs > 0L) { + // Subtract ~1 frame to ensure encoder doesn't overshoot. This compensates + // for encoder rounding to next frame/audio sample boundary. + if (clip.reverseVideo) { + newStartInSource = minOf( + newEndInSource, + newStartInSource + endTrimOffsetUs + frameCompensationUs + ) + Log.d( + RENDER_TAG, + "Adjusting reversed clip source start by ${endTrimOffsetUs / 1000}ms (with frame compensation)" + ) + } else { + newEndInSource = maxOf( + newStartInSource, + newEndInSource - endTrimOffsetUs - frameCompensationUs + ) + Log.d( + RENDER_TAG, + "Adjusting clip end by ${endTrimOffsetUs / 1000}ms (with frame compensation)" + ) + } } // Only add if there's still content left @@ -614,7 +651,8 @@ class VideoSequenceBuilder( startUs = newStartInSource, endUs = newEndInSource, volume = clip.volume, - playbackSpeed = clip.playbackSpeed + playbackSpeed = clip.playbackSpeed, + reverseVideo = clip.reverseVideo ) ) val trimmedDuration = newEndInSource - newStartInSource @@ -643,4 +681,58 @@ class VideoSequenceBuilder( return result } + + /** + * Expands reversed clips into frame-sized forward slices ordered from end to start. + * + * Media3 Transformer does not provide a native negative-speed effect. Small slices + * preserve the existing effect pipeline while producing backwards visual playback. + */ + private fun expandReversedClips(clips: List): List { + val result = mutableListOf() + + for (clip in clips) { + if (!clip.reverseVideo) { + result.add(clip) + continue + } + + val sourceStartUs = clip.startUs ?: 0L + val sourceEndUs = clip.endUs ?: MediaInfoExtractor.getVideoDuration(clip.inputPath) + if (sourceEndUs <= sourceStartUs) { + Log.w(RENDER_TAG, "Skipping reversed clip with invalid range: ${clip.inputPath}") + continue + } + + val frameRate = MediaInfoExtractor.getVideoFrameRate(clip.inputPath) + ?.takeIf { it > 0f } + ?: 30f + val frameDurationUs = (1_000_000f / frameRate).toLong().coerceAtLeast(1L) + var cursorEndUs = sourceEndUs + var sliceCount = 0 + + while (cursorEndUs > sourceStartUs) { + val sliceStartUs = maxOf(sourceStartUs, cursorEndUs - frameDurationUs) + result.add( + VideoClip( + inputPath = clip.inputPath, + startUs = sliceStartUs, + endUs = cursorEndUs, + volume = clip.volume, + playbackSpeed = clip.playbackSpeed, + reverseVideo = false + ) + ) + cursorEndUs = sliceStartUs + sliceCount++ + } + + Log.d( + RENDER_TAG, + "Expanded reversed clip into $sliceCount slices at ${frameRate}fps: ${clip.inputPath}" + ) + } + + return result + } } 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 ca600e13..55c25fbd 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 @@ -12,13 +12,15 @@ import io.flutter.plugin.common.MethodCall * @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) + * @property reverseVideo Whether to render this clip backwards */ data class VideoClip( val inputPath: String, val startUs: Long?, val endUs: Long?, val volume: Float? = null, - val playbackSpeed: Float? = null + val playbackSpeed: Float? = null, + val reverseVideo: Boolean = false ) /** @@ -234,11 +236,12 @@ data class RenderConfig( startUs = (clipMap["startUs"] as? Number)?.toLong(), endUs = (clipMap["endUs"] as? Number)?.toLong(), volume = (clipMap["volume"] as? Number)?.toFloat(), - playbackSpeed = (clipMap["playbackSpeed"] as? Number)?.toFloat() + playbackSpeed = (clipMap["playbackSpeed"] as? Number)?.toFloat(), + reverseVideo = clipMap["reverseVideo"] as? Boolean ?: false ) Log.d( PACKAGE_TAG, - "Clip $index: path=${clip.inputPath}, start=${clip.startUs}, end=${clip.endUs}, volume=${clip.volume}, speed=${clip.playbackSpeed}" + "Clip $index: path=${clip.inputPath}, start=${clip.startUs}, end=${clip.endUs}, volume=${clip.volume}, speed=${clip.playbackSpeed}, reverse=${clip.reverseVideo}" ) clip } diff --git a/example/integration_test/video_render_test.dart b/example/integration_test/video_render_test.dart index 889f5cc1..2d1c605a 100644 --- a/example/integration_test/video_render_test.dart +++ b/example/integration_test/video_render_test.dart @@ -285,6 +285,59 @@ void main() { ); }); + testWidgets('per-clip reverse: single trimmed segment', (tester) async { + final meta = await testRender( + description: 'Per-clip reverse single segment', + renderModel: VideoRenderData( + outputFormat: VideoOutputFormat.mp4, + videoSegments: [ + VideoSegment( + video: h264Video, + startTime: const Duration(seconds: 2), + endTime: const Duration(seconds: 3), + reverseVideo: true, + ), + ], + ), + ); + + expect( + meta.duration.inMilliseconds, + closeTo(1000, 400), + reason: 'Reversed 2sโ€“3s trim should keep roughly the same duration', + ); + }); + + testWidgets('per-clip reverse: mixed forward and reversed segments', ( + tester, + ) async { + final meta = await testRender( + description: 'Per-clip reverse mixed segments', + renderModel: VideoRenderData( + outputFormat: VideoOutputFormat.mp4, + videoSegments: [ + VideoSegment( + video: h264Video, + startTime: const Duration(seconds: 0), + endTime: const Duration(seconds: 2), + ), + VideoSegment( + video: h264Video, + startTime: const Duration(seconds: 2), + endTime: const Duration(seconds: 3), + reverseVideo: true, + ), + ], + ), + ); + + expect( + meta.duration.inMilliseconds, + closeTo(3000, 600), + reason: 'Forward 2s + reversed 1s should render as roughly 3s', + ); + }); + testWidgets('remove audio', (tester) async { await testRender( description: 'Audio removed', diff --git a/example/lib/features/render/video_renderer_page.dart b/example/lib/features/render/video_renderer_page.dart index 070f4e17..a319cfc7 100644 --- a/example/lib/features/render/video_renderer_page.dart +++ b/example/lib/features/render/video_renderer_page.dart @@ -161,6 +161,27 @@ class _VideoRendererPageState extends State { await _renderVideo(data); } + /// Reverse playback per video segment. + Future _perClipReverse() async { + var data = VideoRenderData( + videoSegments: [ + VideoSegment( + video: _video, + startTime: const Duration(seconds: 0), + endTime: const Duration(seconds: 4), + ), + VideoSegment( + video: _video, + startTime: const Duration(seconds: 0), + endTime: const Duration(seconds: 4), + reverseVideo: true, + ), + ], + ); + + await _renderVideo(data); + } + Future _removeAudio() async { var data = VideoRenderData( videoSegments: [VideoSegment(video: _video)], @@ -1281,6 +1302,12 @@ class _VideoRendererPageState extends State { 'Clip 1: 2ร— fast-forward ยท Clip 2: 0.5ร— slow-motion', ), ), + ListTile( + onTap: _perClipReverse, + leading: const Icon(Icons.replay_outlined), + title: const Text('Per-clip reverse playback'), + subtitle: const Text('Clip 1 forward ยท Clip 2 backwards'), + ), ListTile( onTap: _layers, leading: const Icon(Icons.layers_outlined), diff --git a/example/linux/flutter/generated_plugins.cmake b/example/linux/flutter/generated_plugins.cmake index 559b4ef0..4938690b 100644 --- a/example/linux/flutter/generated_plugins.cmake +++ b/example/linux/flutter/generated_plugins.cmake @@ -11,7 +11,6 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST - jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift index aba9da97..768b999d 100644 --- a/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -26,7 +26,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { ProImageEditorPlugin.register(with: registry.registrar(forPlugin: "ProImageEditorPlugin")) ProVideoEditorPlugin.register(with: registry.registrar(forPlugin: "ProVideoEditorPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) - VideoPlayerPlugin.register(with: registry.registrar(forPlugin: "VideoPlayerPlugin")) + FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin")) VolumeControllerPlugin.register(with: registry.registrar(forPlugin: "VolumeControllerPlugin")) WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) } diff --git a/example/windows/flutter/generated_plugins.cmake b/example/windows/flutter/generated_plugins.cmake index 361febbd..98161824 100644 --- a/example/windows/flutter/generated_plugins.cmake +++ b/example/windows/flutter/generated_plugins.cmake @@ -11,7 +11,6 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST - jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/ios/Classes/src/features/render/RenderVideo.swift b/ios/Classes/src/features/render/RenderVideo.swift index 972bec81..4755ffc9 100644 --- a/ios/Classes/src/features/render/RenderVideo.swift +++ b/ios/Classes/src/features/render/RenderVideo.swift @@ -75,7 +75,8 @@ class RenderVideo { startUs: clip.startUs, endUs: clip.endUs, volume: clip.volume, - playbackSpeed: clip.playbackSpeed + playbackSpeed: clip.playbackSpeed, + reverseVideo: clip.reverseVideo ) } return clip diff --git a/ios/Classes/src/features/render/helpers/VideoSequenceBuilder.swift b/ios/Classes/src/features/render/helpers/VideoSequenceBuilder.swift index 8c486e61..aed6c7b9 100644 --- a/ios/Classes/src/features/render/helpers/VideoSequenceBuilder.swift +++ b/ios/Classes/src/features/render/helpers/VideoSequenceBuilder.swift @@ -191,13 +191,29 @@ internal class VideoSequenceBuilder { let clipTimeRange = clampedRange.duration > .zero ? clampedRange : rawClipTimeRange let clipDuration = clipTimeRange.duration let insertStart = totalDuration + let sourceRanges: [CMTimeRange] + if clip.reverseVideo { + sourceRanges = reverseTimeRanges( + for: clipTimeRange, + frameDuration: frameDuration(for: nominalFrameRate) + ) + } else { + sourceRanges = [clipTimeRange] + } - // Insert video clip into the composition track - try compositionVideoTrack.insertTimeRange( - clipTimeRange, - of: videoTrack, - at: insertStart - ) + // Insert video clip into the composition track. + var segmentInsertTime = insertStart + for sourceRange in sourceRanges { + try compositionVideoTrack.insertTimeRange( + sourceRange, + of: videoTrack, + at: segmentInsertTime + ) + segmentInsertTime = CMTimeAdd(segmentInsertTime, sourceRange.duration) + } + if clip.reverseVideo { + PluginLog.print("โช Clip \(index) reversed with \(sourceRanges.count) frame slice(s)") + } // Apply per-clip playback speed by scaling the inserted segment. // The global config.playbackSpeed is still applied via @@ -238,11 +254,15 @@ internal class VideoSequenceBuilder { PluginLog.print(" Format: \(audioTrack.mediaType)") do { - try sharedAudioTrack.insertTimeRange( - clipTimeRange, - of: audioTrack, - at: insertStart - ) + var audioInsertTime = insertStart + for sourceRange in sourceRanges { + try sharedAudioTrack.insertTimeRange( + sourceRange, + of: audioTrack, + at: audioInsertTime + ) + audioInsertTime = CMTimeAdd(audioInsertTime, sourceRange.duration) + } // 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) @@ -268,7 +288,7 @@ internal class VideoSequenceBuilder { PluginLog.print("โœ… Clip \(index) added successfully") 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" + " - Time range in composition: \(String(format: "%.2f", insertStart.seconds))s - \(String(format: "%.2f", totalDuration.seconds))s" ) } @@ -331,6 +351,35 @@ internal class VideoSequenceBuilder { let duration = CMTimeSubtract(endTime, startTime) return CMTimeRange(start: startTime, duration: duration) } + + /// Returns a frame-sized duration used for reverse rendering slices. + private func frameDuration(for nominalFrameRate: Float) -> CMTime { + let fps = nominalFrameRate > 0 ? nominalFrameRate : 30.0 + let timescale = max(1, Int32(fps.rounded())) + return CMTime(value: 1, timescale: timescale) + } + + /// Builds forward-playable source slices ordered from the end of the range to the start. + private func reverseTimeRanges(for timeRange: CMTimeRange, frameDuration: CMTime) -> [CMTimeRange] { + var ranges: [CMTimeRange] = [] + var cursorEnd = CMTimeRangeGetEnd(timeRange) + var remainingDuration = timeRange.duration + + while CMTimeCompare(remainingDuration, CMTime.zero) > 0 { + let sliceDuration: CMTime + if CMTimeCompare(remainingDuration, frameDuration) < 0 { + sliceDuration = remainingDuration + } else { + sliceDuration = frameDuration + } + let sliceStart = CMTimeSubtract(cursorEnd, sliceDuration) + ranges.append(CMTimeRange(start: sliceStart, duration: sliceDuration)) + cursorEnd = sliceStart + remainingDuration = CMTimeSubtract(remainingDuration, sliceDuration) + } + + return ranges + } } /// Instruction for a single clip in the sequence. diff --git a/ios/Classes/src/features/render/models/RenderConfig.swift b/ios/Classes/src/features/render/models/RenderConfig.swift index 7537280a..6401e89a 100644 --- a/ios/Classes/src/features/render/models/RenderConfig.swift +++ b/ios/Classes/src/features/render/models/RenderConfig.swift @@ -270,7 +270,8 @@ struct RenderConfig { startUs: (clipMap["startUs"] as? NSNumber)?.int64Value, endUs: (clipMap["endUs"] as? NSNumber)?.int64Value, volume: (clipMap["volume"] as? NSNumber)?.floatValue, - playbackSpeed: (clipMap["playbackSpeed"] as? NSNumber)?.floatValue + playbackSpeed: (clipMap["playbackSpeed"] as? NSNumber)?.floatValue, + reverseVideo: clipMap["reverseVideo"] as? Bool ?? false ) } } diff --git a/ios/Classes/src/features/render/models/VideoClip.swift b/ios/Classes/src/features/render/models/VideoClip.swift index 87369a5c..d6befd1d 100644 --- a/ios/Classes/src/features/render/models/VideoClip.swift +++ b/ios/Classes/src/features/render/models/VideoClip.swift @@ -7,18 +7,21 @@ internal struct VideoClip { let endUs: Int64? let volume: Float? let playbackSpeed: Float? + let reverseVideo: Bool init( inputPath: String, startUs: Int64? = nil, endUs: Int64? = nil, volume: Float? = nil, - playbackSpeed: Float? = nil + playbackSpeed: Float? = nil, + reverseVideo: Bool = false ) { self.inputPath = inputPath self.startUs = startUs self.endUs = endUs self.volume = volume self.playbackSpeed = playbackSpeed + self.reverseVideo = reverseVideo } } diff --git a/lib/core/models/video/video_segment_model.dart b/lib/core/models/video/video_segment_model.dart index 7646d958..9d2834c4 100644 --- a/lib/core/models/video/video_segment_model.dart +++ b/lib/core/models/video/video_segment_model.dart @@ -17,6 +17,7 @@ class VideoSegment { this.endTime, this.volume, this.playbackSpeed, + this.reverseVideo = false, }) : assert( startTime == null || endTime == null || startTime < endTime, 'startTime must be before endTime', @@ -62,6 +63,14 @@ class VideoSegment { /// If null, the original speed is used. final double? playbackSpeed; + /// Whether to render this segment backwards. + /// + /// When `true`, this segment plays from its trimmed end back to its trimmed + /// start. Other segments keep their own order and direction. + /// + /// **Default**: `false` + final bool reverseVideo; + /// Converts this clip to a map for platform channel communication. Future> toAsyncMap() async { final inputPath = await video.safeFilePath(); @@ -72,6 +81,7 @@ class VideoSegment { 'endUs': endTime?.inMicroseconds, 'volume': volume, 'playbackSpeed': playbackSpeed, + 'reverseVideo': reverseVideo, }; } @@ -82,6 +92,7 @@ class VideoSegment { Duration? endTime, double? volume, double? playbackSpeed, + bool? reverseVideo, }) { return VideoSegment( video: video ?? this.video, @@ -89,6 +100,7 @@ class VideoSegment { endTime: endTime ?? this.endTime, volume: volume ?? this.volume, playbackSpeed: playbackSpeed ?? this.playbackSpeed, + reverseVideo: reverseVideo ?? this.reverseVideo, ); } @@ -100,7 +112,8 @@ class VideoSegment { other.startTime == startTime && other.endTime == endTime && other.volume == volume && - other.playbackSpeed == playbackSpeed; + other.playbackSpeed == playbackSpeed && + other.reverseVideo == reverseVideo; } @override @@ -109,7 +122,8 @@ class VideoSegment { startTime.hashCode ^ endTime.hashCode ^ volume.hashCode ^ - playbackSpeed.hashCode; + playbackSpeed.hashCode ^ + reverseVideo.hashCode; } @override @@ -118,7 +132,8 @@ class VideoSegment { 'startTime: $startTime, ' 'endTime: $endTime, ' 'volume: $volume, ' - 'playbackSpeed: $playbackSpeed)'; + 'playbackSpeed: $playbackSpeed, ' + 'reverseVideo: $reverseVideo)'; } Map toMap() { @@ -128,6 +143,7 @@ class VideoSegment { 'endTime': endTime?.inMicroseconds, 'volume': volume, 'playbackSpeed': playbackSpeed, + 'reverseVideo': reverseVideo, }; } @@ -142,6 +158,7 @@ class VideoSegment { : null, volume: tryParseDouble(map['volume']), playbackSpeed: tryParseDouble(map['playbackSpeed']), + reverseVideo: map['reverseVideo'] as bool? ?? false, ); } diff --git a/macos/Classes/src/features/render/RenderVideo.swift b/macos/Classes/src/features/render/RenderVideo.swift index 8566d9e8..fac120da 100644 --- a/macos/Classes/src/features/render/RenderVideo.swift +++ b/macos/Classes/src/features/render/RenderVideo.swift @@ -76,7 +76,8 @@ class RenderVideo { startUs: clip.startUs, endUs: clip.endUs, volume: clip.volume, - playbackSpeed: clip.playbackSpeed + playbackSpeed: clip.playbackSpeed, + reverseVideo: clip.reverseVideo ) } return clip diff --git a/macos/Classes/src/features/render/helpers/VideoSequenceBuilder.swift b/macos/Classes/src/features/render/helpers/VideoSequenceBuilder.swift index 0b37bc0e..b9e45f27 100644 --- a/macos/Classes/src/features/render/helpers/VideoSequenceBuilder.swift +++ b/macos/Classes/src/features/render/helpers/VideoSequenceBuilder.swift @@ -186,13 +186,29 @@ internal class VideoSequenceBuilder { let clipTimeRange = clampedRange.duration > .zero ? clampedRange : rawClipTimeRange let clipDuration = clipTimeRange.duration let insertStart = totalDuration + let sourceRanges: [CMTimeRange] + if clip.reverseVideo { + sourceRanges = reverseTimeRanges( + for: clipTimeRange, + frameDuration: frameDuration(for: nominalFrameRate) + ) + } else { + sourceRanges = [clipTimeRange] + } - // Insert video clip into the composition track - try compositionVideoTrack.insertTimeRange( - clipTimeRange, - of: videoTrack, - at: insertStart - ) + // Insert video clip into the composition track. + var segmentInsertTime = insertStart + for sourceRange in sourceRanges { + try compositionVideoTrack.insertTimeRange( + sourceRange, + of: videoTrack, + at: segmentInsertTime + ) + segmentInsertTime = CMTimeAdd(segmentInsertTime, sourceRange.duration) + } + if clip.reverseVideo { + PluginLog.print("โช Clip \(index) reversed with \(sourceRanges.count) frame slice(s)") + } // Apply per-clip playback speed by scaling the inserted segment. // The global config.playbackSpeed is still applied via @@ -233,11 +249,15 @@ internal class VideoSequenceBuilder { PluginLog.print(" Format: \(audioTrack.mediaType)") do { - try sharedAudioTrack.insertTimeRange( - clipTimeRange, - of: audioTrack, - at: insertStart - ) + var audioInsertTime = insertStart + for sourceRange in sourceRanges { + try sharedAudioTrack.insertTimeRange( + sourceRange, + of: audioTrack, + at: audioInsertTime + ) + audioInsertTime = CMTimeAdd(audioInsertTime, sourceRange.duration) + } // 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) @@ -263,7 +283,7 @@ internal class VideoSequenceBuilder { PluginLog.print("โœ… Clip \(index) added successfully") 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" + " - Time range in composition: \(String(format: "%.2f", insertStart.seconds))s - \(String(format: "%.2f", totalDuration.seconds))s" ) } @@ -336,6 +356,35 @@ internal class VideoSequenceBuilder { let duration = CMTimeSubtract(endTime, startTime) return CMTimeRange(start: startTime, duration: duration) } + + /// Returns a frame-sized duration used for reverse rendering slices. + private func frameDuration(for nominalFrameRate: Float) -> CMTime { + let fps = nominalFrameRate > 0 ? nominalFrameRate : 30.0 + let timescale = max(1, Int32(fps.rounded())) + return CMTime(value: 1, timescale: timescale) + } + + /// Builds forward-playable source slices ordered from the end of the range to the start. + private func reverseTimeRanges(for timeRange: CMTimeRange, frameDuration: CMTime) -> [CMTimeRange] { + var ranges: [CMTimeRange] = [] + var cursorEnd = CMTimeRangeGetEnd(timeRange) + var remainingDuration = timeRange.duration + + while CMTimeCompare(remainingDuration, CMTime.zero) > 0 { + let sliceDuration: CMTime + if CMTimeCompare(remainingDuration, frameDuration) < 0 { + sliceDuration = remainingDuration + } else { + sliceDuration = frameDuration + } + let sliceStart = CMTimeSubtract(cursorEnd, sliceDuration) + ranges.append(CMTimeRange(start: sliceStart, duration: sliceDuration)) + cursorEnd = sliceStart + remainingDuration = CMTimeSubtract(remainingDuration, sliceDuration) + } + + return ranges + } } /// Instruction for a single clip in the sequence. diff --git a/macos/Classes/src/features/render/models/RenderConfig.swift b/macos/Classes/src/features/render/models/RenderConfig.swift index e9676643..12ea24d8 100644 --- a/macos/Classes/src/features/render/models/RenderConfig.swift +++ b/macos/Classes/src/features/render/models/RenderConfig.swift @@ -270,7 +270,8 @@ struct RenderConfig { startUs: (clipMap["startUs"] as? NSNumber)?.int64Value, endUs: (clipMap["endUs"] as? NSNumber)?.int64Value, volume: (clipMap["volume"] as? NSNumber)?.floatValue, - playbackSpeed: (clipMap["playbackSpeed"] as? NSNumber)?.floatValue + playbackSpeed: (clipMap["playbackSpeed"] as? NSNumber)?.floatValue, + reverseVideo: clipMap["reverseVideo"] as? Bool ?? false ) } } diff --git a/macos/Classes/src/features/render/models/VideoClip.swift b/macos/Classes/src/features/render/models/VideoClip.swift index 87369a5c..d6befd1d 100644 --- a/macos/Classes/src/features/render/models/VideoClip.swift +++ b/macos/Classes/src/features/render/models/VideoClip.swift @@ -7,18 +7,21 @@ internal struct VideoClip { let endUs: Int64? let volume: Float? let playbackSpeed: Float? + let reverseVideo: Bool init( inputPath: String, startUs: Int64? = nil, endUs: Int64? = nil, volume: Float? = nil, - playbackSpeed: Float? = nil + playbackSpeed: Float? = nil, + reverseVideo: Bool = false ) { self.inputPath = inputPath self.startUs = startUs self.endUs = endUs self.volume = volume self.playbackSpeed = playbackSpeed + self.reverseVideo = reverseVideo } } diff --git a/pubspec.yaml b/pubspec.yaml index ba5e9eca..f8b92c30 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.16.3 +version: 1.17.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_segment_model_test.dart b/test/core/models/video/video_segment_model_test.dart index 5bb58d0d..5954df36 100644 --- a/test/core/models/video/video_segment_model_test.dart +++ b/test/core/models/video/video_segment_model_test.dart @@ -19,6 +19,14 @@ void main() { expect(map['startTime'], 2000000); expect(map['endTime'], 10000000); expect(map['volume'], 0.8); + expect(map['reverseVideo'], isFalse); + }); + + test('serializes reverseVideo correctly', () { + final reversed = VideoSegment(video: video, reverseVideo: true); + final map = reversed.toMap(); + + expect(map['reverseVideo'], isTrue); }); test('serializes null fields as null', () { @@ -28,6 +36,7 @@ void main() { expect(map['startTime'], isNull); expect(map['endTime'], isNull); expect(map['volume'], isNull); + expect(map['reverseVideo'], isFalse); }); }); @@ -40,6 +49,7 @@ void main() { expect(restored.startTime, segment.startTime); expect(restored.endTime, segment.endTime); expect(restored.volume, segment.volume); + expect(restored.reverseVideo, segment.reverseVideo); }); test('handles null optional fields', () { @@ -50,6 +60,16 @@ void main() { expect(restored.startTime, isNull); expect(restored.endTime, isNull); expect(restored.volume, isNull); + expect(restored.reverseVideo, isFalse); + }); + + test('deserializes reverseVideo correctly', () { + final restored = VideoSegment.fromMap({ + 'video': {'assetPath': 'assets/test.mp4'}, + 'reverseVideo': true, + }); + + expect(restored.reverseVideo, isTrue); }); test('parses numeric strings safely', () { @@ -76,9 +96,10 @@ void main() { group('copyWith', () { test('creates copy with updated fields', () { - final copy = segment.copyWith(volume: 1.5); + final copy = segment.copyWith(volume: 1.5, reverseVideo: true); expect(copy.volume, 1.5); + expect(copy.reverseVideo, isTrue); expect(copy.video, segment.video); expect(copy.startTime, segment.startTime); });