Как мы трансформируем 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 було близько 20% онлайн-подій,...
Останній дайджест був досить давно, отже є достатньо новин та проектів, які ми можемо розглянути зараз, і ніхто не скаже —...
3 сентября стартует сразу два набора в группы выходного дня по направлению Front-End разработки для людей с разным начальным...
Об авторе: Денис Морозов, к.ф.-м.н., работает на должности доцента кафедры математики НаУКМА, также является консультантом...
Our technology-driven society has become more and more dependent on the Internet for its necessary information and entertainment. This is why today’s business environment has to include an Internet presence. This...
Яндекс.Метрика