Fixes for seeking.
authorMichael Zucchi <notzed@gmail.com>
Mon, 21 Oct 2019 01:14:13 +0000 (11:44 +1030)
committerMichael Zucchi <notzed@gmail.com>
Mon, 21 Oct 2019 01:14:13 +0000 (11:44 +1030)
 - Change convertPTS() to not offset by the stream start.
 - When seeking use an offset of ~32 frames to in order to hit a key frame.
 - Provide another seek function which takes the keyframe for seek.
Some formatting changes.
Revert some java9+ code that broke jjoctave.

contrib/octave/jjoctave/VideoReader.java
src/notzed.jjmpeg/classes/au/notzed/jjmpeg/AVRational.java
src/notzed.jjmpeg/classes/au/notzed/jjmpeg/io/JJMediaReader.java

index dca883c..cd3b2f0 100644 (file)
@@ -163,7 +163,7 @@ public class VideoReader {
         * @return Frame timestamp in milliseconds.
         */
        public long getTimeStamp(int sid) {
-               return videos[sid].absolutePTS(frames[sid].getPTS());
+               return videos[sid].convertPTS(frames[sid].getPTS());
        }
 
        /**
index 7dd1674..3120144 100644 (file)
@@ -39,6 +39,10 @@ public class AVRational extends Number implements Comparable<AVRational> {
                AVObject.init();
        }
 
+       public AVRational inv() {
+               return new AVRational(den, num);
+       }
+
        /**
         * Calls av_d2q
         *
index 9892eaf..ad679da 100644 (file)
@@ -35,6 +35,7 @@ import au.notzed.jjmpeg.AVRational;
 import au.notzed.jjmpeg.AVSampleFormat;
 import au.notzed.jjmpeg.AVStream;
 import java.io.FileNotFoundException;
+import java.util.AbstractList;
 import java.util.Iterator;
 import java.util.List;
 import java.util.logging.Logger;
@@ -42,9 +43,29 @@ import java.util.logging.Logger;
 /**
  * High level interface for scanning audio and video frames.
  * <p>
- * TODO: handle all frames.
- *
- * @author notzed
+ * <h2>Seeking</h2>
+ * <p>
+ * {@link AVFormatContext#seekFile} has unreliable behaviour
+ * depending on the codec and container. In general it seems
+ * to seek to the keyframe at or after the requested timestamp.
+ * As such, for reliable seeking the seek must be offset by a
+ * delay and then the correct frame found by decoding frames
+ * one by one.
+ * <p>
+ * Each JJReaderStream has a pts and millisecond offset which is
+ * estimated based on the frame-rate of the video file. A
+ * conservative estimate is used which means seeking may be slower
+ * than possible.
+ * For all-keyframe formats this shouldn't be required but it has not
+ * been special-cased.
+ * <p>
+ * An alternative is provided by the 2-argument {@link #seekMS}
+ * method which takes the timestamp of a keyframe together with
+ * the timestamp of a specific frame. This does the obvious and
+ * seeks to the keyframe and then single-steps to the desired frame
+ * and provides the highest performance general seek function but
+ * requires an external index.
+ * <p>
  */
 public class JJMediaReader implements AutoCloseable {
 
@@ -139,9 +160,20 @@ public class JJMediaReader implements AutoCloseable {
        }
 
        public List<? extends JJReaderStream> getStreams() {
-               return List.of(streams);
+               // Ideally List.of(streams) but that's java 9+
+               return new AbstractList<JJReaderStream>() {
+                       @Override
+                       public JJReaderStream get(int index) {
+                               return streams[index];
+                       }
+
+                       @Override
+                       public int size() {
+                               return streams.length;
+                       }
+               };
        }
-       
+
        public Iterable<? extends JJReaderStream> streams() {
                return () -> {
                        return new Iterator<JJReaderStream>() {
@@ -194,6 +226,16 @@ public class JJMediaReader implements AutoCloseable {
                return pts;
        }
 
+       /**
+        * Retrieve current timestamp against the given stream.
+        *
+        * @param rs
+        * @return time in milliseconds
+        */
+       public long convertPTS(JJReaderStream rs) {
+               return rs.convertPTS(pts);
+       }
+
        /**
         * call flushBuffers() on all opened streams codecs.
         * <p>
@@ -205,37 +247,41 @@ public class JJMediaReader implements AutoCloseable {
                }
        }
 
-       private long currentVideoMS() {
-               for (JJReaderStream r: streams) {
-                       if (r instanceof JJReaderVideo) {
-                               return r.convertPTS(getPTS());
-                       }
-               }
-               return -1;
-       }
-
        /**
         * Attempt to seek to the nearest millisecond.
         * <p>
         * The next frame will have pts (in milliseconds) &ge; stamp.
         *
+        * @param rs Stream to seek against.
         * @param stamp
         * @throws AVIOException
         */
-       public void seekMS(long stamp) throws AVIOException {
-               // This is an attempt at an optimisation, for small seeks (< 1 second)
-               // just parse/discard frames.
-               // This is going to be faster on many codecs than a flush/seek which will probably
-               // have to jump through more frames after a keyframe anyway
-               long current = currentVideoMS();
-               if (current != -1 && (stamp - current) >= 0 && (stamp - current) < 1000) {
-                       seekms = stamp;
-               } else {
-                       format.seekFile(-1, 0, stamp * 1000, stamp * 1000, 0);
+       public void seekMS(JJReaderStream rs, long stamp) throws AVIOException {
+               long current = rs.convertPTS(getPTS());
+
+               if (current != -1 && (stamp - current) > 0 && (stamp - current) < 1000) {
                        seekms = stamp;
+                       return;
+               }
+
+               format.seekFile(-1, 0, Math.max(0, stamp - rs.seekOffsetMS) * 1000, stamp * 1000, 0);
+               seekms = stamp;
+
+               flushCodec();
+       }
+
+       public void seekMS(JJReaderStream rs, long keyStamp, long stamp) throws AVIOException {
+               long current = rs.convertPTS(getPTS());
 
-                       flushCodec();
+               if (current != -1 && (stamp - current) > 0 && (stamp - current) < 2000) {
+                       seekms = stamp;
+                       return;
                }
+
+               format.seekFile(-1, 0, keyStamp * 1000, keyStamp * 1000, 0);
+               seekms = stamp;
+
+               flushCodec();
        }
 
        /**
@@ -243,11 +289,12 @@ public class JJMediaReader implements AutoCloseable {
         * <p>
         * The next frame will have pts &ge; stamp
         *
+        * @param rs Stream to seek against.
         * @param stamp
         * @throws AVIOException
         */
-       public void seek(long stamp) throws AVIOException {
-               format.seekFile(-1, 0, stamp, stamp, 0);
+       public void seek(JJReaderStream rs, long stamp) throws AVIOException {
+               format.seekFile(rs.stream.getIndex(), 0, Math.max(0, stamp - rs.seekOffset), stamp, 4);
                seekid = stamp;
 
                flushCodec();
@@ -270,24 +317,34 @@ public class JJMediaReader implements AutoCloseable {
                                int index = packet.getStreamIndex();
                                JJReaderStream ms = streams[index];
 
-                               if (ms != null) {
-                                       if (ms.isOpened() && ms.decode(packet)) {
-                                               pts = packet.getDTS();
+                               if (ms != null && ms.isOpened() && ms.decode(packet)) {
+                                       pts = packet.getDTS();
 
-                                               // If seeking, attempt to get to the exact frame
-                                               if (seekid != -1
-                                                       && pts < seekid) {
+                                       // If seeking, attempt to get to the exact frame
+                                       if (seekid != -1) {
+                                               if (pts < seekid) {
+                                                       Logger.getLogger("jjmpeg.io").fine(()
+                                                               -> String.format("seek=%d but @ %d  flags=%d", seekid, pts, packet.getFlags()));
                                                        continue;
-                                               } else if (seekms != -1
-                                                       && ms.convertPTS(pts) < seekms) {
+                                               }
+                                       } else if (seekms != -1) {
+                                               long ptsms = ms.convertPTS(pts);
+
+                                               if (ptsms < seekms) {
+                                                       Logger.getLogger("jjmpeg.io").fine(()
+                                                               -> String.format("seekms=%d but @ %d  flags=%d", seekms, ms.convertPTS(pts), packet.getFlags()));
                                                        continue;
                                                }
-                                               seekid = -1;
-                                               seekms = -1;
-                                               freePacket = true;
-                                               return ms;
+                                               Logger.getLogger("jjmpeg.io").fine(()
+                                                       -> String.format("seekms=%d now @ %d  flags=%d *", seekms, ms.convertPTS(pts), packet.getFlags()));
                                        }
+
+                                       seekid = -1;
+                                       seekms = -1;
+                                       freePacket = true;
+                                       return ms;
                                }
+
                        } finally {
                                if (!freePacket) {
                                        packet.clear();
@@ -348,6 +405,7 @@ public class JJMediaReader implements AutoCloseable {
                }
 
                return null;
+
        }
 
        static public abstract class JJReaderStream {
@@ -355,7 +413,6 @@ public class JJMediaReader implements AutoCloseable {
                AVStream stream;
                AVCodecContext c;
                AVCodecParameters cp;
-               int streamID = -1;
                protected AVCodec codec;
                protected boolean opened = false;
                // timebase
@@ -371,6 +428,9 @@ public class JJMediaReader implements AutoCloseable {
                final AVFrame frame;
                // Incremented every time a frame is decoded
                long frameIndex;
+               // Some formats (mpeg?) always seek to the next keyframe, which is really not desirable.
+               long seekOffset;
+               long seekOffsetMS;
 
                public JJReaderStream(AVStream stream, AVCodecParameters cp) throws AVIOException {
                        this.stream = stream;
@@ -393,6 +453,10 @@ public class JJMediaReader implements AutoCloseable {
                        startms = tb.rescale(startpts, 1000);
                        duration = stream.getDuration();
                        durationms = tb.rescale(duration, 1000);
+
+                       // conservatively assume keyframe every 32 frames
+                       seekOffsetMS = stream.getAverageFrameRate().inv().rescale(1000, 32);
+                       seekOffset = (long) tb.inv().mul(new AVRational((int) seekOffsetMS, 1000)).doubleValue();
                }
 
                public void open() throws AVIOException {
@@ -408,7 +472,8 @@ public class JJMediaReader implements AutoCloseable {
                                codec.release();
                                c.release();
                        }
-                       frame.release();
+                       if (frame != null)
+                               frame.release();
                        stream.release();
                }
 
@@ -467,22 +532,12 @@ public class JJMediaReader implements AutoCloseable {
                }
 
                /**
-                * Convert the 'pts' provided to milliseconds relative to the start of the stream.
+                * Convert the 'pts' provided to milliseconds.
                 *
                 * @param pts
                 * @return
                 */
                public long convertPTS(long pts) {
-                       return tb.rescale(pts - startpts, 1000);
-               }
-
-               /**
-                * Convert the 'pts' to milliseconds as presented in the stream.
-                *
-                * @param pts
-                * @return
-                */
-               public long absolutePTS(long pts) {
                        return tb.rescale(pts, 1000);
                }
 
@@ -500,9 +555,8 @@ public class JJMediaReader implements AutoCloseable {
                        boolean done = false;
                        if (isOpened()) {
                                if (packet != null && packet.getSize() == 0) {
-                                       Logger.getLogger("jjmpeg.io")
-                                               .fine(()
-                                                       -> String.format("stream %d packet size 0", streamID));
+                                       Logger.getLogger("jjmpeg.io").fine(()
+                                               -> String.format("stream %d packet size 0", stream.getIndex()));
                                        return done;
                                }
 
@@ -579,17 +633,15 @@ public class JJMediaReader implements AutoCloseable {
                        //      throw new AVIOException(AVError.AVERROR_INVALIDDATA, "No decodable video present");
                        //}
 
-                       Logger.getLogger("jjmpeg.io")
-                               .fine(() -> {
-                                       return String.format("Open video reader\n"
-                                               + " video %dx%d %s [aspect %s]\n"
-                                               + " codec %s\n",
-                                               cp.getWidth(),
-                                               cp.getHeight(),
-                                               AVPixelFormat.toString(cp.getPixelFormat()),
-                                               cp.getAspectRatio(),
-                                               AVCodecID.toString(cp.getCodecID()));
-                               });
+                       Logger.getLogger("jjmpeg.io").fine(()
+                               -> String.format("Open video reader\n"
+                                       + " video %dx%d %s [aspect %s]\n"
+                                       + " codec %s\n",
+                                       cp.getWidth(),
+                                       cp.getHeight(),
+                                       AVPixelFormat.toString(cp.getPixelFormat()),
+                                       cp.getAspectRatio(),
+                                       AVCodecID.toString(cp.getCodecID())));
 
                        super.open();
                }
@@ -635,16 +687,14 @@ public class JJMediaReader implements AutoCloseable {
 
                @Override
                public void open() throws AVIOException {
-                       Logger.getLogger("jjmpeg.io")
-                               .fine(() -> {
-                                       return String.format("Open audio reader\n"
-                                               + " audio x%d %dHz %s\n"
-                                               + " codec %s\n",
-                                               cp.getNumChannels(),
-                                               cp.getSampleRate(),
-                                               AVSampleFormat.toString(cp.getSampleFormat()),
-                                               AVCodecID.toString(cp.getCodecID()));
-                               });
+                       Logger.getLogger("jjmpeg.io").fine(()
+                               -> String.format("Open audio reader\n"
+                                       + " audio x%d %dHz %s\n"
+                                       + " codec %s\n",
+                                       cp.getNumChannels(),
+                                       cp.getSampleRate(),
+                                       AVSampleFormat.toString(cp.getSampleFormat()),
+                                       AVCodecID.toString(cp.getCodecID())));
 
                        super.open();
                }