diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8666592..a638e44 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/MediaToolkit.podspec b/MediaToolkit.podspec index 3cd16f9..e6dffca 100644 --- a/MediaToolkit.podspec +++ b/MediaToolkit.podspec @@ -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", ] diff --git a/README.md b/README.md index cc3191e..579dbb9 100644 --- a/README.md +++ b/README.md @@ -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 }); @@ -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` `options` itself is optional — pass nothing to extract a full-res JPEG at time 0. diff --git a/android/build.gradle b/android/build.gradle index ecdc5d7..a63ae7b 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -111,6 +111,12 @@ android { jvmTarget = "17" } + testOptions { + unitTests.all { + systemProperty "mediatoolkit.testFixtures", file("../tests/fixtures").absolutePath + } + } + externalNativeBuild { cmake { path "CMakeLists.txt" @@ -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. diff --git a/android/src/main/java/com/mediatoolkit/HybridMediaToolkit.kt b/android/src/main/java/com/mediatoolkit/HybridMediaToolkit.kt index 85d3867..b6f4d92 100644 --- a/android/src/main/java/com/mediatoolkit/HybridMediaToolkit.kt +++ b/android/src/main/java/com/mediatoolkit/HybridMediaToolkit.kt @@ -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, diff --git a/android/src/main/java/com/mediatoolkit/VideoProcessor.kt b/android/src/main/java/com/mediatoolkit/VideoProcessor.kt index 263eb86..4045a71 100644 --- a/android/src/main/java/com/mediatoolkit/VideoProcessor.kt +++ b/android/src/main/java/com/mediatoolkit/VideoProcessor.kt @@ -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, @@ -399,8 +401,6 @@ internal object VideoProcessor { ): Map { 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 @@ -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 @@ -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).") } @@ -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 -> diff --git a/android/src/main/java/com/mediatoolkit/VideoTrimRange.kt b/android/src/main/java/com/mediatoolkit/VideoTrimRange.kt new file mode 100644 index 0000000..e1c1231 --- /dev/null +++ b/android/src/main/java/com/mediatoolkit/VideoTrimRange.kt @@ -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 + } + } +} diff --git a/android/src/test/java/com/mediatoolkit/VideoTrimRangeTest.kt b/android/src/test/java/com/mediatoolkit/VideoTrimRangeTest.kt new file mode 100644 index 0000000..2079b97 --- /dev/null +++ b/android/src/test/java/com/mediatoolkit/VideoTrimRangeTest.kt @@ -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() + } +} diff --git a/example/package.json b/example/package.json index e8a07d9..0c76b08 100644 --- a/example/package.json +++ b/example/package.json @@ -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" }, diff --git a/ios/HybridMediaToolkit.swift b/ios/HybridMediaToolkit.swift index facd453..13e2264 100644 --- a/ios/HybridMediaToolkit.swift +++ b/ios/HybridMediaToolkit.swift @@ -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, diff --git a/ios/VideoCompressionFallback.swift b/ios/VideoCompressionFallback.swift new file mode 100644 index 0000000..e68d0bc --- /dev/null +++ b/ios/VideoCompressionFallback.swift @@ -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) + } +} diff --git a/ios/VideoProcessor.swift b/ios/VideoProcessor.swift index 07fe785..be790fa 100644 --- a/ios/VideoProcessor.swift +++ b/ios/VideoProcessor.swift @@ -745,9 +745,10 @@ class VideoProcessor: NSObject { // ─── COMPRESS ──────────────────────────────────────────────────────────── - @objc static func compressVideo( uri: String, + startMs: Double?, + endMs: Double?, quality: String, bitrate: Double, targetSizeInMB: Double, @@ -763,13 +764,30 @@ class VideoProcessor: NSObject { return } + // Trim inside the same export so the encoder only ever runs at the output + // resolution; a separate trim would re-encode at source resolution. + let sourceDurationSecs = asset.duration.seconds + var trimRange: CMTimeRange? = nil + do { + if let range = try VideoTrimRange.resolve(startMs: startMs, endMs: endMs, durationMs: sourceDurationSecs * 1000) { + trimRange = CMTimeRange( + start: CMTime(seconds: range.startMs / 1000, preferredTimescale: 1_000_000), + end: CMTime(seconds: range.endMs / 1000, preferredTimescale: 1_000_000) + ) + } + } catch { + completion(nil, MediaToolkitError.invalidInput(error.localizedDescription)) + return + } let out = outputPath ?? tempPath(ext: "mp4") let outURL = URL(fileURLWithPath: out) removeIfExists(outURL) + let durationSecs = trimRange?.duration.seconds ?? sourceDurationSecs + // Budget the trimmed range, not the whole source. + let trimFraction = sourceDurationSecs > 0 ? durationSecs / sourceDurationSecs : 1.0 var preset: String = AVAssetExportPresetMediumQuality if targetSizeInMB > 0 { - let durationSecs = asset.duration.seconds if durationSecs > 0 { // --- Impossible Compression Rejection Logic --- let minRequiredBitrate: Double = 400_000 + (muteAudio ? 0 : 96_000) @@ -783,10 +801,11 @@ class VideoProcessor: NSObject { if let url = (asset as? AVURLAsset)?.url, url.isFileURL, let attr = try? FileManager.default.attributesOfItem(atPath: url.path), let size = attr[.size] as? Int64 { - origSizeMB = Double(size) / (1024.0 * 1024.0) + origSizeMB = Double(size) / (1024.0 * 1024.0) * trimFraction } - if origSizeMB > 0 && targetSizeInMB >= origSizeMB { + // A trimmed range's size is only an estimate; encode it rather than reject. + if trimRange == nil && origSizeMB > 0 && targetSizeInMB >= origSizeMB { let reqMBStr = String(format: "%.1f", origSizeMB) completion(nil, MediaToolkitError.invalidInput("Target size (\(targetSizeInMB)MB) must be smaller than the original video size (\(reqMBStr)MB).")) return @@ -871,6 +890,7 @@ class VideoProcessor: NSObject { bitrate: Int(bitrate), muteAudio: muteAudio, maxWidth: maxWidth, + timeRange: trimRange, outputURL: outURL, onProgress: onProgress, completion: completion @@ -908,6 +928,9 @@ class VideoProcessor: NSObject { session.outputFileType = .mp4 session.outputURL = outURL + if let trimRange = trimRange { + session.timeRange = trimRange + } if targetSizeInMB > 0 { // AVAssetExportSession accepts fileLengthLimit natively — Apple adjusts @@ -918,7 +941,7 @@ class VideoProcessor: NSObject { if let attr = try? FileManager.default.attributesOfItem(atPath: url.path), let size = attr[.size] as? Int64, size > 0 { // Prevent inflation: cap at original file size if it's already smaller than target - fileLimit = min(fileLimit, size) + fileLimit = min(fileLimit, Int64(Double(size) * trimFraction)) } } session.fileLengthLimit = fileLimit @@ -942,20 +965,14 @@ class VideoProcessor: NSObject { // Fallback: If encoder inflates the file beyond original size, // revert to original to prevent making it worse. (Matches Android behavior) - if !muteAudio, - let sourceURL = (asset as? AVURLAsset)?.url, sourceURL.isFileURL, - let origAttr = try? FileManager.default.attributesOfItem(atPath: sourceURL.path), - let origSize = origAttr[.size] as? Int64, origSize > 0, - let outAttr = try? FileManager.default.attributesOfItem(atPath: out), - let outSize = outAttr[.size] as? Int64, outSize > origSize { - let origMB = Double(origSize) / (1024.0 * 1024.0) - let outMB = Double(outSize) / (1024.0 * 1024.0) - NSLog("[MediaToolkit] Encoder inflated file from %.1fMB to %.1fMB. Reverting to original.", origMB, outMB) - try? FileManager.default.removeItem(at: outURL) - try? FileManager.default.copyItem(at: sourceURL, to: outURL) - } + VideoCompressionFallback.restoreOriginalIfLarger( + sourceURL: (asset as? AVURLAsset)?.url, + outputURL: outURL, + hasTrimRange: trimRange != nil, + muteAudio: muteAudio + ) - let durationMs = asset.duration.seconds * 1000 + let durationMs = durationSecs * 1000 completion(videoResult(path: out, asset: asset, trimmed: durationMs), nil) default: completion(nil, session.error ?? MediaToolkitError.processingFailed("Export failed")) @@ -974,6 +991,7 @@ class VideoProcessor: NSObject { bitrate: Int, muteAudio: Bool, maxWidth: Double, + timeRange: CMTimeRange?, outputURL: URL, onProgress: @escaping ProgressHandler, completion: @escaping Completion @@ -1019,6 +1037,9 @@ class VideoProcessor: NSObject { completion(nil, MediaToolkitError.processingFailed("Cannot create asset reader: \(error.localizedDescription)")) return } + if let timeRange = timeRange { + reader.timeRange = timeRange + } let videoReaderOutput = AVAssetReaderTrackOutput(track: videoTrack, outputSettings: [ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange @@ -1105,9 +1126,9 @@ class VideoProcessor: NSObject { completion(nil, writer.error ?? MediaToolkitError.processingFailed("Cannot start writing")) return } - writer.startSession(atSourceTime: .zero) + writer.startSession(atSourceTime: timeRange?.start ?? .zero) - let totalDuration = max(asset.duration.seconds, 0.001) + let totalDuration = max(timeRange?.duration.seconds ?? asset.duration.seconds, 0.001) let group = DispatchGroup() let videoQueue = DispatchQueue(label: "com.mediatoolkit.compress.video") let audioQueue = DispatchQueue(label: "com.mediatoolkit.compress.audio") @@ -1259,18 +1280,12 @@ class VideoProcessor: NSObject { // Anti-inflation fallback: if output is larger than original, revert. // (Matches AVAssetExportSession path and Android behavior) - if !muteAudio, - let sourceURL = (asset as? AVURLAsset)?.url, sourceURL.isFileURL, - let origAttr = try? FileManager.default.attributesOfItem(atPath: sourceURL.path), - let origSize = origAttr[.size] as? Int64, origSize > 0, - let outAttr = try? FileManager.default.attributesOfItem(atPath: outPath), - let outSize = outAttr[.size] as? Int64, outSize > origSize { - let origMB = Double(origSize) / (1024.0 * 1024.0) - let outMB = Double(outSize) / (1024.0 * 1024.0) - NSLog("[MediaToolkit] Encoder inflated file from %.1fMB to %.1fMB. Reverting to original.", origMB, outMB) - try? FileManager.default.removeItem(at: outputURL) - try? FileManager.default.copyItem(at: sourceURL, to: outputURL) - } + VideoCompressionFallback.restoreOriginalIfLarger( + sourceURL: (asset as? AVURLAsset)?.url, + outputURL: outputURL, + hasTrimRange: timeRange != nil, + muteAudio: muteAudio + ) let durationMs = asset.duration.seconds * 1000 completion(videoResult(path: outPath, asset: asset, trimmed: durationMs), nil) diff --git a/ios/VideoTrimRange.swift b/ios/VideoTrimRange.swift new file mode 100644 index 0000000..bd1849d --- /dev/null +++ b/ios/VideoTrimRange.swift @@ -0,0 +1,26 @@ +import Foundation + +struct VideoTrimRange { + let startMs: Double + let endMs: Double + + struct ValidationError: LocalizedError { + let errorDescription: String? + } + + static func resolve(startMs: Double?, endMs: Double?, durationMs: Double) throws -> VideoTrimRange? { + guard startMs != nil || endMs != nil else { return nil } + guard durationMs.isFinite, durationMs > 0 else { + throw ValidationError(errorDescription: "Cannot determine video duration for trimming.") + } + let start = startMs ?? 0 + let end = endMs ?? durationMs + guard start.isFinite, end.isFinite, start >= 0, end > start, end <= durationMs else { + throw ValidationError(errorDescription: "Trim range [\(start), \(end)]ms must satisfy 0 <= startTime < endTime <= \(durationMs)ms.") + } + guard end - start >= 1 else { + throw ValidationError(errorDescription: "Trim range must span at least one millisecond.") + } + return VideoTrimRange(startMs: start, endMs: end) + } +} diff --git a/nitrogen/generated/android/c++/JCompressVideoOptions.hpp b/nitrogen/generated/android/c++/JCompressVideoOptions.hpp index 32ea7cd..a39078e 100644 --- a/nitrogen/generated/android/c++/JCompressVideoOptions.hpp +++ b/nitrogen/generated/android/c++/JCompressVideoOptions.hpp @@ -32,6 +32,10 @@ namespace margelo::nitro::mediatoolkit { [[nodiscard]] CompressVideoOptions toCpp() const { static const auto clazz = javaClassStatic(); + static const auto fieldStartTime = clazz->getField("startTime"); + jni::local_ref startTime = this->getFieldValue(fieldStartTime); + static const auto fieldEndTime = clazz->getField("endTime"); + jni::local_ref endTime = this->getFieldValue(fieldEndTime); static const auto fieldTargetSizeInMB = clazz->getField("targetSizeInMB"); jni::local_ref targetSizeInMB = this->getFieldValue(fieldTargetSizeInMB); static const auto fieldMinResolution = clazz->getField("minResolution"); @@ -47,6 +51,8 @@ namespace margelo::nitro::mediatoolkit { static const auto fieldOutputPath = clazz->getField("outputPath"); jni::local_ref outputPath = this->getFieldValue(fieldOutputPath); return CompressVideoOptions( + startTime != nullptr ? std::make_optional(startTime->value()) : std::nullopt, + endTime != nullptr ? std::make_optional(endTime->value()) : std::nullopt, targetSizeInMB != nullptr ? std::make_optional(targetSizeInMB->value()) : std::nullopt, minResolution != nullptr ? std::make_optional(minResolution->value()) : std::nullopt, quality != nullptr ? std::make_optional(quality->toStdString()) : std::nullopt, @@ -63,11 +69,13 @@ namespace margelo::nitro::mediatoolkit { */ [[maybe_unused]] static jni::local_ref fromCpp(const CompressVideoOptions& value) { - using JSignature = JCompressVideoOptions(jni::alias_ref, jni::alias_ref, jni::alias_ref, jni::alias_ref, jni::alias_ref, jni::alias_ref, jni::alias_ref); + using JSignature = JCompressVideoOptions(jni::alias_ref, jni::alias_ref, jni::alias_ref, jni::alias_ref, jni::alias_ref, jni::alias_ref, jni::alias_ref, jni::alias_ref, jni::alias_ref); static const auto clazz = javaClassStatic(); static const auto create = clazz->getStaticMethod("fromCpp"); return create( clazz, + value.startTime.has_value() ? jni::JDouble::valueOf(value.startTime.value()) : nullptr, + value.endTime.has_value() ? jni::JDouble::valueOf(value.endTime.value()) : nullptr, value.targetSizeInMB.has_value() ? jni::JDouble::valueOf(value.targetSizeInMB.value()) : nullptr, value.minResolution.has_value() ? jni::JDouble::valueOf(value.minResolution.value()) : nullptr, value.quality.has_value() ? jni::make_jstring(value.quality.value()) : nullptr, diff --git a/nitrogen/generated/android/kotlin/com/margelo/nitro/com/mediatoolkit/CompressVideoOptions.kt b/nitrogen/generated/android/kotlin/com/margelo/nitro/com/mediatoolkit/CompressVideoOptions.kt index 68f862a..17d263a 100644 --- a/nitrogen/generated/android/kotlin/com/margelo/nitro/com/mediatoolkit/CompressVideoOptions.kt +++ b/nitrogen/generated/android/kotlin/com/margelo/nitro/com/mediatoolkit/CompressVideoOptions.kt @@ -18,6 +18,12 @@ import java.util.Objects @DoNotStrip @Keep data class CompressVideoOptions( + @DoNotStrip + @Keep + val startTime: Double?, + @DoNotStrip + @Keep + val endTime: Double?, @DoNotStrip @Keep val targetSizeInMB: Double?, @@ -45,7 +51,9 @@ data class CompressVideoOptions( override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is CompressVideoOptions) return false - return Objects.deepEquals(this.targetSizeInMB, other.targetSizeInMB) + return Objects.deepEquals(this.startTime, other.startTime) + && Objects.deepEquals(this.endTime, other.endTime) + && Objects.deepEquals(this.targetSizeInMB, other.targetSizeInMB) && Objects.deepEquals(this.minResolution, other.minResolution) && Objects.deepEquals(this.quality, other.quality) && Objects.deepEquals(this.bitrate, other.bitrate) @@ -56,6 +64,8 @@ data class CompressVideoOptions( override fun hashCode(): Int { return arrayOf( + startTime, + endTime, targetSizeInMB, minResolution, quality, @@ -74,8 +84,8 @@ data class CompressVideoOptions( @Keep @Suppress("unused") @JvmStatic - private fun fromCpp(targetSizeInMB: Double?, minResolution: Double?, quality: String?, bitrate: Double?, width: Double?, muteAudio: Boolean?, outputPath: String?): CompressVideoOptions { - return CompressVideoOptions(targetSizeInMB, minResolution, quality, bitrate, width, muteAudio, outputPath) + private fun fromCpp(startTime: Double?, endTime: Double?, targetSizeInMB: Double?, minResolution: Double?, quality: String?, bitrate: Double?, width: Double?, muteAudio: Boolean?, outputPath: String?): CompressVideoOptions { + return CompressVideoOptions(startTime, endTime, targetSizeInMB, minResolution, quality, bitrate, width, muteAudio, outputPath) } } } diff --git a/nitrogen/generated/ios/swift/CompressVideoOptions.swift b/nitrogen/generated/ios/swift/CompressVideoOptions.swift index 52d6b16..90f7f0e 100644 --- a/nitrogen/generated/ios/swift/CompressVideoOptions.swift +++ b/nitrogen/generated/ios/swift/CompressVideoOptions.swift @@ -18,8 +18,20 @@ public extension CompressVideoOptions { /** * Create a new instance of `CompressVideoOptions`. */ - init(targetSizeInMB: Double?, minResolution: Double?, quality: String?, bitrate: Double?, width: Double?, muteAudio: Bool?, outputPath: String?) { + init(startTime: Double?, endTime: Double?, targetSizeInMB: Double?, minResolution: Double?, quality: String?, bitrate: Double?, width: Double?, muteAudio: Bool?, outputPath: String?) { self.init({ () -> bridge.std__optional_double_ in + if let __unwrappedValue = startTime { + return bridge.create_std__optional_double_(__unwrappedValue) + } else { + return .init() + } + }(), { () -> bridge.std__optional_double_ in + if let __unwrappedValue = endTime { + return bridge.create_std__optional_double_(__unwrappedValue) + } else { + return .init() + } + }(), { () -> bridge.std__optional_double_ in if let __unwrappedValue = targetSizeInMB { return bridge.create_std__optional_double_(__unwrappedValue) } else { @@ -64,6 +76,30 @@ public extension CompressVideoOptions { }()) } + @inline(__always) + var startTime: Double? { + return { () -> Double? in + if bridge.has_value_std__optional_double_(self.__startTime) { + let __unwrapped = bridge.get_std__optional_double_(self.__startTime) + return __unwrapped + } else { + return nil + } + }() + } + + @inline(__always) + var endTime: Double? { + return { () -> Double? in + if bridge.has_value_std__optional_double_(self.__endTime) { + let __unwrapped = bridge.get_std__optional_double_(self.__endTime) + return __unwrapped + } else { + return nil + } + }() + } + @inline(__always) var targetSizeInMB: Double? { return { () -> Double? in diff --git a/nitrogen/generated/shared/c++/CompressVideoOptions.hpp b/nitrogen/generated/shared/c++/CompressVideoOptions.hpp index f154ea4..c07b88d 100644 --- a/nitrogen/generated/shared/c++/CompressVideoOptions.hpp +++ b/nitrogen/generated/shared/c++/CompressVideoOptions.hpp @@ -40,6 +40,8 @@ namespace margelo::nitro::mediatoolkit { */ struct CompressVideoOptions final { public: + std::optional startTime SWIFT_PRIVATE; + std::optional endTime SWIFT_PRIVATE; std::optional targetSizeInMB SWIFT_PRIVATE; std::optional minResolution SWIFT_PRIVATE; std::optional quality SWIFT_PRIVATE; @@ -50,7 +52,7 @@ namespace margelo::nitro::mediatoolkit { public: CompressVideoOptions() = default; - explicit CompressVideoOptions(std::optional targetSizeInMB, std::optional minResolution, std::optional quality, std::optional bitrate, std::optional width, std::optional muteAudio, std::optional outputPath): targetSizeInMB(targetSizeInMB), minResolution(minResolution), quality(quality), bitrate(bitrate), width(width), muteAudio(muteAudio), outputPath(outputPath) {} + explicit CompressVideoOptions(std::optional startTime, std::optional endTime, std::optional targetSizeInMB, std::optional minResolution, std::optional quality, std::optional bitrate, std::optional width, std::optional muteAudio, std::optional outputPath): startTime(startTime), endTime(endTime), targetSizeInMB(targetSizeInMB), minResolution(minResolution), quality(quality), bitrate(bitrate), width(width), muteAudio(muteAudio), outputPath(outputPath) {} public: friend bool operator==(const CompressVideoOptions& lhs, const CompressVideoOptions& rhs) = default; @@ -66,6 +68,8 @@ namespace margelo::nitro { static inline margelo::nitro::mediatoolkit::CompressVideoOptions fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) { jsi::Object obj = arg.asObject(runtime); return margelo::nitro::mediatoolkit::CompressVideoOptions( + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "startTime"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "endTime"))), JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "targetSizeInMB"))), JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "minResolution"))), JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "quality"))), @@ -77,6 +81,8 @@ namespace margelo::nitro { } static inline jsi::Value toJSI(jsi::Runtime& runtime, const margelo::nitro::mediatoolkit::CompressVideoOptions& arg) { jsi::Object obj(runtime); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "startTime"), JSIConverter>::toJSI(runtime, arg.startTime)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "endTime"), JSIConverter>::toJSI(runtime, arg.endTime)); obj.setProperty(runtime, PropNameIDCache::get(runtime, "targetSizeInMB"), JSIConverter>::toJSI(runtime, arg.targetSizeInMB)); obj.setProperty(runtime, PropNameIDCache::get(runtime, "minResolution"), JSIConverter>::toJSI(runtime, arg.minResolution)); obj.setProperty(runtime, PropNameIDCache::get(runtime, "quality"), JSIConverter>::toJSI(runtime, arg.quality)); @@ -94,6 +100,8 @@ namespace margelo::nitro { if (!nitro::isPlainObject(runtime, obj)) { return false; } + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "startTime")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "endTime")))) return false; if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "targetSizeInMB")))) return false; if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "minResolution")))) return false; if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "quality")))) return false; diff --git a/package.json b/package.json index 262f424..e5bbb66 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "!android/gradlew", "!android/gradlew.bat", "!android/local.properties", + "!android/src/test", "!**/__tests__", "!**/__fixtures__", "!**/__mocks__", diff --git a/src/MediaToolkit.nitro.ts b/src/MediaToolkit.nitro.ts index 3ff2e2c..6cf86bf 100644 --- a/src/MediaToolkit.nitro.ts +++ b/src/MediaToolkit.nitro.ts @@ -132,6 +132,10 @@ export interface VideoCropOptions { } export interface CompressVideoOptions { + /** Trim start in milliseconds; encodes only [startTime, endTime] in the same pass */ + startTime?: number; + /** Trim end in milliseconds (defaults to the end of the video) */ + endTime?: number; /** Target file size in Megabytes. Computes an optimal bitrate based on duration. */ targetSizeInMB?: number; /** Minimum resolution (shortest side in px) to protect quality when target size is small. Auto-calculated if omitted. */ diff --git a/tests/fixtures/video-trim-ranges.tsv b/tests/fixtures/video-trim-ranges.tsv new file mode 100644 index 0000000..0702910 --- /dev/null +++ b/tests/fixtures/video-trim-ranges.tsv @@ -0,0 +1,26 @@ +name start end duration expectedStart expectedEnd +omitted null null 10000 null null +omitted_unknown_duration null null 0 null null +start_only 2000 null 10000 2000 10000 +end_only null 7000 10000 0 7000 +explicit_zero_start 0 null 10000 0 10000 +full_range 0 10000 10000 0 10000 +middle_range 2000 7000 10000 2000 7000 +last_millisecond 9999 10000 10000 9999 10000 +empty_zero 0 0 10000 error error +empty_end_only null 0 10000 error error +empty_middle 2000 2000 10000 error error +reversed 7000 2000 10000 error error +negative_start -1 7000 10000 error error +negative_start_only -1 null 10000 error error +negative_end null -1 10000 error error +end_beyond_duration 2000 10001 10000 error error +start_at_end 10000 null 10000 error error +start_beyond_end 10001 null 10000 error error +unknown_duration 0 7000 0 error error +nan_start nan 7000 10000 error error +nan_end 0 nan 10000 error error +infinite_start inf null 10000 error error +infinite_end 0 inf 10000 error error +negative_infinite_start -inf 7000 10000 error error +sub_millisecond_range 0.1 0.2 10000 error error diff --git a/tests/native/README.md b/tests/native/README.md new file mode 100644 index 0000000..5878700 --- /dev/null +++ b/tests/native/README.md @@ -0,0 +1,34 @@ +# Video compression regressions + +Both native implementations consume the same 25 range cases in +`tests/fixtures/video-trim-ranges.tsv`. These cover omitted and one-sided bounds, +explicit zero, a full range, empty and reversed ranges, negative and out-of-bounds +values, unknown duration, non-finite values, and a sub-millisecond range. + +Android runs the contract through JUnit as part of `yarn example build:android`. +To run only that test after generating the example Android project: + +```sh +cd example/android +./gradlew :react-native-media-toolkit:testDebugUnitTest +``` + +The iOS CI job compiles the production Foundation helpers and runs the shared +range contract plus five file-content checks for the compression fallback. +On macOS, run from the repository root: + +```sh +swiftc ios/VideoTrimRange.swift ios/VideoCompressionFallback.swift tests/native/swift/main.swift -o /tmp/video-compression-tests +/tmp/video-compression-tests tests/fixtures/video-trim-ranges.tsv +``` + +The fallback checks use a larger encoded output with different contents from +the source. A trimmed export must retain the output bytes, while an untrimmed, +unmuted export can still revert to the smaller source. Muted and already-smaller +outputs must retain their bytes as well. + +These tests do not exercise device codecs. For an end-to-end check, compress a +middle range of a local clip on iOS using an explicit bitrate large enough that +the encoded range exceeds the source size and `muteAudio: false`; confirm the +output still contains only the selected range. Repeat with the preset pipeline, +one-sided ranges, and `targetSizeInMB` on iOS and Android. diff --git a/tests/native/swift/main.swift b/tests/native/swift/main.swift new file mode 100644 index 0000000..8e36373 --- /dev/null +++ b/tests/native/swift/main.swift @@ -0,0 +1,60 @@ +import Foundation + +func expect(_ condition: @autoclosure () -> Bool, _ message: String) { + if !condition() { fatalError(message) } +} + +func parse(_ value: String) -> Double? { + switch value { + case "null": return nil + case "nan": return .nan + case "inf": return .infinity + case "-inf": return -.infinity + default: return Double(value)! + } +} + +let fixture = try String(contentsOfFile: CommandLine.arguments[1], encoding: .utf8) +let cases = fixture.split(separator: "\n").dropFirst() +for line in cases { + let columns = line.split(separator: "\t").map(String.init) + let name = columns[0] + let shouldFail = columns[4] == "error" + do { + let result = try VideoTrimRange.resolve( + startMs: parse(columns[1]), endMs: parse(columns[2]), durationMs: Double(columns[3])! + ) + expect(!shouldFail, "\(name): accepted an invalid range") + expect(result?.startMs == parse(columns[4]), "\(name): incorrect start") + expect(result?.endMs == parse(columns[5]), "\(name): incorrect end") + } catch { + expect(shouldFail && error is VideoTrimRange.ValidationError, "\(name): unexpected error \(error)") + } +} +expect(cases.count == 25, "Missing range contract cases") + +let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) +try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) +defer { try? FileManager.default.removeItem(at: directory) } +let source = directory.appendingPathComponent("source.mp4") +let output = directory.appendingPathComponent("output.mp4") +let originalData = Data(repeating: 1, count: 32) +let largerClip = Data(repeating: 2, count: 64) +let smallerClip = Data(repeating: 3, count: 16) +try originalData.write(to: source) + +for (trimmed, muted, encoded, expected) in [ + (true, false, largerClip, largerClip), + (true, true, largerClip, largerClip), + (false, true, largerClip, largerClip), + (false, false, largerClip, originalData), + (false, false, smallerClip, smallerClip), +] { + try encoded.write(to: output) + VideoCompressionFallback.restoreOriginalIfLarger( + sourceURL: source, outputURL: output, hasTrimRange: trimmed, muteAudio: muted + ) + let actual = try Data(contentsOf: output) + expect(actual == expected, "Incorrect output contents for trimmed=\(trimmed), muted=\(muted)") +} +print("Passed \(cases.count) range cases and 5 compression fallback cases") diff --git a/turbo.json b/turbo.json index 8b2bf08..21fc887 100644 --- a/turbo.json +++ b/turbo.json @@ -6,16 +6,18 @@ "build:android": { "env": ["ANDROID_HOME", "ORG_GRADLE_PROJECT_newArchEnabled"], "inputs": [ + "$TURBO_ROOT$/package.json", + "$TURBO_ROOT$/android/**", + "!$TURBO_ROOT$/android/build/**", + "$TURBO_ROOT$/src/**", + "$TURBO_ROOT$/nitrogen/**", + "$TURBO_ROOT$/tests/fixtures/**", "package.json", - "android", - "!android/build", - "src/*.ts", - "src/*.tsx", - "example/package.json", - "example/android", - "!example/android/.gradle", - "!example/android/build", - "!example/android/app/build" + "src/**", + "android/**", + "!android/.gradle/**", + "!android/build/**", + "!android/app/build/**" ], "outputs": [] }, @@ -27,15 +29,17 @@ "RCT_USE_PREBUILT_RNCORE" ], "inputs": [ + "$TURBO_ROOT$/package.json", + "$TURBO_ROOT$/*.podspec", + "$TURBO_ROOT$/ios/**", + "!$TURBO_ROOT$/ios/build/**", + "$TURBO_ROOT$/src/**", + "$TURBO_ROOT$/nitrogen/**", "package.json", - "*.podspec", - "ios", - "src/*.ts", - "src/*.tsx", - "example/package.json", - "example/ios", - "!example/ios/build", - "!example/ios/Pods" + "src/**", + "ios/**", + "!ios/build/**", + "!ios/Pods/**" ], "outputs": [] }