From d937fd20f6b9b5291a89b18bd42e5902afdb797a Mon Sep 17 00:00:00 2001 From: Boris Grozev <boris@jitsi.org> Date: Fri, 20 Jun 2014 10:03:34 +0200 Subject: [PATCH] Adds a webm data sink. --- .../impl/neomedia/recording/WebmDataSink.java | 508 ++++++++++++++++++ 1 file changed, 508 insertions(+) create mode 100644 src/org/jitsi/impl/neomedia/recording/WebmDataSink.java diff --git a/src/org/jitsi/impl/neomedia/recording/WebmDataSink.java b/src/org/jitsi/impl/neomedia/recording/WebmDataSink.java new file mode 100644 index 00000000..c939c2cf --- /dev/null +++ b/src/org/jitsi/impl/neomedia/recording/WebmDataSink.java @@ -0,0 +1,508 @@ +/* + * Jitsi, the OpenSource Java VoIP and Instant Messaging client. + * + * Distributable under LGPL license. + * See terms of license at gnu.org. + */ +package org.jitsi.impl.neomedia.recording; + +import org.jitsi.service.neomedia.MediaType; +import org.jitsi.service.neomedia.control.*; +import org.jitsi.service.neomedia.recording.*; +import org.jitsi.util.*; + +import javax.media.*; +import javax.media.datasink.*; +import javax.media.format.*; +import javax.media.protocol.*; +import java.io.*; + +/** + * A <tt>DataSink</tt> implementation which writes output in webm format. + * + * @author Boris Grozev + */ +public class WebmDataSink + implements DataSink, + BufferTransferHandler +{ + /** + * The <tt>Logger</tt> used by the <tt>WebmDataSink</tt> class and its + * instances for logging output. + */ + private static final Logger logger + = Logger.getLogger(WebmDataSink.class); + /** + * The <tt>WebmWriter</tt> which we use to write the frames to a file. + */ + private WebmWriter writer = null; + + private RecorderEventHandler eventHandler; + private long ssrc = -1; + + /** + * Whether we are in a state of waiting for a keyframe and discarding + * non-key frames. + */ + private boolean waitingForKeyframe = true; + + /** + * The height of the video. Initialized on the first received keyframe. + */ + private int height = 0; + + /** + * The height of the video. Initialized on the first received keyframe. + */ + private int width = 0; + + /** + * A <tt>Buffer</tt> used to transfer frames. + */ + private Buffer buffer = new Buffer(); + + private WebmWriter.FrameDescriptor fd = new WebmWriter.FrameDescriptor(); + + /** + * Our <tt>DataSource</tt>. + */ + private DataSource dataSource = null; + + /** + * The name of the file into which we will write. + */ + private String filename; + + /** + * The RTP time stamp of the first frame written to the output webm file. + */ + private long firstFrameRtpTimestamp = -1; + + /** + * The time as returned by <tt>System.currentTimeMillis()</tt> of the first + * frame written to the output webm file. + */ + private long firstFrameTime = -1; + + /** + * The PTS (presentation timestamp) of the last frame written to the output + * file. In milliseconds. + */ + private long lastFramePts = -1; + + /** + * The <tt>KeyFrameControl</tt> which we will use to request a keyframe. + */ + private KeyFrameControl keyFrameControl = null; + + /** + * Whether we have already requested a keyframe. + */ + private boolean keyframeRequested = false; + + private int framesSinceLastKeyframeRequest = 0; + private static int REREQUEST_KEYFRAME_INTERVAL = 100; + + + /** + * Initialize a new <tt>WebmDataSink</tt> instance. + * @param filename the name of the file into which to write. + * @param dataSource the <tt>DataSource</tt> to use. + */ + public WebmDataSink(String filename, DataSource dataSource) + { + this.filename = filename; + this.dataSource = dataSource; + } + + /** + * {@inheritDoc} + */ + @Override + public void addDataSinkListener(DataSinkListener dataSinkListener) + { + } + + /** + * {@inheritDoc} + */ + @Override + public void close() + { + if (writer != null) + writer.close(); + if (eventHandler != null && firstFrameTime != -1 && lastFramePts != -1) + { + RecorderEvent event = new RecorderEvent(); + event.setType(RecorderEvent.Type.RECORDING_ENDED); + event.setSsrc(ssrc); + event.setFilename(filename); + + // make sure that the difference in the 'instant'-s of the + // STARTED and ENDED events matches the duration of the file + event.setDuration(lastFramePts); + + event.setMediaType(MediaType.VIDEO); + eventHandler.handleEvent(event); + } + } + + /** + * {@inheritDoc} + */ + @Override + public String getContentType() + { + return null; + } + + /** + * {@inheritDoc} + */ + @Override + public MediaLocator getOutputLocator() + { + return null; + } + + /** + * {@inheritDoc} + */ + @Override + public void open() throws IOException, SecurityException + { + if (dataSource instanceof PushBufferDataSource) + { + PushBufferDataSource pbds = (PushBufferDataSource) dataSource; + PushBufferStream[] streams = pbds.getStreams(); + + //XXX: should we allow for multiple streams in the data source? + for (PushBufferStream stream : streams) + { + //XXX whats the proper way to check for this? and handle? + if (!stream.getFormat().matches(new VideoFormat("VP8"))) + throw new IOException("Unsupported stream format"); + + stream.setTransferHandler(this); + } + } + dataSource.connect(); + } + + /** + * {@inheritDoc} + */ + @Override + public void removeDataSinkListener(DataSinkListener dataSinkListener) + { + } + + /** + * {@inheritDoc} + */ + @Override + public void setOutputLocator(MediaLocator mediaLocator) + { + } + + /** + * {@inheritDoc} + */ + @Override + public void start() throws IOException + { + writer = new WebmWriter(filename); + dataSource.start(); + if (logger.isInfoEnabled()) + logger.info("Created WebmWriter on " + filename); + } + + /** + * {@inheritDoc} + */ + @Override + public void stop() throws IOException + { + //XXX: should we do something here? reset waitingForKeyframe? + } + + /** + * {@inheritDoc} + */ + @Override + public Object getControl(String s) + { + return null; + } + + /** + * {@inheritDoc} + */ + @Override + public Object[] getControls() + { + return new Object[0]; + } + + /** + * {@inheritDoc} + */ + @Override + public void setSource(DataSource dataSource) + throws IOException, IncompatibleSourceException + { + //maybe we should throw an exception here, since we don't support + //changing the data source? + } + + /** + * {@inheritDoc} + */ + @Override + public void transferData(PushBufferStream stream) + { + try + { + stream.read(buffer); + } + catch (IOException ioe) + { + ioe.printStackTrace(); + } + + byte[] data = (byte[])buffer.getData(); + int offset = buffer.getOffset(); + int len = buffer.getLength(); + + /* + * Until an SDES packet is received by FMJ, it doesn't correctly set + * the packets' timestamps. To avoid waiting, we use the RTP time stamps + * directly. We can do this because VP8 always uses a rate of 90000. + */ + long rtpTimeStamp = buffer.getRtpTimeStamp(); + + boolean key = isKeyFrame(data, offset); + boolean valid = isKeyFrameValid(data, offset); + if (waitingForKeyframe && key) + { + if (valid) + { + waitingForKeyframe = false; + width = getWidth(data, offset); + height = getHeight(data, offset); + firstFrameRtpTimestamp = rtpTimeStamp; + firstFrameTime = System.currentTimeMillis(); + + writer.writeWebmFileHeader(width, height); + + if (logger.isInfoEnabled()) + logger.info("Received the first keyframe (width=" + + width + "; height=" + height + ")"+" ssrc="+ssrc); + + if (eventHandler != null) + { + RecorderEvent event = new RecorderEvent(); + event.setType(RecorderEvent.Type.RECORDING_STARTED); + event.setSsrc(ssrc); + if (height*4 == width*3) + event.setAspectRatio( + RecorderEvent.AspectRatio.ASPECT_RATIO_4_3); + else if (height*16 == width*9) + event.setAspectRatio( + RecorderEvent.AspectRatio.ASPECT_RATIO_16_9); + + event.setFilename(filename); + event.setInstant(firstFrameTime); + event.setRtpTimestamp(rtpTimeStamp); + event.setMediaType(MediaType.VIDEO); + eventHandler.handleEvent(event); + } + } + else + { + keyframeRequested = false; + if (logger.isInfoEnabled()) + logger.info("Received an invalid first keyframe. " + + "Requesting a new one."+ssrc); + } + } + + framesSinceLastKeyframeRequest++; + if (framesSinceLastKeyframeRequest > REREQUEST_KEYFRAME_INTERVAL) + keyframeRequested = false; + + if (waitingForKeyframe && !keyframeRequested) + { + if (logger.isInfoEnabled()) + logger.info("Requesting keyframe. "+ssrc); + if (keyFrameControl != null) + keyframeRequested = keyFrameControl.requestKeyFrame(false); + framesSinceLastKeyframeRequest = 0; + } + + //that's temporary, aimed at debugging a specific issue + if (key && logger.isInfoEnabled()) + { + String s = ""; + for (int i = 0; i<10 && i<len; i++) + s += String.format("%02x", data[offset+i]); + logger.info("Keyframe. First 10 bytes: "+s); + } + + if (!waitingForKeyframe) + { + if (key) + { + if (!valid) + { + if (logger.isInfoEnabled()) + logger.info("Dropping an invalid VP8 keyframe."); + return; + } + + int oldWidth = width; + width = getWidth(data, offset); + int oldHeight = height; + height = getHeight(data, offset); + // TODO generate an event? start writing in a new file? + if (width != oldWidth || height != oldHeight) + { + if (logger.isInfoEnabled()) + { + logger.info("VP8 stream width/height changed. Old: " + + oldWidth + "/" + oldHeight + + ". New: " + width + "/" + height + "."); + } + } + } + fd.buffer = data; + fd.offset = offset; + fd.length = len; + fd.flags = key ? WebmWriter.FLAG_FRAME_IS_KEY : 0; + if (!isShowFrame(data, offset)) + fd.flags |= WebmWriter.FLAG_FRAME_IS_INVISIBLE; + + long diff = rtpTimeStamp - firstFrameRtpTimestamp; + if (diff < -(1L<<31)) + diff += 1L<<32; + //pts is in milliseconds, the VP8 rtp clock rate is 90000 + fd.pts = diff / 90; + writer.writeFrame(fd); + + lastFramePts = fd.pts; + } + } + + /** + * Returns <tt>true</tt> if the VP8 compressed frame contained in + * <tt>buf</tt> at offset <tt>offset</tt> is a keyframe. + * TODO: move it to a more general class? + * + * @param buf the buffer containing a compressed VP8 frame. + * @param offset the offset in <tt>buf</tt> where the VP8 compressed frame + * starts. + * + * @return <tt>true</tt>if the VP8 compressed frame contained in + * <tt>buf</tt> at offset <tt>offset</tt> is a keyframe. + */ + private boolean isKeyFrame(byte[] buf, int offset) + { + return (buf[offset] & 0x01) == 0; + } + + /** + * Returns <tt>true</tt> if the VP8 compressed keyframe contained in + * <tt>buf</tt> at offset <tt>offset</tt> is valid. + * TODO: move it to a more general class? + * + * @param buf the buffer containing a compressed VP8 frame. + * @param offset the offset in <tt>buf</tt> where the VP8 compressed frame + * starts. + * + * @return <tt>true</tt>if the VP8 compressed keyframe contained in + * <tt>buf</tt> at offset <tt>offset</tt> is valid. + */ + private boolean isKeyFrameValid(byte[] buf, int offset) + { + return (buf[offset + 3] == (byte) 0x9d) && + (buf[offset + 4] == (byte) 0x01) && + (buf[offset + 5] == (byte) 0x2a); + } + + /** + * Returns the width of the VP8 compressed frame contained in <tt>buf</tt> + * at offset <tt>offset</tt>. See the format defined in RFC6386. + * TODO: move it to a more general class? + * + * @param buf the buffer containing a compressed VP8 frame. + * @param offset the offset in <tt>buf</tt> where the VP8 compressed frame + * starts. + * + * @return the width of the VP8 compressed frame contained in <tt>buf</tt> + * at offset <tt>offset</tt>. + */ + private int getWidth(byte[] buf, int offset) + { + return (((buf[offset+7] & 0xff) << 8) | (buf[offset+6] & 0xff)) & 0x3fff; + } + + /** + * Returns the height of the VP8 compressed frame contained in <tt>buf</tt> + * at offset <tt>offset</tt>. See the format defined in RFC6386. + * TODO: move it to a more general class? + * + * @param buf the buffer containing a compressed VP8 frame. + * @param offset the offset in <tt>buf</tt> where the VP8 compressed frame + * starts. + * + * @return the height of the VP8 compressed frame contained in <tt>buf</tt> + * at offset <tt>offset</tt>. + */ + private int getHeight(byte[] buf, int offset) + { + return (((buf[offset+9] & 0xff) << 8) | (buf[offset+8] & 0xff)) & 0x3fff; + } + + + /** + * Returns the value of the <tt>show_frame</tt> field from the + * "uncompressed data chunk" in the VP8 compressed frame contained in + * <tt>buf</tt> at offset <tt>offset</tt>. + * RFC6386 isn't clear about the format, so the interpretation of + * @{link http://tools.ietf.org/html/draft-ietf-payload-vp8-11} is used. + * TODO: move it to a more general class? + * + * @param buf the buffer containing a compressed VP8 frame. + * @param offset the offset in <tt>buf</tt> where the VP8 compressed frame + * starts. + * + * @return the value of the <tt>show_frame</tt> field from the + * "uncompressed data chunk" in the VP8 compressed frame contained in + * <tt>buf</tt> at offset <tt>offset</tt>. + */ + private boolean isShowFrame(byte[] buf, int offset) + { + return (buf[offset] & 0x10) == 0; + } + + public void setKeyFrameControl(KeyFrameControl keyFrameControl) + { + this.keyFrameControl = keyFrameControl; + } + + public RecorderEventHandler getEventHandler() + { + return eventHandler; + } + + public void setEventHandler(RecorderEventHandler eventHandler) + { + this.eventHandler = eventHandler; + } + + public void setSsrc(long ssrc) + { + this.ssrc = ssrc; + } + +} -- GitLab