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.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`).

Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Triple<Int, Int, Int>>()

data class CropConfig(
val width: Int?,
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
}

Expand Down Expand Up @@ -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) {
Expand All @@ -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)
}
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<VideoClip>): List<VideoClip> {
val result = mutableListOf<VideoClip>()

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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

/**
Expand Down Expand Up @@ -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
}
Expand Down
Loading
Loading