Как мы трансформируем legacy-плагины для Photoshop и Lightroom

Добрый день. Меня зовут Константин Дудник, я Team Lead, Consultant проекта Nik Collection компании Infopulse. Nik Collection — это набор плагинов для Adobe Photoshop и Lightroom. Если вы занимаетесь профессиональной фотографией, то могли о нем слышать. Если нет, просто поверьте, что это весьма популярная вещь среди фотографов. Ну или не верьте нам, а погуглите Nik Collection — и вы найдете кучу примеров того, какую красоту люди способны создавать с помощью подходящих инструментов.

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

История проекта

В 1995 году была создана компания Nik Software, которая начала разрабатывать инструменты обработки изображений с прицелом на профессиональную аудиторию фотографов. На тех, кому «обычного фотошопа» не хватает. Так появились первые продукты, которые со временем были объединены в Nik Collection. Компанией заинтересовался Nikon, который в 2005-м приобрел ее часть (Википедия говорит, что 35%). Далее разработка продолжилась, выпускались определённые продукты специально для Nikon, но Nik Collection продолжала существовать и развиваться отдельно.

В 2012 году всю компанию Nik Software купил Google, после чего использовал часть ее наработок в других своих продуктах. Что же касается Nik Collection, то Google не увидел для себя возможности зарабатывать на ней деньги и просто сделал бесплатной. Да, был период, когда такой большой профессиональный продукт можно было бесплатно скачать с google.com. Из плохих новостей — Google прекратил активную разработку Nik Collection, чем вызвал волну недовольства на фотофорумах.

Потом Google еще немного поразмышлял, что делать с Nik Collection и, так и не придумав ей место в своей экосистеме, в 2017 году продал продукт компании DxO, которая и продолжила разработку, уже продавая Nik Collection за деньги. DxO была уже хорошо известна на фоторынке благодаря своим обзорам камеры и линз DXOMARK, продукту для обработки фотографий PhotoLab. Так что Nik Collection попал в хорошие руки. Через некоторое время DxO наняла подрядчиком нас и работа продолжилась уже вместе.

Пара примеров того, что умеет Nik Collection

Nik Collection — это набор из 8 отдельных плагинов. Они могут интегрироваться в Adobe Photoshop, Lightroom, Affinity Photo или даже использоваться как standalone-приложения. Работают под Windows и Mac.

Плагин Perspective Efex умеет работать с оптическими искажениями изображения и перспективой. Например, может из вот такой картинки:

Сделать такую:

Здесь и далее использованы иллюстрации с официального сайта продукта

Или добавить к достаточно тривиальной фотографии:

Такой эффект миниатюры:

Плагин Dfine позволяет бороться с шумами:

Как вы понимаете, это совершенно не «Reduce Noise...» из Photoshop.

Благодаря Silver Efex Pro можно сделать из цветной картинки черно-белую. Но погодите скептически поднимать бровь! Не просто сделать из цветной картинки черно-белую, а как бы «переснять» ее в черно-белом режиме, причем даже с указанием нужной вам фотопленки:

Можно подобрать зернистость или контрастность. А еще разузнать, на какой пленке была сделана классическая черно-белая фотография, и воспроизвести этот эффект на своем фото в один клик.

Тут же скажем о похожем плагине Analog Efex Pro — для воспроизведения эффекта съемки на классическую аналоговую камеру, да еще и с возможностями ее настройки и эмуляции определенных типов объективов:

Хорошее видео с примером использования этого плагина: Analog Efex Pro 2: Exploring Creativity: The Super Cell.

Еще есть такие плагины:

  • HDR Efex Pro — для объединения нескольких изображений с разными экспозициями в одно и постэффектов для создания HDR.
  • Color Efex Pro — для быстрого применения фильтров типа «дым», «туман», «сияние» (55 штук).
  • Viveza — цветокоррекция с упором на точечное применение к определенным областям/цветам.
  • Sharpener Pro — работа с чёткостью изображения. Возможность, например, поработать над реалистичностью текстуры кожи на портрете.

