Imported from maitrungduc1410/react-native-video-trim (
AGENTS.md). Install upstream withnpx skills add maitrungduc1410/react-native-video-trim. Copyright stays with the author.
AGENTS.md — react-native-video-trim
Project Overview
React Native library for trimming video and audio. Supports both the Old Architecture (Bridge) and New Architecture (Fabric/TurboModules). Built with react-native-builder-bob, Yarn 4 workspaces, and TypeScript. Uses FFmpegKit for media processing on both platforms.
- iOS: Swift + Obj-C++ (
AVFoundation,AVKit,UIKit,Photos, FFmpegKit) - Android: Kotlin + Java (
FFmpeg-mobile,MediaMetadataRetriever,FileProvider) - JS/TS: TurboModule spec with codegen (
VideoTrimSpec)
Repository Layout
src/ # TypeScript source — public API, TurboModule spec, old-arch bridge
index.tsx # Entry point: architecture detection, factory functions, exported API
NativeVideoTrim.ts # TurboModule Spec (codegen source of truth for API surface & events)
OldArch.ts # NativeModules fallback for Old Architecture
__tests__/ # Jest tests (placeholder)
ios/ # Flat directory — all Swift/Obj-C++ native code
VideoTrim.mm # Dual-arch RN bridge (New Arch: NativeVideoTrimSpecBase, Old Arch: RCT_EXTERN)
VideoTrim.swift # Core implementation: RCTEventEmitter, FFmpeg trim, editor, file helpers
VideoTrimProtocol.swift # Delegate protocol for New Arch event forwarding
VideoTrimmerViewController.swift # Full-screen editor UI (theme, transforms, crop, player lifecycle)
VideoTrimmer.swift # Custom UIControl trimmer (thumbnails, handles, scrub, zoom, waveform)
VideoTrimmerThumb.swift # Trimmer handle visuals
AudioWaveformView.swift # UIView that renders audio waveform bars (rounded-rect, normalised amplitudes)
CropOverlayView.swift # Freeform crop overlay (brackets, grid, drag/pinch, theme-aware colors)
AssetLoader.swift # Async AVURLAsset loading
ErrorCode.swift # Error code enum
ProgressAlertController.swift # Modal progress UI during FFmpeg trim
VideoTrim-Bridging-Header.h # Swift/Obj-C interop
android/
build.gradle # Library module: plugins, SDK versions, source sets, FFmpeg dependency
gradle.properties # Default SDK/NDK/FFmpeg versions
src/main/ # Shared base classes, UI, utilities
java/com/videotrim/
BaseVideoTrimModule.kt # Core logic: editor, FFmpeg, file ops, VideoTrimListener
VideoTrimFileProvider.kt # FileProvider subclass owning our manifest merge key
VideoTrimPackage.kt # NativeModule registration
enums/ErrorCode.java
interfaces/ # VideoTrimListener, IVideoTrimmerView
utils/ # MediaMetadataUtil, StorageUtil, VideoTrimmerUtil
widgets/VideoTrimmerView.kt # Full-screen trimmer UI (theme, transforms, crop, player lifecycle, waveform)
widgets/AudioWaveformView.kt # Custom View that renders audio waveform bars (rounded-rect, normalised amplitudes)
widgets/CropOverlayView.kt # Freeform crop overlay (brackets, grid, drag/pinch, theme-aware colors)
java/iknow/android/utils/ # Screen, dp/px, background/UI thread helpers
res/ # Drawables, layout, colors, strings, video_trim_file_paths.xml
src/oldarch/ # Old Architecture module (ReactModule, DeviceEventEmitter)
src/newarch/ # New Architecture module (TurboModule, codegen emitters)
example/ # Yarn workspace example app
src/App.tsx # Active demo (New Arch event listeners)
src/App.OldArch.tsx # Old Arch demo (NativeEventEmitter)
VideoTrim.podspec # CocoaPods spec (reads FFMPEGKIT_PACKAGE env var)
Architecture & Data Flow
Architecture Detection
Runtime detection in src/index.tsx:
const isFabric = !!(global as any).nativeFabricUIManager;
const VideoTrim = isFabric
? require('./NativeVideoTrim').default
: require('./OldArch').default;
Native Module Name
The module is registered as "VideoTrim" on both platforms and both architectures.
Event System
Both platforms emit the same logical events: onShow, onHide, onLoad, onStartTrimming, onFinishTrimming, onCancelTrimming, onCancel, onLog, onStatistics, onError.
- Old Architecture: a single native event
"VideoTrim"is emitted. The payload includes a"name"field with the logical event name. JS listens viaNativeEventEmitter. - New Architecture: each logical event maps to a dedicated codegen emitter (
emitOnLoad,emitOnFinishTrimming, etc.) declared on theSpecasEventEmitter<T>fields.
Factory Functions
src/index.tsx provides factory functions (createBaseOptions, createEditorConfig, createTrimOptions, createCompressOptions, createFrameExtractionOptions, createExtractAudioOptions, createGifOptions, createMergeOptions) that merge user overrides with defaults and run processColor on color string props before passing to native.
Headless APIs
In addition to the editor UI (showEditor) and headless trim (trim), the library provides several headless (no-UI) media processing APIs:
| API | Description | FFmpeg | Platform-native |
|---|---|---|---|
getFrameAt |
Extract a single frame as JPEG/PNG | — | AVAssetImageGenerator (iOS), MediaMetadataRetriever (Android) |
extractAudio |
Strip video, keep audio track | -vn |
— |
compress |
Re-encode with quality/bitrate/resolution controls | h264_videotoolbox / h264_mediacodec | — |
toGif |
Two-pass palette-based GIF conversion | palettegen + paletteuse |
— |
merge |
Concatenate multiple clips into one | concat filter + re-encode | — |
Key decisions:
extractAudiodefaults tom4a(AAC) because the default FFmpegKit builds lacklibmp3lamefor mp3 encoding.mergeuses the concat filter (-filter_complex concat=n=N:v=1:a=1) rather than the concat demuxer (-f concat -c copy). Each input is normalized to the first clip's resolution and frame rate viascale+pad+setsar+format+fpsfilters before entering the concat, so clips with different dimensions, pixel formats, SARs, or frame rates merge correctly (mismatched aspect ratios get letterboxed/pillarboxed with black bars; frame rate is capped at 30 fps to prevent massive frame duplication).mergeprobes each input for an audio track (METADATA_KEY_HAS_AUDIOon Android,tracks(withMediaType: .audio)on iOS). If no input has audio, orremoveAudiois set, the concat runs witha=0and the output has no audio (-an). If only some inputs have audio, each silent input is given ananullsrc+atrimtrack of its own duration so concat still gets one audio stream per segment. An input that cannot be probed is treated as having audio, which keeps the previous behavior.mergeprobes all input videos for bitrate and uses the maximum as the output target (-b:v) to preserve quality. Falls back to 10 Mbps.mergeonly supports local file paths. Remote URLs are not supported because the default FFmpegKit build disables OpenSSL.getFrameAtexplicitly sets full-resolution output:AVAssetImageGenerator.maximumSizeon iOS (to the video's natural size),MediaMetadataRetriever.getScaledFrameAtTimeon Android (API 27+).- All FFmpeg-based headless APIs include full FFmpeg log output in error messages for debugging.
Utility Functions
Three standalone utility functions handle saving/sharing output files from any API:
| Function | iOS Implementation | Android Implementation |
|---|---|---|
saveToPhoto |
PHPhotoLibrary — detects image vs video by extension, calls appropriate PHAssetChangeRequest factory |
MediaStore — dispatches to Images.Media or Video.Media collection based on extension, uses IS_PENDING pattern on Q+ |
saveToDocuments |
UIDocumentPickerViewController in exportToService mode |
ACTION_CREATE_DOCUMENT via SAF (Storage Access Framework) |
share |
UIActivityViewController |
ACTION_SEND with FileProvider content URI |
File Storage Strategy
| Output source | Directory | Lifecycle |
|---|---|---|
showEditor, trim |
Documents / filesDir (persistent) | Survives app restarts, must be manually deleted |
getFrameAt, extractAudio, compress, toGif, merge |
Caches / cacheDir | OS may purge under storage pressure |
listFiles() and cleanFiles() scan both directories. deleteFile() validates paths against both allowed directories before deletion.
Encoder Fallback Chain (Android only)
The Android re-encode path uses an encoder fallback chain in VideoTrimmerUtil.executeWithEncoderFallback:
h264_mediacodec— hardware H.264, fast, default. Keeps the source resolution.hevc_mediacodec— hardware H.265/HEVC (-tag:v hvc1for Apple-player compatibility). Different MediaCodec component than the H.264 encoder, so it often configures on devices whose H.264 encoder is broken (LG G8 ThinQ). Hardware-fast, full resolution, decodable on modern Android + iOS. No external lib needed (MediaCodec is a system library, present in every build incl.min).mpeg4 -q:v 3— software MPEG-4 Part 2, always present in every FFmpegKit build, lower quality. Downscaled so the long side ≤VideoTrimmerUtil.MPEG4_FALLBACK_MAX_LONG_SIDE(1280px) because Android's software MPEG-4 decoder rejects full-res mpeg4 (NO_EXCEEDS_CAPABILITIES) — the file would otherwise encode fine but fail to play back on-device / in ExoPlayer. The cap is carried onEncoderConfig.maxLongSideand applied per call site (see the pattern snippet below).
When an attempt fails, runAttempt retries the next rung if either VideoTrimmerUtil.classifyFFmpegError matched a hardware signature (MediaCodec configure failed / Error initializing output stream + mediacodec) or the failing attempt used any *_mediacodec encoder (so a hardware failure with an unrecognized log still reaches the software mpeg4 floor). A software mpeg4 failure is never retried. Each transition emits a notice through the existing onLog event and logcat (no new event — keeps the JS surface minimal). The selected/succeeded/failed encoder is logged as Encoder selected: <name> / Encoder succeeded: <name> / Encoder '<name>' failed to configure… via both logcat (VideoTrimmerUtil tag) and onLog. If every attempt fails, the editor path emits onError with ErrorCode.HARDWARE_ENCODER_FAILED; the headless paths reject the Promise with their original error message format ("Compression failed: rc N\n<logs>" etc.).
Every Android entry point that opens a video encoder routes through the helper:
| Entry point | File | Cancellation |
|---|---|---|
VideoTrimmerUtil.trim (editor save) |
utils/VideoTrimmerUtil.kt |
Returns TrimSession handle so VideoTrimmerView can cancel across attempts via trimSession.cancel() |
BaseVideoTrimModule.trim (headless) |
BaseVideoTrimModule.kt |
Discards handle — headless trim is not user-cancellable |
BaseVideoTrimModule.compress |
BaseVideoTrimModule.kt |
Same as headless trim |
BaseVideoTrimModule.merge |
BaseVideoTrimModule.kt |
Same as headless trim |
TrimCallbacks.onError has signature (message: String, code: ErrorCode, session: FFmpegSession?) -> Unit. Callers can use the default message (editor save, headless trim) or extract session.allLogsAsString / session.returnCode to build a custom message (compress, merge — which preserve their pre-existing "<X> failed: rc N\n<full logs>" rejection format so consumers matching on that prefix don't break).
Why this is Android-only:
- Android's
h264_mediacodecgoes through OMX / MediaCodec → vendor IL → vendor hardware. Every chipset vendor (Qualcomm, MediaTek, Samsung, Huawei, Unisoc, …) ships their own implementation with their own quirks; configure-time rejection of valid H.264 inputs is real and reproducible (LG G8 ThinQ on Snapdragon 855, certain Samsung Galaxy models on HEVC inputs, etc.). The failure can hit any encode path — the bug is encoder-specific, not API-specific. - iOS's
h264_videotoolboxgoes through VideoToolbox → Apple's media driver → Apple silicon. One vendor end-to-end, one curated device matrix that Apple regression-tests. No known reproducible configure-time failure on supported iOS devices.
iOS still adopts the HARDWARE_ENCODER_FAILED error code in ErrorCode.swift and VideoTrim.classifyFFmpegError so the cross-platform JS contract is symmetric — if the rare VideoToolbox failure does occur, consumers get the same specific errorCode rather than a generic TRIMMING_FAILED. But there is no fallback chain on iOS; the iOS classifier only re-labels the error.
Adding a new Android API that re-encodes: thread it through VideoTrimmerUtil.executeWithEncoderFallback. Don't call FFmpegKit.executeWithArgumentsAsync directly with -c:v h264_mediacodec — that bypasses the fallback and will fail on the affected devices. Pattern:
val buildCommand: (VideoTrimmerUtil.EncoderConfig) -> Array<String> = { config ->
// Insert `config.args` in place of the encoder portion
// (`-c:v h264_mediacodec -b:v <bitrate>` would have gone).
//
// If `config.maxLongSide != null` (set on the mpeg4 software-fallback attempt),
// you MUST downscale so the frame's long side stays within it — otherwise the
// mpeg4 output is undecodable on-device (Android's software MPEG-4 decoder
// rejects full-res mpeg4 with NO_EXCEEDS_CAPABILITIES). For `-vf` chains append
// `VideoTrimmerUtil.capLongSideFilter(it)`; for `-filter_complex` paths with
// fixed target dimensions use `VideoTrimmerUtil.capDimensionsToLongSide(...)`.
}
VideoTrimmerUtil.executeWithEncoderFallback(
encoderConfigs = VideoTrimmerUtil.reEncodeEncoderConfigs(bitrateStr),
buildCommand = buildCommand,
videoDurationMs = 0,
callbacks = VideoTrimmerUtil.TrimCallbacks(...),
)
If a real iOS-device failure is ever reported, add a two-step chain (h264_videotoolbox → mpeg4) mirroring the Android shape — the classifier already returns the right code, so only the retry plumbing would be new.
Editor Time Labels
The editor's start / current / end time labels share a single token-based formatter on each platform, controlled by the durationFormat option on EditorConfig:
| Token | Example | Notes |
|---|---|---|
mm:ss |
01:23 |
No fractional seconds |
mm:ss.SS |
01:23.45 |
Centiseconds (2 fractional digits) |
mm:ss.SSS |
01:23.456 |
Milliseconds — default |
hh:mm:ss |
00:01:23 |
Hours-padded, no fractional |
hh:mm:ss.SSS |
00:01:23.456 |
Hours + ms |
Default is mm:ss.SSS on both platforms. Unknown tokens fall back to the default. To add a new token: extend the JSDoc enum in src/NativeVideoTrim.ts, the switch in iOS CMTime.displayString(format:), and the when in Android VideoTrimmerView.formatTime.
iOS-specific: time labels use UIFont.monospacedDigitSystemFont(...) (in UILabel.createLabel(...) extension) so digit width is constant — without this the label width changes as digits change and the surrounding stack view re-lays out, making the label appear to "shake" while scrubbing. Android's default font already uses tabular figures so no equivalent fix is needed there.
Audio Waveform Visualization
When the editor opens an audio file (type: "audio"), the thumbnail track is replaced with a waveform bar visualization. Both platforms follow the same strategy:
- Amplitude extraction — PCM samples are decoded from the audio track and grouped into per-bar buckets. Each bar's height is the RMS (root-mean-square) of its bucket, normalised so the loudest bar = 1.0. This produces visually consistent output across platforms.
- Remote file handling —
AVAssetReader(iOS) andMediaExtractor(Android) both require (or strongly benefit from) local file access. Remote URLs are downloaded once to a temporary cache file; all subsequent reads (including zoom re-extractions) use the cached file, avoiding redundant network I/O. - Zoom — On zoom-in the visible time range narrows; the waveform is re-extracted for that sub-range at higher resolution. On zoom-out the cached full-view amplitudes are restored instantly.
iOS
- Decode:
AVAssetReader+AVAssetReaderTrackOutputwith 32-bit float PCM output settings. - Remote URL workaround:
AVAssetReaderrejects non-local URLs.downloadAudioForWaveform()usesURLSession.downloadTaskto save to a temp file with a correct file extension inferred from HTTP headers (Content-Disposition → MIME type → URL path →m4afallback). iOS'sAVURLAssetrelies on the extension to identify the codec. - Rendering:
AudioWaveformView(UIView) draws all bars in a singleCGContextpass viaUIBezierPath(roundedRect:). - Cleanup:
deinit+asset.didSetcancel the download task, asset reader, and delete the temp file.viewWillDisappearsetstrimmer.asset = nilto trigger cleanup.
Android
- Decode:
MediaExtractor(demux) →MediaCodec(hardware PCM decode, 16-bit short). RMS is accumulated on-the-fly into per-bar buckets. - Remote URL handling:
resolveLocalAudioPath()downloads viaHttpURLConnectiontocacheDirwith a.tmpextension. Android'sMediaExtractorprobes file content for codec detection, so the extension doesn't matter. - Progressive display: The decode loop fires
onProgresscallbacks at 5 % and then every 20 % of bars filled, so the UI renders incrementally. - Rendering:
AudioWaveformView(custom View) draws bars viaCanvas.drawRoundRect(). - Cleanup:
onDestroy()setsisGeneratingWaveform = false(background loops check this flag), cancels namedBackgroundExecutortasks, and deletes the temp file viacleanupLocalAudioFile().
JS API
Waveform options are part of EditorConfig in NativeVideoTrim.ts:
| Option | Type | Default | Description |
|---|---|---|---|
waveformColor |
color string | "white" |
Bar fill color |
waveformBackgroundColor |
color string | "#3478F6" |
Track background behind bars |
waveformBarWidth |
number (dp/pt) | 3 |
Width of each bar |
waveformBarGap |
number (dp/pt) | 2 |
Gap between bars |
waveformBarCornerRadius |
number (dp/pt) | 1.5 |
Corner radius for rounded bars |
Colors are passed through processColor() in src/index.tsx before reaching native.
Adding a New Feature
- Define the TypeScript interface in
src/NativeVideoTrim.ts— add toSpec, create option/result types. - Create a factory function in
src/index.tsx(e.g.createMyOptions) that merges user overrides with defaults. - Implement in base classes:
ios/VideoTrim.swift(as@objc public static funcfor New Arch class methods + Old Arch instance wrapper) andandroid/.../BaseVideoTrimModule.kt. - Bridge the method in
ios/VideoTrim.mm(New Arch dispatch) and in bothandroid/src/oldarch/VideoTrimModule.ktandandroid/src/newarch/VideoTrimModule.kt. - Wire events (if any) in both Android arch modules and in
ios/VideoTrim.mm(emitEventToJSWithEventNamedispatch). - Export from
src/index.tsx— add the public function with input validation. - Choose output directory: persistent (documents/filesDir) for editor-produced files, cache (cachesDirectory/cacheDir) for headless API outputs.
- Test both architectures on iOS and Android via the
example/app.
Adding a new EditorConfig field
Editor options take a different path than method arguments — they're delivered as a config struct/dict, and on iOS New Arch the codegen-generated C++ struct is destructured field-by-field into an NSDictionary before reaching Swift. Forgetting this step is the most common reason a new option silently does nothing on iOS New Arch only, while Android (both archs) and iOS Old Arch work fine because they pass the dict-like config through unchanged.
Checklist for any new field on EditorConfig:
- Define the field in
EditorConfiginsrc/NativeVideoTrim.tswith a JSDoc comment describing allowed values. - Default the field in
createEditorConfiginsrc/index.tsx. - iOS New Arch — copy into the dict in
ios/VideoTrim.mminsideshowEditor:config:. Required strings:dict[@"foo"] = config.foo();. Optional strings: nil-check first (mirrorstheme/durationFormat). Optional numbers: use thehas_value()pattern (mirrorstrimmerColor,zoomOnWaitingDuration). - iOS — read in Swift inside
VideoTrimmerViewController.configure(config:)viaconfig["foo"] as? Type ?? default. Store on a private property if the value is needed at multiple call sites. - Android — read inside
VideoTrimmerView.configure(...)viaconfig.hasKey("foo")+config.getString/getInt/.... No bridge step needed —ReadableMapreflects the JS dict directly. - Test on iOS New Arch specifically — Old Arch will work even if step 3 is skipped, hiding the bug.
Code Style & Conventions
Formatting
Prettier config lives in package.json:
- Single quotes, 2-space indent, no tabs, trailing commas (
es5), consistent quote props.
Linting
ESLint 9 flat config (eslint.config.mjs) extends @react-native + Prettier. Ignores node_modules/ and lib/.
TypeScript
Strict mode enabled: strict, noUnusedLocals, noUnusedParameters, noUncheckedIndexedAccess, verbatimModuleSyntax. Module resolution: bundler. JSX: react-jsx.
Commits
Conventional commits enforced by commitlint (config in package.json, extends @commitlint/config-conventional). Pre-commit hooks managed by Lefthook (lefthook.yml):
pre-commit: ESLint on staged files +tsctypecheckcommit-msg:commitlint --edit
Commit prefixes: fix, feat, refactor, docs, test, chore.
Changesets (required for user visible changes)
Every change that users of the library can see (a bug fix, a new API or option, changed behaviour, a native fix on either platform) must ship with a changeset in the same commit or pull request. Without one the change is not released and has no changelog entry.
Add it with yarn changeset, or write .changeset/<short-kebab-name>.md directly, which is the easier path for an agent. The file must start with the --- frontmatter line:
---
'react-native-video-trim': patch
---
Fix the crash when merging clips without an audio track.
- Bump:
patchfor fixes,minorfor new features/options/APIs,majorfor breaking changes to the JS API, events, defaults or minimum platform versions. - Summary: one or two sentences written for library users, describing the behaviour change, not the implementation. Name the public API (
merge(),EditorConfig.theme) and the platform when only one is affected. - One changeset per independent user visible change. Docs, CI, tests and
example/changes need none. - Never edit
versioninpackage.jsonorCHANGELOG.mdby hand;yarn version-packagesin the release workflow owns both. - Run the CLI only through
yarn changeset ...(scripts/changeset.mjs), nevernpx changeset. Because of theexampleworkspace, the bare CLI does not see the library package and fails with "not in the workspace".
Build & Development Commands
# Install dependencies (Yarn 4 with node-modules linker)
yarn
# Build library (react-native-builder-bob → lib/)
yarn prepare
# Lint & typecheck
yarn lint
yarn typecheck
# Run tests
yarn test
# Example app
yarn example start # Metro bundler
yarn example android # Run on Android
yarn example ios # Run on iOS
# Native builds via Turborepo
yarn turbo run build:android
yarn turbo run build:ios
# Test specific architecture (Android)
ORG_GRADLE_PROJECT_newArchEnabled=true yarn example android # New Arch
ORG_GRADLE_PROJECT_newArchEnabled=false yarn example android # Old Arch
# Add a changeset (release note) for a user visible change
yarn changeset
# Clean build artifacts
yarn clean
Node version: pinned to v22.23.2 (.nvmrc). The Changesets CLI needs Node.js 22.11 or newer.
CI Pipeline
GitHub Actions (.github/workflows/ci.yml) runs on push/PR to main and merge queue:
| Job | What it does |
|---|---|
lint |
yarn lint + yarn typecheck |
test |
yarn test --maxWorkers=2 --coverage |
build-library |
yarn prepare |
build-android |
Turbo-cached Android build with JDK 17 |
build-ios |
Turbo-cached iOS build with Xcode 16.2, CocoaPods |
Caching: Yarn deps, Gradle, CocoaPods, Turborepo outputs.
.github/workflows/release.yml runs on every push to master (see Release).
Platform-Specific Notes
iOS
- Podspec:
VideoTrim.podspecat repo root. FFmpegKit package variant is configurable viaENV['FFMPEGKIT_PACKAGE'](defaults tomin). Version viaENV['FFMPEGKIT_PACKAGE_VERSION'](defaults to~> 6.0). - Bridge pattern:
VideoTrim.mmis Obj-C++ — under New Arch it subclassesNativeVideoTrimSpecBaseand holds aVideoTrimSwiftinstance; under Old Arch it usesRCT_EXTERN_REMAP_MODULE. Swift classVideoTrim(exposed asVideoTrimSwiftto Obj-C) is the real implementation. - Config dict (New Arch):
showEditor:config:inVideoTrim.mmdestructures the codegenJS::NativeVideoTrim::EditorConfigstruct field-by-field into anNSMutableDictionarybefore handing it to Swift. Old Arch passes the dict through unchanged viaRCT_EXTERN_METHOD. Any newEditorConfigfield must be added to this dict-copy block or it will silently default on iOS New Arch only — see "Adding a newEditorConfigfield" above. - Event forwarding (New Arch): Swift calls
delegate?.emitEventToJS(eventName:body:)→ Obj-CVideoTrimdispatches to codegenemitOn*methods. - Frameworks:
AVFoundation,AVKit,UIKit,Photos. - Theming:
VideoTrimmerViewControllerreads thethemeprop from config and propagatesisLightThemetoVideoTrimmer,CropOverlayView, and all alert dialogs. Light theme uses white background, black icons/text, and black crop overlay brackets/grid. - Background handling: Player pauses automatically when the app resigns active (
UIApplication.willResignActiveNotification). - Crop overlay animation: Rotation triggers a cross-fade (fade out → rotate video → update crop rect → fade in) rather than rotating the overlay directly.
Android
- Gradle:
android/build.gradle(Groovy). Source sets switch betweensrc/oldarchandsrc/newarchbased onnewArchEnabledproject property. Codegen output goes togenerated/. - Composition pattern:
VideoTrimModulewrapsBaseVideoTrimModule(not inheritance). ThesendEventlambda bridges to arch-specific emission. - Old Arch events:
DeviceEventManagerModule.RCTDeviceEventEmitter.emit(NAME, map)— single event"VideoTrim"with logical name in payload. - New Arch events:
when(eventName)dispatch to codegenemitOn*methods. - FFmpeg dependency:
io.github.maitrungduc1410:ffmpeg-kit-<package>:<version>configurable viagradle.propertiesor consumer's rootext/properties. - FileProvider: Bundled by the library (host apps need no setup) and used by
share()to hand outcontent://URIs. Everything it contributes to the consumer's merged app is namespaced, and all three parts must stay that way:android:namepoints at our ownVideoTrimFileProvidersubclass because the manifest merger keys<provider>byandroid:nameandandroidx.core.content.FileProviderwould collide with any other manifest declaring it; the authority is${applicationId}.videotrimproviderrather than the docs-default.provider; and the paths resource isvideo_trim_file_paths.xmlrather thanfile_paths.xml, which a same-named resource elsewhere would silently override. The authority is duplicated inBaseVideoTrimModule.FILE_PROVIDER_AUTHORITY_SUFFIX— keep the two in sync. Keep the<meta-data android:name="android.support.FILE_PROVIDER_PATHS">child too: the staticgetUriForFilere-reads the paths XML throughPackageManager, so theFileProvider(@XmlRes int)constructor is not a substitute for it. - Min SDK: 24, Target: 34, Compile: 35.
- Theming:
VideoTrimmerViewreads thethemeprop and callsapplyThemeColors()to set background/text/icon colors and crop overlay bracket/grid colors. Light theme uses white background, black icons/text, and black crop brackets/grid. - Background handling:
BaseVideoTrimModuleimplementsLifecycleEventListener;onHostPausepauses the media player.onSurfaceTextureDestroyedreturnsfalseto keep the SurfaceTexture alive, preserving the video frame across backgrounding. - Haptic feedback: Uses lighter amplitudes (30 for light feedback, 80 for heavy) and triggers haptic when trimmer handles hit absolute video boundaries (start/end of timeline).
- Crop overlay animation: Same cross-fade pattern as iOS during rotation.
Testing
- Unit tests: Jest with
react-nativepreset. Currently a placeholder (src/__tests__/index.test.tsx). - Manual testing: Use the
example/app.example/src/App.tsxdemonstrates all API functions and event listeners. - Architecture verification: Metro logs
"fabric":truewhen running New Architecture.
Release
Uses Changesets and .github/workflows/release.yml; there is no local release command.
- Pending changesets on
master→ the workflow runsyarn version-packages(changeset versionthroughscripts/changeset.mjs) and opens or updates thechore: release vX.Y.Zpull request from branchrelease/vX.Y.Z:package.jsonversion bump, newCHANGELOG.mdsection, consumed changesets deleted; the body holds the release notes. The branch is rebuilt frommasteron every run, so fix wording in the changesets onmaster, not on the release branch. When more changesets raise the version, the old release pull request is closed ("Superseded by #N") and its branch deleted. - Merging that pull request → no pending changesets, so the workflow publishes the version if it is not on npm yet (
npm publish --provenancevia npm trusted publishing,nextdist-tag for prerelease versions) and creates the GitHub releasev${version}with the notes extracted byscripts/release-notes.mjsfromCHANGELOG.md.
Every step checks npm and GitHub first, so rerunning the workflow is safe. Config: .changeset/config.json (baseBranch: master).
scripts/changeset.mjs hides yarn.lock while the Changesets CLI runs. With workspaces plus yarn.lock, Changesets treats the repo as a Yarn monorepo whose only package is example/. Listing "." in workspaces would fix that but breaks turbo (No "extends" key found). For the same reason .changeset/config.json sets "format": false: Changesets formats through yarn exec prettier, which cannot run while yarn.lock is hidden. If a run is killed before it restores the lockfile, git shows yarn.lock as deleted and yarn fails with "This package doesn't seem to be present in your lockfile"; fix it with mv yarn.lock.changeset yarn.lock.
The release pull request is created by a script step with gh, not changesets/action: the action always uses the branch changeset-release/<base> and a fixed title, so releases could not be found by version.
Key Files Reference
| File | Purpose |
|---|---|
src/NativeVideoTrim.ts |
TurboModule Spec — source of truth for API surface, events, option/result types |
src/index.tsx |
Public JS API, architecture detection, factory functions for all APIs |
src/OldArch.ts |
Old Architecture NativeModules bridge |
ios/VideoTrim.mm |
iOS dual-arch native bridge |
ios/VideoTrim.swift |
iOS core: editor, headless APIs (trim/compress/toGif/merge/extractAudio/getFrameAt), utilities (saveToPhoto/saveToDocuments/share), file management |
ios/VideoTrimmerViewController.swift |
iOS editor UI — theme, transforms, crop, player lifecycle, speed menu (UIMenu on iOS 14+) |
ios/VideoTrimmer.swift |
iOS trimmer control — timeline, thumbnails, handles, waveform |
ios/AudioWaveformView.swift |
iOS waveform bar renderer |
ios/CropOverlayView.swift |
iOS crop overlay — brackets, grid, theme-aware colors |
android/.../BaseVideoTrimModule.kt |
Android core: editor, headless APIs, utilities, file management, activity result handling |
android/.../utils/StorageUtil.kt |
Android file paths, gallery saving (image/video dispatch, IS_PENDING pattern), cache/persistent directory management |
android/.../widgets/VideoTrimmerView.kt |
Android editor UI — theme, transforms, crop, player lifecycle, waveform, speed PopupMenu |
android/.../widgets/AudioWaveformView.kt |
Android waveform bar renderer |
android/.../widgets/CropOverlayView.kt |
Android crop overlay — brackets, grid, theme-aware colors |
android/src/oldarch/VideoTrimModule.kt |
Android Old Arch module |
android/src/newarch/VideoTrimModule.kt |
Android New Arch module |
VideoTrim.podspec |
CocoaPods spec for iOS |
android/build.gradle |
Android library Gradle config |
package.json |
Scripts, dependencies, prettier, commitlint, bob, codegen config |
.github/workflows/ci.yml |
CI pipeline |
.github/workflows/release.yml |
Release pull request, npm publish, GitHub release |
.changeset/ |
Pending changesets and Changesets config |
scripts/changeset.mjs |
Runs the Changesets CLI with the library as the only package (yarn changeset, yarn version-packages) |
CHANGELOG.md |
Generated by changeset version; do not edit by hand outside the release pull request |
CONTRIBUTING.md |
Contributor guide |
