Handle rotated videos (by metadata).

Use "exiftool" to determine video metadata.
Allow for user specified ffmpeg binary.
Embedded Jetty.
Sample videos.
This commit is contained in:
Erik Forkalsrud 2013-01-20 23:07:47 -08:00
parent bdbcf6b030
commit 7b2bfc368c
9 changed files with 308 additions and 56 deletions

BIN
photos/video/IMG_0841.MOV Normal file

Binary file not shown.

BIN
photos/video/IMG_0842.MOV Normal file

Binary file not shown.

BIN
photos/video/IMG_0843.MOV Normal file

Binary file not shown.

BIN
photos/video/IMG_0844.MOV Normal file

Binary file not shown.

25
pom.xml
View file

@ -174,22 +174,22 @@
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>1.5.10</version>
<version>1.6.6</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-log4j12</artifactId>
<version>1.5.10</version>
<version>1.6.6</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>jul-to-slf4j</artifactId>
<version>1.5.10</version>
<version>1.6.6</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>jcl-over-slf4j</artifactId>
<version>1.5.10</version>
<version>1.6.6</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
@ -198,6 +198,23 @@
<type>jar</type>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.codehaus.jackson</groupId>
<artifactId>jackson-mapper-asl</artifactId>
<version>1.9.11</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-server</artifactId>
<version>7.6.8.v20121106</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-webapp</artifactId>
<version>7.6.8.v20121106</version>
<scope>test</scope>
</dependency>
</dependencies>
<repositories>
<repository>

View file

@ -9,9 +9,12 @@ import java.util.LinkedList;
import java.util.List;
import org.forkalsrud.album.exif.Dimension;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class FlvMetadata {
private static Logger log = LoggerFactory.getLogger(FlvMetadata.class);
private abstract class Attr<T> {
@ -98,7 +101,7 @@ public class FlvMetadata {
public void read(InputStream in) throws FlvFormatException, IOException {
int type = in.read();
if (type != 0) {
throw new FlvFormatException("Not a boolean: " + type);
throw new FlvFormatException("Not a number: " + type);
}
set(readDouble(in));
}
@ -167,7 +170,7 @@ public class FlvMetadata {
throw new FlvFormatException("Not an object: " + type);
}
if (!in.markSupported()) {
throw new FlvFormatException("Need redahead");
throw new FlvFormatException("Need readahead");
}
List<Integer> filePositions = null;
List<Double> times = null;
@ -203,6 +206,14 @@ public class FlvMetadata {
private StringAttr creator = new StringAttr("creator");
private StringAttr metadataCreator = new StringAttr("metadatacreator");
private StringAttr majorBrand = new StringAttr("major_brand");
private StringAttr minorVersion = new StringAttr("minor_version");
private StringAttr compatibleBrands = new StringAttr("compatible_brands");
private StringAttr creationTime = new StringAttr("creation_time");
private StringAttr encoder = new StringAttr("encoder");
private StringAttr encoderEng = new StringAttr( "encoder-eng");
private StringAttr date = new StringAttr("date");
private StringAttr dateEng = new StringAttr("date-eng");
private BooleanAttr hasKeyframes = new BooleanAttr("hasKeyframes");
private BooleanAttr hasVideo = new BooleanAttr("hasVideo");
@ -247,6 +258,15 @@ public class FlvMetadata {
attrs.add(creator);
attrs.add(metadataCreator);
attrs.add(majorBrand);
attrs.add(minorVersion);
attrs.add(compatibleBrands);
attrs.add(creationTime);
attrs.add(date);
attrs.add(dateEng);
attrs.add(encoder);
attrs.add(encoderEng);
attrs.add(hasKeyframes);
attrs.add(hasVideo);
attrs.add(hasAudio);
@ -511,10 +531,10 @@ public class FlvMetadata {
throw new FlvFormatException("Not an ECMA array: " + type);
}
int arrayLen = readEcmaArrayHeader(in);
for (int i = 0; i < arrayLen; i++) {
while (!isLookingAtEnd(in)) {
readProperty(in);
}
readEcmaArrayFooter(in);
readEcmaArrayFoter(in);
} catch (FlvFormatException e) {
LoggerFactory.getLogger(getClass()).error("invalid", e);
}
@ -583,7 +603,7 @@ public class FlvMetadata {
return len;
}
protected void readEcmaArrayFooter(InputStream in) throws FlvFormatException, IOException {
protected void readEcmaArrayFoter(InputStream in) throws FlvFormatException, IOException {
readObjectEnd(in);
}
@ -607,10 +627,26 @@ public class FlvMetadata {
String name = readString(in);
Attr attr = findAttrByName(name);
if (attr == null) {
throw new FlvFormatException("Unknown attribute: " + name);
}
attr.read(in);
if (attr != null) {
attr.read(in);
} else {
log.warn("Unknown metadata property: " + name);
readUnknownProperty(in, name);
}
}
protected void readUnknownProperty(InputStream in, String name) throws IOException, FlvFormatException {
in.mark(1);
int type = in.read();
in.reset();
switch (type) {
case 2:
new StringAttr(name).read(in);
break;
default:
break;
}
}
private Attr findAttrByName(String name) {

View file

@ -14,6 +14,7 @@ import java.util.List;
import java.util.Map;
import org.apache.commons.io.IOUtils;
import org.codehaus.jackson.map.ObjectMapper;
import org.forkalsrud.album.db.Chunk;
import org.forkalsrud.album.db.MovieDatabase;
import org.forkalsrud.album.exif.Dimension;
@ -27,6 +28,7 @@ public class MovieCoder {
private String ffmpegExecutable;
private String mplayerExecutable;
private String exiftoolExecutable;
private PictureScaler pictureScaler;
private MovieDatabase movieDb;
private HashMap<String, EncodingProcess> currentEncodings = new HashMap<String, EncodingProcess>();
@ -39,15 +41,16 @@ public class MovieCoder {
public void init() throws Exception {
ExecUtil util = new ExecUtil();
this.ffmpegExecutable = util.findExecutableInShellPath("ffmpeg");
if (this.ffmpegExecutable == null) {
this.ffmpegExecutable = util.findExecutableInShellPath("ffmpeg");
}
this.mplayerExecutable = util.findExecutableInShellPath("mplayer");
this.exiftoolExecutable = util.findExecutableInShellPath("exiftool");
}
/**
*
* @param f the movie file
@ -57,35 +60,159 @@ public class MovieCoder {
*/
public Map<String, String> generateVideoProperties(File f) throws IOException, InterruptedException {
/*
* [{
"SourceFile": "/home/erik/local/IMG_0837.mov",
"ExifToolVersion": 9.12,
"FileName": "IMG_0837.mov",
"Directory": "/home/erik/local",
"FileSize": "1438 kB",
"FileModifyDate": "2013:01:19 18:36:53-08:00",
"FileAccessDate": "2013:01:19 18:36:57-08:00",
"FileInodeChangeDate": "2013:01:19 18:36:53-08:00",
"FilePermissions": "rw-rw-r--",
"FileType": "MOV",
"MIMEType": "video/quicktime",
"MajorBrand": "Apple QuickTime (.MOV/QT)",
"MinorVersion": "0.0.0",
"CompatibleBrands": ["qt "],
"MovieDataSize": 1467141,
"MovieHeaderVersion": 0,
"ModifyDate": "2012:12:30 06:44:31",
"TimeScale": 600,
"Duration": "9.07 s",
"PreferredRate": 1,
"PreferredVolume": "100.00%",
"PreviewTime": "0 s",
"PreviewDuration": "0 s",
"PosterTime": "0 s",
"SelectionTime": "0 s",
"SelectionDuration": "0 s",
"CurrentTime": "0 s",
"NextTrackID": 3,
"TrackHeaderVersion": 0,
"TrackCreateDate": "2012:12:30 06:44:26",
"TrackModifyDate": "2012:12:30 06:44:31",
"TrackID": 1,
"TrackDuration": "9.05 s",
"TrackLayer": 0,
"TrackVolume": "100.00%",
"Balance": 0,
"AudioChannels": 1,
"AudioBitsPerSample": 16,
"AudioSampleRate": 44100,
"AudioFormat": "chan",
"MatrixStructure": "0 1 0 -1 0 0 540 0 1",
"ImageWidth": 960,
"ImageHeight": 540,
"CleanApertureDimensions": "960x540",
"ProductionApertureDimensions": "960x540",
"EncodedPixelsDimensions": "960x540",
"MediaHeaderVersion": 0,
"MediaCreateDate": "2012:12:30 06:44:26",
"MediaModifyDate": "2012:12:30 06:44:31",
"MediaTimeScale": 600,
"MediaDuration": "9.10 s",
"MediaLanguageCode": "und",
"GraphicsMode": "ditherCopy",
"OpColor": "32768 32768 32768",
"HandlerClass": "Data Handler",
"HandlerVendorID": "Apple",
"HandlerDescription": "Core Media Data Handler",
"CompressorID": "avc1",
"SourceImageWidth": 960,
"SourceImageHeight": 540,
"XResolution": 72,
"YResolution": 72,
"CompressorName": "H.264",
"BitDepth": 24,
"VideoFrameRate": 30,
"CameraIdentifier": "Back",
"FrameReadoutTime": "28512 microseconds",
"Make": "Apple",
"SoftwareVersion": "6.0.1",
"CreateDate": "2012:12:29 16:30:21-08:00",
"Model": "iPhone 4S",
"HandlerType": "Metadata Tags",
"Make-und-US": "Apple",
"CreationDate-und-US": "2012:12:29 16:30:21-08:00",
"Software-und-US": "6.0.1",
"Model-und-US": "iPhone 4S",
"AvgBitrate": "1.29 Mbps",
"ImageSize": "960x540",
"Rotation": 90
}]
*/
Map<String, String> props = new HashMap<String, String>();
ProcessBuilder pb = new ProcessBuilder().command(
mplayerExecutable, "-vo", "null", "-ao", "null", "-frames", "0", "-identify", f.getAbsolutePath());
this.exiftoolExecutable, "-j", f.getAbsolutePath());
pb.redirectErrorStream(false);
Process p = pb.start();
p.getOutputStream().close();
List<String> lines = IOUtils.readLines(p.getInputStream());
ObjectMapper mapper = new ObjectMapper(); // can reuse, share globally
@SuppressWarnings("unchecked")
List<Map<String,Object>> userDataList = mapper.readValue(p.getInputStream(), List.class);
p.waitFor();
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd-HHmmss");
String width = "", height = "", length = "";
for (String line : lines) {
int pos = line.indexOf('=');
if (pos >= 0) {
String name = line.substring(0, pos);
String value = line.substring(pos + 1);
if (name.equals("ID_VIDEO_WIDTH")) width = value;
if (name.equals("ID_VIDEO_HEIGHT")) height = value;
if (name.equals("ID_LENGTH")) length = value;
}
}
props.put("type", "movie");
props.put("orientation", "1");
props.put("dimensions", new Dimension(width, height).toString());
Map<String, Object> userData = userDataList.get(0);
System.out.println(userData);
{
// The orientation is about flipping and rotating. Here is what an 'F' looks like
// on pictures with each orientation.
//
// 1 2 3 4 5 6 7 8
//
// 888888 888888 88 88 8888888888 88 88 8888888888
// 88 88 88 88 88 88 88 88 88 88 88 88
// 8888 8888 8888 8888 88 8888888888 8888888888 88
// 88 88 88 88
// 88 88 888888 888888
//
// The first four are obtained with only flipping X and/or Y
// The last four are obtained by rotating 90 degrees and then flipping X and/or Y.
//
String orientation;
Object rotationObj = userData.get("Rotation");
if (rotationObj == null) {
orientation = "1";
} else {
String rotation = rotationObj.toString();
if ("0".equals(rotation)) {
orientation = "1";
} else if ("90".equals(rotation)) {
orientation = "6";
} else if ("180".equals(rotation)) {
orientation = "3";
} else if ("270".equals(rotation)) {
orientation = "8";
} else {
log.warn("unknown rotation: " + rotation + " for file " + f);
orientation = "1";
}
}
props.put("orientation", orientation);
}
props.put("dimensions", userData.get("ImageSize").toString());
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd-HHmmss");
props.put("captureDate", sdf.format(new Date(f.lastModified())));
props.put("etag", Integer.toHexString(f.getName().hashCode() + Long.valueOf(f.lastModified()).hashCode()));
props.put("length", length);
for (String prop : new String[] { "Duration", "MediaDuration", "PlayDuration" }) {
Object o = userData.get(prop);
if (o != null) {
props.put("length", o.toString().split(" ")[0]);
break;
}
}
return props;
}
public File createTempDirectory() throws IOException {
final File temp = File.createTempFile("temp", Long.toString(System.nanoTime()));
@ -160,6 +287,7 @@ public class MovieCoder {
private FlvFilter filter;
private String dbKey;
private long fileTimestamp;
private int orientation;
public EncodingProcess(File file, Thumbnail thumbnail, Dimension size) {
this.file = file;
@ -171,6 +299,7 @@ public class MovieCoder {
FlvMetadata extraMeta = new FlvMetadata();
extraMeta.setDuration(thumbnail.getDuration());
this.filter = new FlvFilter(this, extraMeta);
this.orientation = thumbnail.getOrientation();
}
/*
@ -190,28 +319,49 @@ public class MovieCoder {
@Override
public void run() {
// -vf transpose=1 (rotate 90)
// -vf transpose=2 (rotate 270)
// -vf vflip,hflip (rotate 180)
// 1 2 3 4 5 6 7 8
//
// 888888 888888 88 88 8888888888 88 88 8888888888
// 88 88 88 88 88 88 88 88 88 88 88 88
// 8888 8888 8888 8888 88 8888888888 8888888888 88
// 88 88 88 88
// 88 88 888888 888888
String vf = new String[] {
null, // 1
"hflip", // 2
"vflip,hflip", // 3
"vflip", // 4
"transpose=1,hflip", // 5
"transpose=1", // 6
"transpose=2,hflip", // 7
"transpose=2", // 8
}[orientation - 1];
try {
/*
ProcessBuilder pb = new ProcessBuilder().command(
ffmpegExecutable, "-i", file.getAbsolutePath(),
"-aspect", (thumbnail.getSize().getWidth() + ":" + thumbnail.getSize().getHeight()),
"-s", (targetSize.getWidth() + "x" + targetSize.getHeight()),
"-b", "1000000",
"-crf", "25",
// "-vcodec", "libx264", "-vpre", "knut_low",
// "-acodec", "libfaac", "-aq", "100",
"-f", "mpeg",
"-");
*/
ProcessBuilder pb = new ProcessBuilder().command(
ffmpegExecutable, "-i", file.getAbsolutePath(),
// "-aspect", (thumbnail.getSize().getWidth() + ":" + thumbnail.getSize().getHeight()),
"-s", (targetSize.getWidth() + "x" + targetSize.getHeight()),
"-crf", "30",
"-acodec", "libmp3lame", "-ar", "22050", "-vcodec", "libx264",
"-g", "150", "-vpre", "medium",
"-f", "flv",
"-");
ArrayList<String> command = new ArrayList<String>();
command.add(ffmpegExecutable);
command.add("-i");
command.add(file.getAbsolutePath());
command.add("-s");
command.add(targetSize.getWidth() + "x" + targetSize.getHeight());
command.add("-crf");
command.add("30");
command.add("-acodec"); command.add("libmp3lame");
command.add("-ar"); command.add("22050");
command.add("-vcodec"); command.add("libx264");
command.add("-g"); command.add("150");
if (vf != null) {
command.add("-vf");
command.add(vf);
}
command.add("-f"); command.add("flv");
command.add("-");
ProcessBuilder pb = new ProcessBuilder(command);
log.info(pb.command().toString());
pb.redirectErrorStream(false);
@ -470,4 +620,15 @@ public class MovieCoder {
notify();
}
}
public void setFfmpegPath(String property) {
if (property != null) {
File program = new File(property);
if (program.canExecute()) {
this.ffmpegExecutable = property;
}
}
}
}

View file

@ -142,7 +142,17 @@ public class AlbumServlet
basePrefix = "/" + base.getName();
String dbDirName = props.getProperty("dbdir");
File dbDir = dbDirName != null ? new File(dbDirName) : new File(System.getProperty("java.io.tmpdir"), "album");
File dbDir;
if (dbDirName != null) {
dbDir = new File(dbDirName);
} else {
dbDir = new File(System.getProperty("java.io.tmpdir"), "album");
if (dbDir.isDirectory() && dbDir.canWrite()) {
for (File f : dbDir.listFiles()) {
f.delete();
}
}
}
dbDir.mkdirs();
EnvironmentConfig environmentConfig = new EnvironmentConfig();
@ -158,6 +168,7 @@ public class AlbumServlet
pictureScaler = new PictureScaler();
movieCoder = new MovieCoder(pictureScaler, movieDb);
movieCoder.setFfmpegPath(props.getProperty("ffmpeg.path"));
try {
movieCoder.init();
} catch (Exception e) {
@ -320,7 +331,7 @@ public class AlbumServlet
thumbDb.store(key, cimg);
log.info(" " + key + " added to the cache with size " + cimg.bits.length + " -- now " + thumbDb.size() + " entries");
} catch (Exception e) {
e.fillInStackTrace();
//e.fillInStackTrace();
throw new RuntimeException("sadness", e);
}
}

View file

@ -0,0 +1,27 @@
package org.forkalsrud.album;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.webapp.WebAppContext;
public class Runner {
/**
* @param args
* @throws Exception
*/
public static void main(String[] args) throws Exception {
Server server = new Server(8080);
WebAppContext context = new WebAppContext();
context.setDescriptor("src/main/webapp/WEB-INF/web.xml");
context.setResourceBase("src/main/webapp");
context.setContextPath("/");
context.setParentLoaderPriority(true);
server.setHandler(context);
server.start();
server.join();
}
}