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

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

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

Рендерить видео будем в 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 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 spShellWindows;
    // Создаем экземпляр IShellWindows
    spShellWindows.CoCreateInstance(CLSID_ShellWindows); 

    CComVariant vtLoc(CSIDL_DESKTOP);
    CComVariant vtEmpty;
    long lhwnd;
    CComPtr spdisp;
    // Находим окно по его идентификатору SW (SW_DESKTOP в случае рабочего стола)
    spShellWindows->FindWindowSW(
        &vtLoc, &vtEmpty,
        SWC_DESKTOP, &lhwnd, SWFO_NEEDDISPATCH, &spdisp); 

    CComPtr spBrowser;
    CComQIPtr(spdisp)->
        QueryService(SID_STopLevelBrowser,
            IID_PPV_ARGS(&spBrowser));

    CComPtr 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(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 handles(totalScreenCapacity);
    std::vector blacks(totalScreenCapacity);
    std::vector 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.

© Habrahabr.ru