From 1e04937fab7e04b162a84feb882385caaa9b254c Mon Sep 17 00:00:00 2001 From: Knut Forkalsrud Date: Sat, 26 Mar 2011 21:41:54 -0700 Subject: [PATCH] More work on video scaling. dynamic.html now successfully includes movies in the UI. --- pom.xml | 5 +- .../java/org/forkalsrud/album/db/Chunk.java | 14 + .../forkalsrud/album/db/MovieDatabase.java | 106 +++++ .../org/forkalsrud/album/exif/Dimension.java | 19 + .../forkalsrud/album/exif/DirectoryEntry.java | 25 +- .../album/exif/DirectoryEntryFactory.java | 36 ++ .../exif/DirectoryMetadataGenerator.java | 115 +----- .../forkalsrud/album/video/MovieCoder.java | 369 ++++++++++++++++++ .../forkalsrud/album/web/AlbumServlet.java | 64 ++- .../forkalsrud/album/web/PictureScaler.java | 22 +- src/main/webapp/WEB-INF/velocity/photo.vm | 2 +- src/main/webapp/dynamic.html | 12 +- 12 files changed, 642 insertions(+), 147 deletions(-) create mode 100644 src/main/java/org/forkalsrud/album/db/Chunk.java create mode 100644 src/main/java/org/forkalsrud/album/db/MovieDatabase.java create mode 100644 src/main/java/org/forkalsrud/album/exif/DirectoryEntryFactory.java create mode 100644 src/main/java/org/forkalsrud/album/video/MovieCoder.java diff --git a/pom.xml b/pom.xml index 6edc349..0af970c 100644 --- a/pom.xml +++ b/pom.xml @@ -36,6 +36,7 @@ org.apache.maven.plugins maven-compiler-plugin + 2.3.2 1.6 1.6 @@ -49,6 +50,7 @@ org.apache.maven.plugins maven-eclipse-plugin + 2.8 1.5 photo @@ -131,9 +133,6 @@ je 4.0.103 - org.springframework spring-webmvc diff --git a/src/main/java/org/forkalsrud/album/db/Chunk.java b/src/main/java/org/forkalsrud/album/db/Chunk.java new file mode 100644 index 0000000..4b550e2 --- /dev/null +++ b/src/main/java/org/forkalsrud/album/db/Chunk.java @@ -0,0 +1,14 @@ +/** + * + */ +package org.forkalsrud.album.db; + +public class Chunk { + + public byte[] bits; + + public Chunk(int capacity) { + this.bits = new byte[capacity]; + } + +} \ No newline at end of file diff --git a/src/main/java/org/forkalsrud/album/db/MovieDatabase.java b/src/main/java/org/forkalsrud/album/db/MovieDatabase.java new file mode 100644 index 0000000..3e38049 --- /dev/null +++ b/src/main/java/org/forkalsrud/album/db/MovieDatabase.java @@ -0,0 +1,106 @@ +/** + * + */ +package org.forkalsrud.album.db; + +import java.nio.charset.Charset; + +import org.forkalsrud.album.web.CachedImage; + +import com.sleepycat.bind.tuple.TupleBinding; +import com.sleepycat.bind.tuple.TupleInput; +import com.sleepycat.bind.tuple.TupleOutput; +import com.sleepycat.je.Database; +import com.sleepycat.je.DatabaseConfig; +import com.sleepycat.je.DatabaseEntry; +import com.sleepycat.je.Environment; +import com.sleepycat.je.OperationStatus; +import com.sleepycat.je.Transaction; + +/** + * @author knut + * + */ +public class MovieDatabase extends TupleBinding { + + private static Charset UTF8 = Charset.forName("utf-8"); + + private Environment environment; + private Database db; + + public MovieDatabase(Environment environment) { + + this.environment = environment; + + String dbname = "movies"; + + DatabaseConfig databaseConfig = new DatabaseConfig(); + databaseConfig.setAllowCreate(true); + databaseConfig.setTransactional(true); + // perform other database configurations + this.db = environment.openDatabase(null, dbname, databaseConfig); + } + + public void destroy() { + db.close(); + } + + public long size() { + return db.count(); + } + + public Chunk load(String key, int seq) { + + DatabaseEntry data = new DatabaseEntry(); + Transaction txn = environment.beginTransaction(null, null); + OperationStatus status = db.get(txn, key(key, seq), data, null); + txn.commitNoSync(); + if (OperationStatus.SUCCESS.equals(status)) { + return entryToObject(data); + } else { + return null; + } + } + + public void store(String key, int seq, Chunk chunk) { + + DatabaseEntry data = new DatabaseEntry(); + objectToEntry(chunk, data); + DatabaseEntry binKey = key(key, seq); + Transaction txn = environment.beginTransaction(null, null); + db.delete(txn, binKey); + db.put(txn, binKey, data); + txn.commitSync(); + } + + + private DatabaseEntry key(String key, int seq) { + DatabaseEntry returnValue = new DatabaseEntry(); + returnValue.setData((key + "#" + seq).getBytes(UTF8)); + return returnValue; + } + + + @Override + public Chunk entryToObject(TupleInput in) { + + int version = in.readInt(); + if (version != 1) { + throw new RuntimeException("I only understand version 1"); + } + int lobLen = in.readInt(); + Chunk chunk = new Chunk(lobLen); + in.read(chunk.bits, 0, lobLen); + return chunk; + } + + + @Override + public void objectToEntry(Chunk chunk, TupleOutput out) { + + out.writeInt(1); // version 1 + out.writeInt(chunk.bits.length); + out.write(chunk.bits); + } + +} diff --git a/src/main/java/org/forkalsrud/album/exif/Dimension.java b/src/main/java/org/forkalsrud/album/exif/Dimension.java index 511da78..3a0d205 100644 --- a/src/main/java/org/forkalsrud/album/exif/Dimension.java +++ b/src/main/java/org/forkalsrud/album/exif/Dimension.java @@ -80,4 +80,23 @@ public class Dimension { return h; } + /** + * @param size + * @return + */ + public Dimension scale(String size) { + Dimension outd; + if (size.endsWith("h")) { + int height = Integer.parseInt(size.substring(0, size.length() - 1)); + outd = scaleHeight(height); + } else if (size.endsWith("w")) { + int width = Integer.parseInt(size.substring(0, size.length() - 1)); + outd = scaleWidth(width); + } else { + int worh = Integer.parseInt(size); + outd = scaled(worh); + } + return outd; + } + } diff --git a/src/main/java/org/forkalsrud/album/exif/DirectoryEntry.java b/src/main/java/org/forkalsrud/album/exif/DirectoryEntry.java index 3128e76..ee98424 100644 --- a/src/main/java/org/forkalsrud/album/exif/DirectoryEntry.java +++ b/src/main/java/org/forkalsrud/album/exif/DirectoryEntry.java @@ -17,6 +17,7 @@ import java.util.List; import org.forkalsrud.album.db.DirectoryDatabase; import org.forkalsrud.album.db.DirectoryProps; +import org.forkalsrud.album.video.MovieCoder; /** * @author knut @@ -26,26 +27,32 @@ public class DirectoryEntry extends EntryWithChildren { final static String CACHE_FILE = "cache.properties"; final static String OVERRIDE_FILE = "album.properties"; - DirectoryDatabase db; + public interface ServiceApi { + + public DirectoryDatabase getDirectoryDatabase(); + public DirectoryMetadataGenerator getMetadataGenerator(); + } + + ServiceApi services; DirectoryProps cache; boolean childrenLoaded = false; Comparator sort = null; Date earliest = null; - public DirectoryEntry(DirectoryDatabase db, File file) { + public DirectoryEntry(ServiceApi api, File file) { super(file); if (!file.isDirectory()) { throw new RuntimeException("Use DirectoryEntry only for directories: " + file); } - this.db = db; - cache = db.load(file.getAbsolutePath()); + this.services = api; + cache = this.services.getDirectoryDatabase().load(file.getAbsolutePath()); if (cache == null) { cache = new DirectoryProps(); } } - public DirectoryEntry(DirectoryDatabase db, Entry parent, File file) { - this(db, file); + public DirectoryEntry(ServiceApi api, Entry parent, File file) { + this(api, file); this.parent = parent; } @@ -114,8 +121,8 @@ public class DirectoryEntry extends EntryWithChildren { DirectoryProps generateCache() throws IOException, InterruptedException { - DirectoryProps props = new DirectoryMetadataGenerator(file).generate(); - db.store(file.getAbsolutePath(), props); + DirectoryProps props = services.getMetadataGenerator().generate(file); + services.getDirectoryDatabase().store(file.getAbsolutePath(), props); return props; } @@ -171,7 +178,7 @@ public class DirectoryEntry extends EntryWithChildren { String name = key.substring("dir.".length()); boolean hidden = Boolean.parseBoolean(props.getProperty("dir." + name + ".hidden")); if (!hidden) { - DirectoryEntry dir = new DirectoryEntry(db, this, new File(file, name)); + DirectoryEntry dir = new DirectoryEntry(services, this, new File(file, name)); children.add(dir); if (name != null && name.equals(coverFileName)) { setThumbnail(dir.getThumbnail()); diff --git a/src/main/java/org/forkalsrud/album/exif/DirectoryEntryFactory.java b/src/main/java/org/forkalsrud/album/exif/DirectoryEntryFactory.java new file mode 100644 index 0000000..a322b46 --- /dev/null +++ b/src/main/java/org/forkalsrud/album/exif/DirectoryEntryFactory.java @@ -0,0 +1,36 @@ +package org.forkalsrud.album.exif; + +import java.io.File; + +import org.forkalsrud.album.db.DirectoryDatabase; +import org.forkalsrud.album.video.MovieCoder; + +public class DirectoryEntryFactory implements DirectoryEntry.ServiceApi { + + private DirectoryDatabase dirDb; + private MovieCoder movieCoder; + private DirectoryMetadataGenerator generator; + + public DirectoryEntryFactory(DirectoryDatabase dirDb, MovieCoder movieCoder) { + this.dirDb = dirDb; + this.movieCoder = movieCoder; + this.generator = new DirectoryMetadataGenerator(movieCoder); + } + + + public DirectoryEntry getEntry(File dir, DirectoryEntry parent) { + return new DirectoryEntry(this, parent, dir); + } + + + @Override + public DirectoryDatabase getDirectoryDatabase() { + return dirDb; + } + + + @Override + public DirectoryMetadataGenerator getMetadataGenerator() { + return generator; + } +} diff --git a/src/main/java/org/forkalsrud/album/exif/DirectoryMetadataGenerator.java b/src/main/java/org/forkalsrud/album/exif/DirectoryMetadataGenerator.java index 0dc5858..8a61b7a 100644 --- a/src/main/java/org/forkalsrud/album/exif/DirectoryMetadataGenerator.java +++ b/src/main/java/org/forkalsrud/album/exif/DirectoryMetadataGenerator.java @@ -3,16 +3,12 @@ package org.forkalsrud.album.exif; import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; -import java.io.InputStream; -import java.io.PrintStream; import java.text.DecimalFormat; import java.text.NumberFormat; import java.text.SimpleDateFormat; -import java.util.Arrays; import java.util.Date; import java.util.HashMap; import java.util.Iterator; -import java.util.List; import java.util.Map; import java.util.Properties; @@ -21,8 +17,8 @@ import javax.imageio.ImageReadParam; import javax.imageio.ImageReader; import javax.imageio.stream.ImageInputStream; -import org.apache.commons.io.IOUtils; import org.forkalsrud.album.db.DirectoryProps; +import org.forkalsrud.album.video.MovieCoder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -39,19 +35,18 @@ public class DirectoryMetadataGenerator { final static String CACHE_FILE = "cache.properties"; final static String OVERRIDE_FILE = "album.properties"; - File dir; - + MovieCoder movieCoder; - public DirectoryMetadataGenerator(File f) { - this.dir = f; + public DirectoryMetadataGenerator(MovieCoder movieCoder) { + this.movieCoder = movieCoder; } - public DirectoryProps generate() throws IOException, InterruptedException { + public DirectoryProps generate(File dir) throws IOException, InterruptedException { long start = System.currentTimeMillis(); DirectoryProps props = new DirectoryProps(); - generateFileEntries(props); + generateFileEntries(dir, props); long end = System.currentTimeMillis(); props.setTimestamp(end); log.info("Time to generate properties for " + dir.getPath() + ": " + (end - start)/1000d + " s"); @@ -59,7 +54,7 @@ public class DirectoryMetadataGenerator { } - private void generateFileEntries(Properties props) throws IOException, InterruptedException { + private void generateFileEntries(File dir, Properties props) throws IOException, InterruptedException { File[] files = dir.listFiles(); for (File f : files) { @@ -86,7 +81,7 @@ public class DirectoryMetadataGenerator { Map p = generateThumbnailProperties(f); addPropsForFile(props, f, p); } else if (name.endsWith(".mov") || name.endsWith(".MOV") || name.endsWith(".mp4") || name.endsWith(".avi")) { - Map p = generateVideoProperties(f); + Map p = movieCoder.generateVideoProperties(f); addPropsForFile(props, f, p); } } @@ -184,100 +179,6 @@ public class DirectoryMetadataGenerator { return props; } - - /** - * The crazy Mac OSX does not even set the PATH to a reasonable value, so - * we have to jump through hoops to guess where we may find the executables - * for mplayer and friends. - * - * @param name - * @return - * @throws IOException - * @throws InterruptedException - */ - private String findExecutableInShellPath(String name) throws IOException, InterruptedException { - - String executableForName = name; - ProcessBuilder pb = new ProcessBuilder(Arrays.asList(System.getenv("SHELL"))); - pb.redirectErrorStream(true); // send errors to stdout - Process p = pb.start(); - PrintStream stdin = new PrintStream(p.getOutputStream()); - stdin.print("echo $PATH"); // This is still not entirely portable. Windows would like %PATH% - stdin.close(); - InputStream stdout = p.getInputStream(); - String searchPath = IOUtils.toString(stdout); - int returnStatus = p.waitFor(); - - String separator = System.getProperty("path.separator"); - if (searchPath != null && separator != null && !"".equals(separator)) { - String[] elements = searchPath.split(separator); - for (String path : elements) { - - File executable = new File(path, name); - if (executable.isFile()) { - executableForName = executable.getAbsolutePath(); - break; - } - } - } - return executableForName; - } - - - /* - * Transcoding probably needs to go in at some point. - * A suitable command line may be like the one below, as suggested by - * http://rob.opendot.cl/index.php/useful-stuff/ffmpeg-x264-encoding-guide/ - * http://rob.opendot.cl/index.php/useful-stuff/encoding-a-flv-video-for-embedded-web-playback/ - * - * Assuming video in is 1280x720 pixels resolution and we want to show 640x360 - * and we want an output bitrate about 1 Mbit/sec - * we can do a one pass encoding like this: - * - * ffmpeg -i -aspect 1280:720 -s 640x360 -b 1000000 -crf 25 \ - * -vcodec libx264 -vpre knut_low \ - * -acodec libfaac -aq 100 \ - * .mp4 - */ - - - /** - * - * @param f the movie file - * @return a map with the following keys: orientation dimensions captureDate comment etag - * @throws IOException - * @throws InterruptedException - */ - private Map generateVideoProperties(File f) throws IOException, InterruptedException { - - String mplayerExecutable = findExecutableInShellPath("mplayer"); - - Map props = new HashMap(); - ProcessBuilder pb = new ProcessBuilder().command( - mplayerExecutable, "-vo", "null", "-ao", "null", "-frames", "0", "-identify", f.getAbsolutePath()); - pb.redirectErrorStream(false); - Process p = pb.start(); - p.getOutputStream().close(); - List lines = IOUtils.readLines(p.getInputStream()); - int returnStatus = p.waitFor(); - SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd-HHmmss"); - String width = "", height = ""; - 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; - } - } - props.put("type", "movie"); - props.put("orientation", "1"); - props.put("dimensions", new Dimension(width, height).toString()); - props.put("captureDate", sdf.format(new Date(f.lastModified()))); - props.put("etag", Integer.toHexString(f.getName().hashCode() + Long.valueOf(f.lastModified()).hashCode())); - return props; - } private Date getExifDate(Directory exifDirectory, int tagName) throws MetadataException { diff --git a/src/main/java/org/forkalsrud/album/video/MovieCoder.java b/src/main/java/org/forkalsrud/album/video/MovieCoder.java new file mode 100644 index 0000000..2a1e431 --- /dev/null +++ b/src/main/java/org/forkalsrud/album/video/MovieCoder.java @@ -0,0 +1,369 @@ +package org.forkalsrud.album.video; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.LineNumberReader; +import java.io.OutputStream; +import java.io.PrintStream; +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +import org.apache.commons.io.IOUtils; +import org.forkalsrud.album.db.MovieDatabase; +import org.forkalsrud.album.db.Chunk; +import org.forkalsrud.album.exif.Dimension; +import org.forkalsrud.album.exif.Thumbnail; +import org.forkalsrud.album.web.CachedImage; +import org.forkalsrud.album.web.PictureScaler; + +public class MovieCoder { + + private static org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(MovieCoder.class); + + private String ffmpegExecutable; + private String mplayerExecutable; + private PictureScaler pictureScaler; + private MovieDatabase movieDb; + + public MovieCoder(PictureScaler pictureScaler, MovieDatabase movieDb) { + this.pictureScaler = pictureScaler; + this.movieDb = movieDb; + } + + public void init() throws Exception { + this.ffmpegExecutable = findExecutableInShellPath("ffmpeg"); + this.mplayerExecutable = findExecutableInShellPath("mplayer"); + } + + + /** + * The crazy Mac OSX does not even set the PATH to a reasonable value, so + * we have to jump through hoops to guess where we may find the executables + * for mplayer and friends. + * + * @param name + * @return + * @throws IOException + * @throws InterruptedException + */ + private String findExecutableInShellPath(String name) throws IOException, InterruptedException { + + String executableForName = name; + ProcessBuilder pb = new ProcessBuilder(Arrays.asList(System.getenv("SHELL"))); + pb.redirectErrorStream(true); // send errors to stdout + Process p = pb.start(); + PrintStream stdin = new PrintStream(p.getOutputStream()); + stdin.print("echo $PATH"); // This is still not entirely portable. Windows would like %PATH% + stdin.close(); + InputStream stdout = p.getInputStream(); + String searchPath = IOUtils.toString(stdout); + int returnStatus = p.waitFor(); + + String separator = System.getProperty("path.separator"); + if (searchPath != null && separator != null && !"".equals(separator)) { + String[] elements = searchPath.split(separator); + for (String path : elements) { + + File executable = new File(path, name); + if (executable.isFile()) { + executableForName = executable.getAbsolutePath(); + break; + } + } + } + return executableForName; + } + + + + + /** + * + * @param f the movie file + * @return a map with the following keys: orientation dimensions captureDate comment etag + * @throws IOException + * @throws InterruptedException + */ + public Map generateVideoProperties(File f) throws IOException, InterruptedException { + + Map props = new HashMap(); + ProcessBuilder pb = new ProcessBuilder().command( + mplayerExecutable, "-vo", "null", "-ao", "null", "-frames", "0", "-identify", f.getAbsolutePath()); + pb.redirectErrorStream(false); + Process p = pb.start(); + p.getOutputStream().close(); + List lines = IOUtils.readLines(p.getInputStream()); + int returnStatus = p.waitFor(); + SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd-HHmmss"); + String width = "", height = ""; + 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; + } + } + props.put("type", "movie"); + props.put("orientation", "1"); + props.put("dimensions", new Dimension(width, height).toString()); + props.put("captureDate", sdf.format(new Date(f.lastModified()))); + props.put("etag", Integer.toHexString(f.getName().hashCode() + Long.valueOf(f.lastModified()).hashCode())); + return props; + } + + public File createTempDirectory() throws IOException { + + final File temp = File.createTempFile("temp", Long.toString(System.nanoTime())); + if (!temp.delete()) { + throw new IOException("Could not delete temp file: " + temp.getAbsolutePath()); + } + if (!temp.mkdir()) { + throw new IOException("Could not create temp directory: " + temp.getAbsolutePath()); + } + return temp; + } + + public void deleteTempDirectory(File dir) { + for (File sub : dir.listFiles()) { + if (sub.isDirectory()) { + deleteTempDirectory(sub); + } + sub.delete(); + } + } + + + public CachedImage extractFrame(File file, int secondNo, Thumbnail thumbnail, String size) throws IOException, InterruptedException { + + File tmpDir = createTempDirectory(); + try { + File frame = new File(tmpDir, "00000001.jpg"); + ProcessBuilder pb = new ProcessBuilder().command( + mplayerExecutable, "-vo", "jpeg:quality=95:outdir=" + tmpDir.getAbsolutePath(), "-ao", "null", "-frames", "1", file.getAbsolutePath()); + pb.redirectErrorStream(true); + Process p = pb.start(); + p.getOutputStream().close(); + log.debug(IOUtils.toString(p.getInputStream())); + int returnStatus = p.waitFor(); + + String key = file.getPath() + ":" + secondNo + ":" + size; + CachedImage ci = pictureScaler.scalePicture(frame, thumbnail, size); + return ci; + } finally { + deleteTempDirectory(tmpDir); + } + } + + + class TailingOutputStream extends OutputStream { + + int currentPos; + int remainingBytes; + OutputStream dst; + + public TailingOutputStream(OutputStream dst, int startPos) { + this.dst = dst; + this.currentPos = startPos; + this.remainingBytes = Integer.MAX_VALUE; + } + + @Override + public void write(final int b) throws IOException { + this.write(new byte[] { (byte) (b & 0xff) }, 0, 1); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + dst.write(b, off, len); + currentPos += len; + remainingBytes -= len; + } + + } + + class EncodingProcess implements Runnable { + + final int chunkSize = 65536; + int currentPos = 0; + File file; + Thumbnail thumbnail; + Dimension targetSize; + LinkedList consumers = new LinkedList(); + Chunk currentChunk = null; + int chunkPos; + int remainingCapacity; + int chunkNo = 0; + + public EncodingProcess(File file, Thumbnail thumbnail, Dimension size) { + this.file = file; + this.thumbnail = thumbnail; + this.targetSize = size; + } + + /** + * + */ + private void startNewChunk() { + + this.currentChunk = new Chunk(chunkSize); + this.chunkPos = 0; + this.remainingCapacity = chunkSize; + } + + /** + * + */ + private void endChunk() { + if (currentChunk != null && chunkPos > 0) { + movieDb.store(file.getPath() + ":" + targetSize.getWidth(), chunkNo++, currentChunk); + } + } + + + public synchronized void setEncodedData(byte[] buf, int offset, int length) throws IOException { + + for (TailingOutputStream consumer : consumers) { + + long bytesToSkip = consumer.currentPos - this.currentPos; + int i = offset; + if (bytesToSkip > 0) { + i += bytesToSkip; + } + int remaining = offset + length - i; + int bytesToCopy = Math.min(remaining, consumer.remainingBytes); + if (bytesToCopy > 0) { + consumer.write(buf, i, bytesToCopy); + } else { + // consumer.done(); + } + } + this.currentPos += length; + } + + /* + * Transcoding probably needs to go in at some point. + * A suitable command line may be like the one below, as suggested by + * http://rob.opendot.cl/index.php/useful-stuff/ffmpeg-x264-encoding-guide/ + * http://rob.opendot.cl/index.php/useful-stuff/encoding-a-flv-video-for-embedded-web-playback/ + * + * Assuming video in is 1280x720 pixels resolution and we want to show 640x360 + * and we want an output bitrate about 1 Mbit/sec + * we can do a one pass encoding like this: + * + * ffmpeg -i -aspect 1280:720 -s 640x360 -b 1000000 -crf 25 \ + * -vcodec libx264 -vpre knut_low \ + * -acodec libfaac -aq 100 \ + * .mp4 + */ + + public void run() { + + 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()), + "-b", "600k", + "-acodec", "libmp3lame", "-ar", "22050", "-vcodec", "flv", + "-g", "150", "-cmp", "2", "-subcmp", "2", "-mbd", "2", + "-flags", "+aic+cbp+mv0+mv4", "-trellis", "1", + "-f", "flv", + "-"); + + pb.redirectErrorStream(false); + Process p = pb.start(); + p.getOutputStream().close(); + InputStream movieStream = p.getInputStream(); + InputStream diagnostic = p.getErrorStream(); + new Thread(new ErrorStreamPumper(diagnostic)).start(); + + int len; + startNewChunk(); + while ((len = movieStream.read(currentChunk.bits, chunkPos, remainingCapacity)) > 0) { + + setEncodedData(currentChunk.bits, chunkPos, len); + chunkPos += len; + remainingCapacity -= len; + + if (remainingCapacity == 0) { + endChunk(); + startNewChunk(); + } + } + endChunk(); + + } catch (Exception e) { + e.printStackTrace(System.err); + } + } + + public void streamTo(OutputStream out) { + consumers.add(new TailingOutputStream(out, 0)); + run(); + consumers.clear(); + } + } + + class ErrorStreamPumper implements Runnable { + + InputStream is; + public ErrorStreamPumper(InputStream is) { + this.is = is; + } + + @Override + public void run() { + try { + LineNumberReader lnr = new LineNumberReader(new InputStreamReader(is)); + String line; + while ((line = lnr.readLine()) != null) { + System.err.println(line); + } + } catch (Exception e) { + e.printStackTrace(System.err); + } + } + } + + HashMap encodingsInProgress; + + + + public void stream(File file, Thumbnail thumbnail, String size, OutputStream out) throws IOException, InterruptedException { + + Dimension targetSize = thumbnail.getSize().scale(size); +// new EncodingProcess(file, thumbnail, targetSize).streamTo(out); + String key = file.getPath() + ":" + targetSize.getWidth(); + int chunkNo = 0; + boolean done = false; + while (!done) { + Chunk chunk = movieDb.load(key, chunkNo++); + if (chunk == null) { + break; + } + out.write(chunk.bits); + } + } + +} diff --git a/src/main/java/org/forkalsrud/album/web/AlbumServlet.java b/src/main/java/org/forkalsrud/album/web/AlbumServlet.java index a65ca90..76c4c4e 100644 --- a/src/main/java/org/forkalsrud/album/web/AlbumServlet.java +++ b/src/main/java/org/forkalsrud/album/web/AlbumServlet.java @@ -6,9 +6,6 @@ import java.io.FileOutputStream; import java.io.FileReader; import java.io.IOException; import java.io.PrintWriter; -import java.lang.reflect.InvocationHandler; -import java.lang.reflect.Method; -import java.lang.reflect.Proxy; import java.util.Calendar; import java.util.Date; import java.util.Properties; @@ -27,13 +24,16 @@ import javax.servlet.http.HttpServletResponse; import org.apache.log4j.PropertyConfigurator; import org.forkalsrud.album.db.DirectoryDatabase; +import org.forkalsrud.album.db.MovieDatabase; import org.forkalsrud.album.db.ThumbnailDatabase; import org.forkalsrud.album.exif.DirectoryEntry; +import org.forkalsrud.album.exif.DirectoryEntryFactory; import org.forkalsrud.album.exif.Entry; import org.forkalsrud.album.exif.FileEntry; import org.forkalsrud.album.exif.SearchEngine; import org.forkalsrud.album.exif.SearchResults; import org.forkalsrud.album.exif.Thumbnail; +import org.forkalsrud.album.video.MovieCoder; import org.springframework.web.util.HtmlUtils; import com.sleepycat.je.Environment; @@ -101,8 +101,11 @@ public class AlbumServlet private Environment environment; ThumbnailDatabase thumbDb; DirectoryDatabase dirDb; + MovieDatabase movieDb; Timer timer; Entry cachedRootNode = null; + MovieCoder movieCoder; + DirectoryEntryFactory dirEntryFactory; @Override public void init() @@ -149,8 +152,20 @@ public class AlbumServlet thumbDb = new ThumbnailDatabase(environment); dirDb = new DirectoryDatabase(environment); + movieDb = new MovieDatabase(environment); + pictureScaler = new PictureScaler(); + + movieCoder = new MovieCoder(pictureScaler, movieDb); + try { + movieCoder.init(); + } catch (Exception e) { + throw new ServletException("unable to locate movie helpers (mplayer and ffmpeg)", e); + } + + dirEntryFactory = new DirectoryEntryFactory(dirDb, movieCoder); + lastCacheFlushTime = System.currentTimeMillis(); } @@ -211,6 +226,18 @@ public class AlbumServlet return; } + if (pathInfo.endsWith(".frame")) { + pathInfo = pathInfo.substring(0, pathInfo.length() - ".frame".length()); + handleMovieFrame(req, res, (FileEntry)resolveEntry(pathInfo)); + return; + } + + if (pathInfo.endsWith(".movie")) { + pathInfo = pathInfo.substring(0, pathInfo.length() - ".movie".length()); + handleMovie(req, res, (FileEntry)resolveEntry(pathInfo)); + return; + } + if (pathInfo.endsWith(".edit")) { pathInfo = pathInfo.substring(0, pathInfo.length() - ".edit".length()); handleEdit(req, res, (FileEntry)resolveEntry(pathInfo)); @@ -268,6 +295,35 @@ public class AlbumServlet } } + void handleMovieFrame(HttpServletRequest req, HttpServletResponse res, FileEntry entry) { + try { + String size = req.getParameter("size"); + CachedImage cimg = movieCoder.extractFrame(entry.getPath(), 3, entry.getThumbnail(), size != null ? size : "250"); + res.setStatus(HttpServletResponse.SC_OK); + res.setDateHeader("Last-Modified", entry.getPath().lastModified()); + res.setDateHeader("Expires", System.currentTimeMillis() + (30 * 24 * 3600 * 1000L)); // 30 days + res.setContentType(cimg.mimeType); + res.setContentLength(cimg.bits.length); + res.getOutputStream().write(cimg.bits); + } catch (Exception ex) { + throw new RuntimeException("sadness", ex); + } + } + + void handleMovie(HttpServletRequest req, HttpServletResponse res, FileEntry entry) { + try { + String size = req.getParameter("size"); + res.setStatus(HttpServletResponse.SC_OK); + res.setDateHeader("Last-Modified", entry.getPath().lastModified()); + res.setDateHeader("Expires", System.currentTimeMillis() + (30 * 24 * 3600 * 1000L)); // 30 days + res.setContentType("application/octet-stream"); + movieCoder.stream(entry.getPath(), entry.getThumbnail(), size, res.getOutputStream()); + } catch (Exception ex) { + throw new RuntimeException("sadness", ex); + } + } + + void handleJson(HttpServletRequest req, HttpServletResponse res, DirectoryEntry entry) { try { Mapper mapper = new Mapper(); @@ -426,7 +482,7 @@ public class AlbumServlet if (base.equals(file.getAbsoluteFile())) { synchronized (this) { if (cachedRootNode == null) { - cachedRootNode = new DirectoryEntry(dirDb, null, file); + cachedRootNode = dirEntryFactory.getEntry(file, null); } } return cachedRootNode; diff --git a/src/main/java/org/forkalsrud/album/web/PictureScaler.java b/src/main/java/org/forkalsrud/album/web/PictureScaler.java index 3906e15..91207e3 100644 --- a/src/main/java/org/forkalsrud/album/web/PictureScaler.java +++ b/src/main/java/org/forkalsrud/album/web/PictureScaler.java @@ -47,8 +47,7 @@ public class PictureScaler { } - synchronized PictureRequest getPictureRequest(File file, Thumbnail thumbnail, String size) { - String key = file.getPath() + ":" + size; + synchronized PictureRequest getPictureRequest(String key, File file, Thumbnail thumbnail, String size) { PictureRequest req = outstandingRequests.get(key); if (req == null) { req = new PictureRequest(key, file, thumbnail, size); @@ -117,8 +116,11 @@ public class PictureScaler { public CachedImage scalePicture(File file, Thumbnail thumbnail, String size) { - - PictureRequest req = getPictureRequest(file, thumbnail, size); + String key = file.getPath() + ":" + size; + return scalePicture(key, file, thumbnail, size); + } + public CachedImage scalePicture(String key, File file, Thumbnail thumbnail, String size) { + PictureRequest req = getPictureRequest(key, file, thumbnail, size); try { return req.waitForIt(30, TimeUnit.SECONDS); // } catch (TimeoutException toe) { @@ -148,17 +150,7 @@ public class PictureScaler { CachedImage scalePictureReally(File file, Thumbnail thumbnail, String size) throws IOException { Dimension orig = thumbnail.getSize(); - Dimension outd; - if (size.endsWith("h")) { - int height = Integer.parseInt(size.substring(0, size.length() - 1)); - outd = orig.scaleHeight(height); - } else if (size.endsWith("w")) { - int width = Integer.parseInt(size.substring(0, size.length() - 1)); - outd = orig.scaleWidth(width); - } else { - int worh = Integer.parseInt(size); - outd = orig.scaled(worh); - } + Dimension outd = orig.scale(size); /* In order to make the quality as good as possible we follow the advice from * http://today.java.net/pub/a/today/2007/04/03/perils-of-image-getscaledinstance.html diff --git a/src/main/webapp/WEB-INF/velocity/photo.vm b/src/main/webapp/WEB-INF/velocity/photo.vm index 0f7b72c..6d4ac24 100644 --- a/src/main/webapp/WEB-INF/velocity/photo.vm +++ b/src/main/webapp/WEB-INF/velocity/photo.vm @@ -123,7 +123,7 @@ $(document).ready(function() { #end
$en.name
-#if($en.isFile()) +#if($en.type == "image")
#else
diff --git a/src/main/webapp/dynamic.html b/src/main/webapp/dynamic.html index 2720d67..6182b5d 100644 --- a/src/main/webapp/dynamic.html +++ b/src/main/webapp/dynamic.html @@ -94,7 +94,7 @@ $(function() { } - $.getJSON('album/Photos.json', function(data, textStatus) { + $.getJSON('album/photos.json', function(data, textStatus) { $("#name").html(data.name); @@ -105,7 +105,7 @@ $(function() { var gridDiv = $("
\n" + " " + entry.name + "
\n" + " \n" + + "
\n" + "

\n" + "
\n"); gridDiv.appendTo('body'); @@ -116,7 +116,7 @@ $(function() { switch (entry.type) { case "movie": - $("#ent" + idx).attr("rel", "album").attr("href", "raw/" + entry.name).fancybox({ + $("#ent" + idx).attr("rel", "album").attr("href", "/album" + entry.path + ".movie?size=640").fancybox({ 'titlePosition' : 'inside', 'transitionIn' : 'elastic', 'transitionOut' : 'elastic', @@ -132,11 +132,7 @@ $(function() { 'allowfullscreen' : 'true', 'wmode' : 'transparent', 'flashvars': - "config={ 'clip': { 'url': 'raw/" + escape(entry.name) + "', 'provider': 'h264streaming' },\ - 'plugins': {\ - 'h264streaming': {\ - 'url': 'assets/flowplayer/flowplayer.h264streaming-3.0.5.swf'\ - },\ + "config={ 'clip': { 'url': '/album" + escape(entry.path) + ".movie?size=640' },\ 'controls': {\ 'url': 'assets/flowplayer/flowplayer.controls-3.0.3.swf',\ 'backgroundColor': 'transparent', 'progressColor': 'transparent', 'bufferColor': 'transparent',\