Nik Collection 3

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

Еще была версия продукта, собранная из исходников от Google уже нашим заказчиком (DxO), с гарантией совместимости с последним Photoshop/Lightroom и актуальными ОС. Из нововведений там были только некоторое количество преднастроенных фильтров («рецептов»), что, конечно, хорошо, но мало. Нужно было быстро показать профессиональному фотосообществу, что продукт жив и развивается. Для этого выбрали несколько направлений.

Визуальная часть

То, что «встречают по одежке» — вообще не секрет, а уж в сфере нашего продукта, где каждый первый — художник или фотограф, выглядеть приятно особо важно. И вот покажем панель запуска плагинов в той версии Nik Collection, которая была получена от Google:

«Невероятно красиво», не правда ли?

Мы полностью переделали панель (об использованных технологиях будет ниже), и теперь она выглядит так:

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

Новый функционал — режим неразрушающего редактирования

При использовании плагинов Nik Collection (да и любых других) совместно с Adobe Lightroom всегда существовала проблема «разрывности» или «деструктивности» редактирования. Например, есть изображение в Lightroom, мы применили к нему плагин, и теперь у нас обработанное плагином изображение. И между этими двумя «контрольными точками» нет никаких промежуточных.

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

В Nik Collection 3 мы развязали пользователю руки. Теперь можно из Lightroom передать картинку в плагин, применить фильтры и сохранить результат в специальный формат — многостраничный TIFF:

При этом в него будут сохранены первоначальное изображение (в одну страницу TIFF-контейнера), конечное изображение (в другую страницу TIFF-контейнера) и все настройки всех использованных фильтров (в метаданные TIFF):

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

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

Неразрушающее редактирование позволяет делать кучу разных интересных вещей: распределять работу во времени и между людьми, хранить историю изменений, откатываться назад. В общем, если раньше нужно было «сделать всё сразу или даже не начинать», то теперь, наконец, можно нормально работать.

Техническая часть

Я хотел бы сразу извиниться перед теми, кто пришел сюда почитать о крутой «математике» обработки изображений. Она, конечно, существует (ради нее Nik Collection и покупают), но является ценной интеллектуальной собственностью, раскрывать которую нам нельзя. Но зато расскажем о всяких технологиях, применяемых в работе.

О UI-фреймворках

Продукт достался нам от Google в весьма странном с точки зрения UI состоянии. Часть плагинов была написана c использованием библиотеки wxWidgets, еще часть — на самописном UI-фреймворке, который разрабатывала компания Nik Software еще лет 20 назад. Как вы понимаете, внешний вид плагинов из-за этого несколько отличался, что вызывало вопросы у пользователей.

Кроме того, и самописный UI-фреймворк, и использованная версия wxWidgets были уже старыми, в них возникала куча проблем. Например, несовместимость с современными high-DPI дисплеями и мacOS Catalina. Какого-то простого способа решения этого не было, поскольку разработка самописного UI-фреймворка была давно заброшена, а wxWidgets в приложении был форкнут и существенно переработан, что сделало невозможным апгрейд на новую версию.

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

Заказчик сразу заявил о своем желании использовать именно бесплатную опенсорсную версию Qt. Лицензия фреймворка позволяет его бесплатное использование даже в закрытых коммерческих продуктах, однако в этом случае доступна лишь динамическая линковка библиотек Qt (собрать весь Qt к себе в один бинарник нельзя). В этом, на первый взгляд, нет никаких проблем: мы делаем плагин (это библиотека, которая будет загружаться в процесс Photoshop), он подменяет library search path, загружает библиотеки Qt, и дальше все работает:

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

И вот когда в процесс Photoshop вдруг загружается несколько комплектов библиотек Qt (возможно, одной версии, а возможно, разных), это порой приводит к непредвиденным последствиям. Когда создается инстанс QtApplication, он запускает singleton-instance QCoreApplication и инициализирует несколько статических переменных, которые (если они разных версий) могут повлиять друг на друга и привести к неправильной работе одного из модулей.

Иногда Photoshop попросту падал. Другой раз ивенты начинали приходить не тем получателям. Можно было попробовать это исправить, но в общем виде задача «быть совместимыми со всеми остальными плагинами Photoshop, которые используют Qt» является плохо разрешимой ввиду неизвестного количества этих плагинов, используемых ими версий Qt и способов применения. Нельзя гарантировать совместимость с тем, чего не знаешь. И мы решили пойти другим путем.

Наш плагин был разделен на две части: в процесс Photoshop мы загружали относительно небольшой модуль, который вообще не использовал Qt и лишь интегрировался с Photoshop через его SDK. Сразу при загрузке он запускает отдельный процесс, в который уже загружается Qt без риска несовместимости с другими плагинами. Для взаимодействия модуля, загружаемого в Photoshop и standalone-процесса с UI, мы построили IPC на базе gRPC.

Дополнительно пришлось написать сериализацию всех внутренних параметров для всех плагинов. Теперь их можно завернуть в XML и передавать между процессами.

Отдельно стоит сказать, что в связи с последними веяниями в мире Qt прослеживаются некоторые риски относительно возможностей, ограничений и цены (явной или косвенной) фреймворка Qt в будущем. Поэтому мы сразу решили отделять и изолировать UI-слой от всего остального. Да, мы используем QML и все его возможности, но как только данные доходят до слоя бизнес-логики, никакого Qt там больше нет. Таким образом, если в будущем придется заменить Qt на что-то другое, это хоть и займет некоторое время, но будет возможно и не затронет никакие другие слои приложения, кроме UI.

О gRPC

gRPC расшифровывается как gRPC Remote Procedure Calls. Это отличный современный стандарт взаимодействия компонентов в том случае, когда нужно «быстро, сжато, между своими внутренними компонентами». Если надо публичный API — тут мир завоевал REST. Но вот если между своими процессами или в своем дата-центре — здесь вотчина gRPC. Разумеется, все надежно, протестировано, кроссплатформенно, открыто и бесплатно.

Итак, что же умеет gRPC? Опустим нюансы его установки и подключения к проекту. Вы описываете функционал своего межкомпонентного взаимодействия в терминах сервисов и действий, которые эти сервисы умеют делать. Давайте, например, объявим сервис PingPong:

service PingPongService {
  rpc SendPing (PingRequest) returns (PongReply) {}
}

message PingRequest {
  string text = 1;
}

message PongReply {
  string text = 1;
}

Далее с помощью функционала gRPC вы скармливаете этот proto-файл его компилятору и получаете на выходе набор классов для нужного вам языка программирования, которые можно подключить себе в проект. Для С++ это выглядит как пара заголовочного файла и файла с кодом. Включаем заголовочный файл в код компонента-сервера и реализуем методы:

class PingPongServiceImpl final : public PingPongService::Service 
{
  Status SendPing(ServerContext* context, const PingRequest* request, PongReply* reply) override 
  {
    ...
  }
};

Затем включаем его же в код компонента-клиента и реализуем отправку сообщения:

class PingPongClient 
{
 public:
  PingPongClient(std::shared_ptr<Channel> channel)
    : stub_(PingPongService::NewStub(channel)) {}

  std::string SendPing(const std::string& text) 
  {
    PingRequest request;
    request.set_text(text);

    PongReply reply;
    ClientContext context;
    Status status = stub_->SendPing(&context, request, &reply);
    ...
  }

 private:
  std::unique_ptr<PingPongService::Stub> stub_;
};

Особенная прелесть в том, что на основе того же описанного в начале proto-файла можно сгенерировать и Python-обертку, которую используют, например, в автотестах:

def runPingPongTest():
  with grpc.insecure_channel('localhost:12345') as channel:
    stub = pingpong_pb2_grpc.PingPongServiceStub(channel)
    reply = stub.SendPing(pingpong_pb2.PingRequest(text='ping'))
    print("Response: " + reply.text)

О кроссплатформенной разработке

Adobe Photoshop и Lightroom работают как под Windows, так и под Mac. Соответственно, нужно было гарантировать, что наш продукт работает и там, и там. C++ и Qt дают неплохой шанс этого добиться, но все же есть определенные нюансы, которые следует учитывать.

Например, мы столкнулись с проблемой совместимости с Adobe Lightroom для Mac. На этой платформе есть два способа установки Lightroom — инсталлятором от Adobe или через Apple App Store. При установке вторым способом есть требование работы в sandbox-режиме — приложение изолируется, не мешает другим (и ему никто не мешает). Нельзя запускать дочерние приложения, для доступа в интернет нужна соответствующая подпись. Это наложило некоторый отпечаток на то, как мы инициализируем IPC.

Если под Windows можем просто создать сокет и передавать данные по нему, то под Mac (в версии для App Store) пришлось использовать Unix domain socket, который, в отличие от обычного, работает только локально, зато без ограничений песочницы Apple. Вот примерный код запуска gRPC-сервера, пытающегося использовать обычные сокеты, но при неудаче переключающегося на Unix domain socket (код основан на «Hello world!» для gRPC от Google):

void RunServer() {
  std::string server_address("0.0.0.0:50051");
  GreeterServiceImpl service;

  grpc::EnableDefaultHealthCheckService(true);
  grpc::reflection::InitProtoReflectionServerBuilderPlugin();
  ServerBuilder builder;
  
  int selected_port = 0; // used to fetch bound port of the server, if successfully bound returns positive port number, 0 otherwise
  
  // Listen on the given address without any authentication mechanism.
  builder.AddListeningPort(server_address, grpc::InsecureServerCredentials(),&selected_port);
  
  // Register "service" as the instance through which we'll communicate with
  // clients. In this case it corresponds to an *synchronous* service.
  builder.RegisterService(&service);
  // Finally assemble the server.
  std::unique_ptr<Server> server(builder.BuildAndStart());
  
  // in case we were not successfully at registering on localhost tcp, try to fallback to UDS 
  if( 0 == selected_port )
  {
#if __APPLE__
    // fallback case for Apple Sandboxed applications that have no
    // permission to create network sockets, try to use unix domain sockets
    {
      std::cout << "Failed to create network GRPC, fallback to UDS" << std::endl;
      server.reset(nullptr);
      ServerBuilder sandboxBuilder;
      // create named socket at homeDir, in sandboxed app this will direct us to the container home dir
      const auto homePath = eos::getHomeDir(); // NSHomeDirectory() equivalent;
      const std::stringstream udsPath = "unix://" << homePath << "/FallbackSocket.uds";
      sandboxBuilder.AddListeningPort(udsPath.str(), grpc::InsecureServerCredentials(), &selected_port);
      sandboxBuilder.RegisterService(this);
      server = std::unique_ptr<Server>(sandboxBuilder.BuildAndStart());
      // creating named socket returns 1 as a result, if it remains 0 - we failed creating one
      if (m_port == 0)
      {
        std::cout << "Failed to create fallback GRPC Server" << std::endl;
        return;
      }
    }
#endif
  }
  std::cout << "Server listening on " << server_address << std::endl;

  // Wait for the server to shutdown. Note that some other thread must be
  // responsible for shutting down the server for this call to ever return.
  server->Wait();
}

О режиме неразрушающего редактирования

Еще раз кратко о том, что такое режим неразрушающего редактирования: это когда после обработки изображения остается оригинал, результат обработки и набор параметров, которые позволяют из первого получить второе. Это дает возможность вернуться к обработке и продолжить ее с того места, где в прошлый раз закончили, а не с самого начала.

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

Второй вариант — организовать какую-то базу данных и хранить настройки в ней. Но это также создавало проблемы: как передать файл и набор настроек на другой компьютер, как отслеживать перемещение оригинала изображения из одной папки в другую? Отказались и от этого.

Мы решили хранить все (оригинал, результат и настройки) в TIFF. Это контейнерный формат, специально предназначенный для хранения нескольких изображений и метаданных. Мы предоставили пользователю возможность решить, хочет ли он в будущем вернуться к редактированию данной фотографии. Если да — мы создаем соответствующий TIFF-файл и даем ему такую возможность. Ценой этому является увеличение (примерно вдвое) размера фотографии, но игра стоит свеч.

Об инструментах

У нас много разных средств разработки. Одних только IDE в регулярном использовании целых три: Qt Creator для работы с QML (есть плагины для поддержки QML в других IDE, но их качество хуже, и мы от них отказались), Visual Studio (сейчас 2019) для разработки под Windows и Xcode (сейчас 11) для Mac. Также используем практически весь набор инструментов Atlassian: Confluence для документации, Crucible для code review, Bitbucket для хранения Git-репозитория, Bamboo для CI/CD.

Мы рассматривали возможность применения пакетных менеджеров Conan и vcpkg. Второй лучше интегрируется с Microsoft Visual Studio, которую мы активно используем. Поэтому мы остановились на нем.

О C++

Мы пишем на С++. Конкретно сейчас — на стандарте С++17, из которого используем такие фичи, как:

Планируем переходить на С++20, как только его поддержка в компиляторах стабилизируется. В С++20 есть «большая четвёрка»: Concepts, Ranges, Modules, Coroutines. И из нее полезным будет вот буквально всё.

Для генерации проектов выбрали CMake. Инсталяхи под Win и Mac собираем самописными скриптами на Python. Используем Boost и еще горстку небольших библиотек.

Об использовании Boost

Boost — это отличный, проверенный и свободный набор библиотек. Лицензия позволяет использовать его где угодно.

Сигналы

Одна из мощных штук, которая есть как в Boost, так и в Qt — это система сигналов и слотов. Поскольку в некоторых модулях мы не можем использовать Qt, то применяем сигналы Boost.

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

Сигналы и слоты Boost позволяют реализовать слабую связь компонентов:

class Producer
{
	...
    boost::signals2::signal<void ()> ourCoolSignal;
};

class Listener
{
    ...
    void ourCoolEventHandler() 
    { 
    	std::cout << "Event!"; 
    }
};
...
Producer producer;
Listener listener;

producer.ourCoolSignal.connect(std::bind(&Listener::ourCoolEventHandler, &listener));

Program options

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

using namespace boost::program_options;
    ...
options_description options{"Options"};
options.add_options()
      ("param1", value<int>()->default_value(1), "param 1")
      ("param2", value<std::string>(), "param 2");

 variables_map variables;
 store(parse_command_line(argc, argv, options), variables);

 if (variables.count("param1"))
     std::cout << "param1: " << variables["param1"].as<int>();

Локали

Разрабатывая программы для глобального рынка, важно помнить, что в разных странах есть свои правила форматирования дат, чисел, преобразования регистра текста и так далее. В мире C++ с подобными задачами хорошо справляется библиотека Boost.Locale:

using namespace boost::locale;
using namespace std;
   
generator ourGenerator;
locale ourLocale = ourGenerator("");
locale::global(ourLocale); // use this locale globally
cout.imbue(ourLocale); // use this locale for cout
  
cout<<"Numbers in this locale "<<as::number << 58.17 <<endl;
cout<<"Currency in this locale "<<as::currency << 58.17<<endl;
cout<<"Date in this locale "<<as::date << std::time(0) <<endl;
cout<<"Time in this locale "<<as::time << std::time(0) <<endl;
cout<<"Upper case "<<to_upper("Upper Case!")<<endl;
cout<<"Lower case "<<to_lower("Lower Case!")<<endl;

Все это выглядит простенько или даже не нужно, пока ты не задумываешься, как хотят видеть дату американцы, сумму китайцы или слово «grüßen» в верхнем регистре немцы. А с библиотекой Boost.Locale разработчику и не надо об этом задумываться.

О тестировании

Мы рассматривали несколько способов тестирования UI нашего приложения. Во-первых, есть мощный инструмент Squish. Умеет все: тестирует UI любых приложений и под любые платформы, позволяет «записать и воспроизвести» тест, писать тесты на Python/Ruby/JS (и еще на куче языков), имеет свою IDE, хорошо интегрируется с CI/CD-системами. И самое важное — хорошо понимает Qt и QML.

Мы можем выполнить какое-то действие на UI, а затем дернуть property какого-то QML-объекта, посмотреть, что получилось. Или работать в стиле «черного ящика», писать тесты, опирающиеся на внутреннюю структуру UI. С Squish все хорошо, кроме одного — его цены. Вот, например, скромная конфигурация «5 пользователей, две платформы (Win, Mac)» обойдётся в €10 695 в год.

Мини-альтернативой для Squish может быть, например, Spix — умеет меньше, но базовые вещи (контроль приложения, понимание QML) здесь реализовать тоже можно. Ну и все преимущества open-source: если чего-то не хватает, берешь и дописываешь себе. Из минусов — поддержка QML несколько ограничена. Например, можно инспектировать только property типа string. Это иногда накладывает дополнительные ограничения: если есть целочисленная property, которую тестировщик хочет использовать в автотесте, приходится делать еще одну property типа string, «оборачивающую» первую.

Отдельно стоит упомянуть утилиту Gamma Ray — это инструмент интроспекции для Qt/QML. Он позволяет в реальном времени просматривать любые свойства любых объектов внутри QML-приложения. Это достигается подменой стандартных библиотек Qt на специально модифицированные версии, благодаря которым можно не только видеть извне приложения всю его UI-структуру, но и менять QML-свойства на лету.

Изображение взято с сайта Gamma Ray

Заказчик сразу попросил писать весь новый код с покрытием тестами. Под «весь» и вправду подразумевалось почти весь: «90-95% нового кода должно быть покрыто тестами». Это требование было значительно строже того, согласно которому писался код. Но никогда не поздно попробовать начать делать хорошо, и мы начали. Test-driven development, unit-тесты, функциональные тесты — теперь все это у нас есть. Используем googletest (для написания и запуска самих тестов) и gcovr для анализа тестового покрытия. Работает хорошо, рекомендуем.

О планах

Nik Collection 3 уже выпущен. Мы продолжаем делать следующую версию продукта. Пока не можем рассказывать, что туда войдет. Наверняка будем использовать все то, о чем писалось выше, и, возможно, что-то новое. Скорее всего, перейдем на С++20. Возможно, перейдем на Qt 6 (а возможно, останемся на пятом). Может быть, начнем использовать Vulkan и Metal (это зависит от состояния их поддержки в Qt). Улучшим UI наших плагинов, будем добиваться его унификации. Задач в бэклоге у нас много, хватило бы рук.

Спасибо за внимание!


Статья написана в соавторстве с Александром Колотинцем, Владимиром Корнейчуком, Ириной Кибалко, Максимом Стороженко.

Похожие статьи:
За традицією представляємо на DOU новий рейтинг шкіл за результатами ЗНО-2021 на основі відкритих даних Українського центру оцінювання...
На нашем YouTube канале появились новые видеоролики.Обзор Casio Edifice EBQ-500:Обзор детских GPS-часов Gator Caref Watch:Обзор Xiaomi Redmi Note 2:Обзор Microsoft Lumia...
В Україні підписали постанову, яка дозволяє використовувати лише електронне водійське посвідчення в Дії без пластикового...
Просматривая истории релокаций на ДОУ, я в основном находил статьи о сеньорах, у которых, как сказал мой хороший друг,...
Игровой планшет Acer Predator 8 стал доступен для предварительного заказа, правда, пока речь идет о выпуске на американском...
Яндекс.Метрика