Как мы трансформируем 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 , из которого используем такие фичи, как:
- атрибуты [[fallthrough]] , [[nodiscard]] , [[maybe_unused]] :
- structural bindings ;
- многие новые контейнеры: variant , optional , string_view .
Планируем переходить на С++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 наших плагинов, будем добиваться его унификации. Задач в бэклоге у нас много, хватило бы рук.
Спасибо за внимание!
Статья написана в соавторстве с Александром Колотинцем , Владимиром Корнейчуком , Ириной Кибалко , Максимом Стороженко .
Опубліковано: 01/10/20 @ 10:00
Розділ Різне
Рекомендуємо:
100+ запитань з Python для Junior, Middle та Senior
Навіщо розробнику "відрощувати" м'які скіли, або Ще одна стаття про soft skills
Превращаем пожелания заказчика в Acceptance Criteria: 3 практики
Exploratory Testing: три истории применения тест-дизайна
5 советов начинающим программистам: как выбрать специализацию