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
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@ jobs:
with:
xcode-version: ${{ env.XCODE_VERSION }}

- name: Test video compression range and fallback contracts
run: |
swiftc ios/VideoTrimRange.swift ios/VideoCompressionFallback.swift tests/native/swift/main.swift -o "$RUNNER_TEMP/video-compression-tests"
"$RUNNER_TEMP/video-compression-tests" tests/fixtures/video-trim-ranges.tsv

# Prebuild must run BEFORE setup-ruby because expo prebuild creates example/ios/
- name: Prebuild expo app for iOS
if: env.turbo_cache_hit != 1
Expand Down
2 changes: 2 additions & 0 deletions MediaToolkit.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ Pod::Spec.new do |s|
"ios/HybridMediaToolkit.swift",
"ios/ImageProcessor.swift",
"ios/VideoProcessor.swift",
"ios/VideoTrimRange.swift",
"ios/VideoCompressionFallback.swift",
"ios/MediaToolkitErrors.swift",
"ios/MediaFilters.swift",
]
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@ The compressor supports two modes. Use **one** of them:
const result = await MediaToolkit.compressVideo(videoUri, {
targetSizeInMB: 8, // required for this mode — target output size in MB
minResolution: 480, // optional — minimum short-edge resolution (auto-calculated if omitted)
startTime: 2000, // optional — trim range in ms, encoded in the same pass
endTime: 7000,
muteAudio: false, // optional — strip audio track (default false)
width: 1280, // optional — max output width, aspect ratio preserved
});
Expand Down Expand Up @@ -469,8 +471,12 @@ If none of the three are passed, the library falls back to `quality: 'medium'`.
| `bitrate` | `number` | — | **Optional.** Explicit target bitrate in bps. Overrides `quality`; ignored if `targetSizeInMB` is set. |
| `width` | `number` | original | **Optional.** Max output width in px (aspect ratio preserved). |
| `muteAudio` | `boolean` | `false` | **Optional.** Strip audio track from the output. |
| `startTime` | `number` | `0` | **Optional.** Trim start in ms. Compresses only `[startTime, endTime]` in the same encode pass. Must be finite and nonnegative. |
| `endTime` | `number` | video end | **Optional.** Trim end in ms. Must be finite, greater than `startTime`, and within the video duration. An explicit `0` is invalid. |
| `outputPath` | `string` | temp file | **Optional.** Absolute path for the output file. |

When a trim range is given, `targetSizeInMB` budgets the trimmed duration (not the whole file), the "target must be smaller than the original" check is skipped since the range's size is only an estimate, and invalid ranges reject with `invalidInput` before any session starts. Ranges must span at least one millisecond; omitted bounds default to the start/end, while negative, non-finite, reversed, empty, or out-of-bounds values are rejected. A trimmed export is never replaced with the full source if encoding increases its size, including the explicit-bitrate iOS path. Prefer this over `trimVideo` followed by `compressVideo`: a trim that does not start on a keyframe is a full re-encode at the source resolution on Android, so the two-step path encodes twice.

### `getThumbnail(uri, options?): Promise<ThumbnailResult>`

`options` itself is optional — pass nothing to extract a full-res JPEG at time 0.
Expand Down
8 changes: 8 additions & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,12 @@ android {
jvmTarget = "17"
}

testOptions {
unitTests.all {
systemProperty "mediatoolkit.testFixtures", file("../tests/fixtures").absolutePath
}
}

externalNativeBuild {
cmake {
path "CMakeLists.txt"
Expand All @@ -120,6 +126,8 @@ android {
}

dependencies {
testImplementation "junit:junit:4.13.2"

// react-android is compileOnly: needed by Kotlin to compile (ReactPackage, etc.)
// but NOT included in the library's runtime DEX output.
// The host app always provides react-android at runtime.
Expand Down
2 changes: 2 additions & 0 deletions android/src/main/java/com/mediatoolkit/HybridMediaToolkit.kt
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ class HybridMediaToolkit : HybridMediaToolkitSpec() {
val raw = VideoProcessor.compressVideo(
ctx,
uri,
options.startTime,
options.endTime,
options.quality ?: "medium",
(options.bitrate ?: 0.0).toInt(), // 0 = use quality preset (mirrors iOS behaviour)
options.targetSizeInMB ?: 0.0,
Expand Down
32 changes: 28 additions & 4 deletions android/src/main/java/com/mediatoolkit/VideoProcessor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,8 @@ internal object VideoProcessor {
fun compressVideo(
context: Context,
uri: String,
startMs: Double?, // null = from the start
endMs: Double?, // null = to the end
quality: String,
bitrate: Int, // 0 = use quality preset (matches iOS behaviour)
targetSizeInMB: Double,
Expand All @@ -399,8 +401,6 @@ internal object VideoProcessor {
): Map<String, Any> {
val out = outputPath ?: tempPath()
val mediaUri = toAndroidUri(uri)
val mediaItem = MediaItem.Builder().setUri(mediaUri).build()

// Get duration and dimensions using MediaMetadataRetriever
var durationMs = 0L
var videoW = 0
Expand Down Expand Up @@ -456,6 +456,29 @@ internal object VideoProcessor {
android.util.Log.e("VideoProcessor", "Metadata extraction failed", e)
}

val trimRange = try {
VideoTrimRange.resolve(startMs, endMs, durationMs)
} catch (e: IllegalArgumentException) {
throw MediaToolkitException.InvalidInput(e.message ?: "Invalid trim range")
}
val trimRequested = trimRange != null
// Validate before creating Media3's clipping configuration so all invalid
// ranges reject as InvalidInput, including reversed and out-of-bounds ranges.
val mediaItemBuilder = MediaItem.Builder().setUri(mediaUri)
if (trimRange != null) {
mediaItemBuilder.setClippingConfiguration(
MediaItem.ClippingConfiguration.Builder()
.setStartPositionMs(trimRange.startMs)
.setEndPositionMs(trimRange.endMs)
.build()
)
val trimDurationMs = trimRange.endMs - trimRange.startMs
// Budget the trimmed range, not the whole source.
origSizeMB = origSizeMB * trimDurationMs / durationMs
durationMs = trimDurationMs
}
val mediaItem = mediaItemBuilder.build()

var computedBitrate = 0
var finalWidth = videoW
var finalHeight = videoH
Expand All @@ -471,7 +494,8 @@ internal object VideoProcessor {
val reqMBStr = String.format("%.1f", minRequiredMB)
throw MediaToolkitException.InvalidInput("Target size (${targetSizeInMB}MB) is impossible for a ${durationSecs.toInt()}s video. Minimum required limit is ~${reqMBStr}MB to prevent corruption.")
}
if (origSizeMB > 0 && targetSizeInMB >= origSizeMB) {
// A trimmed range's size is only an estimate; encode it rather than reject.
if (!trimRequested && origSizeMB > 0 && targetSizeInMB >= origSizeMB) {
val reqMBStr = String.format("%.1f", origSizeMB)
throw MediaToolkitException.InvalidInput("Target size (${targetSizeInMB}MB) must be smaller than the original video size (${reqMBStr}MB).")
}
Expand Down Expand Up @@ -593,7 +617,7 @@ internal object VideoProcessor {

// Fallback: If hardware encoder inflates the file beyond original size,
// revert to original to prevent making it worse.
if (origSizeMB > 0 && !muteAudio && finalSizeMB > origSizeMB) {
if (!trimRequested && origSizeMB > 0 && !muteAudio && finalSizeMB > origSizeMB) {
android.util.Log.w("VideoProcessor", "Hardware encoder inflated file from ${String.format("%.1f", origSizeMB)}MB to ${String.format("%.1f", finalSizeMB)}MB. Reverting to original file.")
if (mediaUri.scheme == "content") {
context.contentResolver.openInputStream(mediaUri)?.use { input ->
Expand Down
22 changes: 22 additions & 0 deletions android/src/main/java/com/mediatoolkit/VideoTrimRange.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.margelo.nitro.com.mediatoolkit

/** A validated range in Media3's millisecond timebase. */
internal data class VideoTrimRange(val startMs: Long, val endMs: Long) {
companion object {
fun resolve(startMs: Double?, endMs: Double?, durationMs: Long): VideoTrimRange? {
if (startMs == null && endMs == null) return null

require(durationMs > 0) { "Cannot determine video duration for trimming." }
val start = startMs ?: 0.0
val end = endMs ?: durationMs.toDouble()
require(start.isFinite() && end.isFinite() && start >= 0 && end > start && end <= durationMs.toDouble()) {
"Trim range [$start, $end]ms must satisfy 0 <= startTime < endTime <= ${durationMs}ms."
}
require(end - start >= 1) { "Trim range must span at least one millisecond." }

val range = VideoTrimRange(start.toLong(), end.toLong())
require(range.endMs > range.startMs) { "Trim range must span at least one millisecond." }
return range
}
}
}
37 changes: 37 additions & 0 deletions android/src/test/java/com/mediatoolkit/VideoTrimRangeTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.margelo.nitro.com.mediatoolkit

import java.io.File
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Test

class VideoTrimRangeTest {
@Test
fun validatesSharedRangeContract() {
val fixture = File(System.getProperty("mediatoolkit.testFixtures"), "video-trim-ranges.tsv")
val cases = fixture.readLines().drop(1).filter { it.isNotBlank() }
for (line in cases) {
val columns = line.split('\t')
val (name, start, end, duration, expectedStart) = columns
val expectedEnd = columns[5]
if (expectedStart == "error") {
assertThrows(name, IllegalArgumentException::class.java) {
VideoTrimRange.resolve(parse(start), parse(end), duration.toLong())
}
} else {
val result = VideoTrimRange.resolve(parse(start), parse(end), duration.toLong())
assertEquals(name, parse(expectedStart)?.toLong(), result?.startMs)
assertEquals(name, parse(expectedEnd)?.toLong(), result?.endMs)
}
}
assertEquals(25, cases.size)
}

private fun parse(value: String): Double? = when (value) {
"null" -> null
"nan" -> Double.NaN
"inf" -> Double.POSITIVE_INFINITY
"-inf" -> Double.NEGATIVE_INFINITY
else -> value.toDouble()
}
}
2 changes: 1 addition & 1 deletion example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"web": "expo start --web",
"postinstall": "node -e \"fs.rmSync('node_modules/expo-video/local-maven-repo', {recursive:true, force:true})\"",
"build:ios": "xcodebuild ONLY_ACTIVE_ARCH=YES EXCLUDED_ARCHS=x86_64 -workspace ios/MediaToolkitExample.xcworkspace -UseNewBuildSystem=YES -scheme MediaToolkitExample -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build -quiet",
"build:android": "cd android && ./gradlew assembleDebug -DtestBuildType=debug -Dorg.gradle.jvmargs=-Xmx4g",
"build:android": "cd android && ./gradlew :react-native-media-toolkit:testDebugUnitTest assembleDebug -DtestBuildType=debug -Dorg.gradle.jvmargs=-Xmx4g",
"build:web": "expo export --platform web",
"clean": "npx rimraf android/build android/app/build android/app/.cxx android/.gradle ios/build ios/Pods node_modules/.cache"
},
Expand Down
2 changes: 2 additions & 0 deletions ios/HybridMediaToolkit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ class HybridMediaToolkit: HybridMediaToolkitSpec {
try await withCheckedThrowingContinuation { continuation in
VideoProcessor.compressVideo(
uri: uri,
startMs: options.startTime,
endMs: options.endTime,
quality: options.quality ?? "medium",
bitrate: options.bitrate ?? 0,
targetSizeInMB: options.targetSizeInMB ?? 0,
Expand Down
25 changes: 25 additions & 0 deletions ios/VideoCompressionFallback.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Foundation

enum VideoCompressionFallback {
/// A trimmed export must keep the selected content, even if encoding grows it.
static func restoreOriginalIfLarger(
sourceURL: URL?,
outputURL: URL,
hasTrimRange: Bool,
muteAudio: Bool
) {
guard !hasTrimRange, !muteAudio,
let sourceURL = sourceURL, sourceURL.isFileURL,
let originalAttributes = try? FileManager.default.attributesOfItem(atPath: sourceURL.path),
let originalSize = originalAttributes[.size] as? Int64, originalSize > 0,
let outputAttributes = try? FileManager.default.attributesOfItem(atPath: outputURL.path),
let outputSize = outputAttributes[.size] as? Int64, outputSize > originalSize else {
return
}

NSLog("[MediaToolkit] Encoder inflated file from %.1fMB to %.1fMB. Reverting to original.",
Double(originalSize) / (1024 * 1024), Double(outputSize) / (1024 * 1024))
try? FileManager.default.removeItem(at: outputURL)
try? FileManager.default.copyItem(at: sourceURL, to: outputURL)
}
}
Loading
Loading