├── package.json ├── src ├── ios │ ├── VideoSnapshot.h │ └── VideoSnapshot.m └── android │ └── VideoSnapshot.java ├── www └── VideoSnapshot.js ├── plugin.xml └── README.md /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cordova-videosnapshot", 3 | "version": "0.2.9", 4 | "description": "Cordova Video Snapshot Plugin", 5 | "cordova": { 6 | "id": "com.sebible.cordova.videosnapshot.VideoSnapshot", 7 | "platforms": [ 8 | "ios", 9 | "android" 10 | ] 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/sebible/cordova-videosnapshot.git" 15 | }, 16 | "keywords": [ 17 | "ecosystem:cordova", 18 | "cordova-ios", 19 | "cordova-android", 20 | "video", 21 | "snapshot" 22 | ], 23 | "engines": [ 24 | { 25 | "name": "cordova", 26 | "version": ">=3.0.0" 27 | } 28 | ], 29 | "author": "Sebible", 30 | "license": "Apache 2.0", 31 | "bugs": { 32 | "url": "https://github.com/sebible/cordova-videosnapshot/issues" 33 | }, 34 | "homepage": "https://github.com/sebible/cordova-videosnapshot#readme" 35 | } 36 | -------------------------------------------------------------------------------- /src/ios/VideoSnapshot.h: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Copyright 2014 Sebible Limited 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | 17 | */ 18 | #import 19 | #import 20 | #import 21 | #import 22 | 23 | 24 | @interface VideoSnapshot : CDVPlugin 25 | 26 | - (NSString *)applicationDocumentsDirectory; 27 | - (void)snapshot:(CDVInvokedUrlCommand*)command; 28 | - (void)fail:(CDVInvokedUrlCommand*)command withMessage:(NSString*)message; 29 | - (void)success:(CDVInvokedUrlCommand*)command withDictionary:(NSDictionary*)ret; 30 | - (UIImage *)drawTimestamp:(CMTime)timestamp withPrefix:(NSString*)prefix ofSize:(int)textSize toImage:(UIImage *)img; 31 | 32 | @end 33 | -------------------------------------------------------------------------------- /www/VideoSnapshot.js: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Copyright 2014 Sebible Limited 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | 17 | */ 18 | 19 | var exec = require('cordova/exec'); 20 | 21 | module.exports = { 22 | /** 23 | * Take snapshots of a video. 24 | * 25 | * Time points will be calculated automatically according to the count of shots specified by user 26 | * and the duration of the video. 27 | * 28 | * @param success success callback function. Will receive {"result": true, "snapshots": [absolute_path...]} 29 | * @param fail fail callback function with param error object or string 30 | * @param options options object. Possible keys: 31 | * source: string, a file url of the source file 32 | * count: int, count of snapshots that will be taken, default 1 33 | * countPerMinute: int, if specified, count will be calculated according to the duration, 34 | * default 0 (disabled) 35 | * timeStamp: bool, add a timestamp at the lower-right corner, default true 36 | * textSize: int, timestamp size, default 48 37 | * prefix: string, optional text to print before timestamp 38 | * quality: int 0 2 | 3 | 7 | Video Snapshot 8 | 9 | Cordova Video Snapshot Plugin 10 | Apache 2.0 11 | cordova,video,snapshot 12 | https://github.com/sebible/cordova-videosnapshot.git 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Cordova Video Snapshot 2 | ====================== 3 | 4 | A cordova plugin for generating video snapshots. 5 | 6 | Platforms 7 | --------- 8 | 9 | * Android 10 | * IOS 11 | 12 | Installation 13 | ------------ 14 | 15 | Install with `cordova plugin` or `plugman`. The javascript module will be injected automatically. 16 | 17 | Usage 18 | ----- 19 | 20 | `window.sebible.videosnapshot.snapshot(success, fail, options)` 21 | 22 | Take snapshots of a video. 23 | 24 | Time points will be calculated automatically according to the count of shots specified by user 25 | and the duration of the video. 26 | 27 | All processing is done on worker threads so no blocking on the javascript thread and it's pretty fast! 28 | 29 | 30 | * **success** success callback function. Will receive {"result": true, "snapshots": [absolute_path...]} 31 | * **fail** fail callback function with param error object or string 32 | * **options** options object. Possible keys: 33 | * source: string, a file url of the source file 34 | * count: int, count of snapshots that will be taken, default 1 35 | * countPerMinute: int, if specified, count will be calculated according to the duration, default 0 (disabled) 36 | * timeStamp: bool, add a timestamp at the lower-right corner, default true 37 | * textSize: int, relative timestamp size, default (48 * video_width / 1280) 38 | * prefix: string, optional text to print before timestamp 39 | * quality: int 0").attr("src", absfilepath).appendTo("body"); 51 | } 52 | } 53 | } 54 | 55 | function fail(err) { 56 | console.log(err); 57 | } 58 | 59 | // This generates 3 snapshots of the source video no matter what its duration is (with timestamps printed at the lower right) 60 | var options = { 61 | source: "file:///mnt/sdcard/DCIM/Camera/0.mp4", 62 | count: 3, 63 | timeStamp: true 64 | } 65 | 66 | sebible.videosnapshot.snapshot(success, fail, options); 67 | 68 | // This generates 3 snapshots for every minute of the source video (with timestamps as well). 69 | var options2 = { 70 | source: "file:///mnt/sdcard/DCIM/Camera/0.mp4", 71 | countPerMinute: 3, 72 | timeStamp: true 73 | } 74 | 75 | sebible.videosnapshot.snapshot(success, fail, options2); 76 | 77 | License 78 | ------- 79 | 80 | Apache 2.0 -------------------------------------------------------------------------------- /src/android/VideoSnapshot.java: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Copyright 2014 Sebible Limited 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | 17 | */ 18 | package com.sebible.cordova.videosnapshot; 19 | 20 | import java.io.FileOutputStream; 21 | import java.io.FileInputStream; 22 | import java.io.BufferedInputStream; 23 | import java.util.ArrayList; 24 | import java.io.File; 25 | import java.lang.Exception; 26 | 27 | import android.media.MediaMetadataRetriever; 28 | import android.util.Log; 29 | import android.os.Environment; 30 | import android.graphics.Bitmap; 31 | import android.graphics.Canvas; 32 | import android.graphics.Paint; 33 | import android.graphics.Rect; 34 | import android.graphics.Color; 35 | import android.graphics.PorterDuffXfermode; 36 | import android.graphics.PorterDuff; 37 | import android.net.Uri; 38 | 39 | import org.apache.cordova.CallbackContext; 40 | import org.apache.cordova.CordovaPlugin; 41 | import org.apache.cordova.LOG; 42 | import org.apache.cordova.PluginResult; 43 | import org.json.JSONArray; 44 | import org.json.JSONException; 45 | import org.json.JSONObject; 46 | 47 | public class VideoSnapshot extends CordovaPlugin { 48 | 49 | private CallbackContext callbackContext; 50 | 51 | @Override 52 | public boolean execute(String action, JSONArray args, CallbackContext callbackContext) throws JSONException { 53 | String source = ""; 54 | int count = 1; 55 | 56 | this.callbackContext = callbackContext; 57 | 58 | JSONObject options = args.optJSONObject(0); 59 | if (options == null) { 60 | fail("No options provided"); 61 | return false; 62 | } 63 | 64 | 65 | if (action.equals("snapshot")) { 66 | // Run async 67 | snapshot(options); 68 | return true; 69 | } 70 | 71 | return false; 72 | } 73 | 74 | 75 | private void drawTimestamp(Bitmap bm, String prefix, long timeMs, int textSize) { 76 | float w = bm.getWidth(), h = bm.getHeight(); 77 | float size = (float)(textSize * bm.getWidth()) / 1280; 78 | float margin = (float)(w < h ? w : h) * 0.05f; 79 | 80 | Canvas c = new Canvas(bm); 81 | Paint p = new Paint(); 82 | p.setColor(Color.WHITE); 83 | p.setStrokeWidth((int)(size / 10)); 84 | p.setTextSize((int)size); // Text Size 85 | p.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_OVER)); // Text Overlapping Pattern 86 | 87 | long second = (timeMs / 1000) % 60; 88 | long minute = (timeMs / (1000 * 60)) % 60; 89 | long hour = (timeMs / (1000 * 60 * 60)) % 24; 90 | 91 | String text = String.format("%s %02d:%02d:%02d", prefix, hour, minute, second); 92 | Rect r = new Rect(); 93 | p.getTextBounds(text, 0, text.length(), r); 94 | //c.drawBitmap(originalBitmap, 0, 0, paint); 95 | c.drawText(text, bm.getWidth() - r.width() - margin, bm.getHeight() - r.height() - margin, p); 96 | } 97 | 98 | /** 99 | * Take snapshots of a video file 100 | * 101 | * @param source path of the file 102 | * @param count of snapshots that are gonna be taken 103 | */ 104 | private void snapshot(final JSONObject options) { 105 | final CallbackContext context = this.callbackContext; 106 | this.cordova.getThreadPool().execute(new Runnable() { 107 | public void run() { 108 | MediaMetadataRetriever retriever = new MediaMetadataRetriever(); 109 | try { 110 | int count = options.optInt("count", 1); 111 | int countPerMinute = options.optInt("countPerMinute", 0); 112 | int quality = options.optInt("quality", 90); 113 | String source = options.optString("source", ""); 114 | Boolean timestamp = options.optBoolean("timeStamp", true); 115 | String prefix = options.optString("prefix", ""); 116 | int textSize = options.optInt("textSize", 48); 117 | 118 | if (source.isEmpty()) { 119 | throw new Exception("No source provided"); 120 | } 121 | 122 | JSONObject obj = new JSONObject(); 123 | obj.put("result", false); 124 | JSONArray results = new JSONArray(); 125 | 126 | Log.i("snapshot", "Got source: " + source); 127 | Uri p = Uri.parse(source); 128 | String filename = p.getLastPathSegment(); 129 | 130 | FileInputStream in = new FileInputStream(new File(p.getPath())); 131 | retriever.setDataSource(in.getFD()); 132 | String tmp = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION); 133 | long duration = Long.parseLong(tmp); 134 | if (countPerMinute > 0) { 135 | count = (int)(countPerMinute * duration / (60 * 1000)); 136 | } 137 | if (count < 1) { 138 | count = 1; 139 | } 140 | long delta = duration / (count + 1); // Start at duration * 1 and ends at duration * count 141 | if (delta < 1000) { // min 1s 142 | delta = 1000; 143 | } 144 | 145 | Log.i("snapshot", "duration:" + duration + " delta:" + delta); 146 | 147 | File storage = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES); 148 | for (int i = 1; delta * i < duration && i <= count; i++) { 149 | String filename2 = filename.replace('.', '_') + "-snapshot" + i + ".jpg"; 150 | File dest = new File(storage, filename2); 151 | if (!storage.exists() && !storage.mkdirs()) { 152 | throw new Exception("Unable to access storage:" + storage.getPath()); 153 | } 154 | FileOutputStream out = new FileOutputStream(dest); 155 | Bitmap bm = retriever.getFrameAtTime(i * delta * 1000); 156 | if (timestamp) { 157 | drawTimestamp(bm, prefix, delta * i, textSize); 158 | } 159 | bm.compress(Bitmap.CompressFormat.JPEG, quality, out); 160 | out.flush(); 161 | out.close(); 162 | results.put(dest.getAbsolutePath()); 163 | } 164 | 165 | obj.put("result", true); 166 | obj.put("snapshots", results); 167 | context.success(obj); 168 | } catch (Exception ex) { 169 | ex.printStackTrace(); 170 | Log.e("snapshot", "Exception:", ex); 171 | fail("Exception: " + ex.toString()); 172 | }finally { 173 | try { 174 | retriever.release(); 175 | } catch (RuntimeException ex) { 176 | } 177 | } 178 | } 179 | }); 180 | } 181 | 182 | private void fail(String message) { 183 | this.callbackContext.error(message); 184 | PluginResult r = new PluginResult(PluginResult.Status.ERROR); 185 | callbackContext.sendPluginResult(r); 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/ios/VideoSnapshot.m: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Copyright 2014 Sebible Limited 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | 17 | */ 18 | #import "VideoSnapshot.h" 19 | 20 | @implementation VideoSnapshot 21 | 22 | - (NSString *)applicationDocumentsDirectory { 23 | return [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) lastObject]; 24 | } 25 | 26 | -(UIImage *)drawTimestamp:(CMTime)timestamp withPrefix:(NSString*)prefix ofSize:(int)textSize toImage:(UIImage *)img{ 27 | CGFloat w = img.size.width, h = img.size.height; 28 | CGFloat size = (CGFloat)(textSize * w) / 1280; 29 | CGFloat margin = (w < h? w : h) * 0.05; 30 | NSString* fontName = @"Helvetica"; 31 | 32 | long timeMs = (long)(1000 * CMTimeGetSeconds(timestamp)); 33 | int second = (timeMs / 1000) % 60; 34 | int minute = (timeMs / (1000 * 60)) % 60; 35 | int hour = (timeMs / (1000 * 60 * 60)) % 24; 36 | NSString* text = [NSString stringWithFormat:@"%@ %02d:%02d:%02d", prefix, hour, minute, second]; 37 | //CGSize sizeText = [text sizeWithFont:[UIFont fontWithName:@"Helvetica" size:size] minFontSize:size actualFontSize:nil forWidth:783 lineBreakMode:NSLineBreakModeTailTruncation]; 38 | UIFont* font = [UIFont fontWithName:fontName size:size]; 39 | UIColor* color = [UIColor whiteColor]; 40 | NSDictionary* attrs = [NSDictionary dictionaryWithObjectsAndKeys: font, NSFontAttributeName, color, NSForegroundColorAttributeName, nil]; 41 | CGSize sizeText = CGSizeMake(0.0f, 0.0f); 42 | if ([text respondsToSelector:@selector(sizeWithAttributes)]) { 43 | sizeText = [text sizeWithAttributes:attrs]; 44 | } else { 45 | sizeText = [text sizeWithFont:font]; 46 | } 47 | 48 | CGFloat posX = w - margin - sizeText.width; 49 | CGFloat posY = h - margin - sizeText.height; 50 | NSLog(@"Drawing at (%f, %f) of size: %f. Image size: (%f, %f)", posX, posY, size, w, h); 51 | 52 | UIGraphicsBeginImageContextWithOptions(img.size, NO, 0.0f); 53 | [img drawAtPoint:CGPointMake(0.0f, 0.0f)]; 54 | if ([text respondsToSelector:@selector(drawAtPoint:withAttributes:)]) { 55 | [text drawAtPoint:CGPointMake(posX, posY) withAttributes:attrs]; 56 | } else { 57 | CGContextRef context = UIGraphicsGetCurrentContext(); 58 | CGContextSetFillColorWithColor(context, color.CGColor); 59 | [text drawAtPoint:CGPointMake(posX, posY) withFont:font]; 60 | } 61 | UIImage *result = UIGraphicsGetImageFromCurrentImageContext(); 62 | UIGraphicsEndImageContext(); 63 | 64 | return result; 65 | } 66 | 67 | - (void)snapshot:(CDVInvokedUrlCommand*)command 68 | { 69 | NSDictionary* options = [command.arguments objectAtIndex:0]; 70 | NSLog(@"In plugin. Options:%@", options); 71 | 72 | if (options == nil) { 73 | [self fail:command withMessage:@"No options provided"]; 74 | return; 75 | } 76 | 77 | int count = 1; 78 | int countPerMinute = 0; 79 | int textSize = 48; 80 | bool timestamp = true; 81 | float quality = 0.9f; 82 | NSString* prefix = @""; 83 | 84 | NSNumber* nscount = [options objectForKey:@"count"]; 85 | NSNumber* nscountPerMinute = [options objectForKey:@"countPerMinute"]; 86 | NSNumber* nstextSize = [options objectForKey:@"textSize"]; 87 | NSString* source = [options objectForKey:@"source"]; 88 | NSNumber* nstimestamp = [options objectForKey:@"timeStamp"]; 89 | NSNumber* nsquality = [options objectForKey:@"quality"]; 90 | NSString* nsprefix = [options objectForKey:@"prefix"]; 91 | 92 | if (source == nil) { 93 | [self fail:command withMessage:@"No source provided"]; 94 | return; 95 | } 96 | //source = [self.applicationDocumentsDirectory stringByAppendingPathComponent:@"test.mov"]; 97 | 98 | if (nscount != nil) { 99 | count = [nscount intValue]; 100 | } 101 | 102 | if (nscountPerMinute != nil) { 103 | countPerMinute = [nscountPerMinute intValue]; 104 | } 105 | 106 | if (nstimestamp != nil) { 107 | timestamp = [nstimestamp boolValue]; 108 | } 109 | 110 | if (nsquality != nil) { 111 | quality = (float)[nsquality intValue] / 100; 112 | } 113 | 114 | if (nsprefix != nil) { 115 | prefix = nsprefix; 116 | } 117 | 118 | if (nstextSize != nil) { 119 | textSize = [nstextSize intValue]; 120 | } 121 | 122 | NSURL* url = [NSURL fileURLWithPath:source]; 123 | if (url == nil) { 124 | [self fail:command withMessage:@"Unable to open path"]; 125 | return; 126 | } 127 | 128 | NSString* filename = [url.lastPathComponent stringByReplacingOccurrencesOfString:@"." withString:@"_"]; 129 | NSString* tmppath = NSTemporaryDirectory(); 130 | AVURLAsset *asset = [[AVURLAsset alloc] initWithURL:url options:nil]; 131 | AVAssetImageGenerator *generate = [[AVAssetImageGenerator alloc] initWithAsset:asset]; 132 | generate.appliesPreferredTrackTransform = true; 133 | NSError *err = NULL; 134 | NSMutableArray* paths = [[NSMutableArray alloc] init]; 135 | if (asset.duration.value == 0) { 136 | [self fail:command withMessage:@"Unable to load video (duration == 0)"]; 137 | return; 138 | } 139 | 140 | Float64 duration = CMTimeGetSeconds(asset.duration); 141 | if (countPerMinute > 0) { 142 | count = countPerMinute * duration / 60; 143 | } 144 | if (count < 1) { 145 | count = 1; 146 | } 147 | Float64 delta = duration / (count + 1); 148 | if (delta < 1.f) { 149 | delta = 1.f; 150 | } 151 | 152 | NSMutableArray* times = [[NSMutableArray alloc] init]; 153 | for (int i = 1; delta * i < duration && i <= count; i++) { 154 | [times addObject:[NSValue valueWithCMTime:CMTimeMakeWithSeconds(delta * i, asset.duration.timescale)]]; 155 | } 156 | 157 | [generate generateCGImagesAsynchronouslyForTimes:times completionHandler:^(CMTime requestedTime, CGImageRef image, CMTime actualTime, AVAssetImageGeneratorResult result, NSError *error) { 158 | NSLog(@"err==%@, imageRef==%@", err, image); 159 | if (err != nil) { 160 | return; 161 | } 162 | 163 | int sec = (int)CMTimeGetSeconds(actualTime); 164 | NSString* path = [tmppath stringByAppendingPathComponent: [NSString stringWithFormat:@"%@-snapshot%d.jpg", filename, sec]]; 165 | UIImage *uiImage = [UIImage imageWithCGImage:image]; 166 | if (timestamp) { 167 | uiImage = [self drawTimestamp:actualTime withPrefix:prefix ofSize:textSize toImage:uiImage]; 168 | } 169 | 170 | NSData *jpgData = UIImageJPEGRepresentation(uiImage, quality); 171 | [jpgData writeToFile:path atomically:NO]; 172 | 173 | @synchronized (paths){ 174 | [paths addObject:path]; 175 | if (paths.count == times.count) { 176 | NSDictionary* ret = [NSDictionary dictionaryWithObjectsAndKeys: 177 | [NSNumber numberWithBool:true], @"result", paths, @"snapshots", nil]; 178 | 179 | [self success:command withDictionary:ret]; 180 | } 181 | } 182 | //CFRelease(image); 183 | }]; 184 | } 185 | 186 | - (void)success:(CDVInvokedUrlCommand*)command withDictionary:(NSDictionary*)ret 187 | { 188 | NSLog(@"Plugin success. Result: %@", ret); 189 | CDVPluginResult* pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsDictionary:ret]; 190 | [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; 191 | } 192 | 193 | - (void)fail:(CDVInvokedUrlCommand*)command withMessage:(NSString*)message 194 | { 195 | NSLog(@"Plugin failed. Error: %@", message); 196 | NSDictionary* ret = [NSDictionary dictionaryWithObjectsAndKeys: 197 | [NSNumber numberWithBool:false], @"result", message, @"error", nil]; 198 | CDVPluginResult* pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR messageAsDictionary:ret]; 199 | [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; 200 | } 201 | 202 | @end 203 | --------------------------------------------------------------------------------