Bad Apple на значках рабочего стола — работаем с WinAPI

от автора

Если что-то существует, на этом можно запустить Bad Apple
Правило 86

За последние лет 15, Bad Apple запустили множестве вещей — на самодельном RISC-V процессоре, на осциллографе, на яблоках. Попробуем запустить Bad Apple на значках рабочего стола с помощью вызовов API Windows и нескольких других.

Требования

Visual Studio 2022

Нужная нагрузка для Visual Studio:

Требуемые пакеты VS

Требуемые пакеты VS
.NET Core

Подойдет SDK версии 7+:

winget install Microsoft.DotNet.SDK.7

ffmpeg
winget install --id=Gyan.FFmpeg  -e

Опционально: yt-dlp

Нужно только для скачивания видео с YouTube, можно и по другому

winget install yt-dlp

  • Windows 10 с пустым рабочим столом

На чем будем отображать

Цвета значков

Рендерить видео будем в 2 цветах — черных и белых. Есть два варианта, как поменять значок файла:

  • Если файл — изображение, то поменять изображение, оно будет отображаться в миниатюрном варианте

  • Изменить расширение файла так, чтобы Windows выбрала нужный нам значок.

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

Придумаем 2 расширения файлов, для каждых из которых Windows будет отображать свой значок. Например: .baclrw — белый цвет, и .baclrb — черный. Теперь необходимо зарегистрировать в реестре иконки для этих расширений.

Создадим 2 иконки в формате .ico в папкеC:\badappleresources с черным и белым сплошным заполнением. Создадим и запустим файл badapple.reg с таким содержимым:

Windows Registry Editor Version 5.00  [HKEY_CLASSES_ROOT\.baclrb] [HKEY_CLASSES_ROOT\.baclrb\DefaultIcon] @="C:\\badappleresources\\badapple_black.ico"  [HKEY_CLASSES_ROOT\.baclrw] [HKEY_CLASSES_ROOT\.baclrw\DefaultIcon] @="C:\\badappleresources\\badapple_white.ico"

В результате, Windows начнет правильно отображать файлы с нашими расширениями:

Значки для придуманных нами расширений файлов

Значки для придуманных нами расширений файлов

Размеры значков

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

Рабочий стол в Windows NT — это практически такое-же окно проводника как и любая папка. Значит, с ним можно работать как и с любой другой папкой — извлекать информацию о расположении значков, их параметрах и т.д.

Для того, чтобы с помощью Win32 изменить размер значков рабочего стола нужно выполнить несколько COM вызовов:

  • Получить IFolderView2 — интерфейс папки, которая отображается в окне рабочего стола.

  • Вызвать метод IFolderView2::GetViewModeAndIconSize , чтобы получить текущий режим отображения и размер значка

  • Вызвать метод IFolderView2::SetViewModeAndIconSize для задания нового размера значка

CoInitialize(NULL); // Запускаем COM CComPtr<IFolderView2> spView; FindDesktopFolderView(IID_PPV_ARGS(&spView)); //Получим IFolderView2 окна рабочего стола const auto desiredSize = 67; FOLDERVIEWMODE viewMode; int iconSize; spView->GetViewModeAndIconSize(&viewMode, &iconSize); spView->SetViewModeAndIconSize(viewMode, desiredSize);
Код функции FindDesktopFolderView
void FindDesktopFolderView(REFIID riid, void** ppv) {     CComPtr<IShellWindows> spShellWindows;     // Создаем экземпляр IShellWindows     spShellWindows.CoCreateInstance(CLSID_ShellWindows);       CComVariant vtLoc(CSIDL_DESKTOP);     CComVariant vtEmpty;     long lhwnd;     CComPtr<IDispatch> spdisp;     // Находим окно по его идентификатору SW (SW_DESKTOP в случае рабочего стола)     spShellWindows->FindWindowSW(         &vtLoc, &vtEmpty,         SWC_DESKTOP, &lhwnd, SWFO_NEEDDISPATCH, &spdisp);       CComPtr<IShellBrowser> spBrowser;     CComQIPtr<IServiceProvider>(spdisp)->         QueryService(SID_STopLevelBrowser,             IID_PPV_ARGS(&spBrowser));      CComPtr<IShellView> spView;     // Находим активный IShellView в выбранном окне     spBrowser->QueryActiveShellView(&spView);      // Находим выбранный объект (в нашем случае IFolderView2) в IShellView     spView->QueryInterface(riid, ppv); }

Регулируя значение desiredSize ищем такой размер значка, при котором отступы по краям иконки будут минимальными. Такое значение — 67.

Разные отступы при разных размерах значка (67 - слева)

Разные отступы при разных размерах значка (67 — слева)

Данный код будет изменять размер значков на 67 каждый раз при запуске программы.

Разрешение «виртуального экрана»

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

RECT desktop; // Получаем HANDLE окна рабочего стола HWND hDesktop = GetDesktopWindow(); // Получаем прямоугольник окна рабочего стола GetWindowRect(hDesktop, &desktop);  POINT spacing; //Получаем ширину значка вместе с отступами spView->GetSpacing(&spacing); auto xCount = desktop.right / spacing.x; auto yCount = desktop.bottom / spacing.y;

IFolderView2::GetSpacing возвращает размеры иконки с учетом отступов (что нам и нужно). Делим сторону экрана на сторону значка и получаем количество значков на сторону, на моем мониторе 2560×1440 пикселей это 34×11 значков. Запоминаем эти числа, они пригодятся позже.

Что будем отображать

Подготовка видео

Подготовим нужные файлы. Скачаем оригинальное видео:

yt-dlp "https://www.youtube.com/watch?v=FtutLA63Cp8" -o "badapple.mp4"

Необходимо понизить разрешение видео до 34×11, и частоту до 10 кадров в секунду:

ffmpeg -i badapple.mp4 -s 34x12 -c:a copy -filter:v fps=10 downscaled-33x12.mp4

Обратите внимание на указанное разрешение — 34 на 12. К сожалению, ffmpeg требует четного количества пикселей по вертикали, придется игнорировать последнюю строку.

Преобразование видео в пиксели

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

Сделаем так, чтобы приложение рендерило только измененные пиксели. Для этого, предыдущий кадр будем хранить в буфере и записывать в бинарный файл данные только изменившихся пикселей. Для этого напишем приложение на C#, для чтения видео будем использовать пакет GleamTech.VideoUltimate.

Код конвертера видео в бинарный формат
using System.Runtime.CompilerServices; using GleamTech.Drawing; using GleamTech.VideoUltimate;  // Ширина входного видео const int WIDTH = 34; // Высота входного видео (с учетом игнорируемой последней строки) const int HEIGHT = 11;  // Значение байта для БЕЛОГО пикселя const byte BYTE_ONE = 255; // Значение байта для ЧЕРНОГО пикселя const byte BYTE_ZERO = 0; // Значение байта для команды "ПОКРАСИТЬ ПИКСЕЛЬ" const byte BYTE_FRAME_PIXEL = 0; // Значение байта для команды "Сделать скриншот" const byte BYTE_FRAME_SCREENSHOT = 1;  // Ссылка на выходной файл var outputPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "framedata.bapl"); // Поток выходного файла using var outputStream = File.Open(outputPath, FileMode.Create, FileAccess.ReadWrite); // Входной файл using var videoReader = new VideoFrameReader(File.OpenRead("downscaled-33x12.mp4"));  // Буфер предыдущего кадра var buffer = new Span<bool>(new bool[WIDTH * HEIGHT]);  //Считываем кадры, пока они доступны for (var i = 0; videoReader.Read(); i++) {     Console.WriteLine(i);     //Получаем кадр и преобразуем его к Bitmap из .NET     using var frame = videoReader.GetFrame();     using var bitmap = frame.ToSystemDrawingBitmap();     for (byte x = 0; x < WIDTH; x++)     {         for (byte y = 0; y < HEIGHT; y++)         {             //Индекс пикселя в буфере             var bufferValue = WIDTH * y + x;             //Получим значение пикселя (канал значения не имеет, видео черно-белое)             var color = bitmap.GetPixel(x, y).R > 128;             if (buffer[bufferValue] != color)             {                 // Записываем байт команды изменения пикселя                 outputStream.WriteByte(BYTE_FRAME_PIXEL);                 // Записываем данные измененного пикселя                 outputStream.WriteByte(x);                 outputStream.WriteByte(y);                 outputStream.WriteByte(color ? BYTE_ONE : BYTE_ZERO);                 buffer[bufferValue] = color;             }         }     }     //Записываем байт команды скриншота     outputStream.WriteByte(BYTE_FRAME_SCREENSHOT); }

Конвертер покадрово просматривает видео и сравнивает пиксели с буфером предыдущего кадра. Если значение цвета отличается, то в выходной файл записывается команда отрисовки пикселя:

  • Байт BYTE_FRAME_PIXEL(0)

  • Координата X пикселя

  • Координата Y пикселя

  • ЗначениеBYTE_ONE или BYTE_ZERO — белый или черный цвет пикселя.

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

Еще одно небольшое улучшение (на самом деле нет)

Можно сэкономить примерно 25% бинарного файла, если не передавать четвертый байт команды отрисовки «виртуального пикселя» — отрисовщик может хранить последнее состояние каждой иконки и менять его на противоположное. Однако, это приведет к потере FPS при отрисовке видео — будут тратиться дополнительные ресурсы на вычисление нового состояния иконки

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

Как будем отображать

Заполним рабочий стол файлами с иконками черного цвета. Для этого, получим путь к рабочему столу текущего пользователя с помощью SHGetSpecialFolderPathA:

// desktopPath будет указывать на строку wstring - путь к рабочему столу текущего пользователя   char desktop_path[MAX_PATH + 1];   SHGetSpecialFolderPathA(HWND_DESKTOP, desktop_path, CSIDL_DESKTOP, FALSE);    const auto totalScreenCapacity = desktopResolution.x * desktopResolution.y;   auto desktopPath = std::string(desktop_path);   auto desktopPathW = std::wstring(desktopPath.begin(), desktopPath.end());

Далее, заполним 2 вектора — полные пути к файлам белых цветов и черных цветов.

for (auto y = 0; y < desktopResolution.y; y++)     {         for (auto x = 0; x < desktopResolution.x; x++)         {             blacks[y * desktopResolution.x + x] = desktopPathW + line_separator + std::to_wstring(x) + L"_" + std::to_wstring(y) + black_extension;             whites[y * desktopResolution.x + x] = desktopPathW + line_separator + std::to_wstring(x) + L"_" + std::to_wstring(y) + white_extension;         }     }

По итогу, в blacks и whites будут лежать строки вида C:\Users\[User]\Desktop\[x]_[y].baclr['w'|'b'] .

Иконку файла будем менять переименованием.

Переименование файла

Для переименования файла в Win32 API предусмотрена функция MoveFile. Мы могли бы изменить иконку с индексом i с белого на черный цвет так:

MoveFile(whites[i], blacks[i]);

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

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

 for (int i = 0; i < totalScreenCapacity; i++) {     // Создаем файлы с черной иконкой с доступом на чтение, запись и удаление     handles[i] = CreateFile(blacks[i].c_str(), GENERIC_READ | GENERIC_WRITE | DELETE, 0, NULL, CREATE_ALWAYS, 0, NULL); }

Осталось непосредственно переименовать файл, зная его HANDLE, для этого есть SetFileInformationByHandle. Напишем функцию:

void RenameFileByHandle(HANDLE handle, std::wstring newName) {     auto newNameStr = newName.c_str();     // Создадим структуру с информацией о длине файла     union     {         FILE_RENAME_INFO file_rename_info;         BYTE buffer[offsetof(FILE_RENAME_INFO, FileName[MAX_PATH])];     };      file_rename_info.ReplaceIfExists = TRUE;     file_rename_info.RootDirectory = nullptr;     //Заполним информацию о длине названия файла     file_rename_info.FileNameLength = (ULONG)wcslen(newNameStr) * sizeof(WCHAR);     // Запишем нули в название файла (для нормальной работы SetFileInformationByHandle название файла должно кончаться на \0)     memset(file_rename_info.FileName, 0, MAX_PATH);     // Скопируем нужное название файла в память     memcpy_s(file_rename_info.FileName, MAX_PATH * sizeof(WCHAR), newNameStr, file_rename_info.FileNameLength);      // Переименуем файл     SetFileInformationByHandle(handle, FileRenameInfo, &buffer, sizeof buffer); }

Делаем скриншоты экрана

Будем использовать GDI+, напишем функцию SaveScreenshotToFile.

Код SaveScreenshotToFile
void SaveScreenshotToFile(const std::wstring& filename) {     // Получим контекст устройства экрана     HDC hScreenDC = CreateDC(L"DISPLAY", NULL, NULL, NULL);      // Получим размер экрана     int ScreenWidth = GetDeviceCaps(hScreenDC, HORZRES);     int ScreenHeight = GetDeviceCaps(hScreenDC, VERTRES);      // Создадим изображение     HDC hMemoryDC = CreateCompatibleDC(hScreenDC);     HBITMAP hBitmap = CreateCompatibleBitmap(hScreenDC, ScreenWidth, ScreenHeight);     HBITMAP hOldBitmap = (HBITMAP)SelectObject(hMemoryDC, hBitmap);      // Скопируем скриншот из контекста экрана в контекст памяти (изображения)     BitBlt(hMemoryDC, 0, 0, ScreenWidth, ScreenHeight, hScreenDC, 0, 0, SRCCOPY);     hBitmap = (HBITMAP)SelectObject(hMemoryDC, hOldBitmap);      // Сохраним изображение в файл     BITMAPFILEHEADER bmfHeader;     BITMAPINFOHEADER bi;      bi.biSize = sizeof(BITMAPINFOHEADER);     bi.biWidth = ScreenWidth;     bi.biHeight = ScreenHeight;     bi.biPlanes = 1;     bi.biBitCount = 32;     bi.biCompression = BI_RGB;     bi.biSizeImage = 0;     bi.biXPelsPerMeter = 0;     bi.biYPelsPerMeter = 0;     bi.biClrUsed = 0;     bi.biClrImportant = 0;      DWORD dwBmpSize = ((ScreenWidth * bi.biBitCount + 31) / 32) * 4 * ScreenHeight;      HANDLE hDIB = GlobalAlloc(GHND, dwBmpSize);     char* lpbitmap = (char*)GlobalLock(hDIB);      // Скопируем биты изображения в буффер     GetDIBits(hMemoryDC, hBitmap, 0, (UINT)ScreenHeight, lpbitmap, (BITMAPINFO*)&bi, DIB_RGB_COLORS);      // Создадим файл с будущим скриншотом     HANDLE hFile = CreateFile(filename.c_str(), GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);      // Размер в байтах заголовка изображения     DWORD dwSizeofDIB = dwBmpSize + sizeof(BITMAPFILEHEADER) + sizeof(BITMAPINFOHEADER);      // Сдвиг данных пикселей     bmfHeader.bfOffBits = (DWORD)sizeof(BITMAPFILEHEADER) + (DWORD)sizeof(BITMAPINFOHEADER);      //Размер файла     bmfHeader.bfSize = dwSizeofDIB;      //0x4d42 = 'BM' в кодировке ASCII, обязательное значение     bmfHeader.bfType = 0x4D42; //BM         DWORD dwBytesWritten = 0;     WriteFile(hFile, (LPSTR)&bmfHeader, sizeof(BITMAPFILEHEADER), &dwBytesWritten, NULL);     WriteFile(hFile, (LPSTR)&bi, sizeof(BITMAPINFOHEADER), &dwBytesWritten, NULL);     WriteFile(hFile, (LPSTR)lpbitmap, dwBmpSize, &dwBytesWritten, NULL);      //Очищаем данные контекстов     GlobalUnlock(hDIB);     GlobalFree(hDIB);      //Закрываем файлы     CloseHandle(hFile);      //Очищаем мусор после себя     DeleteObject(hBitmap);     DeleteDC(hMemoryDC);     DeleteDC(hScreenDC); }

Перед снимком экрана необходимо обновить содержимое окна и дождаться пока обновление произойдет. Для этого воспользуемся SendMessage и напишем небольшую функцию:

void TakeScreenshot(int index){     // Путь i-того скриншота     auto path = screenshot_path + L"shot_" + std::to_wstring(index) + L".png";     // Отправляем сообщение для обновления     SendMessage(HWND_BROADCAST, WM_SETTINGCHANGE, 0, 0);     // Ждем DEFAULT_SLEEP_TIME миллисекунд     std::this_thread::sleep_for(DEFAULT_SLEEP_TIME);     // Делаем скриншот     SaveScreenshotToFile(path); }

Команда SendMessage предназначена для отправки сообщения в выбранное окно (0 аргумент), мы используем HWND_BROADCAST, поэтому адресатами будут все окна. В нашем случае сообщением является WM_SETTINGCHANGE — изменение настроек окна. Это сообщение заставляет окна отрисовать заново себя и свои дочерние окна.

Почти финал — собираем все воедино

Функция main отрисовщика будет выглядеть примерно так:

int main() {     // Получаем параметры рабочего стола     auto desktopResolution = GetDesktopParams();     const auto totalScreenCapacity = desktopResolution.x * desktopResolution.y;      // Создаем файлы и заполняем векторы с названиями файлов и дескрипторами     std::vector<HANDLE> handles(totalScreenCapacity);     std::vector<std::wstring> blacks(totalScreenCapacity);     std::vector<std::wstring> whites(totalScreenCapacity);     FillDesktop(desktopResolution, handles, blacks, whites);      // Считываем содержимое файла framedata.bapl     auto bytes = ReadAllBytes(pixel_source_path);     auto i = 0;      auto frame = 0;      // Отрисовываем созданные файлы     SendMessage(HWND_BROADCAST, WM_SETTINGCHANGE, 0, 0);          while (i < bytes.size()) {         i++;         //Считываем очередной байт         char value = bytes[i];         // Если это команда для снимка экрана - делаем скриншот         if (value == BYTE_FRAME_SCREENSHOT) {             TakeScreenshot(frame);             frame++;         }         else {             // Получаем координаты и цвет пикселя             auto x = bytes[i + 1];             auto y = bytes[i + 2];             auto color = bytes[i + 3];             i += 3;              // Переименовываем соответствующий файл             auto position = y * desktopResolution.x + x;             RenameFileByHandle(handles[position], color == BYTE_ONE ? whites[position] : blacks[position]);         }     }     // Делаем финальный скриншот     TakeScreenshot(frame);     return 0; }

Отрисовщик считывает файл framedata.bapl байт за байтом, переименовывает соответствующий файл или делает скриншот в нужный момент. На выходе получаем множество файлов формата .bmp — скриншоты окна для каждого из кадров видео Bad Apple.

Сборка снимков экрана обратно в видео

Осталось еще немного, скоро дойдем до видео)

Используем ffmpeg:

ffmpeg -framerate 10 -i "scan_%d.bmp" output.mp4

Осталось добавить звук в вашем любимом видеоредакторе.

Финал

Итоги

В рамках статьи мы смогли запустить Bad Apple на значках рабочего стола. В ходе работы мы использовали множество функций API Windows, научились обращаться к некоторым COM интерфейсам, делать скриншоты с помощью GDI+ и компоновать их в видео при помощи ffmpeg.

Ссылка на репозиторий проекта


ссылка на оригинал статьи https://habr.com/ru/articles/794536/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *