Русский
Русский
English
Статистика
Реклама

Live streaming to java

Android Camera2 API от чайника, часть 6. Стрим видео сначала кодировали, теперь декодируем

20.06.2020 18:10:45 | Автор: admin

Итак, в предыдущем посте мы занимались кодированием живого видео формата H.264 на Android устройстве, которое затем отправляли для просмотра на персональный компьютер под виндой. Там наш видеопоток успешно раскодировывался и лицезрелся с помощью VLC плеера. А так же с помощью библиотеки VLCJ CAPRICA благополучно впихивался и в окошки JAVA приложения. Правда, каким именно образом он (VLC плеер) всё это проделывал, так и осталось загадкой. Но с другой стороны работает, да и ладно.

Подстольный настольный компьютер, ноутбук, лэптоп всё это прекрасно, но тем не менее, всё больше народа смотрит видео и управляет разными девайсами не из-за стола, а чаще валяясь на диване, со смартфоном в руках. И соответственно, к примеру, даже нашей роботележкой ныне удобней управлять именно оттуда. Поэтому настало время выяснить, как наш закодированный видео поток принять и отобразить на экране такого же Android устройства. Естественно, как и раньше мы проделаем всё через Camera2 API, концепцию Surface, да ещё и асинхронно!

Кому интересно вперёд.

Собственно, для чего мне это понадобилось, кроме как обычно стремления, по выражению ослика Иа, к обалдеванию новыми знаниями? Откровенно говоря, про роботележку я упомянул только для примера (я думаю за такое количество статей она уже всем успела изрядно надоесть). На самом деле, кодируя-декодируя мы соберём незамысловатый видеотелефон для домашней сети.

Но это будет:
в следующей статье. Поскольку надо ещё и аудио канал прикрутить.

А в этом посте мы будем работать только со стримом видео (без звука). Но тоже с благородной целью, будем помогать сознанию выходить из тела. Есть такие интересные эксперименты у нейробиологов, когда одному человеку транслируют прямо в глаз то, что видит другой и соответственно тому другому в обратном порядке.

Этот эксперимент можно повторить с помощью нашего приложения, пары VR-очков для смартфона и естественно с помощью двух смартфонов. Одни очки хорошие и дорогие, которыми вы обычно пользуетесь, а другие Да, даже за четыреста рублей сойдут.

image

Второй телефон можно отобрать у своей девицы и использовать её (девицу) в качестве подопытного кролика второго участника эксперимента.

Если по существу, то изображение с камеры первого Android устройства передается на экран второго и раздваивается там (VR очки же). И наоборот, со второго девайса видео поток подается на первый в таком же порядке. То есть, вы видите то же, что должен видеть ваш партнер, а он (она), видит то, что должны видеть вы. Поскольку мы, в буквальном смысле, есть там, где есть наши глаза, ощущения будут у вас непередаваемые, особенно, если вы будете стараться двигаться синхронно и смотреть на свое (её) тело. Для неврологических экспериментов просто поле непаханное, ну или для камасутры всякой.

А ещё можно подавать для каждого глаза разные изображения, чтобы посмотреть, что получится и как переклинит ваши мозговые полушария.

Спойлер
Ничего не переклинит. Просто вы будете раз в несколько секунд переключаться между этими изображениями (видеть то одно, то другое). Но вот, если картинки будут отличаться не сильно (только некоторыми деталями), то тогда ощущения нереальности происходящего гарантированы.

Вы, конечно, можете ничем таким не заниматься, а просто сделать видеочатик на своем телефоне и звонить друг другу бесплатно в пределах своей домашней сетки (как мы в следующей статье). Но, в любом случае, для всех этих штук нам необходимо разобраться в работе видео декодера на андроиде.

А находится он, как известно, в классе:

MEDIA CODEC


В пред пред предыдущем посте с его помощью мы кодировали в формате H.264 видео поток полученный с камеры и отправляли его по UDP каналу на нужный адрес. Поэтому, чтобы начать работу, нам весьма пригодится код из того самого поста. Чтобы не парить голову с отладкой программы на двух телефонах сразу, для начала мы сделаем всё на одном. Просто для понимания принципа работы. В одном окошке мы будем снимать видео, кодировать и отправлять его по домашней сетке самим себе во второе окошко. Всё по-честному, никаких localhost и адресов 127.0.0.1. Только настоящий сетевой адрес, только хардкор!

В сущности, в дополнение к уже имеющейся программе нам надо добавить:

  1. Изменение UI, то есть добавление второго окна и ассоциированного с ним Surface
  2. Блок кода для получения видео потока по UDP каналу
  3. Процедуру самого декодирования.

Всё это делается легко и просто, но об несколько подводных камней удариться всё равно придется.

Итак, сначала модифицируем макет добавляем второе окно.

activity_main.xml
<?xml version="1.0" encoding="utf-8"?><androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    xmlns:app="http://personeltest.ru/away/schemas.android.com/apk/res-auto"    xmlns:tools="http://personeltest.ru/away/schemas.android.com/tools"    android:layout_width="match_parent"    android:layout_height="match_parent"    tools:context=".MainActivity">    <TextureView        android:id="@+id/textureView"        android:layout_width="320dp"        android:layout_height="240dp"        android:layout_marginTop="88dp"        app:layout_constraintEnd_toEndOf="parent"        app:layout_constraintHorizontal_bias="0.494"        app:layout_constraintStart_toStartOf="parent"        app:layout_constraintTop_toTopOf="parent" />    <TextureView        android:id="@+id/textureView3"        android:layout_width="320dp"        android:layout_height="240dp"        android:layout_marginTop="24dp"        app:layout_constraintEnd_toEndOf="parent"        app:layout_constraintHorizontal_bias="0.494"        app:layout_constraintStart_toStartOf="parent"        app:layout_constraintTop_toBottomOf="@+id/textureView" />    <LinearLayout        android:layout_width="165dp"        android:layout_height="40dp"        app:layout_constraintBottom_toBottomOf="parent"        app:layout_constraintEnd_toEndOf="parent"        app:layout_constraintStart_toStartOf="parent"        app:layout_constraintTop_toBottomOf="@+id/textureView3"        app:layout_constraintVertical_bias="0.838">        <Button            android:id="@+id/button1"            android:layout_width="wrap_content"            android:layout_height="36dp"            android:text="вкл" />        <Button            android:id="@+id/button3"            android:layout_width="wrap_content"            android:layout_height="37dp"            android:text="выкл" />    </LinearLayout></androidx.constraintlayout.widget.ConstraintLayout>


Эта часть вообще объяснений не требует. Идем дальше.

Пишем код для получения дэйтаграмм


Тут есть пара тонкостей. Когда мы дэйтаграммы отправляли, то действовали совсем незамысловато. Мы дожидались, когда сработает коллбэк буфера выходных данных onOutputBufferAvailable, а затем легко и просто пихали полученный от него байтовый массив в UDP пакет. Дальше он уже без всякой помощи с нашей стороны уезжал по указанному адресу. Единственное, мы его ещё рубили на килобайтовые блоки (чтобы гарантированно влез в размер MTU), но сейчас это совершенно излишне (и даже, как мы увидим потом, вредно).

В случае же приёма, надо во-первых, самим для этого организовать отдельный поток ожидания поскольку мы не в курсе, когда придёт следующая дэйтаграмма. Во-вторых, надо знать, скурил ли декодер уже пришедший пакет, чтобы в следующий раз предоставить ему новый, а не бывший в употреблении. В-третьих мы, (вроде как) в Java не знаем размер прибывшего пакета. А чтобы положить его в байтовый массив, размер его знать как-бы хочется.

На просторах stackoverflow я нашел подобный вопрос, но там не было правильного ответа. Пришлось отвечать самому и я надеюсь, что задавший вопрос человек наконец-то выйдет из тупика, пусть и через два года.

В итоге, код оказался несложным:

        Udp_recipient()        {            start();            mNewFrame = false;        }        public void run()        {                         try {                    byte buffer[] = new byte[50000];                    DatagramPacket p = new DatagramPacket(buffer, buffer.length);                    udpSocketIn.receive(p);                    byte bBuffer[] = p.getData();                    outDataForEncoder = new byte[p.getLength()];                     synchronized (outDataForEncoder)                     {                         for (int i = 0; i < outDataForEncoder.length; i++)                         {                             outDataForEncoder[i] = bBuffer[i];                         }                     }                    mNewFrame = true;                } catch (Exception e) {                    Log.i(LOG_TAG, e + "  ");                }             }        }

Итак, поток в наличии. После получения дэйтаграммы устанавливаем флаг NewFrame, чтобы декодер не путался и сбрасываем флаг перед прибытием нового UDP пакета. Здесь неявно предполагается, что декодер скуривает пакеты быстрее, чем они приходят. А это действительно, так и оказалось.

Переходим теперь к самому декодеру


Как и кодер он может работать в двух режимах синхронном и асинхронном. В первом случае вы учреждаете немало потоков, флагов и тщательно следите за тем, чтобы не впасть в состояние гонок (всё ж параллельное). Этот процесс сопровождается кучей стереотипного кода, в просторечии Boiler Plate, и в общем многократно где описан.

Ну, а мы, поскольку кодировали видео асинхронно через коллбэки, то делать шаг назад к синхронности, вроде как, нам не к лицу. Но проблема в том, что примеров асинхронного кода, почему-то намного меньше и основная мысль там такая: я написал, а оно не работает, хелп!.

То ли считается, что там всё тривиально (а оно по сравнению с синхронным вариантом действительно так), то ли правда, оно у всех не работает. Разбираться опять придётся самим.

Итак, сначала учреждаем и инициализируем экземпляр декодера. Это вообще не сложно, поскольку он будет использовать формат кодера и нам только надо установить разрешение выходного окна в пихелях (это мое личное изобретение пихель, так сказать, родним русский и английский). Пока ограничимся скромным 640 на 480. Также надо будет присобачить декодер к Surface этого самого окна, чтобы было куда показывать.

       try {            decoder = MediaCodec.createDecoderByType("video/avc");// H264 декодек        } catch (Exception e) {            Log.i(LOG_TAG, "а нету декодека");        }        int width = 480; // ширина видео        int height = 640; // высота видео        MediaFormat format = MediaFormat.createVideoFormat("video/avc", width, height);        format.setInteger(MediaFormat.KEY_ROTATION,90);        SurfaceTexture texture = mImageViewDown.getSurfaceTexture();        texture.setDefaultBufferSize(480, 640);        mDecoderSurface = new Surface(texture);        decoder.configure(format, mDecoderSurface, null,0);        decoder.setOutputSurface(mDecoderSurface);        decoder.setCallback(new DecoderCallback());        decoder.start();        Log.i(LOG_TAG, "запустили декодер");

Далее обращаемся к коллбэкам. Нужных, как известно, два:

void onInputBufferAvailable(MediaCodec mc, int inputBufferId)void onOutputBufferAvailable(MediaCodec mc, int outputBufferId, )

То есть, при срабатывании первого коллбэка мы в него кладём данные, а при вызове второго вынимаем готовенькое и отправляем на Surface. Может показаться странным, что когда мы делали кодирование (то есть, наоборот из Surface в кодек), то почему-то InputBuffer мы не использовали, а сразу волшебным образом доставали байтовые данные из OutputBuffer. Это мне тоже казалось странным, пока я не прочитал:
When using an input Surface, there are no accessible input buffers, as buffers are automatically passed from the input surface to the codec. Calling dequeueInputBuffer will throw an IllegalStateException, and getInputBuffers() returns a bogus ByteBuffer[] array that MUST NOT be written into.
Короче, автоматически это делается. Ну, и сделали бы при декодировании также. Но нет, придётся самим.

Итак, в метод void (MediaCodec mc, int inputBufferId) я ничтоже сумняшеся прописал:

 private class DecoderCallback extends MediaCodec.Callback {        @Override        public void onInputBufferAvailable(MediaCodec codec, int index) {                  decoderInputBuffer = codec.getInputBuffer(index);                  decoderInputBuffer.clear();                             if(mNewFrame)                             {                               synchronized (outDataForEncoder)                               { b=outDataForEncoder;  }                             }                             decoderInputBuffer.put(b);                 codec.queueInputBuffer(index, 0, b.length,0, 0);              if(mNewFrame)              new Udp_recipient();        }

Ну, по аналогии, как мы делали в прошлый раз в кодере.

То есть, когда декодер вдруг ощущает, что ему срочно нужны данные, он вызывает этот коллбэк и кладёт наш прибывший udp-пакет (который уже доступен в виде байтового массива) в один из своих буферов под номером index. Там их вроде четыре. Естественно, ничего не заработало. Я ж забыл про onOutputBufferAvailable.

Туда тоже необходимо вставить две строчки:

    @Override        public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo info) {            {                  {                      decoderOutputBuffer = codec.getOutputBuffer(index);                      codec.releaseOutputBuffer(index, true);                  }            }        }

Причем значение True/False отвечает за то, будет ли содержимое буфера рендерится на Surface или выкинется на помойку.

Программа заработала, но как-то странно. Коллбэк onInputBufferAvailable переваривал с несколько десятков пакетов, а потом молча помирал, не выбрасывая ни одной ошибки. Но на экране смартфона в это время появлялась какая-то рябь, в которой при большом воображении угадывались детали того, что снимала камера.



А в логах обнаружились какие-то таинственные:

buffer descriptor with invalid usage bits 0x2000
A resource failed to call release.


Но большой ясности они не прибавляли. Некоторое понимание пришло из прошлых наблюдений. Дело в том, что я сначала не использовал флаг NewFrame (просто руки ещё не дошли) и декодер мог обратится к одному и тому же пришедшему пакету несколько раз (данные-то обрабатываются гораздо быстрее, чем приходят, как уже говорилось). На экране была, понятное дело, совершенная каша, но onInputBufferAvailable работал и работал без перебоев!

Оказалось, что имеет место удивительная вещь. Если коллбэк onInputBufferAvailable, сработал, то вынь да положь ему входные данные. Неважно какие, но положь. Иначе он дохнет. Поэтому пришлось предлагать ему байтовый массив нулевой длины в тех случаях, когда пакет актуальных данных уже был обработан, а новый ещё не пришёл. Как говорится, кому расскажешь не поверят.

Как только поправки были произведены, на Output Surface наконец-то появилось полученное через сеть видео.



Правда, как видно, на нём присутствуют некоторые недостатки. Посмотрев на них какое-то время, я стал в душе догадываться, что это все опять из-за onInputBufferAvailable. Ему снова что-то не нравилось. Оказалось, был не по вкусу байтовый массив. Я тогда про это не догадывался, мне лично не нравилась концепция флага NewFrame. Как-то она не сочеталась с реактивным программированием. Поэтому я решил полученные пакеты не просто класть в массив, а заворачивать этот массив в итоге в байтовый поток ByteArrayOutputStream. И пускай коллбэк, что ему надо, сам оттуда забирает, а конкретно каким образом, это его проблема.

Идея сработала блестяще и на экране я увидел это:

image

Согласитесь, прогресс существенный. Но опять чего-то не хватает. Или чего лишнее?

Посмотрев логи я понял что. Новые данные-то в поток кладутся нормально, но забираются каждый раз снова с самого начала. То есть, коллбэк читает при каждом вызове поток с с самого нулевого элемента, а не с начала нового пакета.

Решение было простым:

поток.reset();

И всё заработало как надо.



Теперь можно было насладиться законченным кодом, как обычно крайне минималистическим:

Листинг main_activity
package com.example.encoderdecoder;import androidx.annotation.RequiresApi;import androidx.appcompat.app.AppCompatActivity;import androidx.core.content.ContextCompat;import android.Manifest;import android.content.Context;import android.content.pm.ActivityInfo;import android.content.pm.PackageManager;import android.graphics.SurfaceTexture;import android.hardware.camera2.CameraAccessException;import android.hardware.camera2.CameraCaptureSession;import android.hardware.camera2.CameraDevice;import android.hardware.camera2.CameraManager;import android.hardware.camera2.CaptureRequest;import android.media.MediaCodec;import android.media.MediaCodecInfo;import android.media.MediaFormat;import android.os.Build;import android.os.Bundle;import android.os.Handler;import android.os.HandlerThread;import android.os.StrictMode;import android.util.Log;import android.view.Surface;import android.view.TextureView;import android.view.View;import android.widget.Button;import android.widget.Toast;import java.io.ByteArrayOutputStream;import java.io.IOException;import java.net.DatagramPacket;import java.net.DatagramSocket;import java.net.InetAddress;import java.net.SocketException;import java.nio.ByteBuffer;import java.util.Arrays;public class MainActivity extends AppCompatActivity {    public static final String LOG_TAG = "myLogs";    public static Surface surface = null;    CameraService[] myCameras = null;    private CameraManager mCameraManager = null;    private final int CAMERA1 = 0;    private Button mButtonOpenCamera1 = null;    private Button mButtonTStopStreamVideo = null;    public static TextureView mImageViewUp = null;    public static TextureView mImageViewDown = null;    private HandlerThread mBackgroundThread;    private Handler mBackgroundHandler = null;    private MediaCodec encoder = null; // кодер    private MediaCodec decoder = null;    byte [] b;    Surface mEncoderSurface; // Surface как вход данных для кодера    Surface mDecoderSurface; // Surface как прием данных от кодера    ByteBuffer outPutByteBuffer;    ByteBuffer decoderInputBuffer;    ByteBuffer decoderOutputBuffer;    byte outDataForEncoder [];    DatagramSocket udpSocket;    DatagramSocket udpSocketIn;    String ip_address = "your target address";// сюда пишете IP адрес телефона куда шлете //видео, но можно и себе    InetAddress address;    int port = 40002;    ByteArrayOutputStream  out;    private void startBackgroundThread() {        mBackgroundThread = new HandlerThread("CameraBackground");        mBackgroundThread.start();        mBackgroundHandler = new Handler(mBackgroundThread.getLooper());    }    private void stopBackgroundThread() {        mBackgroundThread.quitSafely();        try {            mBackgroundThread.join();            mBackgroundThread = null;            mBackgroundHandler = null;        } catch (InterruptedException e) {            e.printStackTrace();        }    }    @RequiresApi(api = Build.VERSION_CODES.M)    @Override    protected void onCreate(Bundle savedInstanceState) {        super.onCreate(savedInstanceState);        StrictMode.ThreadPolicy policy = new StrictMode.ThreadPolicy.Builder().permitAll().build();        StrictMode.setThreadPolicy(policy);        setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);        setContentView(R.layout.activity_main);        Log.d(LOG_TAG, "Запрашиваем разрешение");        if (checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED                ||                (ContextCompat.checkSelfPermission(MainActivity.this, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED)        ) {            requestPermissions(new String[]{Manifest.permission.CAMERA, Manifest.permission.WRITE_EXTERNAL_STORAGE}, 1);        }        mButtonOpenCamera1 = findViewById(R.id.button1);        mButtonTStopStreamVideo = findViewById(R.id.button3);        mImageViewUp = findViewById(R.id.textureView);        mImageViewDown = findViewById(R.id.textureView3);        mButtonOpenCamera1.setOnClickListener(new View.OnClickListener() {            @Override            public void onClick(View v) {                setUpMediaCodec();// инициализируем Медиа Кодек                if (myCameras[CAMERA1] != null) {// открываем камеру                    if (!myCameras[CAMERA1].isOpen()) myCameras[CAMERA1].openCamera();                }            }        });        mButtonTStopStreamVideo.setOnClickListener(new View.OnClickListener() {            @Override            public void onClick(View v) {                if (encoder != null) {                    Toast.makeText(MainActivity.this, " остановили стрим", Toast.LENGTH_SHORT).show();                    myCameras[CAMERA1].stopStreamingVideo();                }            }        });        try {            udpSocket = new DatagramSocket();            udpSocketIn = new DatagramSocket(port);// we changed it to DatagramChannell becouse UDP packets may be different in size            try {            }            catch (Exception e){                Log.i(LOG_TAG, "  создали udp канал");            }            new Udp_recipient();            Log.i(LOG_TAG, "  создали udp сокет");        } catch (                SocketException e) {            Log.i(LOG_TAG, " не создали udp сокет");        }        try {            address = InetAddress.getByName(ip_address);            Log.i(LOG_TAG, "  есть адрес");        } catch (Exception e) {        }        mCameraManager = (CameraManager) getSystemService(Context.CAMERA_SERVICE);        try {            // Получение списка камер с устройства            myCameras = new CameraService[mCameraManager.getCameraIdList().length];            for (String cameraID : mCameraManager.getCameraIdList()) {                Log.i(LOG_TAG, "cameraID: " + cameraID);                int id = Integer.parseInt(cameraID);                // создаем обработчик для камеры                myCameras[id] = new CameraService(mCameraManager, cameraID);            }        } catch (CameraAccessException e) {            Log.e(LOG_TAG, e.getMessage());            e.printStackTrace();        }    }    public class CameraService {        private String mCameraID;        private CameraDevice mCameraDevice = null;        private CameraCaptureSession mSession;        private CaptureRequest.Builder mPreviewBuilder;        public CameraService(CameraManager cameraManager, String cameraID) {            mCameraManager = cameraManager;            mCameraID = cameraID;        }        private CameraDevice.StateCallback mCameraCallback = new CameraDevice.StateCallback() {            @Override            public void onOpened(CameraDevice camera) {                mCameraDevice = camera;                Log.i(LOG_TAG, "Open camera  with id:" + mCameraDevice.getId());                startCameraPreviewSession();            }            @Override            public void onDisconnected(CameraDevice camera) {                mCameraDevice.close();                Log.i(LOG_TAG, "disconnect camera  with id:" + mCameraDevice.getId());                mCameraDevice = null;            }            @Override            public void onError(CameraDevice camera, int error) {                Log.i(LOG_TAG, "error! camera id:" + camera.getId() + " error:" + error);            }        };        private void startCameraPreviewSession() {            SurfaceTexture texture = mImageViewUp.getSurfaceTexture();            texture.setDefaultBufferSize(480, 640);            surface = new Surface(texture);            try {                mPreviewBuilder = mCameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW);                mPreviewBuilder.addTarget(surface);                mPreviewBuilder.addTarget(mEncoderSurface);                mCameraDevice.createCaptureSession(Arrays.asList(surface, mEncoderSurface),                        new CameraCaptureSession.StateCallback() {                            @Override                            public void onConfigured(CameraCaptureSession session) {                                mSession = session;                                try {                                    mSession.setRepeatingRequest(mPreviewBuilder.build(), null, mBackgroundHandler);                                } catch (CameraAccessException e) {                                    e.printStackTrace();                                }                            }                            @Override                            public void onConfigureFailed(CameraCaptureSession session) {                            }                        }, mBackgroundHandler);            } catch (CameraAccessException e) {                e.printStackTrace();            }        }        public boolean isOpen() {            if (mCameraDevice == null) {                return false;            } else {                return true;            }        }        public void openCamera() {            try {                if (checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {                    mCameraManager.openCamera(mCameraID, mCameraCallback, mBackgroundHandler);                }            } catch (CameraAccessException e) {                Log.i(LOG_TAG, e.getMessage());            }        }        public void closeCamera() {            if (mCameraDevice != null) {                mCameraDevice.close();                mCameraDevice = null;            }        }        public void stopStreamingVideo() {            if (mCameraDevice != null & encoder != null) {                try {                    mSession.stopRepeating();                    mSession.abortCaptures();                } catch (CameraAccessException e) {                    e.printStackTrace();                }                encoder.stop();                encoder.release();                mEncoderSurface.release();                decoder.stop();                decoder.release();                closeCamera();            }        }    }    private void setUpMediaCodec() {        try {            encoder = MediaCodec.createEncoderByType("video/avc"); // H264 кодек        } catch (Exception e) {            Log.i(LOG_TAG, "а нету кодека");        }        {            int width = 480; // ширина видео            int height = 640; // высота видео            int colorFormat = MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface; // формат ввода цвета            int videoBitrate = 2000000; // битрейт видео в bps (бит в секунду)            int videoFramePerSecond = 30; // FPS            int iframeInterval = 1; // I-Frame интервал в секундах            MediaFormat format = MediaFormat.createVideoFormat("video/avc", width, height);            format.setInteger(MediaFormat.KEY_COLOR_FORMAT, colorFormat);            format.setInteger(MediaFormat.KEY_BIT_RATE, videoBitrate);            format.setInteger(MediaFormat.KEY_FRAME_RATE, videoFramePerSecond);            format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, iframeInterval);            encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); // конфигурируем кодек как кодер            mEncoderSurface = encoder.createInputSurface(); // получаем Surface кодера        }        encoder.setCallback(new EncoderCallback());        encoder.start(); // запускаем кодер        Log.i(LOG_TAG, "запустили кодек");        try {            decoder = MediaCodec.createDecoderByType("video/avc");// H264 декодек        } catch (Exception e) {            Log.i(LOG_TAG, "а нету декодека");        }        int width = 480; // ширина видео        int height = 640; // высота видео        MediaFormat format = MediaFormat.createVideoFormat("video/avc", width, height);        format.setInteger(MediaFormat.KEY_ROTATION,90);        SurfaceTexture texture = mImageViewDown.getSurfaceTexture();        texture.setDefaultBufferSize(480, 640);        mDecoderSurface = new Surface(texture);        decoder.configure(format, mDecoderSurface, null,0);        decoder.setOutputSurface(mDecoderSurface);        decoder.setCallback(new DecoderCallback());        decoder.start();        Log.i(LOG_TAG, "запустили декодер");    }    //CALLBACK FOR DECODER    private class DecoderCallback extends MediaCodec.Callback {        @Override        public void onInputBufferAvailable(MediaCodec codec, int index) {                  decoderInputBuffer = codec.getInputBuffer(index);                  decoderInputBuffer.clear();                                    synchronized (out)                    {                            b =  out.toByteArray();                        out.reset();                          }                            decoderInputBuffer.put(b);                   codec.queueInputBuffer(index, 0, b.length,0, 0);        }        @Override        public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo info) {            {                  {                      decoderOutputBuffer = codec.getOutputBuffer(index);                      codec.releaseOutputBuffer(index, true);                  }            }        }        @Override        public void onError(MediaCodec codec, MediaCodec.CodecException e) {            Log.i(LOG_TAG, "Error: " + e);        }        @Override        public void onOutputFormatChanged(MediaCodec codec, MediaFormat format) {            Log.i(LOG_TAG, "decoder output format changed: " + format);        }    }    private class EncoderCallback extends MediaCodec.Callback {        @Override        public void onInputBufferAvailable(MediaCodec codec, int index) {            Log.i(LOG_TAG, " входные буфера готовы" );            //        }        @Override        public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo info) {            outPutByteBuffer = encoder.getOutputBuffer(index);            byte[] outDate = new byte[info.size];            outPutByteBuffer.get(outDate);            try {                DatagramPacket packet = new DatagramPacket(outDate, outDate.length, address, port);                udpSocket.send(packet);            } catch (IOException e) {                Log.i(LOG_TAG, " не отправился UDP пакет");            }            encoder.releaseOutputBuffer(index, false);        }        @Override        public void onError(MediaCodec codec, MediaCodec.CodecException e) {            Log.i(LOG_TAG, "Error: " + e);        }        @Override        public void onOutputFormatChanged(MediaCodec codec, MediaFormat format) {          //  Log.i(LOG_TAG, "encoder output format changed: " + format);        }    }    @Override    public void onPause() {        if (myCameras[CAMERA1].isOpen()) {            myCameras[CAMERA1].closeCamera();        }        stopBackgroundThread();        super.onPause();    }    @Override    public void onResume() {        super.onResume();        startBackgroundThread();    }    public class Udp_recipient extends Thread {        Udp_recipient()        {            out = new ByteArrayOutputStream(50000);            start();        }        public void run()        {            while (true)            {                try {                    byte buffer[] = new byte[50000];                    DatagramPacket p = new DatagramPacket(buffer, buffer.length);                    udpSocketIn.receive(p);                    byte bBuffer[] = p.getData();                    outDataForEncoder = new byte[p.getLength()];                     synchronized (outDataForEncoder)                     {                         for (int i = 0; i < outDataForEncoder.length; i++)                         {                             outDataForEncoder[i] = bBuffer[i];                         }                     }                     synchronized (out)                            {out.write(outDataForEncoder);}                } catch (Exception e) {                    Log.i(LOG_TAG, e + "  ");                }            }        }        }    }


Как видно из листинга, мы выкинули из кодера фрагмент, где готовый байтовый массив рубился на кусочки длиной не более килобайта из-за опасения, что они могут не влезть в MTU. Опасения, как уже говорилось, оказались напрасными и даже вредными. Дело в том, (как мне показалось ) что кодер лепит эти массивы уже как некие смысловые единицы и соответственно декодер таким же порядком кладёт их в свои входные буферы. А если у нас килобайт попадает в один буфер, а хвостик в другой? Теперь-то скрывать уже нечего, но на самом деле, видео у меня красиво не показывало даже тогда, когда я учредил ByteArrayOutputStream. Не показывало до тех пор, пока я не выкинул этот злосчастный фрагмент кода.

А вот, когда мы гнали видео поток на компьютер и декодировали его VLC плеером, такой проблемы не было. Скорее всего, не было потому, что плеер предварительно формировал кэш (он же плеер, он больше для просмотра кино, а не живого видео). А в кэше, все эти порубленные килобайты вновь красиво срастались.

Причем из-за отсутствия этого кэша, теперь у нас почти отсутствуют лаги, что легко можно узреть из этого видеофрагмента.



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

Для полноты счастья оставалось только провести эксперименты с доступными разрешением и битрейтом. Я сначала хотел было поставить вполне приличные 1280 х 960, но для этого в кодеке H.264 нужен битрейт 5000-6000 Кбит / с. А при установке такой скорости мой декодер, к сожалению, со своей работой уже не справлялся. Пришлось ограничиться разрешением 640 х 480 (тем, что и так уже было) и подходящим для этого битрейтом 2000 Кбит / с. Потому что при приближении к скорости света к 3 000 000 бит/c, декодер начинает иметь бесконечную массу, валять дурака и отваливаться.

Тем не менее, теперь на руках у нас есть рабочий образцовый код на основании которого можно лепить видео телефон (в следующей статье) и аппаратуру для нейрологических исследований (прямо сейчас).

Сама программа особо не поменяется. Макет вообще трогать не будем. Нам всего лишь надо:

  1. Отключить первую Surface от камеры
  2. Подключить к ней декодер. Грубо говоря, скопировать окно.

Я думал, что задача окажется тривиальной, но как всегда ошибся. Казалось бы, надо просто создать вторую ссылку на экземпляр декодера и оно само всё скопируется во второе окно.
Действительно, если у вас к примеру два текстовых окна и вы хотите вывести в них значение переменной Int, то вы же не вычисляете это Int два раза. Вы просто пишете:

TextView1.setText(Int +  );TextView2.setText(Int +  );

Но видимо, поток данных в Surface не так прост как просто вывод текста. Если пробовать решить вопрос таким путем, то у вас будет выводится окно, которое было инициализировано последним. А первая инициализация исчезнет.

Чего я только не делал, даже textureView окна в макете пытался обозвать одинаково, ничего не помогало. На stockoverflow было на этот счёт мало чего (действительно, кому нафиг надо дублировать видео поток в два одинаковых окна на смартфоне), а в единственном обсуждении, что я нашёл, высказывалось туманное предположение, что один экземпляр Mediac Codec может связываться только с одной Surface.

Выход оказался совсем не элегантным. Надо скопировать видео поток во второе окно? Создавай второй экземпляр декодера, не больше ни меньше. Хорошо, что ещё не заставили второй раз данные по UDP каналу пересылать.

Но, тем тем не менее, при всей своей дубовости метод работает.

Листинг кода main_activity
package com.example.twovideosurfaces;import androidx.annotation.RequiresApi;import androidx.appcompat.app.AppCompatActivity;import androidx.core.content.ContextCompat;import android.Manifest;import android.content.Context;import android.content.pm.ActivityInfo;import android.content.pm.PackageManager;import android.graphics.SurfaceTexture;import android.hardware.camera2.CameraAccessException;import android.hardware.camera2.CameraCaptureSession;import android.hardware.camera2.CameraDevice;import android.hardware.camera2.CameraManager;import android.hardware.camera2.CaptureRequest;import android.media.MediaCodec;import android.media.MediaCodecInfo;import android.media.MediaFormat;import android.os.Build;import android.os.Bundle;import android.os.Handler;import android.os.HandlerThread;import android.os.StrictMode;import android.util.Log;import android.view.Surface;import android.view.TextureView;import android.view.View;import android.widget.Button;import android.widget.Toast;import java.io.ByteArrayOutputStream;import java.io.IOException;import java.net.DatagramPacket;import java.net.DatagramSocket;import java.net.InetAddress;import java.net.SocketException;import java.nio.ByteBuffer;import java.util.Arrays;public class MainActivity extends AppCompatActivity  {    public static final String LOG_TAG = "myLogs";    CameraService[] myCameras = null;    private CameraManager mCameraManager = null;    private final int CAMERA1 = 0;    private Button mOn = null;    private Button mOff = null;    public static TextureView mImageViewUp = null;    public static TextureView mImageViewDown = null;    private HandlerThread mBackgroundThread;    private Handler mBackgroundHandler = null;    private MediaCodec encoder = null; // кодер    private MediaCodec decoder = null;    private MediaCodec decoder2 = null;    byte [] b;    byte [] b2;    Surface mEncoderSurface; // Surface как вход данных для кодера    Surface mDecoderSurface; // Surface как прием данных от кодера    Surface mDecoderSurface2; // Surface как прием данных от кодера    ByteBuffer outPutByteBuffer;    ByteBuffer decoderInputBuffer;    ByteBuffer decoderOutputBuffer;    ByteBuffer decoderInputBuffer2;    ByteBuffer decoderOutputBuffer2;    byte outDataForEncoder [];    static  boolean  mNewFrame=false;    DatagramSocket udpSocket;    DatagramSocket udpSocketIn;    String ip_address = "192.168.50.131";    InetAddress address;    int port = 40002;    ByteArrayOutputStream out =new ByteArrayOutputStream(50000);    ByteArrayOutputStream out2 = new ByteArrayOutputStream(50000);    private void startBackgroundThread() {        mBackgroundThread = new HandlerThread("CameraBackground");        mBackgroundThread.start();        mBackgroundHandler = new Handler(mBackgroundThread.getLooper());    }    private void stopBackgroundThread() {        mBackgroundThread.quitSafely();        try {            mBackgroundThread.join();            mBackgroundThread = null;            mBackgroundHandler = null;        } catch (InterruptedException e) {            e.printStackTrace();        }    }    @RequiresApi(api = Build.VERSION_CODES.M)    @Override    protected void onCreate(Bundle savedInstanceState) {        super.onCreate(savedInstanceState);        StrictMode.ThreadPolicy policy = new StrictMode.ThreadPolicy.Builder().permitAll().build();        StrictMode.setThreadPolicy(policy);        setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);        setContentView(R.layout.activity_main);        Log.d(LOG_TAG, "Запрашиваем разрешение");        if (checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED                ||                (ContextCompat.checkSelfPermission(MainActivity.this, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED)        ) {            requestPermissions(new String[]{Manifest.permission.CAMERA, Manifest.permission.WRITE_EXTERNAL_STORAGE}, 1);        }        mOn = findViewById(R.id.button1);        mOff = findViewById(R.id.button3);        mImageViewUp = findViewById(R.id.textureView);        mImageViewDown = findViewById(R.id.textureView3);        mOn.setOnClickListener(new View.OnClickListener() {            @Override            public void onClick(View v) {                setUpMediaCodec();// инициализируем Медиа Кодек                if (myCameras[CAMERA1] != null) {// открываем камеру                    if (!myCameras[CAMERA1].isOpen()) myCameras[CAMERA1].openCamera();                }            }        });        mOff.setOnClickListener(new View.OnClickListener() {            @Override            public void onClick(View v) {                if (encoder != null) {                    Toast.makeText(MainActivity.this, " остановили стрим", Toast.LENGTH_SHORT).show();                    myCameras[CAMERA1].stopStreamingVideo();                }            }        });        try {            udpSocket = new DatagramSocket();            udpSocketIn = new DatagramSocket(port);// we changed it to DatagramChannell becouse UDP packets may be different in size            try {            }            catch (Exception e){                Log.i(LOG_TAG, "  создали udp канал");            }            new Udp_recipient();            Log.i(LOG_TAG, "  создали udp сокет");        } catch (                SocketException e) {            Log.i(LOG_TAG, " не создали udp сокет");        }        try {            address = InetAddress.getByName(ip_address);            Log.i(LOG_TAG, "  есть адрес");        } catch (Exception e) {        }        mCameraManager = (CameraManager) getSystemService(Context.CAMERA_SERVICE);        try {            // Получение списка камер с устройства            myCameras = new CameraService[mCameraManager.getCameraIdList().length];            for (String cameraID : mCameraManager.getCameraIdList()) {                Log.i(LOG_TAG, "cameraID: " + cameraID);                int id = Integer.parseInt(cameraID);                // создаем обработчик для камеры                myCameras[id] = new CameraService(mCameraManager, cameraID);            }        } catch (CameraAccessException e) {            Log.e(LOG_TAG, e.getMessage());            e.printStackTrace();        }    }    public class CameraService {        private String mCameraID;        private CameraDevice mCameraDevice = null;        private CameraCaptureSession mSession;        private CaptureRequest.Builder mPreviewBuilder;        public CameraService(CameraManager cameraManager, String cameraID) {            mCameraManager = cameraManager;            mCameraID = cameraID;        }        private CameraDevice.StateCallback mCameraCallback = new CameraDevice.StateCallback() {            @Override            public void onOpened(CameraDevice camera) {                mCameraDevice = camera;                Log.i(LOG_TAG, "Open camera  with id:" + mCameraDevice.getId());                startCameraPreviewSession();            }            @Override            public void onDisconnected(CameraDevice camera) {                mCameraDevice.close();                Log.i(LOG_TAG, "disconnect camera  with id:" + mCameraDevice.getId());                mCameraDevice = null;            }            @Override            public void onError(CameraDevice camera, int error) {                Log.i(LOG_TAG, "error! camera id:" + camera.getId() + " error:" + error);            }        };        private void startCameraPreviewSession() {            try {                mPreviewBuilder = mCameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_RECORD);                mPreviewBuilder.addTarget(mEncoderSurface);                mCameraDevice.createCaptureSession(Arrays.asList(mEncoderSurface),                        new CameraCaptureSession.StateCallback() {                            @Override                            public void onConfigured(CameraCaptureSession session) {                                mSession = session;                                try {                                    mSession.setRepeatingRequest(mPreviewBuilder.build(), null, mBackgroundHandler);                                } catch (CameraAccessException e) {                                    e.printStackTrace();                                }                            }                            @Override                            public void onConfigureFailed(CameraCaptureSession session) {                            }                        }, mBackgroundHandler);            } catch (CameraAccessException e) {                e.printStackTrace();            }        }        public boolean isOpen() {            if (mCameraDevice == null) {                return false;            } else {                return true;            }        }        public void openCamera() {            try {                if (checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {                    mCameraManager.openCamera(mCameraID, mCameraCallback, mBackgroundHandler);                }            } catch (CameraAccessException e) {                Log.i(LOG_TAG, e.getMessage());            }        }        public void closeCamera() {            if (mCameraDevice != null) {                mCameraDevice.close();                mCameraDevice = null;            }        }        public void stopStreamingVideo() {            if (mCameraDevice != null & encoder != null) {                try {                    mSession.stopRepeating();                    mSession.abortCaptures();                } catch (CameraAccessException e) {                    e.printStackTrace();                }                encoder.stop();                encoder.release();                mEncoderSurface.release();                decoder.stop();                decoder.release();                decoder2.stop();                decoder2.release();                closeCamera();            }        }    }    private void setUpMediaCodec() {        try {            encoder = MediaCodec.createEncoderByType("video/avc"); // H264 кодек        } catch (Exception e) {            Log.i(LOG_TAG, "а нету кодека");        }        {            int width = 640; // ширина видео            int height = 480; // высота видео            int colorFormat = MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface; // формат ввода цвета            int videoBitrate = 2000000; // битрейт видео в bps (бит в секунду)            int videoFramePerSecond = 30; // FPS            int iframeInterval = 1; // I-Frame интервал в секундах            MediaFormat format = MediaFormat.createVideoFormat("video/avc", width, height);            format.setInteger(MediaFormat.KEY_COLOR_FORMAT, colorFormat);            format.setInteger(MediaFormat.KEY_BIT_RATE, videoBitrate);            format.setInteger(MediaFormat.KEY_FRAME_RATE, videoFramePerSecond);            format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, iframeInterval);            encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); // конфигурируем кодек как кодер           mEncoderSurface = encoder.createInputSurface(); // получаем Surface кодера        }        encoder.setCallback(new EncoderCallback());        encoder.start(); // запускаем кодер        Log.i(LOG_TAG, "запустили кодек");        try {            decoder = MediaCodec.createDecoderByType("video/avc");// H264 декодек        } catch (Exception e) {            Log.i(LOG_TAG, "а нету декодека");        }        int width = 480; // ширина видео        int height = 640; // высота видео        MediaFormat format = MediaFormat.createVideoFormat("video/avc", width, height);        format.setInteger(MediaFormat.KEY_ROTATION,90);        SurfaceTexture texture= mImageViewUp.getSurfaceTexture();        mDecoderSurface = new Surface(texture);        decoder.configure(format, mDecoderSurface, null,0);        decoder.setOutputSurface(mDecoderSurface);        decoder.setCallback(new DecoderCallback());        decoder.start();        Log.i(LOG_TAG, "запустили декодер");        try {            decoder2 = MediaCodec.createDecoderByType("video/avc");// H264 декодек        } catch (Exception e) {            Log.i(LOG_TAG, "а нету декодека");        }        int width2 = 480; // ширина видео        int height2 = 640; // высота видео        MediaFormat format2 = MediaFormat.createVideoFormat("video/avc", width2, height2);        format2.setInteger(MediaFormat.KEY_ROTATION,90);        SurfaceTexture texture2= mImageViewDown.getSurfaceTexture();        mDecoderSurface2 = new Surface(texture2);        decoder2.configure(format2, mDecoderSurface2, null,0);        decoder2.setOutputSurface(mDecoderSurface2);        decoder2.setCallback(new DecoderCallback2());        decoder2.start();        Log.i(LOG_TAG, "запустили декодер");    }    private class DecoderCallback2 extends MediaCodec.Callback {        @Override        public void onInputBufferAvailable(MediaCodec codec, int index) {            decoderInputBuffer2 = codec.getInputBuffer(index);            decoderInputBuffer2.clear();            synchronized (out2)            {                b2 =  out2.toByteArray();                out2.reset();            }            decoderInputBuffer2.put(b2);            codec.queueInputBuffer(index, 0, b2.length,0, 0);            if (b2.length!=0)            {                //   Log.i(LOG_TAG, b.length + " декодер вход  "+index );            }        }        @Override        public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo info) {            {                {                    decoderOutputBuffer2 = codec.getOutputBuffer(index);                    codec.releaseOutputBuffer(index, true);                }            }        }        @Override        public void onError(MediaCodec codec, MediaCodec.CodecException e) {            Log.i(LOG_TAG, "Error: " + e);        }        @Override        public void onOutputFormatChanged(MediaCodec codec, MediaFormat format) {            Log.i(LOG_TAG, "decoder output format changed: " + format);        }    }    //CALLBACK FOR DECODER    private class DecoderCallback extends MediaCodec.Callback {        @Override        public void onInputBufferAvailable(MediaCodec codec, int index) {            decoderInputBuffer = codec.getInputBuffer(index);            decoderInputBuffer.clear();            synchronized (out)            {                b =  out.toByteArray();                out.reset();            }            decoderInputBuffer.put(b);            codec.queueInputBuffer(index, 0, b.length,0, 0);                 if (b.length!=0)            {               //  Log.i(LOG_TAG, b.length + " декодер вход  "+index );            }        }        @Override        public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo info) {            {                {                    decoderOutputBuffer = codec.getOutputBuffer(index);                    codec.releaseOutputBuffer(index, true);                }            }        }        @Override        public void onError(MediaCodec codec, MediaCodec.CodecException e) {            Log.i(LOG_TAG, "Error: " + e);        }        @Override        public void onOutputFormatChanged(MediaCodec codec, MediaFormat format) {            Log.i(LOG_TAG, "decoder output format changed: " + format);        }    }    private class EncoderCallback extends MediaCodec.Callback {        @Override        public void onInputBufferAvailable(MediaCodec codec, int index) {            Log.i(LOG_TAG, " входные буфера готовы" );        }        @Override        public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo info) {            outPutByteBuffer = encoder.getOutputBuffer(index);            byte[] outDate = new byte[info.size];            outPutByteBuffer.get(outDate);            try {                //  Log.i(LOG_TAG, " outDate.length : " + outDate.length);                DatagramPacket packet = new DatagramPacket(outDate, outDate.length, address, port);                udpSocket.send(packet);            } catch (IOException e) {                Log.i(LOG_TAG, " не отправился UDP пакет");            }            encoder.releaseOutputBuffer(index, false);        }        @Override        public void onError(MediaCodec codec, MediaCodec.CodecException e) {            Log.i(LOG_TAG, "Error: " + e);        }        @Override        public void onOutputFormatChanged(MediaCodec codec, MediaFormat format) {            //  Log.i(LOG_TAG, "encoder output format changed: " + format);        }    }    @Override    public void onPause() {        if (myCameras[CAMERA1].isOpen()) {            myCameras[CAMERA1].closeCamera();        }        stopBackgroundThread();        super.onPause();    }    @Override    public void onResume() {        super.onResume();        startBackgroundThread();    }    public class Udp_recipient extends Thread {        Udp_recipient() {            start();            //    Log.i(LOG_TAG, "запустили прием данных по udp");        }        public void run() {            while (true) {                try {                    byte buffer[] = new byte[50000];                    DatagramPacket p = new DatagramPacket(buffer, buffer.length);                    udpSocketIn.receive(p);                    byte bBuffer[] = p.getData();                    outDataForEncoder = new byte[p.getLength()];                    synchronized (outDataForEncoder) {                        for (int i = 0; i < outDataForEncoder.length; i++) {                            outDataForEncoder[i] = bBuffer[i];                        }                    }                    mNewFrame = true;                    synchronized (out)                    {out.write(outDataForEncoder);}                    synchronized (out2)                    {out2.write(outDataForEncoder);}                } catch (Exception e) {                    Log.i(LOG_TAG, e + "hggh ");                }            }        }    }}       



Теперь в таргет адресе первого смартофона:

String ip_address = " допустим 192.168.50.131";

указываем адрес его визави и наоборот. Запускаем приложения, надеваем VR-очки и вперёд, двигать науку нейробиологию!
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru