Created
November 10, 2015 16:41
-
-
Save kmturley/5cc2b481af1a6169f80e to your computer and use it in GitHub Desktop.
Extending video editor plugin functionality
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package org.apache.cordova.videoeditor; | |
import java.io.File; | |
import java.io.FileNotFoundException; | |
import java.io.FileOutputStream; | |
import java.io.IOException; | |
import java.net.URL; | |
import java.net.URLDecoder; | |
import java.text.SimpleDateFormat; | |
import java.util.ArrayList; | |
import java.util.Date; | |
import java.util.Locale; | |
import org.apache.cordova.CordovaPlugin; | |
import org.apache.cordova.CallbackContext; | |
import org.apache.cordova.PluginResult; | |
import org.json.JSONArray; | |
import org.json.JSONException; | |
import org.json.JSONObject; | |
import org.ffmpeg.android.FfmpegController; | |
import org.ffmpeg.android.Clip; | |
import org.ffmpeg.android.ShellUtils; | |
import org.ffmpeg.android.ShellUtils.ShellCallback; | |
import android.content.ContentUris; | |
import android.content.Context; | |
import android.content.Intent; | |
import android.content.pm.ApplicationInfo; | |
import android.content.pm.PackageManager; | |
import android.content.pm.PackageManager.NameNotFoundException; | |
import android.database.Cursor; | |
import android.graphics.Bitmap; | |
import android.graphics.Bitmap.CompressFormat; | |
import android.media.ThumbnailUtils; | |
import android.net.Uri; | |
import android.os.Build; | |
import android.os.Environment; | |
import android.provider.DocumentsContract; | |
import android.provider.MediaStore; | |
import android.util.Log; | |
/** | |
* VideoEditor plugin for Android | |
* Created by Ross Martin 2-2-15 | |
*/ | |
public class VideoEditor extends CordovaPlugin { | |
private static final String TAG = "VideoEditor"; | |
private CallbackContext callback; | |
private FfmpegController ffmpegController; | |
private static final int HighQuality = 0; | |
private static final int MediumQuality = 1; | |
private static final int LowQuality = 2; | |
private static final int M4V = 0; | |
private static final int MPEG4 = 1; | |
private static final int M4A = 2; | |
private static final int QUICK_TIME = 3; | |
private static final int MPEG = 4; | |
@Override | |
public boolean execute(String action, JSONArray args, CallbackContext callbackContext) throws JSONException { | |
Log.d(TAG, "execute method starting"); | |
this.callback = callbackContext; | |
if (action.equals("transcodeVideo")) { | |
try { | |
this.transcodeVideo(args); | |
} catch (IOException e) { | |
callback.error(e.toString()); | |
} | |
return true; | |
} else if (action.equals("createThumbnail")) { | |
try { | |
this.createThumbnail(args); | |
} catch (IOException e) { | |
callback.error(e.toString()); | |
} | |
return true; | |
} else if (action.equals("killVideoProcessor")) { | |
try { | |
this.killVideoProcessor(args); | |
} catch (IOException e) { | |
callback.error(e.toString()); | |
} | |
return true; | |
} | |
return false; | |
} | |
private void transcodeVideo(JSONArray args) throws JSONException, IOException { | |
Log.d(TAG, "transcodeVideo firing"); | |
/* transcodeVideo arguments: | |
fileUri: video input url | |
outputFileName: output file name | |
quality: transcode quality | |
outputFileType: output file type | |
optimizeForNetworkUse: optimize for network use | |
saveToLibrary: bool - save to gallery | |
*/ | |
JSONObject options = args.optJSONObject(0); | |
Log.d(TAG, "options: " + options.toString()); | |
final String outputFileName = options.optString( | |
"outputFileName", | |
new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.ENGLISH).format(new Date()) | |
); | |
final int videoQuality = options.optInt("quality", HighQuality); | |
final int outputType = options.optInt("outputFileType", MPEG4); | |
String outputExtension; | |
final int outputWidth; | |
final int outputHeight; | |
switch(outputType) { | |
case QUICK_TIME: | |
outputExtension = ".mov"; | |
break; | |
case M4A: | |
outputExtension = ".m4a"; | |
break; | |
case M4V: | |
outputExtension = ".m4v"; | |
break; | |
case MPEG: | |
outputExtension = ".mpg"; | |
break; | |
case MPEG4: | |
default: | |
outputExtension = ".mp4"; | |
break; | |
} | |
switch(videoQuality) { | |
case LowQuality: | |
outputWidth = 320; | |
outputHeight = 320; | |
break; | |
case MediumQuality: | |
outputWidth = 480; | |
outputHeight = 480; | |
break; | |
case HighQuality: | |
default: | |
outputWidth = 640; | |
outputHeight = 640; | |
break; | |
} | |
final Context appContext = cordova.getActivity().getApplicationContext(); | |
final PackageManager pm = appContext.getPackageManager(); | |
ApplicationInfo ai; | |
try { | |
ai = pm.getApplicationInfo(cordova.getActivity().getPackageName(), 0); | |
} catch (final NameNotFoundException e) { | |
ai = null; | |
} | |
final String appName = (String) (ai != null ? pm.getApplicationLabel(ai) : "Unknown"); | |
final boolean saveToLibrary = options.optBoolean("saveToLibrary", true); | |
File mediaStorageDir; | |
if (saveToLibrary) { | |
mediaStorageDir = new File( | |
Environment.getExternalStorageDirectory() + "/Movies", | |
appName | |
); | |
} else { | |
mediaStorageDir = new File(appContext.getExternalCacheDir().getPath()); | |
} | |
if (!mediaStorageDir.exists()) { | |
if (!mediaStorageDir.mkdir()) { | |
callback.error("Can't access or make Movies directory"); | |
return; | |
} | |
} | |
final String outputFilePath = new File( | |
mediaStorageDir.getPath(), | |
"VID_" + outputFileName + outputExtension | |
).getAbsolutePath(); | |
Log.v(TAG, "outputFilePath: " + outputFilePath); | |
final String videoStartTime = options.getString("startTime"); | |
final String videoEndTime = options.getString("endTime"); | |
final int overlayIndex = options.optInt("overlayIndex"); | |
Log.d(TAG, "videoStartTime: " + videoStartTime); | |
Log.d(TAG, "videoEndTime: " + videoEndTime); | |
Log.d(TAG, "overlayIndex: " + overlayIndex); | |
final File inFile = this.resolveLocalFileSystemURI(options.getString("fileUri")); // intro | |
final File inFile2 = this.resolveLocalFileSystemURI(options.getString("fileUri2")); // recording | |
final File inFile3 = this.resolveLocalFileSystemURI(options.getString("fileUri3")); // outro | |
final File inFile4 = this.resolveLocalFileSystemURI(options.getString("fileUri4")); // overlay | |
cordova.getThreadPool().execute(new Runnable() { | |
public void run() { | |
try { | |
ffmpegController = new FfmpegController(appContext, appContext.getCacheDir()); | |
TranscodeCallback tcCallback = new TranscodeCallback(); | |
ArrayList<Clip> listVideos = new ArrayList<Clip>(); | |
// intro | |
if (inFile.exists()) { | |
Clip clip = new Clip(); | |
clip.path = new File(inFile.getAbsolutePath()).getCanonicalPath(); | |
ffmpegController.getInfo(clip); | |
listVideos.add(clip); | |
} | |
// recording | |
if (inFile2.exists()) { | |
Clip clip2 = new Clip(); | |
clip2.path = new File(inFile2.getAbsolutePath()).getCanonicalPath(); | |
clip2.startTime = videoStartTime; | |
clip2.endTime = videoEndTime; | |
ffmpegController.getInfo(clip2); | |
listVideos.add(clip2); | |
} | |
// outro | |
if (inFile3.exists()) { | |
Clip clip3 = new Clip(); | |
clip3.path = new File(inFile3.getAbsolutePath()).getCanonicalPath(); | |
ffmpegController.getInfo(clip3); | |
listVideos.add(clip3); | |
} | |
// overlay | |
String overlayPath = null; | |
if (inFile4.exists()) { | |
overlayPath = inFile4.getAbsolutePath(); | |
} | |
// output | |
Clip clipOut = new Clip(); | |
clipOut.path = new File(outputFilePath).getCanonicalPath(); | |
// run command line tools | |
int exitValue = ffmpegController.concatAndTrimFilesMP4Stream(listVideos, clipOut, false, false, new ShellUtils.ShellCallback() { | |
@Override | |
public void shellOut(String shellLine) { | |
//System.out.println("fc>" + shellLine); | |
// https://github.com/apache/cordova-plugin-file-transfer/blob/master/src/android/FileTransfer.java#L482 | |
try { | |
JSONObject jsonObj = new JSONObject(); | |
jsonObj.put("loaded", shellLine.toString()); | |
jsonObj.put("total", 0); | |
jsonObj.put("lengthComputable", true); | |
PluginResult progressResult = new PluginResult(PluginResult.Status.OK, jsonObj); | |
progressResult.setKeepCallback(true); | |
callback.sendPluginResult(progressResult); | |
} catch (JSONException e) { | |
Log.d(TAG, "PluginResult error: " + e); | |
} | |
} | |
@Override | |
public void processComplete(int exitValue) { | |
} | |
}, overlayPath, overlayIndex); | |
// create a file from the path returned | |
File outFile = new File(outputFilePath); | |
// check if the file exists | |
if (outFile.exists()) { | |
// if the render was cancelled, return error meesage | |
if (exitValue == 255) { | |
outFile.delete(); | |
callback.error("render was cancelled"); | |
return; | |
} else { | |
if (saveToLibrary) { | |
// remove the original input file when saving to gallery | |
// comment out or remove the delete based on your needs | |
//if (!inFile.delete()) { | |
// Log.d(TAG, "unable to delete in file"); | |
//} | |
Intent scanIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE); | |
//scanIntent.setData(Uri.fromFile(inFile)); | |
//scanIntent.setData(Uri.fromFile(inFile2)); | |
//scanIntent.setData(Uri.fromFile(inFile3)); | |
//scanIntent.setData(Uri.fromFile(inFile4)); | |
scanIntent.setData(Uri.fromFile(outFile)); | |
appContext.sendBroadcast(scanIntent); | |
} | |
callback.success(outputFilePath); | |
} | |
} else { | |
Log.d(TAG, "outputFile doesn't exist!"); | |
callback.error("an error ocurred during transcoding"); | |
return; | |
} | |
} catch (Throwable e) { | |
Log.d(TAG, "transcode exception ", e); | |
callback.error(e.toString()); | |
} | |
} | |
}); | |
} | |
private class TranscodeCallback implements ShellCallback { | |
@Override | |
public void shellOut(String shellLine) { | |
Log.v(TAG, "shellOut: " + shellLine); | |
} | |
@Override | |
public void processComplete(int exitValue) { | |
Log.v(TAG, "processComplete: " + exitValue); | |
} | |
} | |
private void killVideoProcessor(JSONArray args) throws JSONException, IOException { | |
Log.d(TAG, "killVideoProcessor"); | |
ffmpegController.killVideoProcessor(false, true); | |
} | |
@SuppressWarnings("unused") | |
private void createThumbnail(JSONArray args) throws JSONException, IOException { | |
Log.d(TAG, "createThumbnail firing"); | |
/* createThumbnail arguments: | |
fileUri: video input url | |
outputFileName: output file name | |
*/ | |
JSONObject options = args.optJSONObject(0); | |
Log.d(TAG, "options: " + options.toString()); | |
File inFile = this.resolveLocalFileSystemURI(options.getString("fileUri")); | |
if (!inFile.exists()) { | |
Log.d(TAG, "input file does not exist"); | |
callback.error("input video does not exist."); | |
return; | |
} | |
String srcVideoPath = inFile.getAbsolutePath(); | |
String outputFileName = options.optString( | |
"outputFileName", | |
new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.ENGLISH).format(new Date()) | |
); | |
Context appContext = cordova.getActivity().getApplicationContext(); | |
PackageManager pm = appContext.getPackageManager(); | |
ApplicationInfo ai; | |
try { | |
ai = pm.getApplicationInfo(cordova.getActivity().getPackageName(), 0); | |
} catch (final NameNotFoundException e) { | |
ai = null; | |
} | |
final String appName = (String) (ai != null ? pm.getApplicationLabel(ai) : "Unknown"); | |
File tempStoragePath = appContext.getExternalCacheDir(); | |
File outputFile = new File( | |
tempStoragePath.getPath(), | |
"PIC_" + outputFileName + ".jpg" | |
); | |
Bitmap thumbnail = ThumbnailUtils.createVideoThumbnail(srcVideoPath, MediaStore.Images.Thumbnails.MINI_KIND); | |
FileOutputStream theOutputStream; | |
try { | |
if (!outputFile.exists()) { | |
if (!outputFile.createNewFile()) { | |
callback.error("Could not save thumbnail."); | |
} | |
} | |
if (outputFile.canWrite()) { | |
theOutputStream = new FileOutputStream(outputFile); | |
if (theOutputStream != null) { | |
thumbnail.compress(CompressFormat.JPEG, 75, theOutputStream); | |
} else { | |
callback.error("Could not save thumbnail; target not writeable"); | |
} | |
} | |
} catch (IOException e) { | |
callback.error(e.toString()); | |
} | |
callback.success(outputFile.getAbsolutePath()); | |
} | |
@SuppressWarnings("deprecation") | |
private File resolveLocalFileSystemURI(String url) throws IOException, JSONException { | |
Log.d(TAG, "resolveLocalFileSystemURI: " + url); | |
if (url == null || url.isEmpty() || url == "null") { | |
return new File("file:///dummy.txt"); | |
} | |
String decoded = URLDecoder.decode(url, "UTF-8"); | |
File fp = null; | |
// Handle the special case where you get an Android content:// uri. | |
if (decoded.startsWith("content:")) { | |
fp = new File(getPath(this.cordova.getActivity().getApplicationContext(), Uri.parse(decoded))); | |
} else { | |
// Test to see if this is a valid URL first | |
@SuppressWarnings("unused") | |
URL testUrl = new URL(decoded); | |
if (decoded.startsWith("file://")) { | |
int questionMark = decoded.indexOf("?"); | |
if (questionMark < 0) { | |
fp = new File(decoded.substring(7, decoded.length())); | |
} else { | |
Log.d(TAG, "4: " + decoded.substring(7, questionMark)); | |
fp = new File(decoded.substring(7, questionMark)); | |
} | |
} else if (decoded.startsWith("file:/")) { | |
fp = new File(decoded.substring(6, decoded.length())); | |
} else { | |
fp = new File(decoded); | |
} | |
} | |
if (!fp.exists()) { | |
throw new FileNotFoundException(); | |
} | |
if (!fp.canRead()) { | |
throw new IOException(); | |
} | |
return fp; | |
} | |
/** | |
* Get a file path from a Uri. This will get the the path for Storage Access | |
* Framework Documents, as well as the _data field for the MediaStore and | |
* other file-based ContentProviders. | |
* | |
* @param context The context. | |
* @param uri The Uri to query. | |
* @author paulburke | |
*/ | |
public static String getPath(final Context context, final Uri uri) { | |
final boolean isKitKat = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT; | |
// DocumentProvider | |
if (isKitKat && DocumentsContract.isDocumentUri(context, uri)) { | |
// ExternalStorageProvider | |
if (isExternalStorageDocument(uri)) { | |
final String docId = DocumentsContract.getDocumentId(uri); | |
final String[] split = docId.split(":"); | |
final String type = split[0]; | |
if ("primary".equalsIgnoreCase(type)) { | |
return Environment.getExternalStorageDirectory() + "/" + split[1]; | |
} | |
// TODO handle non-primary volumes | |
} | |
// DownloadsProvider | |
else if (isDownloadsDocument(uri)) { | |
final String id = DocumentsContract.getDocumentId(uri); | |
final Uri contentUri = ContentUris.withAppendedId( | |
Uri.parse("content://downloads/public_downloads"), Long.valueOf(id)); | |
return getDataColumn(context, contentUri, null, null); | |
} | |
// MediaProvider | |
else if (isMediaDocument(uri)) { | |
final String docId = DocumentsContract.getDocumentId(uri); | |
final String[] split = docId.split(":"); | |
final String type = split[0]; | |
Uri contentUri = null; | |
if ("image".equals(type)) { | |
contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; | |
} else if ("video".equals(type)) { | |
contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI; | |
} else if ("audio".equals(type)) { | |
contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; | |
} | |
final String selection = "_id=?"; | |
final String[] selectionArgs = new String[] { | |
split[1] | |
}; | |
return getDataColumn(context, contentUri, selection, selectionArgs); | |
} | |
} | |
// MediaStore (and general) | |
else if ("content".equalsIgnoreCase(uri.getScheme())) { | |
return getDataColumn(context, uri, null, null); | |
} | |
// File | |
else if ("file".equalsIgnoreCase(uri.getScheme())) { | |
return uri.getPath(); | |
} | |
return null; | |
} | |
/** | |
* Get the value of the data column for this Uri. This is useful for | |
* MediaStore Uris, and other file-based ContentProviders. | |
* | |
* @param context The context. | |
* @param uri The Uri to query. | |
* @param selection (Optional) Filter used in the query. | |
* @param selectionArgs (Optional) Selection arguments used in the query. | |
* @return The value of the _data column, which is typically a file path. | |
*/ | |
public static String getDataColumn(Context context, Uri uri, String selection, | |
String[] selectionArgs) { | |
Cursor cursor = null; | |
final String column = "_data"; | |
final String[] projection = { | |
column | |
}; | |
try { | |
cursor = context.getContentResolver().query(uri, projection, selection, selectionArgs, | |
null); | |
if (cursor != null && cursor.moveToFirst()) { | |
final int column_index = cursor.getColumnIndexOrThrow(column); | |
return cursor.getString(column_index); | |
} | |
} finally { | |
if (cursor != null) | |
cursor.close(); | |
} | |
return null; | |
} | |
/** | |
* @param uri The Uri to check. | |
* @return Whether the Uri authority is ExternalStorageProvider. | |
*/ | |
public static boolean isExternalStorageDocument(Uri uri) { | |
return "com.android.externalstorage.documents".equals(uri.getAuthority()); | |
} | |
/** | |
* @param uri The Uri to check. | |
* @return Whether the Uri authority is DownloadsProvider. | |
*/ | |
public static boolean isDownloadsDocument(Uri uri) { | |
return "com.android.providers.downloads.documents".equals(uri.getAuthority()); | |
} | |
/** | |
* @param uri The Uri to check. | |
* @return Whether the Uri authority is MediaProvider. | |
*/ | |
public static boolean isMediaDocument(Uri uri) { | |
return "com.android.providers.media.documents".equals(uri.getAuthority()); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment