Pull to refresh

Съемка Time Lapse видео под Android

Reading time 27 min
Views 33K
imageДавайте напишем программу для создания своих собственных фильмов в технике Time Lapse. Завораживающее видео, снятое в этой технике с борта МКС, можно посмотреть здесь, более доступный вариант, который можно повторить с помощью описываемой программы — здесь.

Программа имеет простой интерфейс и несложный принцип работы:
• пользователь задает периодичность снимков встроенной камерой (например, 10 с) и желаемую частоту кадров генерируемого видео (например, 25 кадров в секунду);
• после нажатия кнопки «Старт»  программа каждые 10 секунд делает фотографию и записывает jpg-файл на SD-карту;
• процедура повторяется до нажатия кнопки «Стоп» и «Создать видео», после чего последовательность фотографий превращается в видео файл формата Motion JPEG, который показывает отснятый материал в 250 раз (25 * 10) быстрее реальной скорости происходивших событий.

В программе два основных класса — MainActivity, занимающийся взаимодействием с пользователем и накоплением снимков и MJPEGGenerator, ответственный за превращение последовательности изображений в видео файл.

Класс MJPEGGenerator, взятый с code.google.com, был слегка переделан в связи с тем, что в Android Java отсутствует пакет java.awt.

Процедуры работы с камерой были преимущественно взяты из материала Работа с камерой в Android, где есть хорошее описание примененных решений, проблема «залипаний» камеры после лока/анлока Android-устройства была устранена благодаря stackoverflow.

Программа была отлажена на планшете Prestigio MultiPad 7.0 Prime под Android 4.0.


image


Рассмотрим более подробно работу отдельных компонентов программы, не касающихся собственно камеры.

Для того, чтобы экран во время съемки не выключался, был применен механизм PowerManager.WakeLock:

private PowerManager.WakeLock wl;

public void onCreate(Bundle savedInstanceState) {
...
PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE);
wl = pm.newWakeLock(PowerManager.FULL_WAKE_LOCK, "DoNotDimScreen");
}


Периодичность запуска камеры регулируется таймером:

Timer updateTimer = new Timer();
...
updateTimer = new Timer();
updateTimer.scheduleAtFixedRate(new TimerTask() {
public void run() {
if ((camera != null) && (workMode == 1)) {												camera.takePicture(null, null, null, MainActivity.this);
}}
}, 0, capturePeriod * 1000);


Перед началом работы проверяется наличие SD-карты:

if (!Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED))
Toast.makeText(MainActivity.this, "Please mount SD card", Toast.LENGTH_LONG).show();


Данные, введенные пользователем, проходят предварительную обработку: запятые, введенные в качестве разделителя десятичных разрядов, заменяются на точку; проверяется ввод не цифровых значений; значения введенного периода и частоты кадров проверяются на вхождение в допустимый диапазон:

periodEditText.addTextChangedListener(new TextWatcher() {
public void afterTextChanged(Editable s) {
if (periodEditText.getText().toString().length() == 0)
capturePeriod = 0;
else {
if (isNum(periodEditText.getText().toString().replace(',', '.'))) {
float a = Float.valueOf(periodEditText.getText().toString().replace(',', '.'));
capturePeriod = (int) a;
} else
Toast.makeText(MainActivity.this, periodEditText.getText().toString() + " - not a digit.",
Toast.LENGTH_LONG).show();
}}

...

if ((fps < FPSMIN) || (fps > FPSMAX))
Toast.makeText(MainActivity.this, "FPS should be " + FPSMIN + " to " + FPSMAX + " frames per second",
Toast.LENGTH_LONG).show();


В начале работы из папки программы удаляются все файла *.jpg, оставшиеся после предыдущего сеанса:

String sdPath = Environment.getExternalStorageDirectory().getPath() + "/TimeLapseFolder/";
File saveDir = new File(sdPath);
if (saveDir.isDirectory()) {
String[] children = saveDir.list();
for (int i = 0; i < children.length; i++) {
if (children[i].endsWith(".jpg"))
new File(saveDir, children[i]).delete();
}}
saveDir.delete();


После съемки очередного кадра пользователю показывается оставшееся на карте место:

modeText.setText("Work mode: capturing, " + String.valueOf(roundOneDecimal(megAvailable)) + " Mbyte available on SD card");


Собственно генерация видео:

generator = new MJPEGGenerator(videofile, aviWidth, aviHeight, fps, lastPicture);

for (int addpic = 1; addpic <= lastPicture; addpic++) {
String numWithZeroes = intToString(addpic, 7);
String curjpg = sdPath + numWithZeroes + ".jpg";
publishProgress(numWithZeroes);
if (DEBUG)
Log.v(TAG, "Rendering jpg sdPath = " + curjpg);

Bitmap bmp = BitmapFactory.decodeFile(curjpg);
generator.addImage(bmp);
}


MJPEGGenerator запускается со следующими параметрами:
videofile — название файла видео каждый раз берется новое, нумеруемое по маске TimeLapseMovieXXX.avi, чтобы сохранить файлы, отснятые ранее;
aviWidth, aviHeight — берется из свойств камеры;
fps — задается пользователем;
lastPicture — номер последней снятой фотографии.

Чтобы не подвешивать пользовательский интерфейс, генерация видео запускается в отдельном потоке AsyncTask, взаимодействующим с GUI через onProgressUpdate.

Содержимое ключевых файлов показано ниже, архив проекта можно скачать с sourceforge.net.

MainActivity.java
package com.sample.timelapse;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.text.DecimalFormat;
import java.util.Arrays;
import java.util.Timer;
import java.util.TimerTask;

import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Context;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Color;
import android.graphics.Point;
import android.hardware.Camera;
import android.hardware.Camera.Size;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.os.Handler;
import android.os.PowerManager;
import android.os.StatFs;
import android.text.Editable;
import android.text.TextWatcher;
import android.util.Log;
import android.util.TypedValue;
import android.view.Display;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.view.View;
import android.view.View.OnFocusChangeListener;
import android.view.ViewGroup.LayoutParams;
import android.view.Window;
import android.view.WindowManager;
import android.view.inputmethod.InputMethodManager;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;

public class MainActivity extends Activity implements SurfaceHolder.Callback, View.OnClickListener, Camera.PictureCallback,
		Camera.PreviewCallback {
	private MJPEGGenerator generator;

	Timer updateTimer = new Timer(); // Main shoot timer

	private static final int PERIODMIN = 2; // Seconds
	private static final int PERIODMAX = 1000; // Seconds

	private static final int FPSMIN = 2;
	private static final int FPSMAX = 30;

	int aviHeight = 0; // Dimensions of final video
	int aviWidth = 0;

	// Work mode
	// 0: Ready to start
	// 1: Capturing photos
	// 2: Ready to create video
	// 3: Create video
	private int workMode = 0;

	private int capturePeriod = 0;
	private int fps = 0;

	private Camera camera;
	private SurfaceHolder surfaceHolder;
	private SurfaceView preview;

	private static int LOGLEVEL = 2; // Set logging level
	private static boolean DEBUG = LOGLEVEL > 1;
	@SuppressWarnings("unused")
	private static boolean WARNING = LOGLEVEL > 0;

	public static final String PREFS_NAME = "MyPrefsFile"; // For save and restore preferences

	private static final String TAG = "MainActivity"; // Set logging tag

	int lastPicture = 0; // Current picture counter
	int lastVideo = 0; // Current video file counter

	int sWidth = 0; // Screen width
	int sHeight = 0; // Screen height
	int prevsWidth = 1; // Previous screen width (after previous onWindowFocusChanged)
	int prevsHeight = 1; // Previous screen height (after previous onWindowFocusChanged)

	int commentTextBottom = 0;
	int oldLandCommentTextBottom = 0;

	private TextView periodText;
	private TextView framerateText;
	private TextView totalsnapshotsText;

	private Button startButton; // "Start capture"
	private Button createButton; // "Create video"
	int nativeButtonColor = 0;

	private EditText periodEditText; // Period
	private TextView secondsText;

	private EditText fpsEditText; // Frame rate
	private TextView fpsText;

	private TextView modeText; // Show comments

	float roundOneDecimal(float toround) {
		DecimalFormat twoDForm = new DecimalFormat("#.#");
		return Float.valueOf(twoDForm.format(toround));
	}

	static String intToString(int num, int digits) {
		assert digits > 0 : "Invalid number of digits";

		char[] zeros = new char[digits]; // Create variable length array of zeros
		Arrays.fill(zeros, '0');

		DecimalFormat df = new DecimalFormat(String.valueOf(zeros)); // Format number as String

		return df.format(num);
	}

	public boolean isNum(String s) {
		try {
			Double.parseDouble(s);
		} catch (NumberFormatException e) {
			return false;
		}
		return true;
	}

	private PowerManager.WakeLock wl; // Stop screen from dimming by enforcing wake lock

	@Override
	protected void onPause() {
		super.onPause(); // onPause method in the parent class

		if (DEBUG)
			Log.v(TAG, "onPause");

		surfaceHolder.removeCallback(this);
		if (camera != null) {
			camera.setPreviewCallback(null);
			camera.stopPreview();
			camera.release();
			camera = null;
		}

		preview.setVisibility(View.GONE);

		wl.release();
	}

	@Override
	public void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState); // onCreate method in the parent class

		if (DEBUG)
			Log.v(TAG, "onCreate");

		requestWindowFeature(Window.FEATURE_NO_TITLE); // App without a title
		getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN); // App without a status bar

		setContentView(R.layout.activity_main); // Set user interface

		PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE);
		wl = pm.newWakeLock(PowerManager.FULL_WAKE_LOCK, "DoNotDimScreen");

		periodText = (TextView) findViewById(R.id.periodText); // Text fields
		framerateText = (TextView) findViewById(R.id.framerateText);
		totalsnapshotsText = (TextView) findViewById(R.id.totalsnapshotsText);

		startButton = (Button) findViewById(R.id.startButton); // Start capture button
		startButton.setOnClickListener(this);

		createButton = (Button) findViewById(R.id.createButton); // Create video button
		createButton.setOnClickListener(this);
		nativeButtonColor = createButton.getCurrentTextColor();
		createButton.setTextColor(Color.GRAY);

		periodEditText = (EditText) findViewById(R.id.periodEditText); // Period
		secondsText = (TextView) findViewById(R.id.secondsText);

		periodEditText.addTextChangedListener(new TextWatcher() {
			public void afterTextChanged(Editable s) {
				if (periodEditText.getText().toString().length() == 0)
					capturePeriod = 0;
				else {
					if (isNum(periodEditText.getText().toString().replace(',', '.'))) {
						float a = Float.valueOf(periodEditText.getText().toString().replace(',', '.'));
						capturePeriod = (int) a;
					} else
						Toast.makeText(MainActivity.this, periodEditText.getText().toString() + " - not a digit.",
								Toast.LENGTH_LONG).show();
				}
			}

			public void beforeTextChanged(CharSequence s, int start, int count, int after) {
			}

			public void onTextChanged(CharSequence s, int start, int before, int count) {
			}
		});

		fpsEditText = (EditText) findViewById(R.id.fpsEditText); // fps EditText
		fpsText = (TextView) findViewById(R.id.fpsText);

		fpsEditText.setOnFocusChangeListener(new OnFocusChangeListener() {
			public void onFocusChange(View v, boolean hasFocus) {
				if (!hasFocus) {
					// Hide soft keyboard after input
					InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
					imm.hideSoftInputFromWindow(fpsEditText.getWindowToken(), 0);
				}
			}
		});

		fpsEditText.addTextChangedListener(new TextWatcher() {
			public void afterTextChanged(Editable s) {
				if (fpsEditText.getText().toString().length() == 0)
					fps = 0;
				else {
					if (isNum(fpsEditText.getText().toString().replace(',', '.'))) {
						float a = Float.valueOf(fpsEditText.getText().toString().replace(',', '.'));
						fps = (int) a;
					} else
						Toast.makeText(MainActivity.this, fpsEditText.getText().toString() + " - not a digit.",
								Toast.LENGTH_LONG).show();
				}
			}

			public void beforeTextChanged(CharSequence s, int start, int count, int after) {
			}

			public void onTextChanged(CharSequence s, int start, int before, int count) {
			}
		});

		modeText = (TextView) findViewById(R.id.modeText); // Show comments

		SharedPreferences settings = getSharedPreferences(PREFS_NAME, 0); // Restore preferences
		oldLandCommentTextBottom = settings.getInt("oldLandCommentTextBottom", 0);
	}

	@Override
	protected void onResume() {
		super.onResume(); // onResume method in the parent class

		if (DEBUG)
			Log.v(TAG, "onResume");

		preview = (SurfaceView) findViewById(R.id.mSurfaceView);

		if (camera == null) {
			camera = Camera.open();
			camera.startPreview();
		}

		surfaceHolder = preview.getHolder();
		surfaceHolder.setType(SurfaceHolder.SURFACE_TYPE_PUSH_BUFFERS);
		surfaceHolder.setSizeFromLayout();
		surfaceHolder.addCallback(this);

		preview.setVisibility(View.VISIBLE);

		wl.acquire();

		Size previewSize = camera.getParameters().getPreviewSize();
		aviHeight = previewSize.height;
		aviWidth = previewSize.width;

		modeText.setFocusableInTouchMode(true); // Set focus (and hide soft keyboard)
		modeText.requestFocus();
	}

	public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
		if (DEBUG)
			Log.v(TAG, "surfaceChanged");

		try {
			camera.setPreviewDisplay(surfaceHolder);
		} catch (IOException e) {
			Toast.makeText(MainActivity.this, "Error 1: " + e.toString(), Toast.LENGTH_LONG).show();
		}
		camera.startPreview();
	}

	public void surfaceCreated(SurfaceHolder holder) {
		if (DEBUG)
			Log.v(TAG, "surfaceCreated");

		try {
			camera.setPreviewDisplay(holder);
			camera.setPreviewCallback(this);
		} catch (IOException e) {
			Toast.makeText(MainActivity.this, "Error 2: " + e.toString(), Toast.LENGTH_LONG).show();
			camera.release();
			camera = null;
		}

		Size previewSize = camera.getParameters().getPreviewSize();
		float aspect = (float) previewSize.width / previewSize.height;

		int previewSurfaceWidth = preview.getWidth();

		LayoutParams lp = preview.getLayoutParams();

		// здесь корректируем размер отображаемого preview для ландшафтного вида, чтобы не было искажений
		// camera.setDisplayOrientation(0);
		lp.width = previewSurfaceWidth;
		lp.height = (int) (previewSurfaceWidth / aspect);

		preview.setLayoutParams(lp);
		camera.startPreview();
	}

	public void surfaceDestroyed(SurfaceHolder holder) {
		if (DEBUG)
			Log.v(TAG, "surfaceDestroyed");
	}

	@SuppressLint("NewApi")
	@SuppressWarnings("deprecation")
	void getDisplaySize() {
		try {
			if (Build.VERSION.SDK_INT >= 13) {
				Display display = getWindowManager().getDefaultDisplay();
				Point size = new Point();
				display.getSize(size);
				sWidth = size.x;
				sHeight = size.y;
			} else {
				Display display = getWindowManager().getDefaultDisplay();
				sWidth = display.getWidth();
				sHeight = display.getHeight();
			}
		} catch (Exception e) {
			Toast.makeText(MainActivity.this, "Error 3: " + e.toString(), Toast.LENGTH_LONG).show();
		}
	}

	@Override
	public void onWindowFocusChanged(boolean hasFocus) {
		super.onWindowFocusChanged(hasFocus);
		if (hasFocus) {
			getDisplaySize();

			if ((prevsWidth != sWidth) || (prevsHeight != sHeight)) { // If orientation changed
				commentTextBottom = modeText.getTop() + modeText.getHeight(); // Calculate magnification factor

				float heightRatio = 0;

				// Landscape
				heightRatio = (float) sHeight / (float) commentTextBottom;
				oldLandCommentTextBottom = commentTextBottom;

				if (heightRatio > 1)
					heightRatio = 0.7f * heightRatio;
				else
					heightRatio = heightRatio / 0.7f;

				// Adjust fonts
				periodText.setTextSize(TypedValue.COMPLEX_UNIT_PX, heightRatio * periodText.getTextSize());
				periodEditText.setTextSize(TypedValue.COMPLEX_UNIT_PX, heightRatio * periodEditText.getTextSize());
				secondsText.setTextSize(TypedValue.COMPLEX_UNIT_PX, heightRatio * secondsText.getTextSize());
				framerateText.setTextSize(TypedValue.COMPLEX_UNIT_PX, heightRatio * framerateText.getTextSize());
				fpsEditText.setTextSize(TypedValue.COMPLEX_UNIT_PX, heightRatio * fpsEditText.getTextSize());
				fpsText.setTextSize(TypedValue.COMPLEX_UNIT_PX, heightRatio * fpsText.getTextSize());
				totalsnapshotsText.setTextSize(TypedValue.COMPLEX_UNIT_PX, heightRatio * totalsnapshotsText.getTextSize());
				modeText.setTextSize(TypedValue.COMPLEX_UNIT_PX, heightRatio * modeText.getTextSize());

				// Some components have text size a little less
				startButton.setTextSize(TypedValue.COMPLEX_UNIT_PX, 0.8f * heightRatio * startButton.getTextSize());
				createButton.setTextSize(TypedValue.COMPLEX_UNIT_PX, 0.8f * heightRatio * createButton.getTextSize());

				// If user comment string not formed
				if (modeText.getText().equals(getResources().getString(R.string.longestComment)))
					modeText.setText(getString(R.string.modeText));
			}
			prevsWidth = sWidth;
			prevsHeight = sHeight;
		}
	}

	public void onClick(View v) {
		if (v == startButton) {
			if (workMode == 0) {
				if (!Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED))
					Toast.makeText(MainActivity.this, "Please mount SD card", Toast.LENGTH_LONG).show();
				else if ((capturePeriod < PERIODMIN) || (capturePeriod > PERIODMAX))
					Toast.makeText(MainActivity.this,
							"Snapshots period should be " + PERIODMIN + " to " + PERIODMAX + " seconds", Toast.LENGTH_LONG)
							.show();
				else if ((fps < FPSMIN) || (fps > FPSMAX))
					Toast.makeText(MainActivity.this, "FPS should be " + FPSMIN + " to " + FPSMAX + " frames per second",
							Toast.LENGTH_LONG).show();
				else {
					if (updateTimer != null)
						updateTimer.cancel();

					try {
						updateTimer = new Timer();

						updateTimer.scheduleAtFixedRate(new TimerTask() {
							public void run() {
								if ((camera != null) && (workMode == 1)) {
									camera.takePicture(null, null, null, MainActivity.this);
								}
							}
						}, 0, capturePeriod * 1000);
					} catch (Exception e) {
						Toast.makeText(MainActivity.this, "Error 4: " + e.toString(), Toast.LENGTH_LONG).show();
					}

					// Delete all jpg's
					try {
						String sdPath = Environment.getExternalStorageDirectory().getPath() + "/TimeLapseFolder/";
						if (DEBUG)
							Log.v(TAG, "Delete jpg's sdPath = " + sdPath);

						File saveDir = new File(sdPath);

						if (saveDir.isDirectory()) {
							String[] children = saveDir.list();
							for (int i = 0; i < children.length; i++) {
								if (children[i].endsWith(".jpg"))
									new File(saveDir, children[i]).delete();
							}
						}
						saveDir.delete();
					} catch (Exception e) {
						Toast.makeText(MainActivity.this, "Error 5: " + e.toString(), Toast.LENGTH_LONG).show();
					}

					lastPicture = 0;
					workMode = 1;
					startButton.setText("Stop capture");
					modeText.setText("Work mode: capturing");
					totalsnapshotsText.setText("Total snapshots: " + String.valueOf(lastPicture));
				}
			} else if (workMode == 1) {
				workMode = 2;
				createButton.setTextColor(nativeButtonColor);
				startButton.setText("Start capture");
				startButton.setTextColor(Color.GRAY);
				modeText.setText("Work mode: ready to start");
			}
		}

		if (v == createButton) {
			if (workMode == 2) {
				if (!Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED))
					Toast.makeText(MainActivity.this, "Please mount SD card", Toast.LENGTH_LONG).show();
				else if ((capturePeriod < PERIODMIN) || (capturePeriod > PERIODMAX))
					Toast.makeText(MainActivity.this,
							"Snapshots period should be " + PERIODMIN + " to " + PERIODMAX + " seconds", Toast.LENGTH_LONG)
							.show();
				else if ((fps < FPSMIN) || (fps > FPSMAX))
					Toast.makeText(MainActivity.this, "FPS should be " + FPSMIN + " to " + FPSMAX + " frames per second",
							Toast.LENGTH_LONG).show();
				else {
					workMode = 3;
					createButton.setTextColor(Color.GRAY);
					startButton.setTextColor(Color.GRAY);
					modeText.setText("Work mode: create video file, please wait");
					new CreateMovieInBackground().execute();
				}
			}
		}
	}

	@Override
	protected void onSaveInstanceState(Bundle outState) {
		super.onSaveInstanceState(outState); // onSaveInstanceState method in the parent class

		if (DEBUG)
			Log.v(TAG, "onSaveInstanceState");

		SharedPreferences settings = getSharedPreferences(PREFS_NAME, 0);
		SharedPreferences.Editor editor = settings.edit();
		editor.putInt("oldLandCommentTextBottom", oldLandCommentTextBottom);
		editor.commit();
	}

	@Override
	protected void onRestoreInstanceState(Bundle savedInstanceState) {
		super.onRestoreInstanceState(savedInstanceState); // onRestoreInstanceState method in the parent class

		if (DEBUG)
			Log.v(TAG, "onRestoreInstanceState");
	}

	public void onPictureTaken(byte[] paramArrayOfByte, Camera paramCamera) {
		new SaveInBackground().execute(paramArrayOfByte);

		if (DEBUG)
			Log.v(TAG, "onPictureTaken");

		// после того, как снимок сделан, показ превью отключается. необходимо включить его
		paramCamera.startPreview();

		totalsnapshotsText.setText("Total snapshots: " + String.valueOf(lastPicture));

		StatFs stat = new StatFs(Environment.getExternalStorageDirectory().getPath());

		long bytesAvailable = (long) stat.getBlockSize() * (long) stat.getAvailableBlocks();
		float megAvailable = bytesAvailable / (1024.f * 1024.f);

		modeText.setText("Work mode: capturing, " + String.valueOf(roundOneDecimal(megAvailable))
				+ " Mbyte available on SD card");
	}

	class SaveInBackground extends AsyncTask<byte[], String, String> {
		@Override
		protected String doInBackground(byte[]... arrayOfByte) {
			try {
				String sdPath = Environment.getExternalStorageDirectory().getPath() + "/TimeLapseFolder/";
				File saveDir = new File(sdPath);

				if (!saveDir.exists())
					saveDir.mkdirs();

				lastPicture++;

				String numWithZeroes = intToString(lastPicture, 7);
				String curjpg = sdPath + numWithZeroes + ".jpg";

				if (DEBUG)
					Log.v(TAG, "Save jpg sdPath = " + curjpg);

				FileOutputStream os = new FileOutputStream(curjpg);
				os.write(arrayOfByte[0]);
				os.close();
			} catch (Exception e) {
				Toast.makeText(MainActivity.this, "Error 6: " + e.toString(), Toast.LENGTH_LONG).show();
			}
			return (null);
		}
	}

	class CreateMovieInBackground extends AsyncTask<byte[], String, String> {

		protected void onProgressUpdate(String... values) {
			modeText.setText("Work mode: rendering " + values[0] + ".jpg");
		}

		protected void onPostExecute(String result) {
			workMode = 0;
			totalsnapshotsText.setText("Total snapshots: 0");
			lastPicture = 0;

			String sdPath = Environment.getExternalStorageDirectory().getPath() + "/TimeLapseFolder/";

			modeText.setText("Work mode:" + sdPath + "TimeLapseMovie" + intToString(lastVideo, 3) + ".avi is rendered");
			Handler handler = new Handler();
			handler.postDelayed(new Runnable() {
				public void run() {
					modeText.setText("Work mode: ready to start");
					startButton.setTextColor(nativeButtonColor);
				}
			}, 5000);
		}

		@Override
		protected String doInBackground(byte[]... arrayOfByte) {
			try {

				File videofile = null;

				String sdPath = Environment.getExternalStorageDirectory().getPath() + "/TimeLapseFolder/";

				// Choosing a name for the file
				do {
					lastVideo++;
					String curavi = sdPath + "TimeLapseMovie" + intToString(lastVideo, 3) + ".avi";

					if (DEBUG)
						Log.v(TAG, "AVI name = " + curavi);

					videofile = new File(curavi);
				} while (videofile.exists());

				generator = new MJPEGGenerator(videofile, aviWidth, aviHeight, fps, lastPicture);

				for (int addpic = 1; addpic <= lastPicture; addpic++) {
					String numWithZeroes = intToString(addpic, 7);
					String curjpg = sdPath + numWithZeroes + ".jpg";

					publishProgress(numWithZeroes);

					if (DEBUG)
						Log.v(TAG, "Rendering jpg sdPath = " + curjpg);

					Bitmap bmp = BitmapFactory.decodeFile(curjpg);
					generator.addImage(bmp);
				}

				// Delete all jpg's
				try {
					if (DEBUG)
						Log.v(TAG, "Delete jpg's sdPath = " + sdPath);

					File saveDir = new File(sdPath);

					if (saveDir.isDirectory()) {
						String[] children = saveDir.list();
						for (int i = 0; i < children.length; i++) {
							if (children[i].endsWith(".jpg"))
								new File(saveDir, children[i]).delete();
						}
					}
					saveDir.delete();
				} catch (Exception e) {
					Toast.makeText(MainActivity.this, "Error 7: " + e.toString(), Toast.LENGTH_LONG).show();
				}

				generator.finishAVI();

			} catch (Exception e) {
				Toast.makeText(MainActivity.this, "Error 8: " + e.toString(), Toast.LENGTH_LONG).show();
			}
			return "OK";
		}
	}

	public void onPreviewFrame(byte[] paramArrayOfByte, Camera paramCamera) {
	}
}



MJPEGGenerator.java
package com.sample.timelapse;

 //
 // MJPEGGenerator.java
 //
 // Created on April 17, 2006, 11:48 PM
 //
 // To change this template, choose Tools | Options and locate the template under
 // the Source Creation and Management node. Right-click the template and choose
 // Open. You can then make changes to the template in the Source Editor.
 //

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.RandomAccessFile;
import java.nio.channels.FileChannel;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

import android.graphics.Bitmap;

 //
 //
 // @author monceaux
 //
public class MJPEGGenerator {
	//
	// Info needed for MJPEG AVI
	//
	// - size of file minus "RIFF & 4 byte file size"
	//

	int width = 0;
	int height = 0;
	double framerate = 0;
	int numFrames = 0;
	File aviFile = null;
	FileOutputStream aviOutput = null;
	FileChannel aviChannel = null;

	long riffOffset = 0;
	long aviMovieOffset = 0;

	AVIIndexList indexlist = null;

	// Creates a new instance of MJPEGGenerator
	public MJPEGGenerator(File aviFile, int width, int height, double framerate, int numFrames) throws Exception {
		this.aviFile = aviFile;
		this.width = width;
		this.height = height;
		this.framerate = framerate;
		this.numFrames = numFrames;
		aviOutput = new FileOutputStream(aviFile);
		aviChannel = aviOutput.getChannel();

		RIFFHeader rh = new RIFFHeader();
		aviOutput.write(rh.toBytes());
		aviOutput.write(new AVIMainHeader().toBytes());
		aviOutput.write(new AVIStreamList().toBytes());
		aviOutput.write(new AVIStreamHeader().toBytes());
		aviOutput.write(new AVIStreamFormat().toBytes());
		aviOutput.write(new AVIJunk().toBytes());
		aviMovieOffset = aviChannel.position();
		aviOutput.write(new AVIMovieList().toBytes());
		indexlist = new AVIIndexList();
	}

	public void addImage(Bitmap image) throws Exception {
		byte[] fcc = new byte[] { '0', '0', 'd', 'b' };
		byte[] imagedata = writeImageToBytes(image);
		int useLength = imagedata.length;
		long position = aviChannel.position();
		int extra = (useLength + (int) position) % 4;
		if (extra > 0)
			useLength = useLength + extra;

		indexlist.addAVIIndex((int) position, useLength);

		aviOutput.write(fcc);
		aviOutput.write(intBytes(swapInt(useLength)));
		aviOutput.write(imagedata);
		if (extra > 0) {
			for (int i = 0; i < extra; i++) 				aviOutput.write(0); 		} 		imagedata = null; 	} 	public void finishAVI() throws Exception { 		byte[] indexlistBytes = indexlist.toBytes(); 		aviOutput.write(indexlistBytes); 		aviOutput.close(); 		long size = aviFile.length(); 		RandomAccessFile raf = new RandomAccessFile(aviFile, "rw"); 		raf.seek(4); 		raf.write(intBytes(swapInt((int) size - 8))); 		raf.seek(aviMovieOffset + 4); 		raf.write(intBytes(swapInt((int) (size - 8 - aviMovieOffset - indexlistBytes.length)))); 		raf.close(); 	} 	public static int swapInt(int v) { 		return (v >>> 24) | (v << 24) | ((v << 8) & 0x00FF0000) | ((v >> 8) & 0x0000FF00);
	}

	public static short swapShort(short v) {
		return (short) ((v >>> 8) | (v << 8)); 	} 	public static byte[] intBytes(int i) { 		byte[] b = new byte[4]; 		b[0] = (byte) (i >>> 24);
		b[1] = (byte) ((i >>> 16) & 0x000000FF);
		b[2] = (byte) ((i >>> 8) & 0x000000FF);
		b[3] = (byte) (i & 0x000000FF);

		return b;
	}

	public static byte[] shortBytes(short i) {
		byte[] b = new byte[2];
		b[0] = (byte) (i >>> 8);
		b[1] = (byte) (i & 0x000000FF);

		return b;
	}

	private class RIFFHeader {
		public byte[] fcc = new byte[] { 'R', 'I', 'F', 'F' };
		public int fileSize = 0;
		public byte[] fcc2 = new byte[] { 'A', 'V', 'I', ' ' };
		public byte[] fcc3 = new byte[] { 'L', 'I', 'S', 'T' };
		public int listSize = 200;
		public byte[] fcc4 = new byte[] { 'h', 'd', 'r', 'l' };

		public RIFFHeader() {

		}

		public byte[] toBytes() throws Exception {
			ByteArrayOutputStream baos = new ByteArrayOutputStream();
			baos.write(fcc);
			baos.write(intBytes(swapInt(fileSize)));
			baos.write(fcc2);
			baos.write(fcc3);
			baos.write(intBytes(swapInt(listSize)));
			baos.write(fcc4);
			baos.close();

			return baos.toByteArray();
		}
	}

	private class AVIMainHeader {
		//
		//
		// FOURCC fcc; DWORD cb; DWORD dwMicroSecPerFrame; DWORD dwMaxBytesPerSec; DWORD dwPaddingGranularity; DWORD
		// dwFlags; DWORD dwTotalFrames; DWORD dwInitialFrames; DWORD dwStreams; DWORD dwSuggestedBufferSize; DWORD
		// dwWidth; DWORD dwHeight; DWORD dwReserved[4];
		//

		public byte[] fcc = new byte[] { 'a', 'v', 'i', 'h' };
		public int cb = 56;
		public int dwMicroSecPerFrame = 0; // (1
											// /
											// frames
											// per
											// sec)
											// *
											// 1,000,000
		public int dwMaxBytesPerSec = 10000000;
		public int dwPaddingGranularity = 0;
		public int dwFlags = 65552;
		public int dwTotalFrames = 0; // replace
										// with
										// correct
										// value
		public int dwInitialFrames = 0;
		public int dwStreams = 1;
		public int dwSuggestedBufferSize = 0;
		public int dwWidth = 0; // replace
								// with
								// correct
								// value
		public int dwHeight = 0; // replace
									// with
									// correct
									// value
		public int[] dwReserved = new int[4];

		public AVIMainHeader() {
			dwMicroSecPerFrame = (int) ((1.0 / framerate) * 1000000.0);
			dwWidth = width;
			dwHeight = height;
			dwTotalFrames = numFrames;
		}

		public byte[] toBytes() throws Exception {
			ByteArrayOutputStream baos = new ByteArrayOutputStream();
			baos.write(fcc);
			baos.write(intBytes(swapInt(cb)));
			baos.write(intBytes(swapInt(dwMicroSecPerFrame)));
			baos.write(intBytes(swapInt(dwMaxBytesPerSec)));
			baos.write(intBytes(swapInt(dwPaddingGranularity)));
			baos.write(intBytes(swapInt(dwFlags)));
			baos.write(intBytes(swapInt(dwTotalFrames)));
			baos.write(intBytes(swapInt(dwInitialFrames)));
			baos.write(intBytes(swapInt(dwStreams)));
			baos.write(intBytes(swapInt(dwSuggestedBufferSize)));
			baos.write(intBytes(swapInt(dwWidth)));
			baos.write(intBytes(swapInt(dwHeight)));
			baos.write(intBytes(swapInt(dwReserved[0])));
			baos.write(intBytes(swapInt(dwReserved[1])));
			baos.write(intBytes(swapInt(dwReserved[2])));
			baos.write(intBytes(swapInt(dwReserved[3])));
			baos.close();

			return baos.toByteArray();
		}
	}

	private class AVIStreamList {
		public byte[] fcc = new byte[] { 'L', 'I', 'S', 'T' };
		public int size = 124;
		public byte[] fcc2 = new byte[] { 's', 't', 'r', 'l' };

		public AVIStreamList() {

		}

		public byte[] toBytes() throws Exception {
			ByteArrayOutputStream baos = new ByteArrayOutputStream();
			baos.write(fcc);
			baos.write(intBytes(swapInt(size)));
			baos.write(fcc2);
			baos.close();

			return baos.toByteArray();
		}
	}

	private class AVIStreamHeader {
		//
		// FOURCC fcc; DWORD cb; FOURCC fccType; FOURCC fccHandler; DWORD dwFlags; WORD wPriority; WORD wLanguage; DWORD
		// dwInitialFrames; DWORD dwScale; DWORD dwRate; DWORD dwStart; DWORD dwLength; DWORD dwSuggestedBufferSize;
		// DWORD dwQuality; DWORD dwSampleSize; struct { short int left; short int top; short int right; short int
		// bottom; } rcFrame;
		//

		public byte[] fcc = new byte[] { 's', 't', 'r', 'h' };
		public int cb = 64;
		public byte[] fccType = new byte[] { 'v', 'i', 'd', 's' };
		public byte[] fccHandler = new byte[] { 'M', 'J', 'P', 'G' };
		public int dwFlags = 0;
		public short wPriority = 0;
		public short wLanguage = 0;
		public int dwInitialFrames = 0;
		public int dwScale = 0; // microseconds
								// per
								// frame
		public int dwRate = 1000000; // dwRate
										// /
										// dwScale
										// =
										// frame
										// rate
		public int dwStart = 0;
		public int dwLength = 0; // num
									// frames
		public int dwSuggestedBufferSize = 0;
		public int dwQuality = -1;
		public int dwSampleSize = 0;
		public int left = 0;
		public int top = 0;
		public int right = 0;
		public int bottom = 0;

		public AVIStreamHeader() {
			dwScale = (int) ((1.0 / framerate) * 1000000.0);
			dwLength = numFrames;
		}

		public byte[] toBytes() throws Exception {
			ByteArrayOutputStream baos = new ByteArrayOutputStream();
			baos.write(fcc);
			baos.write(intBytes(swapInt(cb)));
			baos.write(fccType);
			baos.write(fccHandler);
			baos.write(intBytes(swapInt(dwFlags)));
			baos.write(shortBytes(swapShort(wPriority)));
			baos.write(shortBytes(swapShort(wLanguage)));
			baos.write(intBytes(swapInt(dwInitialFrames)));
			baos.write(intBytes(swapInt(dwScale)));
			baos.write(intBytes(swapInt(dwRate)));
			baos.write(intBytes(swapInt(dwStart)));
			baos.write(intBytes(swapInt(dwLength)));
			baos.write(intBytes(swapInt(dwSuggestedBufferSize)));
			baos.write(intBytes(swapInt(dwQuality)));
			baos.write(intBytes(swapInt(dwSampleSize)));
			baos.write(intBytes(swapInt(left)));
			baos.write(intBytes(swapInt(top)));
			baos.write(intBytes(swapInt(right)));
			baos.write(intBytes(swapInt(bottom)));
			baos.close();

			return baos.toByteArray();
		}
	}

	private class AVIStreamFormat {
		//
		// FOURCC fcc; DWORD cb; DWORD biSize; LONG biWidth; LONG biHeight; WORD biPlanes; WORD biBitCount; DWORD
		// biCompression; DWORD biSizeImage; LONG biXPelsPerMeter; LONG biYPelsPerMeter; DWORD biClrUsed; DWORD
		// biClrImportant;
		//

		public byte[] fcc = new byte[] { 's', 't', 'r', 'f' };
		public int cb = 40;
		public int biSize = 40; // same
								// as
								// cb
		public int biWidth = 0;
		public int biHeight = 0;
		public short biPlanes = 1;
		public short biBitCount = 24;
		public byte[] biCompression = new byte[] { 'M', 'J', 'P', 'G' };
		public int biSizeImage = 0; // width
									// x
									// height
									// in
									// pixels
		public int biXPelsPerMeter = 0;
		public int biYPelsPerMeter = 0;
		public int biClrUsed = 0;
		public int biClrImportant = 0;

		public AVIStreamFormat() {
			biWidth = width;
			biHeight = height;
			biSizeImage = width * height;
		}

		public byte[] toBytes() throws Exception {
			ByteArrayOutputStream baos = new ByteArrayOutputStream();
			baos.write(fcc);
			baos.write(intBytes(swapInt(cb)));
			baos.write(intBytes(swapInt(biSize)));
			baos.write(intBytes(swapInt(biWidth)));
			baos.write(intBytes(swapInt(biHeight)));
			baos.write(shortBytes(swapShort(biPlanes)));
			baos.write(shortBytes(swapShort(biBitCount)));
			baos.write(biCompression);
			baos.write(intBytes(swapInt(biSizeImage)));
			baos.write(intBytes(swapInt(biXPelsPerMeter)));
			baos.write(intBytes(swapInt(biYPelsPerMeter)));
			baos.write(intBytes(swapInt(biClrUsed)));
			baos.write(intBytes(swapInt(biClrImportant)));
			baos.close();

			return baos.toByteArray();
		}
	}

	private class AVIMovieList {
		public byte[] fcc = new byte[] { 'L', 'I', 'S', 'T' };
		public int listSize = 0;
		public byte[] fcc2 = new byte[] { 'm', 'o', 'v', 'i' };

		// 00db size jpg image data ...

		public AVIMovieList() {

		}

		public byte[] toBytes() throws Exception {
			ByteArrayOutputStream baos = new ByteArrayOutputStream();
			baos.write(fcc);
			baos.write(intBytes(swapInt(listSize)));
			baos.write(fcc2);
			baos.close();

			return baos.toByteArray();
		}
	}

	private class AVIIndexList {
		public byte[] fcc = new byte[] { 'i', 'd', 'x', '1' };
		public int cb = 0;
		public List ind = new ArrayList();

		public AVIIndexList() {

		}

		@SuppressWarnings("unused")
		public void addAVIIndex(AVIIndex ai) {
			ind.add(ai);
		}

		public void addAVIIndex(int dwOffset, int dwSize) {
			ind.add(new AVIIndex(dwOffset, dwSize));
		}

		public byte[] toBytes() throws Exception {
			cb = 16 * ind.size();

			ByteArrayOutputStream baos = new ByteArrayOutputStream();
			baos.write(fcc);
			baos.write(intBytes(swapInt(cb)));
			for (int i = 0; i < ind.size(); i++) {
				AVIIndex in = (AVIIndex) ind.get(i);
				baos.write(in.toBytes());
			}

			baos.close();

			return baos.toByteArray();
		}
	}

	private class AVIIndex {
		public byte[] fcc = new byte[] { '0', '0', 'd', 'b' };
		public int dwFlags = 16;
		public int dwOffset = 0;
		public int dwSize = 0;

		public AVIIndex(int dwOffset, int dwSize) {
			this.dwOffset = dwOffset;
			this.dwSize = dwSize;
		}

		public byte[] toBytes() throws Exception {
			ByteArrayOutputStream baos = new ByteArrayOutputStream();
			baos.write(fcc);
			baos.write(intBytes(swapInt(dwFlags)));
			baos.write(intBytes(swapInt(dwOffset)));
			baos.write(intBytes(swapInt(dwSize)));
			baos.close();

			return baos.toByteArray();
		}
	}

	private class AVIJunk {
		public byte[] fcc = new byte[] { 'J', 'U', 'N', 'K' };
		public int size = 1808;
		public byte[] data = new byte[size];

		public AVIJunk() {
			Arrays.fill(data, (byte) 0);
		}

		public byte[] toBytes() throws Exception {
			ByteArrayOutputStream baos = new ByteArrayOutputStream();
			baos.write(fcc);
			baos.write(intBytes(swapInt(size)));
			baos.write(data);
			baos.close();

			return baos.toByteArray();
		}
	}

	private byte[] writeImageToBytes(Bitmap image) throws Exception {
		ByteArrayOutputStream stream = new ByteArrayOutputStream();
		image.compress(Bitmap.CompressFormat.JPEG, 100, stream);
		stream.close();
		return stream.toByteArray();
	}

}



activity_main.xml
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:ads="http://schemas.android.com/apk/lib/com.google.ads"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/appBackgroundColor" >

    <TextView
        android:id="@+id/centerEmptyText"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerHorizontal="true"
        android:layout_centerVertical="true" />

    <SurfaceView
        android:id="@+id/mSurfaceView"
        android:layout_width="fill_parent"
        android:layout_height="fill_parent"
        android:layout_toRightOf="@+id/centerEmptyText" >
    </SurfaceView>

    <LinearLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="fill_parent"
        android:layout_height="fill_parent"
        android:layout_marginRight="@dimen/baseui_horizontal_margin"
        android:layout_toLeftOf="@+id/centerEmptyText"
        android:orientation="vertical" >

        <TableLayout
            xmlns:android="http://schemas.android.com/apk/res/android"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content" >

            <TableRow
                android:id="@+id/periodRow"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content" >

                <TextView
                    android:id="@+id/periodText"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_marginLeft="@dimen/baseui_horizontal_margin"
                    android:focusable="true"
                    android:focusableInTouchMode="true"
                    android:text="@string/periodText" >

                    <requestFocus />
                </TextView>

                <EditText
                    android:id="@+id/periodEditText"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_marginLeft="@dimen/baseui_horizontal_margin"
                    android:ems="2"
                    android:inputType="numberDecimal"
                    android:singleLine="true" />

                <TextView
                    android:id="@+id/secondsText"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_marginLeft="@dimen/baseui_horizontal_margin"
                    android:text="@string/seconds" />
            </TableRow>

            <TableRow
                android:id="@+id/fpsRow"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content" >

                <TextView
                    android:id="@+id/framerateText"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_marginLeft="@dimen/baseui_horizontal_margin"
                    android:focusable="true"
                    android:focusableInTouchMode="true"
                    android:text="@string/framerateText" />

                <EditText
                    android:id="@+id/fpsEditText"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_marginLeft="@dimen/baseui_horizontal_margin"
                    android:ems="2"
                    android:inputType="numberDecimal"
                    android:singleLine="true" />

                <TextView
                    android:id="@+id/fpsText"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_marginLeft="@dimen/baseui_horizontal_margin"
                    android:text="@string/fps" />
            </TableRow>
        </TableLayout>

        <TableLayout
            xmlns:android="http://schemas.android.com/apk/res/android"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content" >

            <LinearLayout
                android:layout_width="fill_parent"
                android:layout_height="wrap_content"
                android:orientation="horizontal" >

                <Button
                    android:id="@+id/startButton"
                    style="?android:attr/buttonStyleSmall"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_gravity="center_vertical"
                    android:layout_marginLeft="@dimen/baseui_horizontal_margin"
                    android:layout_weight="1"
                    android:text="@string/startButtonText" />

                <Button
                    android:id="@+id/createButton"
                    style="?android:attr/buttonStyleSmall"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_gravity="center_vertical"
                    android:layout_marginLeft="@dimen/baseui_horizontal_margin"
                    android:layout_weight="1"
                    android:text="@string/createButtonText" />
            </LinearLayout>

            <TableRow
                android:id="@+id/totalsnapshotsRow"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content" >

                <TextView
                    android:id="@+id/totalsnapshotsText"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_marginLeft="@dimen/baseui_horizontal_margin"
                    android:text="@string/totalsnapshotsText" />
            </TableRow>
        </TableLayout>

        <TextView
            android:id="@+id/modeText"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginLeft="@dimen/baseui_horizontal_margin"
            android:text="@string/longestComment" />
    </LinearLayout>

</RelativeLayout>



AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.sample.timelapse"
    android:installLocation="preferExternal"
    android:versionCode="8"
    android:versionName="0.8" >

    <uses-sdk
        android:minSdkVersion="8"
        android:targetSdkVersion="15" />

    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.VIBRATE" />
    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.WAKE_LOCK" />

    <uses-feature android:name="android.hardware.camera" />
    <uses-feature
        android:name="android.hardware.camera.autofocus"
        android:required="false" />
    <uses-feature
        android:name="there.isnt.a.vibrate.feature"
        android:required="false" />

    <application
        android:icon="@drawable/ic_launcher"
        android:label="@string/app_name" >
        <activity
            android:name=".MainActivity"
            android:label="@string/title_activity_main"
            android:screenOrientation="landscape" >
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>



UPD: DevAndrew заметил, что для компиляции проекта необходима библиотека ij.jar (ImageJ, нужна для работы MJPEGGenerator). Библиотека добавлена к файлам проекта на sourceforge.
Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
+20
Comments 15
Comments Comments 15

Articles