diff --git a/src/main/java/org/forkalsrud/album/video/FlvFilter.java b/src/main/java/org/forkalsrud/album/video/FlvFilter.java new file mode 100644 index 0000000..b53fab6 --- /dev/null +++ b/src/main/java/org/forkalsrud/album/video/FlvFilter.java @@ -0,0 +1,170 @@ +package org.forkalsrud.album.video; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.Arrays; + +/** + * Separates the FLV header boxes from the body boxes. + * After streaming a file through the header can be rewritten with metadata appropriate for streaming. + */ +public class FlvFilter extends OutputStream { + + private static org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(FlvFilter.class); + + private final static int FLV_SIZE_TAGHEADER = 11; + private final static int FLV_SIZE_TAGFOOTER = 4; + private final static int FLV_SIZE_FILEHEADER = 13; + + private final static int FLV_TAG_AUDIO = 8; + private final static int FLV_TAG_VIDEO = 9; + private final static int FLV_TAG_SCRIPTDATA = 18; + + private OutputStream headerDst; + private OutputStream bodyDst; + + private byte[] fileHeader = new byte[FLV_SIZE_FILEHEADER]; + private int byteCounter = 0; + private byte[] currentBoxHeader = new byte[FLV_SIZE_TAGHEADER]; + private int currentBoxWriteIdx = 0; + + private int currentTagPos; + private int currentTagType; + private int currentDataSize; + private int currentTagTimestamp; + + private int currentTagSize; + private byte[] currentBox; + + /** + * Receive some bytes of FLV output + * @param b + * @param off + * @param len + * @throws IOException + */ + @Override + public void write(byte[] b, int off, int len) throws IOException { + + // stateful + // initially we receive the header 'FLV.....' + // then we receive one box after another + // if a box can be classified as a header we write it to header dst + // else it is for body dst + int remainingInputLength = len; + int readOffset = off; + if (byteCounter < FLV_SIZE_FILEHEADER) { + int headerBytesToRead = Math.min(FLV_SIZE_FILEHEADER - byteCounter, remainingInputLength); + for (int i = 0; i < headerBytesToRead; i++) { + fileHeader[byteCounter++] = b[readOffset++]; + } + remainingInputLength -= headerBytesToRead; + currentTagPos = byteCounter; + } + while (remainingInputLength > 0) { + // read the header first + if (currentBoxWriteIdx < FLV_SIZE_TAGHEADER) { + int headerBytesToRead = Math.min(FLV_SIZE_TAGHEADER - currentBoxWriteIdx, remainingInputLength); + for (int i = 0; i < headerBytesToRead; i++) { + currentBoxHeader[currentBoxWriteIdx++] = b[readOffset++]; + byteCounter++; + } + remainingInputLength -= headerBytesToRead; + if (currentBoxWriteIdx < FLV_SIZE_TAGHEADER) { + // Don't have a full header yet, wait for next + if (remainingInputLength > 0) { + throw new RuntimeException("something is off in the lengths we're trying to read"); + } + return; + } + // Got the header, prepare the buffer for the entire box + currentTagType = decodeUint8(currentBoxHeader, 0); + currentDataSize = decodeUint24(currentBoxHeader, 1); + currentTagTimestamp = decodeTimestamp(currentBoxHeader, 4); + currentTagSize = currentDataSize + FLV_SIZE_TAGHEADER + FLV_SIZE_TAGFOOTER; + currentBox = new byte[currentTagSize]; + for (int i = 0; i < FLV_SIZE_TAGHEADER; i++) { + currentBox[i] = currentBoxHeader[i]; + } + } + int bodyBytesToRead = Math.min(currentTagSize - currentBoxWriteIdx, remainingInputLength); + for (int i = 0; i < bodyBytesToRead; i++) { + currentBox[currentBoxWriteIdx++] = b[readOffset++]; + byteCounter++; + } + remainingInputLength -= bodyBytesToRead; + if (currentBoxWriteIdx < currentTagSize) { + if (remainingInputLength > 0) { + throw new RuntimeException("something is off in the lengths we're trying to read"); + } + return; + } + processBox(); + currentTagType = 0; + currentDataSize = 0; + currentTagTimestamp = 0; + currentTagSize = 0; + currentBox = null; + currentBoxWriteIdx = 0; + Arrays.fill(currentBoxHeader, (byte)0); + currentTagPos = byteCounter; + } + } + + + void processBox() { + + switch (currentTagType) { + case FLV_TAG_VIDEO: + int flags = decodeUint8(currentBox, FLV_SIZE_TAGHEADER); + boolean isKeyFrame = ((flags >> 4) & 0xf) == 1; + if (isKeyFrame) { + log.info("Keyframe ts " + currentTagTimestamp + " @ " + currentTagPos + " (" + (currentTagPos + currentTagSize) + ")"); + } + break; + case FLV_TAG_SCRIPTDATA: + log.info("SCRIPTDATA @ " + currentTagPos + " - len: " + currentTagSize); + break; + case FLV_TAG_AUDIO: + break; + default: + log.error("Unknown box type: " + currentTagType); + break; + } + } + + + int decodeUint32(byte[] buf, int offset) { + int ch1 = buf[offset + 0] & 0xff; + int ch2 = buf[offset + 1] & 0xff; + int ch3 = buf[offset + 2] & 0xff; + int ch4 = buf[offset + 3] & 0xff; + return ((ch1 << 24) + (ch2 << 16) + (ch3 << 8) + (ch4 << 0)); + } + + int decodeUint24(byte[] buf, int offset) { + int ch1 = buf[offset + 0] & 0xff; + int ch2 = buf[offset + 1] & 0xff; + int ch3 = buf[offset + 2] & 0xff; + return ((ch1 << 16) + (ch2 << 8) + (ch3 << 0)); + } + + int decodeUint8(byte[] buf, int offset) { + return buf[offset] & 0xff; + } + + int decodeTimestamp(byte[] buf, int offset) { + int ch1 = buf[offset + 0] & 0xff; + int ch2 = buf[offset + 1] & 0xff; + int ch3 = buf[offset + 2] & 0xff; + int ch4 = buf[offset + 3] & 0xff; + return ((ch4 << 24) + (ch1 << 16) + (ch2 << 8) + (ch3 << 0)); + } + + + @Override + public void write(int b) throws IOException { + write(new byte[] { (byte)b }); + } + +} diff --git a/src/main/java/org/forkalsrud/album/video/MovieCoder.java b/src/main/java/org/forkalsrud/album/video/MovieCoder.java index 52e5f14..cc37a3c 100644 --- a/src/main/java/org/forkalsrud/album/video/MovieCoder.java +++ b/src/main/java/org/forkalsrud/album/video/MovieCoder.java @@ -16,8 +16,8 @@ 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.db.MovieDatabase; import org.forkalsrud.album.exif.Dimension; import org.forkalsrud.album.exif.Thumbnail; import org.forkalsrud.album.web.CachedImage; @@ -64,7 +64,7 @@ public class MovieCoder { stdin.close(); InputStream stdout = p.getInputStream(); String searchPath = IOUtils.toString(stdout); - int returnStatus = p.waitFor(); + p.waitFor(); String separator = System.getProperty("path.separator"); if (searchPath != null && separator != null && !"".equals(separator)) { @@ -100,7 +100,7 @@ public class MovieCoder { Process p = pb.start(); p.getOutputStream().close(); List lines = IOUtils.readLines(p.getInputStream()); - int returnStatus = p.waitFor(); + p.waitFor(); SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd-HHmmss"); String width = "", height = "", length = ""; for (String line : lines) { @@ -155,9 +155,7 @@ public class MovieCoder { Process p = pb.start(); p.getOutputStream().close(); log.debug(IOUtils.toString(p.getInputStream())); - int returnStatus = p.waitFor(); - - String key = file.getPath() + ":" + secondNo + ":" + size; + p.waitFor(); CachedImage ci = pictureScaler.scalePicture(frame, thumbnail, size); return ci; } finally { @@ -267,6 +265,7 @@ public class MovieCoder { * .mp4 */ + @Override public void run() { try { @@ -357,7 +356,7 @@ public class MovieCoder { System.out.println("being asked to stream " + file + " size=" + size); 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; @@ -368,7 +367,7 @@ System.out.println("being asked to stream " + file + " size=" + size); } 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 76c4c4e..21970de 100644 --- a/src/main/java/org/forkalsrud/album/web/AlbumServlet.java +++ b/src/main/java/org/forkalsrud/album/web/AlbumServlet.java @@ -296,11 +296,41 @@ public class AlbumServlet } void handleMovieFrame(HttpServletRequest req, HttpServletResponse res, FileEntry entry) { + + File file = entry.getPath(); + if (notModified(req, file)) { + res.setStatus(HttpServletResponse.SC_NOT_MODIFIED); + res.setDateHeader("Expires", System.currentTimeMillis() + (30 * 24 * 3600 * 1000L)); // 30 days + log.info(file.getName() + " not modified (based on date)"); + return; + } + int secondNo = 3; + String size = req.getParameter("size"); + if (size == null) { + size = "250"; + } + String key = file.getPath() + ":" + secondNo + ":" + size; + CachedImage cimg = thumbDb.load(key); + if (cimg != null) { + if (cimg.lastModified == file.lastModified()) { + log.info("cache hit on " + key); + } else { + log.info(" " + key + " has changed so cache entry wil be refreshed"); + cimg = null; + } + } + if (cimg == null) { + try { + cimg = movieCoder.extractFrame(file, secondNo, entry.getThumbnail(), size); + thumbDb.store(key, cimg); + log.info(" " + key + " added to the cache with size " + cimg.bits.length + " -- now " + thumbDb.size() + " entries"); + } catch (Exception e) { + throw new RuntimeException("sadness", e); + } + } 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("Last-Modified", file.lastModified()); res.setDateHeader("Expires", System.currentTimeMillis() + (30 * 24 * 3600 * 1000L)); // 30 days res.setContentType(cimg.mimeType); res.setContentLength(cimg.bits.length); diff --git a/src/main/java/org/forkalsrud/album/web/PictureScaler.java b/src/main/java/org/forkalsrud/album/web/PictureScaler.java index 91207e3..2908a85 100644 --- a/src/main/java/org/forkalsrud/album/web/PictureScaler.java +++ b/src/main/java/org/forkalsrud/album/web/PictureScaler.java @@ -86,7 +86,8 @@ public class PictureScaler { } - public CachedImage call() throws Exception { + @Override + public CachedImage call() throws Exception { return scalePictureReally(file, thumbnail, size); } @@ -108,7 +109,8 @@ public class PictureScaler { return new Comparator() { - public int compare(PictureRequest o1, PictureRequest o2) { + @Override + public int compare(PictureRequest o1, PictureRequest o2) { return Long.signum(o1.priority - o2.priority); } }; diff --git a/src/main/webapp/WEB-INF/velocity/photo.vm b/src/main/webapp/WEB-INF/velocity/photo.vm index 6d4ac24..5c96523 100644 --- a/src/main/webapp/WEB-INF/velocity/photo.vm +++ b/src/main/webapp/WEB-INF/velocity/photo.vm @@ -81,7 +81,7 @@ $(document).ready(function() { return captionElement.innerHTML; } var selectedImg = window.location.search; - var selectedPos; + var selectedPos = undefined; if (/^\?focus=/.test(selectedImg)) { selectedImg = selectedImg.substring('?focus='.length); $("a.ss").each(function(index) { @@ -98,7 +98,7 @@ $(document).ready(function() { 'easingOut' : 'easeOutQuad', 'titleFormat' : formatTitle }); - if (selectedPos) { + if (selectedPos !== undefined) { $(gallery[selectedPos]).trigger('click'); } }) diff --git a/src/main/webapp/dynamic.html b/src/main/webapp/dynamic.html index 6182b5d..55e5fb3 100644 --- a/src/main/webapp/dynamic.html +++ b/src/main/webapp/dynamic.html @@ -97,15 +97,19 @@ $(function() { $.getJSON('album/photos.json', function(data, textStatus) { $("#name").html(data.name); + + var thmb = 250; + var picSize = 800; + var movieSize = 640; $.each(data.contents, function(idx, entry) { - var dim = scale(entry.width, entry.height, 250); + var dim = scale(entry.width, entry.height, thmb); var gridDiv = $("
\n" + " " + entry.name + "
\n" - + " \n" + + " \n" + "

\n" + "
\n"); gridDiv.appendTo('body'); @@ -116,7 +120,9 @@ $(function() { switch (entry.type) { case "movie": - $("#ent" + idx).attr("rel", "album").attr("href", "/album" + entry.path + ".movie?size=640").fancybox({ + var size = scale(entry.width, entry.height, movieSize); + var href = "album" + escape(entry.path) + ".movie?size=" + movieSize; + $("#ent" + idx).attr("rel", "album").attr("href", href).fancybox({ 'titlePosition' : 'inside', 'transitionIn' : 'elastic', 'transitionOut' : 'elastic', @@ -125,28 +131,34 @@ $(function() { 'titleFormat' : formatTitle, 'padding' : 0, 'href' : "assets/flowplayer/flowplayer-3.0.3.swf", - 'width' : entry.width, - 'height' : entry.height, + 'width' : size.w, + 'height' : size.h, 'type' : 'swf', 'swf' : { 'allowfullscreen' : 'true', 'wmode' : 'transparent', 'flashvars': - "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',\ - 'play':true,\ - 'fullscreen':true,\ - 'autoHide': 'always'\ - }\ + "config={\ + 'clip': {\ + 'url': '" + href + "'\ + },\ + 'plugins': {\ + 'controls': {\ + 'url': 'assets/flowplayer/flowplayer.controls-3.0.3.swf',\ + 'backgroundColor': 'transparent',\ + 'progressColor': 'transparent',\ + 'bufferColor': 'transparent',\ + 'play':true,\ + 'fullscreen':true,\ + 'autoHide': 'always'\ + }\ }\ }" - } + } }); break; case "image": - var largeDim = scale(entry.width, entry.height, 800); + var largeDim = scale(entry.width, entry.height, picSize); $("#ent" + idx).attr("rel", "album").fancybox({ 'titlePosition' : 'inside', 'transitionIn' : 'elastic', @@ -168,7 +180,7 @@ $(function() { var selectedImg = window.location.search; - var selectedPos; + var selectedPos = undefined; if (/^\?focus=/.test(selectedImg)) { selectedImg = selectedImg.substring('?focus='.length); $("a.ss").each(function(index) { @@ -179,7 +191,7 @@ $(function() { } - if (selectedPos) { + if (selectedPos !== undefined) { $(gallery[selectedPos]).trigger('click'); } }); diff --git a/src/test/java/org/forkalsrud/album/video/FlvFilterTest.java b/src/test/java/org/forkalsrud/album/video/FlvFilterTest.java new file mode 100644 index 0000000..25913cd --- /dev/null +++ b/src/test/java/org/forkalsrud/album/video/FlvFilterTest.java @@ -0,0 +1,20 @@ +package org.forkalsrud.album.video; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import junit.framework.TestCase; + +import org.apache.commons.io.IOUtils; + +public class FlvFilterTest extends TestCase { + + public void testWrite() throws IOException { + + InputStream is = getClass().getResourceAsStream("VideoAd.flv"); + OutputStream os = new FlvFilter(); + IOUtils.copy(is, os); + } + +} diff --git a/src/test/java/org/forkalsrud/album/video/VideoAd.flv b/src/test/java/org/forkalsrud/album/video/VideoAd.flv new file mode 100644 index 0000000..fb14458 Binary files /dev/null and b/src/test/java/org/forkalsrud/album/video/VideoAd.flv differ