Если что-то существует, на этом можно запустить Bad Apple
Правило 86
За последние лет 15, Bad Apple запустили множестве вещей - на самодельном RISC-V процессоре, на осциллографе, на яблоках. Попробуем запустить Bad Apple на значках рабочего стола с помощью вызовов API Windows и нескольких других.
Требования
Visual Studio 2022
Нужная нагрузка для Visual Studio:
.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 каждый раз при запуске программы.
Разрешение "виртуального экрана"
Теперь необходимо узнать, сколько значков помещается на рабочий стол по горизонтали и вертикали. Можно посчитать вручную (неинтересный вариант), можно посчитать автоматически после корректировки размера иконок (вариант поинтереснее):
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 пикселей это 34x11 значков. Запоминаем эти числа, они пригодятся позже.
Что будем отображать
Подготовка видео
Подготовим нужные файлы. Скачаем оригинальное видео:
yt-dlp "https://www.youtube.com/watch?v=FtutLA63Cp8" -o "badapple.mp4"
Необходимо понизить разрешение видео до 34x11, и частоту до 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.