From 8d0653a2ecdb9d971f4db4cd0cd748bcca44c5d6 Mon Sep 17 00:00:00 2001 From: Frikkie Snyman <10909252+FrikkieSnyman@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:17:53 +0200 Subject: [PATCH 1/4] feat: add startTime/endTime to compressVideo for single-pass trim + compress Trim-then-compress runs two full encodes on Android, the first at the source resolution (Media3 transcodes any clip that does not start on a keyframe). A range on compressVideo clips inside the same transform: ClippingConfiguration on Android, timeRange on the AVAssetExportSession and AVAssetReader on iOS. The size budget uses the trimmed duration, the smaller-than-original check and the inflation fallback are skipped for a range, and an empty range rejects before a session starts. Closes #12 --- README.md | 6 +++ .../com/mediatoolkit/HybridMediaToolkit.kt | 2 + .../java/com/mediatoolkit/VideoProcessor.kt | 33 +++++++++++-- ios/HybridMediaToolkit.swift | 2 + ios/VideoProcessor.swift | 46 +++++++++++++++---- .../android/c++/JCompressVideoOptions.hpp | 10 +++- .../com/mediatoolkit/CompressVideoOptions.kt | 16 +++++-- .../ios/swift/CompressVideoOptions.swift | 38 ++++++++++++++- .../shared/c++/CompressVideoOptions.hpp | 10 +++- src/MediaToolkit.nitro.ts | 4 ++ 10 files changed, 150 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 6701d4a..4aedf7c 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. | +| `endTime` | `number` | video end | **Optional.** Trim end in ms. | | `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 an empty range rejects with `invalidInput` before any session starts. 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/src/main/java/com/mediatoolkit/HybridMediaToolkit.kt b/android/src/main/java/com/mediatoolkit/HybridMediaToolkit.kt index 1da9b0c..aa5d05a 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 ?: 0.0).toLong(), + (options.endTime ?: 0.0).toLong(), 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 9f16159..6679cde 100644 --- a/android/src/main/java/com/mediatoolkit/VideoProcessor.kt +++ b/android/src/main/java/com/mediatoolkit/VideoProcessor.kt @@ -380,6 +380,8 @@ internal object VideoProcessor { fun compressVideo( context: Context, uri: String, + startMs: Long, // 0 = from the start + endMs: Long, // 0 = to the end quality: String, bitrate: Int, // 0 = use quality preset (matches iOS behaviour) targetSizeInMB: Double, @@ -391,7 +393,19 @@ internal object VideoProcessor { ): Map { val out = outputPath ?: tempPath() val mediaUri = toAndroidUri(uri) - val mediaItem = MediaItem.Builder().setUri(mediaUri).build() + // Trim inside the same transform so the encoder only ever runs at the + // output resolution; a separate trim would re-encode at source resolution. + val trimRequested = startMs > 0 || endMs > 0 + val mediaItemBuilder = MediaItem.Builder().setUri(mediaUri) + if (trimRequested) { + mediaItemBuilder.setClippingConfiguration( + androidx.media3.common.MediaItem.ClippingConfiguration.Builder() + .setStartPositionMs(maxOf(startMs, 0L)) + .setEndPositionMs(if (endMs > 0) endMs else androidx.media3.common.C.TIME_END_OF_SOURCE) + .build() + ) + } + val mediaItem = mediaItemBuilder.build() // Get duration and dimensions using MediaMetadataRetriever var durationMs = 0L @@ -448,6 +462,18 @@ internal object VideoProcessor { android.util.Log.e("VideoProcessor", "Metadata extraction failed", e) } + if (trimRequested && durationMs > 0) { + val trimEndMs = if (endMs > 0) minOf(endMs, durationMs) else durationMs + val trimStartMs = maxOf(startMs, 0L).coerceAtMost(trimEndMs) + val trimDurationMs = trimEndMs - trimStartMs + if (trimDurationMs <= 0) { + throw MediaToolkitException.InvalidInput("Trim range [${startMs}, ${endMs}]ms is empty for a ${durationMs}ms video.") + } + // Budget the trimmed range, not the whole source. + origSizeMB = origSizeMB * trimDurationMs / durationMs + durationMs = trimDurationMs + } + var computedBitrate = 0 var finalWidth = videoW var finalHeight = videoH @@ -463,7 +489,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).") } @@ -585,7 +612,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/ios/HybridMediaToolkit.swift b/ios/HybridMediaToolkit.swift index 4c67c20..9a949f2 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 ?? 0, + endMs: options.endTime ?? 0, quality: options.quality ?? "medium", bitrate: options.bitrate ?? 0, targetSizeInMB: options.targetSizeInMB ?? 0, diff --git a/ios/VideoProcessor.swift b/ios/VideoProcessor.swift index 07fe785..4817976 100644 --- a/ios/VideoProcessor.swift +++ b/ios/VideoProcessor.swift @@ -748,6 +748,8 @@ class VideoProcessor: NSObject { @objc static func compressVideo( uri: String, + startMs: Double, + endMs: Double, quality: String, bitrate: Double, targetSizeInMB: Double, @@ -767,9 +769,28 @@ class VideoProcessor: NSObject { let outURL = URL(fileURLWithPath: out) removeIfExists(outURL) + // 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 + if startMs > 0 || endMs > 0 { + let trimEndSecs = endMs > 0 ? min(endMs / 1000.0, sourceDurationSecs) : sourceDurationSecs + let trimStartSecs = min(max(startMs / 1000.0, 0), trimEndSecs) + guard trimEndSecs > trimStartSecs else { + completion(nil, MediaToolkitError.invalidInput("Trim range [\(startMs), \(endMs)]ms is empty for a \(Int(sourceDurationSecs * 1000))ms video.")) + return + } + trimRange = CMTimeRange( + start: CMTime(seconds: trimStartSecs, preferredTimescale: 600), + end: CMTime(seconds: trimEndSecs, preferredTimescale: 600) + ) + } + 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 +804,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 +893,7 @@ class VideoProcessor: NSObject { bitrate: Int(bitrate), muteAudio: muteAudio, maxWidth: maxWidth, + timeRange: trimRange, outputURL: outURL, onProgress: onProgress, completion: completion @@ -908,6 +931,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 +944,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,7 +968,7 @@ 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, + if !muteAudio, trimRange == nil, 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, @@ -955,7 +981,7 @@ class VideoProcessor: NSObject { try? FileManager.default.copyItem(at: sourceURL, to: outURL) } - 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 +1000,7 @@ class VideoProcessor: NSObject { bitrate: Int, muteAudio: Bool, maxWidth: Double, + timeRange: CMTimeRange?, outputURL: URL, onProgress: @escaping ProgressHandler, completion: @escaping Completion @@ -1019,6 +1046,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 +1135,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") 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/src/MediaToolkit.nitro.ts b/src/MediaToolkit.nitro.ts index fd54163..a0cfe2f 100644 --- a/src/MediaToolkit.nitro.ts +++ b/src/MediaToolkit.nitro.ts @@ -127,6 +127,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. */ From b734d6de99df096776e43592303af68339bcdedc Mon Sep 17 00:00:00 2001 From: thangdevalone Date: Thu, 15 Oct 2026 19:00:19 +0700 Subject: [PATCH 2/4] fix: validate compression trim ranges and preserve trimmed output --- .github/workflows/ci.yml | 5 ++ MediaToolkit.podspec | 2 + README.md | 6 +- android/build.gradle | 8 +++ .../com/mediatoolkit/HybridMediaToolkit.kt | 4 +- .../java/com/mediatoolkit/VideoProcessor.kt | 43 ++++++------ .../java/com/mediatoolkit/VideoTrimRange.kt | 22 ++++++ .../com/mediatoolkit/VideoTrimRangeTest.kt | 37 ++++++++++ example/package.json | 2 +- ios/HybridMediaToolkit.swift | 4 +- ios/VideoCompressionFallback.swift | 25 +++++++ ios/VideoProcessor.swift | 67 +++++++------------ ios/VideoTrimRange.swift | 26 +++++++ tests/fixtures/video-trim-ranges.tsv | 26 +++++++ tests/native/README.md | 34 ++++++++++ tests/native/swift/main.swift | 60 +++++++++++++++++ turbo.json | 3 +- 17 files changed, 301 insertions(+), 73 deletions(-) create mode 100644 android/src/main/java/com/mediatoolkit/VideoTrimRange.kt create mode 100644 android/src/test/java/com/mediatoolkit/VideoTrimRangeTest.kt create mode 100644 ios/VideoCompressionFallback.swift create mode 100644 ios/VideoTrimRange.swift create mode 100644 tests/fixtures/video-trim-ranges.tsv create mode 100644 tests/native/README.md create mode 100644 tests/native/swift/main.swift 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 7768c09..579dbb9 100644 --- a/README.md +++ b/README.md @@ -471,11 +471,11 @@ 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. | -| `endTime` | `number` | video end | **Optional.** Trim end in ms. | +| `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 an empty range rejects with `invalidInput` before any session starts. 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. +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` 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 d1a1d1c..b6f4d92 100644 --- a/android/src/main/java/com/mediatoolkit/HybridMediaToolkit.kt +++ b/android/src/main/java/com/mediatoolkit/HybridMediaToolkit.kt @@ -175,8 +175,8 @@ class HybridMediaToolkit : HybridMediaToolkitSpec() { val raw = VideoProcessor.compressVideo( ctx, uri, - (options.startTime ?: 0.0).toLong(), - (options.endTime ?: 0.0).toLong(), + 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 a5a1962..4045a71 100644 --- a/android/src/main/java/com/mediatoolkit/VideoProcessor.kt +++ b/android/src/main/java/com/mediatoolkit/VideoProcessor.kt @@ -388,8 +388,8 @@ internal object VideoProcessor { fun compressVideo( context: Context, uri: String, - startMs: Long, // 0 = from the start - endMs: Long, // 0 = to the end + 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, @@ -401,20 +401,6 @@ internal object VideoProcessor { ): Map { val out = outputPath ?: tempPath() val mediaUri = toAndroidUri(uri) - // Trim inside the same transform so the encoder only ever runs at the - // output resolution; a separate trim would re-encode at source resolution. - val trimRequested = startMs > 0 || endMs > 0 - val mediaItemBuilder = MediaItem.Builder().setUri(mediaUri) - if (trimRequested) { - mediaItemBuilder.setClippingConfiguration( - androidx.media3.common.MediaItem.ClippingConfiguration.Builder() - .setStartPositionMs(maxOf(startMs, 0L)) - .setEndPositionMs(if (endMs > 0) endMs else androidx.media3.common.C.TIME_END_OF_SOURCE) - .build() - ) - } - val mediaItem = mediaItemBuilder.build() - // Get duration and dimensions using MediaMetadataRetriever var durationMs = 0L var videoW = 0 @@ -470,17 +456,28 @@ internal object VideoProcessor { android.util.Log.e("VideoProcessor", "Metadata extraction failed", e) } - if (trimRequested && durationMs > 0) { - val trimEndMs = if (endMs > 0) minOf(endMs, durationMs) else durationMs - val trimStartMs = maxOf(startMs, 0L).coerceAtMost(trimEndMs) - val trimDurationMs = trimEndMs - trimStartMs - if (trimDurationMs <= 0) { - throw MediaToolkitException.InvalidInput("Trim range [${startMs}, ${endMs}]ms is empty for a ${durationMs}ms video.") - } + 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 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 1edcc4c..13e2264 100644 --- a/ios/HybridMediaToolkit.swift +++ b/ios/HybridMediaToolkit.swift @@ -157,8 +157,8 @@ class HybridMediaToolkit: HybridMediaToolkitSpec { try await withCheckedThrowingContinuation { continuation in VideoProcessor.compressVideo( uri: uri, - startMs: options.startTime ?? 0, - endMs: options.endTime ?? 0, + 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 4817976..be790fa 100644 --- a/ios/VideoProcessor.swift +++ b/ios/VideoProcessor.swift @@ -745,11 +745,10 @@ class VideoProcessor: NSObject { // ─── COMPRESS ──────────────────────────────────────────────────────────── - @objc static func compressVideo( uri: String, - startMs: Double, - endMs: Double, + startMs: Double?, + endMs: Double?, quality: String, bitrate: Double, targetSizeInMB: Double, @@ -765,26 +764,24 @@ class VideoProcessor: NSObject { return } - let out = outputPath ?? tempPath(ext: "mp4") - let outURL = URL(fileURLWithPath: out) - removeIfExists(outURL) - // 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 - if startMs > 0 || endMs > 0 { - let trimEndSecs = endMs > 0 ? min(endMs / 1000.0, sourceDurationSecs) : sourceDurationSecs - let trimStartSecs = min(max(startMs / 1000.0, 0), trimEndSecs) - guard trimEndSecs > trimStartSecs else { - completion(nil, MediaToolkitError.invalidInput("Trim range [\(startMs), \(endMs)]ms is empty for a \(Int(sourceDurationSecs * 1000))ms video.")) - return + 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) + ) } - trimRange = CMTimeRange( - start: CMTime(seconds: trimStartSecs, preferredTimescale: 600), - end: CMTime(seconds: trimEndSecs, preferredTimescale: 600) - ) + } 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 @@ -968,18 +965,12 @@ 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, trimRange == nil, - 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 = durationSecs * 1000 completion(videoResult(path: out, asset: asset, trimmed: durationMs), nil) @@ -1289,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/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..ef2b3ee 100644 --- a/turbo.json +++ b/turbo.json @@ -15,7 +15,8 @@ "example/android", "!example/android/.gradle", "!example/android/build", - "!example/android/app/build" + "!example/android/app/build", + "$TURBO_ROOT$/tests/fixtures/**" ], "outputs": [] }, From 6419a92e46cd9b4556eb6af98732477642802e4e Mon Sep 17 00:00:00 2001 From: thangdevalone Date: Thu, 15 Oct 2026 19:02:21 +0700 Subject: [PATCH 3/4] chore: exclude Android unit tests from the published package --- package.json | 1 + 1 file changed, 1 insertion(+) 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__", From 54b9b03cb851c582098efd1ad2bd0cfe014cb04d Mon Sep 17 00:00:00 2001 From: thangdevalone Date: Thu, 15 Oct 2026 19:04:20 +0700 Subject: [PATCH 4/4] fix(ci): include library native sources in build cache inputs --- turbo.json | 39 +++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/turbo.json b/turbo.json index ef2b3ee..21fc887 100644 --- a/turbo.json +++ b/turbo.json @@ -6,17 +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", - "$TURBO_ROOT$/tests/fixtures/**" + "src/**", + "android/**", + "!android/.gradle/**", + "!android/build/**", + "!android/app/build/**" ], "outputs": [] }, @@ -28,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": [] }