/*
* Copyright (C) 2011 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package android.filterpacks.videosink;
import android.filterfw.core.Filter;
import android.filterfw.core.FilterContext;
import android.filterfw.core.Frame;
import android.filterfw.core.FrameFormat;
import android.filterfw.core.GenerateFieldPort;
import android.filterfw.core.GLFrame;
import android.filterfw.core.MutableFrameFormat;
import android.filterfw.core.ShaderProgram;
import android.filterfw.format.ImageFormat;
import android.filterfw.geometry.Point;
import android.filterfw.geometry.Quad;
import android.media.MediaRecorder;
import android.media.CamcorderProfile;
import android.filterfw.core.GLEnvironment;
import java.io.IOException;
import java.io.FileDescriptor;
import android.util.Log;
/** @hide */
public class MediaEncoderFilter extends Filter {
/** User-visible parameters */
/** Recording state. When set to false, recording will stop, or will not
* start if not yet running the graph. Instead, frames are simply ignored.
* When switched back to true, recording will restart. This allows a single
* graph to both provide preview and to record video. If this is false,
* recording settings can be updated while the graph is running.
*/
@GenerateFieldPort(name = "recording", hasDefault = true)
private boolean mRecording = true;
/** Filename to save the output. */
@GenerateFieldPort(name = "outputFile", hasDefault = true)
private String mOutputFile = new String("/sdcard/MediaEncoderOut.mp4");
/** File Descriptor to save the output. */
@GenerateFieldPort(name = "outputFileDescriptor", hasDefault = true)
private FileDescriptor mFd = null;
/** Input audio source. If not set, no audio will be recorded.
* Select from the values in MediaRecorder.AudioSource
*/
@GenerateFieldPort(name = "audioSource", hasDefault = true)
private int mAudioSource = NO_AUDIO_SOURCE;
/** Media recorder info listener, which needs to implement
* MediaRecorder.OnInfoListener. Set this to receive notifications about
* recording events.
*/
@GenerateFieldPort(name = "infoListener", hasDefault = true)
private MediaRecorder.OnInfoListener mInfoListener = null;
/** Media recorder error listener, which needs to implement
* MediaRecorder.OnErrorListener. Set this to receive notifications about
* recording errors.
*/
@GenerateFieldPort(name = "errorListener", hasDefault = true)
private MediaRecorder.OnErrorListener mErrorListener = null;
/** Media recording done callback, which needs to implement OnRecordingDoneListener.
* Set this to finalize media upon completion of media recording.
*/
@GenerateFieldPort(name = "recordingDoneListener", hasDefault = true)
private OnRecordingDoneListener mRecordingDoneListener = null;
/** Orientation hint. Used for indicating proper video playback orientation.
* Units are in degrees of clockwise rotation, valid values are (0, 90, 180,
* 270).
*/
@GenerateFieldPort(name = "orientationHint", hasDefault = true)
private int mOrientationHint = 0;
/** Camcorder profile to use. Select from the profiles available in
* android.media.CamcorderProfile. If this field is set, it overrides
* settings to width, height, framerate, outputFormat, and videoEncoder.
*/
@GenerateFieldPort(name = "recordingProfile", hasDefault = true)
private CamcorderProfile mProfile = null;
/** Frame width to be encoded, defaults to 320.
* Actual received frame size has to match this */
@GenerateFieldPort(name = "width", hasDefault = true)
private int mWidth = 0;
/** Frame height to to be encoded, defaults to 240.
* Actual received frame size has to match */
@GenerateFieldPort(name = "height", hasDefault = true)
private int mHeight = 0;
/** Stream framerate to encode the frames at.
* By default, frames are encoded at 30 FPS*/
@GenerateFieldPort(name = "framerate", hasDefault = true)
private int mFps = 30;
/** The output format to encode the frames in.
* Choose an output format from the options in
* android.media.MediaRecorder.OutputFormat */
@GenerateFieldPort(name = "outputFormat", hasDefault = true)
private int mOutputFormat = MediaRecorder.OutputFormat.MPEG_4;
/** The videoencoder to encode the frames with.
* Choose a videoencoder from the options in
* android.media.MediaRecorder.VideoEncoder */
@GenerateFieldPort(name = "videoEncoder", hasDefault = true)
private int mVideoEncoder = MediaRecorder.VideoEncoder.H264;
/** The input region to read from the frame. The corners of this quad are
* mapped to the output rectangle. The input frame ranges from (0,0)-(1,1),
* top-left to bottom-right. The corners of the quad are specified in the
* order bottom-left, bottom-right, top-left, top-right.
*/
@GenerateFieldPort(name = "inputRegion", hasDefault = true)
private Quad mSourceRegion;
/** The maximum filesize (in bytes) of the recording session.
* By default, it will be 0 and will be passed on to the MediaRecorder.
* If the limit is zero or negative, MediaRecorder will disable the limit*/
@GenerateFieldPort(name = "maxFileSize", hasDefault = true)
private long mMaxFileSize = 0;
/** The maximum duration (in milliseconds) of the recording session.
* By default, it will be 0 and will be passed on to the MediaRecorder.
* If the limit is zero or negative, MediaRecorder will record indefinitely*/
@GenerateFieldPort(name = "maxDurationMs", hasDefault = true)
private int mMaxDurationMs = 0;
/** TimeLapse Interval between frames.
* By default, it will be 0. Whether the recording is timelapsed
* is inferred based on its value being greater than 0 */
@GenerateFieldPort(name = "timelapseRecordingIntervalUs", hasDefault = true)
private long mTimeBetweenTimeLapseFrameCaptureUs = 0;
// End of user visible parameters
private static final int NO_AUDIO_SOURCE = -1;
private int mSurfaceId;
private ShaderProgram mProgram;
private GLFrame mScreen;
private boolean mRecordingActive = false;
private long mTimestampNs = 0;
private long mLastTimeLapseFrameRealTimestampNs = 0;
private int mNumFramesEncoded = 0;
// Used to indicate whether recording is timelapsed.
// Inferred based on (mTimeBetweenTimeLapseFrameCaptureUs > 0)
private boolean mCaptureTimeLapse = false;
private boolean mLogVerbose;
private static final String TAG = "MediaEncoderFilter";
// Our hook to the encoder
private MediaRecorder mMediaRecorder;
/** Callback to be called when media recording completes. */
public interface OnRecordingDoneListener {
public void onRecordingDone();
}
public MediaEncoderFilter(String name) {
super(name);
Point bl = new Point(0, 0);
Point br = new Point(1, 0);
Point tl = new Point(0, 1);
Point tr = new Point(1, 1);
mSourceRegion = new Quad(bl, br, tl, tr);
mLogVerbose = Log.isLoggable(TAG, Log.VERBOSE);
}
@Override
public void setupPorts() {
// Add input port- will accept RGBA GLFrames
addMaskedInputPort("videoframe", ImageFormat.create(ImageFormat.COLORSPACE_RGBA,
FrameFormat.TARGET_GPU));
}
@Override
public void fieldPortValueUpdated(String name, FilterContext context) {
if (mLogVerbose) Log.v(TAG, "Port " + name + " has been updated");
if (name.equals("recording")) return;
if (name.equals("inputRegion")) {
if (isOpen()) updateSourceRegion();
return;
}
// TODO: Not sure if it is possible to update the maxFileSize
// when the recording is going on. For now, not doing that.
if (isOpen() && mRecordingActive) {
throw new RuntimeException("Cannot change recording parameters"
+ " when the filter is recording!");
}
}
private void updateSourceRegion() {
// Flip source quad to map to OpenGL origin
Quad flippedRegion = new Quad();
flippedRegion.p0 = mSourceRegion.p2;
flippedRegion.p1 = mSourceRegion.p3;
flippedRegion.p2 = mSourceRegion.p0;
flippedRegion.p3 = mSourceRegion.p1;
mProgram.setSourceRegion(flippedRegion);
}
// update the MediaRecorderParams based on the variables.
// These have to be in certain order as per the MediaRecorder
// documentation
private void updateMediaRecorderParams() {
mCaptureTimeLapse = mTimeBetweenTimeLapseFrameCaptureUs > 0;
final int GRALLOC_BUFFER = 2;
mMediaRecorder.setVideoSource(GRALLOC_BUFFER);
if (!mCaptureTimeLapse && (mAudioSource != NO_AUDIO_SOURCE)) {
mMediaRecorder.setAudioSource(mAudioSource);
}
if (mProfile != null) {
mMediaRecorder.setProfile(mProfile);
mFps = mProfile.videoFrameRate;
// If width and height are set larger than 0, then those
// overwrite the ones in the profile.
if (mWidth > 0 && mHeight > 0) {
mMediaRecorder.setVideoSize(mWidth, mHeight);
}
} else {
mMediaRecorder.setOutputFormat(mOutputFormat);
mMediaRecorder.setVideoEncoder(mVideoEncoder);
mMediaRecorder.setVideoSize(mWidth, mHeight);
mMediaRecorder.setVideoFrameRate(mFps);
}
mMediaRecorder.setOrientationHint(mOrientationHint);
mMediaRecorder.setOnInfoListener(mInfoListener);
mMediaRecorder.setOnErrorListener(mErrorListener);
if (mFd != null) {
mMediaRecorder.setOutputFile(mFd);
} else {
mMediaRecorder.setOutputFile(mOutputFile);
}
try {
mMediaRecorder.setMaxFileSize(mMaxFileSize);
} catch (Exception e) {
// Following the logic in VideoCamera.java (in Camera app)
// We are going to ignore failure of setMaxFileSize here, as
// a) The composer selected may simply not support it, or
// b) The underlying media framework may not handle 64-bit range
// on the size restriction.
Log.w(TAG, "Setting maxFileSize on MediaRecorder unsuccessful! "
+ e.getMessage());
}
mMediaRecorder.setMaxDuration(mMaxDurationMs);
}
@Override
public void prepare(FilterContext context) {
if (mLogVerbose) Log.v(TAG, "Preparing");
mProgram = ShaderProgram.createIdentity(context);
mRecordingActive = false;
}
@Override
public void open(FilterContext context) {
if (mLogVerbose) Log.v(TAG, "Opening");
updateSourceRegion();
if (mRecording) startRecording(context);
}
private void startRecording(FilterContext context) {
if (mLogVerbose) Log.v(TAG, "Starting recording");
// Create a frame representing the screen
MutableFrameFormat screenFormat = new MutableFrameFormat(
FrameFormat.TYPE_BYTE, FrameFormat.TARGET_GPU);
screenFormat.setBytesPerSample(4);
int width, height;
boolean widthHeightSpecified = mWidth > 0 && mHeight > 0;
// If width and height are specified, then use those instead
// of that in the profile.
if (mProfile != null && !widthHeightSpecified) {
width = mProfile.videoFrameWidth;
height = mProfile.videoFrameHeight;
} else {
width = mWidth;
height = mHeight;
}
screenFormat.setDimensions(width, height);
mScreen = (GLFrame)context.getFrameManager().newBoundFrame(
screenFormat, GLFrame.EXISTING_FBO_BINDING, 0);
// Initialize the media recorder
mMediaRecorder = new MediaRecorder();
updateMediaRecorderParams();
try {
mMediaRecorder.prepare();
} catch (IllegalStateException e) {
throw e;
} catch (IOException e) {
throw new RuntimeException("IOException in"
+ "MediaRecorder.prepare()!", e);
} catch (Exception e) {
throw new RuntimeException("Unknown Exception in"
+ "MediaRecorder.prepare()!", e);
}
// Make sure start() is called before trying to
// register the surface. The native window handle needed to create
// the surface is initiated in start()
mMediaRecorder.start();
if (mLogVerbose) Log.v(TAG, "Open: registering surface from Mediarecorder");
mSurfaceId = context.getGLEnvironment().
registerSurfaceFromMediaRecorder(mMediaRecorder);
mNumFramesEncoded = 0;
mRecordingActive = true;
}
public boolean skipFrameAndModifyTimestamp(long timestampNs) {
// first frame- encode. Don't skip
if (mNumFramesEncoded == 0) {
mLastTimeLapseFrameRealTimestampNs = timestampNs;
mTimestampNs = timestampNs;
if (mLogVerbose) Log.v(TAG, "timelapse: FIRST frame, last real t= "
+ mLastTimeLapseFrameRealTimestampNs +
", setting t = " + mTimestampNs );
return false;
}
// Workaround to bypass the first 2 input frames for skipping.
// The first 2 output frames from the encoder are: decoder specific info and
// the compressed video frame data for the first input video frame.
if (mNumFramesEncoded >= 2 && timestampNs <
(mLastTimeLapseFrameRealTimestampNs + 1000L * mTimeBetweenTimeLapseFrameCaptureUs)) {
// If 2 frames have been already encoded,
// Skip all frames from last encoded frame until
// sufficient time (mTimeBetweenTimeLapseFrameCaptureUs) has passed.
if (mLogVerbose) Log.v(TAG, "timelapse: skipping intermediate frame");
return true;
} else {
// Desired frame has arrived after mTimeBetweenTimeLapseFrameCaptureUs time:
// - Reset mLastTimeLapseFrameRealTimestampNs to current time.
// - Artificially modify timestampNs to be one frame time (1/framerate) ahead
// of the last encoded frame's time stamp.
if (mLogVerbose) Log.v(TAG, "timelapse: encoding frame, Timestamp t = " + timestampNs +
", last real t= " + mLastTimeLapseFrameRealTimestampNs +
", interval = " + mTimeBetweenTimeLapseFrameCaptureUs);
mLastTimeLapseFrameRealTimestampNs = timestampNs;
mTimestampNs = mTimestampNs + (1000000000L / (long)mFps);
if (mLogVerbose) Log.v(TAG, "timelapse: encoding frame, setting t = "
+ mTimestampNs + ", delta t = " + (1000000000L / (long)mFps) +
", fps = " + mFps );
return false;
}
}
@Override
public void process(FilterContext context) {
GLEnvironment glEnv = context.getGLEnvironment();
// Get input frame
Frame input = pullInput("videoframe");
// Check if recording needs to start
if (!mRecordingActive && mRecording) {
startRecording(context);
}
// Check if recording needs to stop
if (mRecordingActive && !mRecording) {
stopRecording(context);
}
if (!mRecordingActive) return;
if (mCaptureTimeLapse) {
if (skipFrameAndModifyTimestamp(input.getTimestamp())) {
return;
}
} else {
mTimestampNs = input.getTimestamp();
}
// Activate our surface
glEnv.activateSurfaceWithId(mSurfaceId);
// Process
mProgram.process(input, mScreen);
// Set timestamp from input
glEnv.setSurfaceTimestamp(mTimestampNs);
// And swap buffers
glEnv.swapBuffers();
mNumFramesEncoded++;
}
private void stopRecording(FilterContext context) {
if (mLogVerbose) Log.v(TAG, "Stopping recording");
mRecordingActive = false;
mNumFramesEncoded = 0;
GLEnvironment glEnv = context.getGLEnvironment();
// The following call will switch the surface_id to 0
// (thus, calling eglMakeCurrent on surface with id 0) and
// then call eglDestroy on the surface. Hence, this will
// call disconnect the SurfaceMediaSource, which is needed to
// be called before calling Stop on the mediarecorder
if (mLogVerbose) Log.v(TAG, String.format("Unregistering surface %d", mSurfaceId));
glEnv.unregisterSurfaceId(mSurfaceId);
try {
mMediaRecorder.stop();
} catch (RuntimeException e) {
throw new MediaRecorderStopException("MediaRecorder.stop() failed!", e);
}
mMediaRecorder.release();
mMediaRecorder = null;
mScreen.release();
mScreen = null;
// Use an EffectsRecorder callback to forward a media finalization
// call so that it creates the video thumbnail, and whatever else needs
// to be done to finalize media.
if (mRecordingDoneListener != null) {
mRecordingDoneListener.onRecordingDone();
}
}
@Override
public void close(FilterContext context) {
if (mLogVerbose) Log.v(TAG, "Closing");
if (mRecordingActive) stopRecording(context);
}
@Override
public void tearDown(FilterContext context) {
// Release all the resources associated with the MediaRecorder
// and GLFrame members
if (mMediaRecorder != null) {
mMediaRecorder.release();
}
if (mScreen != null) {
mScreen.release();
}
}
}