diff --git a/CHANGELOG.md b/CHANGELOG.md index 29c55c6755..5280d0ff3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ * Switch useTextureView to default to `true` [#1286](https://github.com/react-native-community/react-native-video/pull/1286) * Re-add fullscreenAutorotate prop [#1303](https://github.com/react-native-community/react-native-video/pull/1303) * Make seek throw a useful error for NaN values [#1283](https://github.com/react-native-community/react-native-video/pull/1283) +* Video Filters and Save Video [#1306](https://github.com/react-native-community/react-native-video/pull/1306) * Fix: volume should not change on onAudioFocusChange event [#1327](https://github.com/react-native-community/react-native-video/pull/1327) ### Version 3.2.0 diff --git a/FilterType.js b/FilterType.js new file mode 100644 index 0000000000..bd477d73ba --- /dev/null +++ b/FilterType.js @@ -0,0 +1,18 @@ +export default { + NONE: '', + INVERT: 'CIColorInvert', + MONOCHROME: 'CIColorMonochrome', + POSTERIZE: 'CIColorPosterize', + FALSE: 'CIFalseColor', + MAXIMUMCOMPONENT: 'CIMaximumComponent', + MINIMUMCOMPONENT: 'CIMinimumComponent', + CHROME: 'CIPhotoEffectChrome', + FADE: 'CIPhotoEffectFade', + INSTANT: 'CIPhotoEffectInstant', + MONO: 'CIPhotoEffectMono', + NOIR: 'CIPhotoEffectNoir', + PROCESS: 'CIPhotoEffectProcess', + TONAL: 'CIPhotoEffectTonal', + TRANSFER: 'CIPhotoEffectTransfer', + SEPIA: 'CISepiaTone' +}; diff --git a/README.md b/README.md index 6198c56f32..96c17151e5 100644 --- a/README.md +++ b/README.md @@ -259,6 +259,7 @@ var styles = StyleSheet.create({ * [audioOnly](#audioonly) * [bufferConfig](#bufferconfig) * [controls](#controls) +* [filter](#filter) * [fullscreen](#fullscreen) * [fullscreenAutorotate](#fullscreenautorotate) * [fullscreenOrientation](#fullscreenorientation) @@ -299,6 +300,7 @@ var styles = StyleSheet.create({ ### Methods * [dismissFullscreenPlayer](#dismissfullscreenplayer) * [presentFullscreenPlayer](#presentfullscreenplayer) +* [save](#save) * [seek](#seek) ### Configurable props @@ -352,6 +354,33 @@ Note on iOS, controls are always shown when in fullscreen mode. Platforms: iOS, react-native-dom +#### filter +Add video filter +* **FilterType.NONE (default)** - No Filter +* **FilterType.INVERT** - CIColorInvert +* **FilterType.MONOCHROME** - CIColorMonochrome +* **FilterType.POSTERIZE** - CIColorPosterize +* **FilterType.FALSE** - CIFalseColor +* **FilterType.MAXIMUMCOMPONENT** - CIMaximumComponent +* **FilterType.MINIMUMCOMPONENT** - CIMinimumComponent +* **FilterType.CHROME** - CIPhotoEffectChrome +* **FilterType.FADE** - CIPhotoEffectFade +* **FilterType.INSTANT** - CIPhotoEffectInstant +* **FilterType.MONO** - CIPhotoEffectMono +* **FilterType.NOIR** - CIPhotoEffectNoir +* **FilterType.PROCESS** - CIPhotoEffectProcess +* **FilterType.TONAL** - CIPhotoEffectTonal +* **FilterType.TRANSFER** - CIPhotoEffectTransfer +* **FilterType.SEPIA** - CISepiaTone + +For more details on these filters refer to the [iOS docs](https://developer.apple.com/library/archive/documentation/GraphicsImaging/Reference/CoreImageFilterReference/index.html#//apple_ref/doc/uid/TP30000136-SW55). + +Notes: +1. Using a filter can impact CPU usage. A workaround is to save the video with the filter and then load the saved video. +2. Video filter is currently not supported on HLS playlists. + +Platforms: iOS + #### fullscreen Controls whether the player enters fullscreen on play. * **false (default)** - Don't display the video in fullscreen @@ -671,6 +700,7 @@ Adjust the volume. Platforms: all + ### Event props #### onAudioBecomingNoisy @@ -879,6 +909,33 @@ this.player.presentFullscreenPlayer(); Platforms: Android ExoPlayer, Android MediaPlayer, iOS +#### save +`save(): Promise` + +Save video to your Photos with current filter prop. Returns promise. + +Example: +``` +let response = await this.save(); +let path = response.uri; +``` + +Notes: + - Currently only supports highest quality export + - Currently only supports MP4 export + - Currently only supports exporting to user's cache directory with a generated UUID filename. + - User will need to remove the saved video through their Photos app + - Works with cached videos as well. (Checkout video-caching example) + - If the video is has not began buffering (e.g. there is no internet connection) then the save function will throw an error. + - If the video is buffering then the save function promise will return after the video has finished buffering and processing. + +Future: + - Will support multiple qualities through options + - Will support more formats in the future through options + - Will support custom directory and file name through options + +Platforms: iOS + #### seek() `seek(seconds)` @@ -909,6 +966,8 @@ this.player.seek(120, 50); // Seek to 2 minutes with +/- 50 milliseconds accurac Platforms: iOS + + ### iOS App Transport Security - By default, iOS will only load encrypted (https) urls. If you want to load content from an unencrypted (http) source, you will need to modify your Info.plist file and add the following entry: diff --git a/Video.js b/Video.js index 03ecb389dc..77b02f0a9e 100644 --- a/Video.js +++ b/Video.js @@ -1,8 +1,9 @@ import React, {Component} from 'react'; import PropTypes from 'prop-types'; -import {StyleSheet, requireNativeComponent, NativeModules, View, ViewPropTypes, Image, Platform} from 'react-native'; +import {StyleSheet, requireNativeComponent, NativeModules, View, ViewPropTypes, Image, Platform, findNodeHandle} from 'react-native'; import resolveAssetSource from 'react-native/Libraries/Image/resolveAssetSource'; import TextTrackType from './TextTrackType'; +import FilterType from './FilterType'; import VideoResizeMode from './VideoResizeMode.js'; const styles = StyleSheet.create({ @@ -11,7 +12,7 @@ const styles = StyleSheet.create({ }, }); -export { TextTrackType }; +export { TextTrackType, FilterType }; export default class Video extends Component { @@ -73,6 +74,10 @@ export default class Video extends Component { this.setNativeProps({ fullscreen: false }); }; + save = async (options?) => { + return await NativeModules.VideoManager.save(options, findNodeHandle(this._root)); + } + _assignRoot = (component) => { this._root = component; }; @@ -277,6 +282,24 @@ export default class Video extends Component { } Video.propTypes = { + filter: PropTypes.oneOf([ + FilterType.NONE, + FilterType.INVERT, + FilterType.MONOCHROME, + FilterType.POSTERIZE, + FilterType.FALSE, + FilterType.MAXIMUMCOMPONENT, + FilterType.MINIMUMCOMPONENT, + FilterType.CHROME, + FilterType.FADE, + FilterType.INSTANT, + FilterType.MONO, + FilterType.NOIR, + FilterType.PROCESS, + FilterType.TONAL, + FilterType.TRANSFER, + FilterType.SEPIA + ]), /* Native only */ src: PropTypes.object, seek: PropTypes.oneOfType([ diff --git a/examples/video-caching/App.ios.js b/examples/video-caching/App.ios.js index e214cfc1e0..20f69745ea 100644 --- a/examples/video-caching/App.ios.js +++ b/examples/video-caching/App.ios.js @@ -5,7 +5,7 @@ */ import React, { Component } from "react"; -import { StyleSheet, Text, View, Dimensions } from "react-native"; +import { StyleSheet, Text, View, Dimensions, TouchableOpacity } from "react-native"; import Video from "react-native-video"; const { height, width } = Dimensions.get("screen"); @@ -28,6 +28,16 @@ export default class App extends Component { }} style={{ flex: 1, height, width }} /> + { + let response = await this.player.save(); + let uri = response.uri; + console.log("Download URI", uri); + }} + style={styles.button} + > + Save + ); } @@ -40,6 +50,14 @@ const styles = StyleSheet.create({ alignItems: "center", backgroundColor: "#F5FCFF" }, + button: { + position: 'absolute', + top: 50, + right: 16, + padding: 10, + backgroundColor: '#9B2FAE', + borderRadius: 8 + }, welcome: { fontSize: 20, textAlign: "center", diff --git a/examples/video-caching/ios/Podfile.lock b/examples/video-caching/ios/Podfile.lock index 2689020a94..941485aa80 100644 --- a/examples/video-caching/ios/Podfile.lock +++ b/examples/video-caching/ios/Podfile.lock @@ -9,9 +9,9 @@ PODS: - glog (0.3.4) - React (0.56.0): - React/Core (= 0.56.0) - - react-native-video/Video (3.1.0): + - react-native-video/Video (3.2.2): - React - - react-native-video/VideoCaching (3.1.0): + - react-native-video/VideoCaching (3.2.2): - DVAssetLoaderDelegate (~> 0.3.1) - React - react-native-video/Video diff --git a/examples/video-caching/ios/VideoCaching/Info.plist b/examples/video-caching/ios/VideoCaching/Info.plist index 171fecca81..9ae00c5e74 100644 --- a/examples/video-caching/ios/VideoCaching/Info.plist +++ b/examples/video-caching/ios/VideoCaching/Info.plist @@ -24,6 +24,21 @@ 1 LSRequiresIPhoneOS + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSExceptionDomains + + localhost + + NSExceptionAllowsInsecureHTTPLoads + + + + + NSLocationWhenInUseUsageDescription + UILaunchStoryboardName LaunchScreen UIRequiredDeviceCapabilities @@ -38,19 +53,5 @@ UIViewControllerBasedStatusBarAppearance - NSLocationWhenInUseUsageDescription - - NSAppTransportSecurity - - - NSExceptionDomains - - localhost - - NSExceptionAllowsInsecureHTTPLoads - - - - diff --git a/ios/Video/RCTVideo.h b/ios/Video/RCTVideo.h index e43fbe50bb..eee5bca278 100644 --- a/ios/Video/RCTVideo.h +++ b/ios/Video/RCTVideo.h @@ -4,6 +4,7 @@ #import "RCTVideoPlayerViewController.h" #import "RCTVideoPlayerViewControllerDelegate.h" #import +#import #if __has_include() #import @@ -41,4 +42,6 @@ - (AVPlayerViewController*)createPlayerViewController:(AVPlayer*)player withPlayerItem:(AVPlayerItem*)playerItem; +- (void)save:(NSDictionary *)options resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject; + @end diff --git a/ios/Video/RCTVideo.m b/ios/Video/RCTVideo.m index e0a5ac2ba4..a56b08a2aa 100644 --- a/ios/Video/RCTVideo.m +++ b/ios/Video/RCTVideo.m @@ -67,6 +67,7 @@ @implementation RCTVideo BOOL _fullscreenAutorotate; NSString * _fullscreenOrientation; BOOL _fullscreenPlayerPresented; + NSString *_filterName; UIViewController * _presentingViewController; #if __has_include() RCTVideoCache * _videoCache; @@ -335,7 +336,8 @@ - (void)setSrc:(NSDictionary *)source [self playerItemForSource:source withCallback:^(AVPlayerItem * playerItem) { _playerItem = playerItem; [self addPlayerItemObservers]; - + [self setFilter:_filterName]; + [_player pause]; [_playerViewController.view removeFromSuperview]; _playerViewController = nil; @@ -1262,6 +1264,42 @@ - (void)videoPlayerViewControllerDidDismiss:(AVPlayerViewController *)playerView } } +- (void)setFilter:(NSString *)filterName { + + _filterName = filterName; + + AVAsset *asset = _playerItem.asset; + + if (asset != nil) { + + CIFilter *filter = [CIFilter filterWithName:filterName]; + + _playerItem.videoComposition = [AVVideoComposition + videoCompositionWithAsset:asset + applyingCIFiltersWithHandler:^(AVAsynchronousCIImageFilteringRequest *_Nonnull request) { + + if (filter == nil) { + + [request finishWithImage:request.sourceImage context:nil]; + + } else { + + CIImage *image = request.sourceImage.imageByClampingToExtent; + + [filter setValue:image forKey:kCIInputImageKey]; + + CIImage *output = [filter.outputImage imageByCroppingToRect:request.sourceImage.extent]; + + [request finishWithImage:output context:nil]; + + } + + }]; + + } + +} + #pragma mark - React View Management - (void)insertReactSubview:(UIView *)view atIndex:(NSInteger)atIndex @@ -1348,4 +1386,78 @@ - (void)removeFromSuperview [super removeFromSuperview]; } -@end +#pragma mark - Export + +- (void)save:(NSDictionary *)options resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + + AVAsset *asset = _playerItem.asset; + + if (asset != nil) { + + AVAssetExportSession *exportSession = [AVAssetExportSession + exportSessionWithAsset:asset presetName:AVAssetExportPresetHighestQuality]; + + if (exportSession != nil) { + NSString *path = nil; + NSArray *array = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES); + path = [self generatePathInDirectory:[[self cacheDirectoryPath] stringByAppendingPathComponent:@"Videos"] + withExtension:@".mp4"]; + NSURL *url = [NSURL fileURLWithPath:path]; + exportSession.outputFileType = AVFileTypeMPEG4; + exportSession.outputURL = url; + exportSession.videoComposition = _playerItem.videoComposition; + exportSession.shouldOptimizeForNetworkUse = true; + [exportSession exportAsynchronouslyWithCompletionHandler:^{ + + switch ([exportSession status]) { + case AVAssetExportSessionStatusFailed: + reject(@"ERROR_COULD_NOT_EXPORT_VIDEO", @"Could not export video", exportSession.error); + break; + case AVAssetExportSessionStatusCancelled: + reject(@"ERROR_EXPORT_SESSION_CANCELLED", @"Export session was cancelled", exportSession.error); + break; + default: + resolve(@{@"uri": url.absoluteString}); + break; + } + + }]; + + } else { + + reject(@"ERROR_COULD_NOT_CREATE_EXPORT_SESSION", @"Could not create export session", nil); + + } + + } else { + + reject(@"ERROR_ASSET_NIL", @"Asset is nil", nil); + + } +} + +- (BOOL)ensureDirExistsWithPath:(NSString *)path { + BOOL isDir = NO; + NSError *error; + BOOL exists = [[NSFileManager defaultManager] fileExistsAtPath:path isDirectory:&isDir]; + if (!(exists && isDir)) { + [[NSFileManager defaultManager] createDirectoryAtPath:path withIntermediateDirectories:YES attributes:nil error:&error]; + if (error) { + return NO; + } + } + return YES; +} + +- (NSString *)generatePathInDirectory:(NSString *)directory withExtension:(NSString *)extension { + NSString *fileName = [[[NSUUID UUID] UUIDString] stringByAppendingString:extension]; + [self ensureDirExistsWithPath:directory]; + return [directory stringByAppendingPathComponent:fileName]; +} + +- (NSString *)cacheDirectoryPath { + NSArray *array = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES); + return array[0]; +} + +@end \ No newline at end of file diff --git a/ios/Video/RCTVideoManager.h b/ios/Video/RCTVideoManager.h index e19a9e1fab..b3bfccb5eb 100644 --- a/ios/Video/RCTVideoManager.h +++ b/ios/Video/RCTVideoManager.h @@ -1,5 +1,6 @@ #import +#import -@interface RCTVideoManager : RCTViewManager +@interface RCTVideoManager : RCTViewManager @end diff --git a/ios/Video/RCTVideoManager.m b/ios/Video/RCTVideoManager.m index ce699a1834..3dd157b45a 100644 --- a/ios/Video/RCTVideoManager.m +++ b/ios/Video/RCTVideoManager.m @@ -1,14 +1,13 @@ #import "RCTVideoManager.h" #import "RCTVideo.h" #import +#import #import @implementation RCTVideoManager RCT_EXPORT_MODULE(); -@synthesize bridge = _bridge; - - (UIView *)view { return [[RCTVideo alloc] initWithEventDispatcher:self.bridge.eventDispatcher]; @@ -16,7 +15,7 @@ - (UIView *)view - (dispatch_queue_t)methodQueue { - return dispatch_get_main_queue(); + return self.bridge.uiManager.methodQueue; } RCT_EXPORT_VIEW_PROPERTY(src, NSDictionary); @@ -39,6 +38,7 @@ - (dispatch_queue_t)methodQueue RCT_EXPORT_VIEW_PROPERTY(fullscreen, BOOL); RCT_EXPORT_VIEW_PROPERTY(fullscreenAutorotate, BOOL); RCT_EXPORT_VIEW_PROPERTY(fullscreenOrientation, NSString); +RCT_EXPORT_VIEW_PROPERTY(filter, NSString); RCT_EXPORT_VIEW_PROPERTY(progressUpdateInterval, float); /* Should support: onLoadStart, onLoad, and onError to stay consistent with Image */ RCT_EXPORT_VIEW_PROPERTY(onVideoLoadStart, RCTBubblingEventBlock); @@ -59,6 +59,22 @@ - (dispatch_queue_t)methodQueue RCT_EXPORT_VIEW_PROPERTY(onPlaybackResume, RCTBubblingEventBlock); RCT_EXPORT_VIEW_PROPERTY(onPlaybackRateChange, RCTBubblingEventBlock); RCT_EXPORT_VIEW_PROPERTY(onVideoExternalPlaybackChange, RCTBubblingEventBlock); +RCT_EXPORT_VIEW_PROPERTY(onVideoSaved, RCTBubblingEventBlock); +RCT_REMAP_METHOD(save, + options:(NSDictionary *)options + reactTag:(nonnull NSNumber *)reactTag + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + [self.bridge.uiManager prependUIBlock:^(__unused RCTUIManager *uiManager, NSDictionary *viewRegistry) { + RCTVideo *view = viewRegistry[reactTag]; + if (![view isKindOfClass:[RCTVideo class]]) { + RCTLogError(@"Invalid view returned from registry, expecting RCTVideo, got: %@", view); + } else { + [view save:options resolve:resolve reject:reject]; + } + }]; +} - (NSDictionary *)constantsToExport { diff --git a/package.json b/package.json index 92366be35b..d223a36563 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-video", - "version": "3.2.1", + "version": "3.2.2", "description": "A