From 561df21b1651cf6b266f241bb1a3945c05c229bf Mon Sep 17 00:00:00 2001 From: Teng-Hui Zhu Date: Sun, 23 Sep 2012 15:02:56 -0700 Subject: The trimming solution with the mp4parser library. bug:7093055 Change-Id: I598a81d80c9c5107696f3af7761207e3ec88f3ff --- .../com/android/gallery3d/common/ApiHelper.java | 2 +- src/com/android/gallery3d/app/PhotoPage.java | 2 + src/com/android/gallery3d/app/ShortenExample.java | 156 ++++++++++++++++++ src/com/android/gallery3d/app/TrimVideo.java | 178 ++++++++++++++++++++- 4 files changed, 332 insertions(+), 6 deletions(-) create mode 100644 src/com/android/gallery3d/app/ShortenExample.java diff --git a/gallerycommon/src/com/android/gallery3d/common/ApiHelper.java b/gallerycommon/src/com/android/gallery3d/common/ApiHelper.java index fe5e795d7..802f4ec18 100644 --- a/gallerycommon/src/com/android/gallery3d/common/ApiHelper.java +++ b/gallerycommon/src/com/android/gallery3d/common/ApiHelper.java @@ -161,7 +161,7 @@ public class ApiHelper { Build.VERSION.SDK_INT >= VERSION_CODES.ICE_CREAM_SANDWICH; public static final boolean HAS_MEDIA_MUXER = - Build.VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN_MR1; + Build.VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN; public static int getIntFieldIfExists(Class klass, String fieldName, Class obj, int defaultVal) { diff --git a/src/com/android/gallery3d/app/PhotoPage.java b/src/com/android/gallery3d/app/PhotoPage.java index ec45cc1be..c5c77e11b 100644 --- a/src/com/android/gallery3d/app/PhotoPage.java +++ b/src/com/android/gallery3d/app/PhotoPage.java @@ -792,6 +792,8 @@ public class PhotoPage extends ActivityState implements case R.id.action_trim: { Intent intent = new Intent(mActivity, TrimVideo.class); intent.setData(manager.getContentUri(path)); + // We need the file path to wrap this into a RandomAccessFile. + intent.putExtra(KEY_MEDIA_ITEM_PATH, current.getFilePath()); mActivity.startActivityForResult(intent, REQUEST_TRIM); return true; } diff --git a/src/com/android/gallery3d/app/ShortenExample.java b/src/com/android/gallery3d/app/ShortenExample.java new file mode 100644 index 000000000..0ac78d935 --- /dev/null +++ b/src/com/android/gallery3d/app/ShortenExample.java @@ -0,0 +1,156 @@ +/* + * Copyright (C) 2012 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. + */ + +// Modified example based on mp4parser google code open source project. +// http://code.google.com/p/mp4parser/source/browse/trunk/examples/src/main/java/com/googlecode/mp4parser/ShortenExample.java + +package com.android.gallery3d.app; + +import com.coremedia.iso.IsoFile; +import com.coremedia.iso.boxes.TimeToSampleBox; +import com.googlecode.mp4parser.authoring.Movie; +import com.googlecode.mp4parser.authoring.Track; +import com.googlecode.mp4parser.authoring.builder.DefaultMp4Builder; +import com.googlecode.mp4parser.authoring.container.mp4.MovieCreator; +import com.googlecode.mp4parser.authoring.tracks.CroppedTrack; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.channels.FileChannel; +import java.util.Arrays; +import java.util.LinkedList; +import java.util.List; + +/** + * Shortens/Crops a track + */ +public class ShortenExample { + + public static void main(String[] args, File src, File dst, int startMs, int endMs) throws IOException { + RandomAccessFile randomAccessFile = new RandomAccessFile(src, "r"); + Movie movie = MovieCreator.build(randomAccessFile.getChannel()); + + // remove all tracks we will create new tracks from the old + List tracks = movie.getTracks(); + movie.setTracks(new LinkedList()); + + double startTime = startMs/1000; + double endTime = endMs/1000; + + boolean timeCorrected = false; + + // Here we try to find a track that has sync samples. Since we can only start decoding + // at such a sample we SHOULD make sure that the start of the new fragment is exactly + // such a frame + for (Track track : tracks) { + if (track.getSyncSamples() != null && track.getSyncSamples().length > 0) { + if (timeCorrected) { + // This exception here could be a false positive in case we have multiple tracks + // with sync samples at exactly the same positions. E.g. a single movie containing + // multiple qualities of the same video (Microsoft Smooth Streaming file) + + throw new RuntimeException("The startTime has already been corrected by another track with SyncSample. Not Supported."); + } + startTime = correctTimeToSyncSample(track, startTime, false); + endTime = correctTimeToSyncSample(track, endTime, true); + timeCorrected = true; + } + } + + for (Track track : tracks) { + long currentSample = 0; + double currentTime = 0; + long startSample = -1; + long endSample = -1; + + for (int i = 0; i < track.getDecodingTimeEntries().size(); i++) { + TimeToSampleBox.Entry entry = track.getDecodingTimeEntries().get(i); + for (int j = 0; j < entry.getCount(); j++) { + // entry.getDelta() is the amount of time the current sample covers. + + if (currentTime <= startTime) { + // current sample is still before the new starttime + startSample = currentSample; + } + if (currentTime <= endTime) { + // current sample is after the new start time and still before the new endtime + endSample = currentSample; + } else { + // current sample is after the end of the cropped video + break; + } + currentTime += (double) entry.getDelta() / (double) track.getTrackMetaData().getTimescale(); + currentSample++; + } + } + movie.addTrack(new CroppedTrack(track, startSample, endSample)); + } + IsoFile out = new DefaultMp4Builder().build(movie); + + if (!dst.exists()) { + dst.createNewFile(); + } + + FileOutputStream fos = new FileOutputStream(dst); + FileChannel fc = fos.getChannel(); + out.getBox(fc); // This one build up the memory. + + fc.close(); + fos.close(); + randomAccessFile.close(); + } + + protected static long getDuration(Track track) { + long duration = 0; + for (TimeToSampleBox.Entry entry : track.getDecodingTimeEntries()) { + duration += entry.getCount() * entry.getDelta(); + } + return duration; + } + + private static double correctTimeToSyncSample(Track track, double cutHere, boolean next) { + double[] timeOfSyncSamples = new double[track.getSyncSamples().length]; + long currentSample = 0; + double currentTime = 0; + for (int i = 0; i < track.getDecodingTimeEntries().size(); i++) { + TimeToSampleBox.Entry entry = track.getDecodingTimeEntries().get(i); + for (int j = 0; j < entry.getCount(); j++) { + if (Arrays.binarySearch(track.getSyncSamples(), currentSample + 1) >= 0) { + // samples always start with 1 but we start with zero therefore +1 + timeOfSyncSamples[Arrays.binarySearch(track.getSyncSamples(), currentSample + 1)] = currentTime; + } + currentTime += (double) entry.getDelta() / (double) track.getTrackMetaData().getTimescale(); + currentSample++; + } + } + double previous = 0; + for (double timeOfSyncSample : timeOfSyncSamples) { + if (timeOfSyncSample > cutHere) { + if (next) { + return timeOfSyncSample; + } else { + return previous; + } + } + previous = timeOfSyncSample; + } + return timeOfSyncSamples[timeOfSyncSamples.length - 1]; + } + + +} diff --git a/src/com/android/gallery3d/app/TrimVideo.java b/src/com/android/gallery3d/app/TrimVideo.java index 4fb255729..f7ff43e96 100644 --- a/src/com/android/gallery3d/app/TrimVideo.java +++ b/src/com/android/gallery3d/app/TrimVideo.java @@ -19,12 +19,18 @@ package com.android.gallery3d.app; import android.app.ActionBar; import android.app.Activity; import android.app.ProgressDialog; +import android.content.ContentResolver; +import android.content.ContentValues; import android.content.Context; import android.content.Intent; +import android.database.Cursor; import android.media.MediaPlayer; import android.net.Uri; import android.os.Bundle; +import android.os.Environment; import android.os.Handler; +import android.provider.MediaStore.Video; +import android.provider.MediaStore.Video.VideoColumns; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; @@ -34,7 +40,12 @@ import android.widget.Toast; import android.widget.VideoView; import com.android.gallery3d.R; +import com.android.gallery3d.util.BucketNames; +import java.io.File; +import java.io.IOException; +import java.sql.Date; +import java.text.SimpleDateFormat; public class TrimVideo extends Activity implements MediaPlayer.OnErrorListener, @@ -58,6 +69,15 @@ public class TrimVideo extends Activity implements public static final String KEY_VIDEO_POSITION = "video_pos"; private boolean mHasPaused = false; + private String mSrcVideoPath = null; + private String mSaveFileName = null; + private static final String TIME_STAMP_NAME = "'TRIM'_yyyyMMdd_HHmmss"; + private File mSrcFile = null; + private File mDstFile = null; + private File mSaveDirectory = null; + // For showing the result. + private String saveFolderName = null; + @Override public void onCreate(Bundle savedInstanceState) { mContext = getApplicationContext(); @@ -68,14 +88,14 @@ public class TrimVideo extends Activity implements Intent intent = getIntent(); mUri = intent.getData(); - + mSrcVideoPath = intent.getStringExtra(PhotoPage.KEY_MEDIA_ITEM_PATH); setContentView(R.layout.trim_view); View rootView = findViewById(R.id.trim_view_root); mVideoView = (VideoView) rootView.findViewById(R.id.surface_view); mController = new TrimControllerOverlay(mContext); - ((ViewGroup)rootView).addView(mController.getView()); + ((ViewGroup) rootView).addView(mController.getView()); mController.setListener(this); mController.setCanReplay(true); @@ -106,6 +126,15 @@ public class TrimVideo extends Activity implements super.onPause(); } + @Override + public void onStop() { + if (mProgress != null) { + mProgress.dismiss(); + mProgress = null; + } + super.onStop(); + } + @Override public void onDestroy() { mVideoView.stopPlayback(); @@ -185,6 +214,42 @@ public class TrimVideo extends Activity implements return true; }; + // Copy from SaveCopyTask.java in terms of how to handle the destination + // path and filename : querySource() and getSaveDirectory(). + private interface ContentResolverQueryCallback { + void onCursorResult(Cursor cursor); + } + + private void querySource(String[] projection, ContentResolverQueryCallback callback) { + ContentResolver contentResolver = getContentResolver(); + Cursor cursor = null; + try { + cursor = contentResolver.query(mUri, projection, null, null, null); + if ((cursor != null) && cursor.moveToNext()) { + callback.onCursorResult(cursor); + } + } catch (Exception e) { + // Ignore error for lacking the data column from the source. + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + private File getSaveDirectory() { + final File[] dir = new File[1]; + querySource(new String[] { + VideoColumns.DATA }, new ContentResolverQueryCallback() { + + @Override + public void onCursorResult(Cursor cursor) { + dir[0] = new File(cursor.getString(0)).getParentFile(); + } + }); + return dir[0]; + } + @Override public boolean onOptionsItemSelected(MenuItem item) { int id = item.getItemId(); @@ -192,14 +257,117 @@ public class TrimVideo extends Activity implements finish(); return true; } else if (id == R.id.action_trim_video) { - // TODO: Add the new MediaMuxer API to support the trimming. - Toast.makeText(getApplicationContext(), - "Trimming will be implemented soon!", Toast.LENGTH_SHORT).show(); + trimVideo(); return true; } return false; } + private void trimVideo() { + // Use the default save directory if the source directory cannot be + // saved. + mSaveDirectory = getSaveDirectory(); + if ((mSaveDirectory == null) || !mSaveDirectory.canWrite()) { + mSaveDirectory = new File(Environment.getExternalStorageDirectory(), + BucketNames.DOWNLOAD); + saveFolderName = getString(R.string.folder_download); + } else { + saveFolderName = mSaveDirectory.getName(); + } + mSaveFileName = new SimpleDateFormat(TIME_STAMP_NAME).format( + new Date(System.currentTimeMillis())); + + mDstFile = new File(mSaveDirectory, mSaveFileName + ".mp4"); + mSrcFile = new File(mSrcVideoPath); + + showProgressDialog(); + + new Thread(new Runnable() { + @Override + public void run() { + try { + ShortenExample.main(null, mSrcFile, mDstFile, mTrimStartTime, mTrimEndTime); + } catch (IOException e) { + e.printStackTrace(); + } + // After trimming is done, trigger the UI changed. + mHandler.post(new Runnable() { + @Override + public void run() { + // TODO: change trimming into a service to avoid + // this progressDialog and add notification properly. + if (mProgress != null) { + mProgress.dismiss(); + // Update the database for adding a new video file. + insertContent(mDstFile); + Toast.makeText(getApplicationContext(), + "Saved into " + saveFolderName, Toast.LENGTH_SHORT) + .show(); + mProgress = null; + } + } + }); + } + }).start(); + } + + private void showProgressDialog() { + // create a background thread to trim the video. + // and show the progress. + mProgress = new ProgressDialog(this); + mProgress.setTitle("Trimming"); + mProgress.setMessage("please wait"); + // TODO: make this cancelable. + mProgress.setCancelable(false); + mProgress.setCanceledOnTouchOutside(false); + mProgress.show(); + } + + /** + * Insert the content (saved file) with proper video properties. + */ + private Uri insertContent(File file) { + long now = System.currentTimeMillis() / 1000; + + final ContentValues values = new ContentValues(12); + values.put(Video.Media.TITLE, mSaveFileName); + values.put(Video.Media.DISPLAY_NAME, file.getName()); + values.put(Video.Media.MIME_TYPE, "video/mp4"); + values.put(Video.Media.DATE_TAKEN, now); + values.put(Video.Media.DATE_MODIFIED, now); + values.put(Video.Media.DATE_ADDED, now); + values.put(Video.Media.DATA, file.getAbsolutePath()); + values.put(Video.Media.SIZE, file.length()); + // Copy the data taken and location info from src. + String[] projection = new String[] { + VideoColumns.DATE_TAKEN, + VideoColumns.LATITUDE, + VideoColumns.LONGITUDE, + VideoColumns.RESOLUTION, + }; + + // Copy some info from the source file. + querySource(projection, new ContentResolverQueryCallback() { + + @Override + public void onCursorResult(Cursor cursor) { + values.put(Video.Media.DATE_TAKEN, cursor.getLong(0)); + double latitude = cursor.getDouble(1); + double longitude = cursor.getDouble(2); + // TODO: Change || to && after the default location issue is + // fixed. + if ((latitude != 0f) || (longitude != 0f)) { + values.put(Video.Media.LATITUDE, latitude); + values.put(Video.Media.LONGITUDE, longitude); + } + values.put(Video.Media.RESOLUTION, cursor.getString(3)); + + } + }); + + return getContentResolver().insert(Video.Media.EXTERNAL_CONTENT_URI, values); + } + @Override public void onPlayPause() { if (mVideoView.isPlaying()) { -- cgit v1.2.3