惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

T
Tenable Blog
MyScale Blog
MyScale Blog
罗磊的独立博客
Hugging Face - Blog
Hugging Face - Blog
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
爱范儿
爱范儿
博客园 - 司徒正美
D
Darknet – Hacking Tools, Hacker News & Cyber Security
量子位
N
News | PayPal Newsroom
S
Secure Thoughts
酷 壳 – CoolShell
酷 壳 – CoolShell
L
LINUX DO - 热门话题
有赞技术团队
有赞技术团队
V
Visual Studio Blog
T
Tailwind CSS Blog
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Project Zero
Project Zero
B
Blog RSS Feed
J
Java Code Geeks
Google Online Security Blog
Google Online Security Blog
Last Week in AI
Last Week in AI
Cyberwarzone
Cyberwarzone
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
小众软件
小众软件
博客园 - 【当耐特】
Latest news
Latest news
T
Threat Research - Cisco Blogs
aimingoo的专栏
aimingoo的专栏
博客园_首页
博客园 - 三生石上(FineUI控件)
Engineering at Meta
Engineering at Meta
D
Docker
Forbes - Security
Forbes - Security
Help Net Security
Help Net Security
Apple Machine Learning Research
Apple Machine Learning Research
P
Proofpoint News Feed
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
Simon Willison's Weblog
Simon Willison's Weblog
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
V2EX - 技术
V2EX - 技术
N
Netflix TechBlog - Medium
The Last Watchdog
The Last Watchdog
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
T
Threatpost
Cloudbric
Cloudbric
T
The Exploit Database - CXSecurity.com
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
博客园 - 叶小钗
Webroot Blog
Webroot Blog

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Bad Apple через CSS: как заставить браузер страдать без единой строчки JavaScript
j4niwzis (Ti · 2026-05-11 · via Все публикации подряд на Хабре

Уровень сложностиСредний

Время на прочтение21 мин

Охват и читатели1.2K

Обзор

Есть проекты, которые рождаются не из практической необходимости, а из глубоко нездорового вопроса: а можно ли сделать это совсем не тем инструментом?

Обычно, если человек хочет показать в браузере видео, он берёт video. Если хочет покадровую анимацию — пишет немного JavaScript. Он же служит и для потоковой передачи данных. Но все эти подходы слишком нормальные, а потому и недостаточно интересные.

Поэтому давайте поставим себе задачу чуть более сомнительную, чем следовало бы: воспроизвести Bad Apple в браузере без JavaScript вообще, опираясь почти целиком на CSS и немного на серверную магию.

Результат посмотреть можно тут и на GitHub.

К счастью, современный веб местами позволяет вещи, которые выглядят как ошибка проектирования, но формально являются фичами. В нашем случае особенно кстати оказывает HTTP streaming: сервер может не просто отдать страницу целиком, а продолжать данные постепенно, кадр за кадром. А если достаточно аккуратно встроить это в структуру документа, то CSS начинает выглядеть не как язык для раскрашивания кнопок, а как весьма странный, но рабочий способ передачи и отображения данных.

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

Ну и чтобы окончательно не сбивать градус абсурда, бекенд мы тоже не будем писать на чем-то скучно-прагматичном. Если заниматься таким делом, то с достоинством: C++26 и Boost.Beast. Потому что когда клиентская часть выглядит как преступление против здравого смысла, серверная часть тоже не должна отставать.

❯ Идея

Проблема в том, что без JavaScript обновление контента уже не столь тривиальная задача, но и не невозможная.

Ещё до эпохи WebSocket разработчики использовали хаки для потоковой передачи от сервера к клиенту. Один из таких заключается в том, чтобы не завершать загрузку страницы сразу же. Браузер умеет рендерить недозагруженный HTML, получая данные по мере поступления. Для этого достаточно установить заголовок Transfer-Encoding: chunked и держать соединение открытым, отправляя чанки тогда, когда это нужно.

Для реализации Bad Apple нужно отправлять кадры как css-стили, которые будут задавать content. Каждый новый <style>, прилетая в поток, перекрывает предыдущий благодаря каскаду и картинка меняется.

Можно задать достаточно простую основу страницы:

<!DOCTYPE html>
<html lang="en">
<body>
  <span class="badapple">BAD_APPLE</span>
  <audio autoplay controls style="display: block; margin-top: 10px;">
    <source src="/bad_apple/bad-apple.ogg" type="audio/ogg">
  </audio>

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

Исправить это можно, используя preload для звука вместе с кешированием. Чтобы браузер загрузил звук заранее, а потом просто продолжил его воспроизведение.

<audio preload="auto">
  <source src="/bad_apple/bad-apple.ogg" type="audio/ogg">
</audio>

Это нужно будет отправить в основной странице. При первой отправке данных через некоторое время, необходимое для загрузки звука, будет отправлен и основной блок, который переиспользует /bad-apple.ogg

Если это сделать так, то данные будут отправляться правильно, а проблем со звуком быть не должно, вот только отображение будет неправильно. Для исправления этого нужно добавить следующие стили:

<style>
.badapple::before{
    display: block;
    white-space: pre;
    font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, "Roboto Mono", "Courier New", monospace;
    font-variant-ligatures: none;
    font-feature-settings: "liga" 0, "clig" 0;
    font-kerning: normal;
    letter-spacing: 0;
}
</style>

Эти стили настраивают моноширинное отображение для псевдоэлемента, сохраняя переносы строк.

Основной поток данных будет выглядеть так:

<style>
.badapple::before {
    content: "...";
}
</style>

Каждый такой блок — это один кадр видео, отрисованный как ASCII-арт.

Подход HTTP Streaming, когда соединение долгое, а данные приходят по мере надобности, для не специфичен для Bad Apple и может быть применён, к примеру, в веб клиенте для irc без js, про который будет написана следующая статья.

Но это не единственный возможный подход. Есть такая вещь как MJPEG, к которому можно применить подобный трюк. Это видео, которое разбито на jpeg фреймы, работающее как обычный img. Можно не закрывать соединение, отправляя обновления, когда нужно. Но это было бы слишком похоже на нормальное видео, а потому в статье в основе описан иной путь.

❯ CMake

Раз уж доступны свежие компиляторы и стандарты, можно использовать модули, а они потребуют настройки в сборочной системе. Достаточно задать CMAKE_EXPERIMENTAL_CXX_IMPORT_STD и CMAKE_CXX_MODULE_STD для добавления модули std, включить стандарт C++26, а исходники с модулями задать как FILE_SET CXX_MODULE:

cmake_minimum_required(VERSION 4.1.1 FATAL_ERROR)

set(CMAKE_EXPERIMENTAL_CXX_IMPORT_STD
  # This specific value changes as experimental support evolves. See
  # `Help/dev/experimental.rst` in the CMake source corresponding to
  # your CMake build for the exact value to use.
  "d0edc3af-4c50-42ea-a356-e2862fe7a444")

set(CMAKE_CXX_MODULE_STD 1)

project(
  bad_apple
  VERSION 1.0.0
  LANGUAGES CXX
)

file(GLOB_RECURSE sources CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/src/*.cc")


add_executable(${PROJECT_NAME} ${CMAKE_CURRENT_SOURCE_DIR}/main.cc)

target_compile_features(${PROJECT_NAME} PRIVATE cxx_std_26)


target_sources(${PROJECT_NAME} PUBLIC FILE_SET CXX_MODULES FILES ${sources})

Не нужно забывать и про Boost:

find_package(Boost 1.84 REQUIRED) 
target_link_libraries(${PROJECT_NAME} PUBLIC
  Boost::headers
)

❯ От пикселей до битов: конвертация видео

Пайплайн начинается с OpenCV. Код читает исходное видео, даунскейлит до 120x42 пикселей, нарезает в 4 FPS, а после кодирует каждый пиксель четырьмя символами: _, +, =, #. Каждый символ обозначает свою градацию яркости, что позволяет уместить пиксель в 2 бита. Полученный бинарный файл (bad-apple.frames.2bpp.bin) служит сырым материалом для дальнейшей работы.

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

Код

tools/video_to_2bpp.cc: ```cpp #include import std;

inline constexpr int target_fps = 4; inline constexpr int target_width = 120; inline constexpr int target_height = 42; inline constexpr int bytes_per_frame = (target_width * target_height + 3) / 4;

constexpr std::uint8_t encode_pixel(std::uint8_t gray) noexcept { return gray / 64; // 0…3 }

struct FileHeader { char magic[4] = {‘B’, ‘A’, ‘P’, ‘P’}; std::uint32_t version = 2; std::uint32_t frame_count = 0; std::uint32_t width = target_width; std::uint32_t height = target_height; std::uint32_t packed_size = bytes_per_frame; };

[[nodiscard]] auto to_bytes(const auto& trivial) { return std::bit_cast<std::array<char, sizeof(trivial)>>(trivial); }

void pack_gray_frame(const cv::Mat& gray, std::vector& out) { std::uint8_t byte = 0; int packed = 0;

for(int y = 0; y < gray.rows; ++y) { const auto* row = gray.ptrstd::uint8_t(y); for(int x = 0; x < gray.cols; ++x) { const auto shift = 6 - packed * 2; byte |= static_caststd::uint8_t(encode_pixel(row[x]) << shift); if(++packed == 4) { out.push_back(static_cast(byte)); byte = 0; packed = 0; } } } if(packed != 0) { out.push_back(static_cast(byte)); } }

int main(int argc, char* argv[]) { if(argc != 3) { std::println(stderr, “Usage: {} <input.mp4> <output.bin>”, argv[0]); return 1; }

std::string_view input_path = argv[1]; std::string_view output_path = argv[2];

cv::VideoCapture cap{std::string(input_path)}; if(!cap.isOpened()) { std::println(stderr, “Error: cannot open ‘{}’”, input_path); return 1; }

const double source_fps = cap.get(cv::CAP_PROP_FPS); const auto total_frames = static_caststd::int64_t(cap.get(cv::CAP_PROP_FRAME_COUNT)); const double duration = (source_fps > 0.0) ? (total_frames / source_fps) : 0.0; const auto expected_out = static_caststd::uint32_t(duration * target_fps + 2);

std::println(“Source: {} @ {:.2f} fps, ~{:.1f} s”, input_path, source_fps, duration); std::println(“Target: {}x{} @ {} fps, {} bytes/frame”, target_width, target_height, target_fps, bytes_per_frame);

std::vector payload; payload.reserve(expected_out * bytes_per_frame);

cv::Mat frame, resized, gray; std::uint32_t out_frame_count = 0; std::uint64_t in_idx = 0; const double dt = 1.0 / target_fps; double next_t = 0.0;

while(cap.read(frame)) { const double t = in_idx / source_fps; if(t + 1e-9 >= next_t) { cv::resize(frame, resized, {target_width, target_height}, 0, 0, cv::INTER_AREA); cv::cvtColor(resized, gray, cv::COLOR_BGR2GRAY); pack_gray_frame(gray, payload); ++out_frame_count; next_t += dt; } ++in_idx; }

FileHeader hdr; hdr.frame_count = out_frame_count;

std::ofstream out(std::string(output_path), std::ios::binary); if(!out) { std::println(stderr, “Error: cannot create ‘{}’”, output_path); return 1; }

auto hdr_bytes = to_bytes(hdr); out.write(hdr_bytes.data(), static_caststd::streamsize(hdr_bytes.size())); out.write(payload.data(), static_caststd::streamsize(payload.size()));

std::println(“Done: {} frames, {} KiB”, out_frame_count, payload.size() / 1024); }


</spoiler>

## ❯ Компилятор как киностудия

Теперь нам нужно превратить этот бинарник в HTTP-чанки прямо внутри исполняемого файла. Здесь на сцену выходит `#embed` из C++26: он позволяет встроить бинарный файл как массив байт, с которым можно работать на этапе компиляции.

Модуль `src/read.cc` разбирает заголовок, предоставляет интерфейс диапазонов (ranges) и декодирует 2bpp обратно в символы:

<spoiler title="src/read.cc">

```cpp
namespace bad_apple {

inline constexpr unsigned char embedded_file[] = {
#embed "bad-apple.frames.2bpp.bin"
};

constexpr std::span<const unsigned char> bytes{embedded_file};

constexpr std::uint32_t read_u32_le(std::span<const unsigned char> data, std::size_t offset) {
  return (static_cast<std::uint32_t>(data[offset + 0]) << 0) | (static_cast<std::uint32_t>(data[offset + 1]) << 8) |
         (static_cast<std::uint32_t>(data[offset + 2]) << 16) | (static_cast<std::uint32_t>(data[offset + 3]) << 24);
}

export struct Header {
  std::array<char, 4> magic{};
  std::uint32_t version{};
  std::uint32_t frame_count{};
  std::uint32_t width{};
  std::uint32_t height{};
  std::uint32_t bytes_per_frame{};
};

export struct Movie {
  std::span<const unsigned char> data{};

  static constexpr std::size_t header_size = 24;

  constexpr Header header() const {
    return Header{
        .magic =
            {
                static_cast<char>(data[0]),
                static_cast<char>(data[1]),
                static_cast<char>(data[2]),
                static_cast<char>(data[3]),
            },
        .version = read_u32_le(data, 4),
        .frame_count = read_u32_le(data, 8),
        .width = read_u32_le(data, 12),
        .height = read_u32_le(data, 16),
        .bytes_per_frame = read_u32_le(data, 20),
    };
  }

  constexpr bool valid() const {
    const auto h = header();
    const auto expected_payload = static_cast<std::size_t>(h.frame_count) * static_cast<std::size_t>(h.bytes_per_frame);

    return data.size() >= header_size && h.magic == std::array<char, 4>{'B', 'A', 'P', 'P'} &&
           data.size() == header_size + expected_payload;
  }

  constexpr std::span<const unsigned char> payload() const {
    return data.subspan(header_size);
  }

  constexpr std::size_t pixel_count_per_frame() const {
    const auto h = header();
    return static_cast<std::size_t>(h.width) * static_cast<std::size_t>(h.height);
  }

  constexpr std::span<const unsigned char> frame_bytes(std::size_t frame_index) const {
    const auto h = header();
    const auto begin = frame_index * static_cast<std::size_t>(h.bytes_per_frame);
    return payload().subspan(begin, h.bytes_per_frame);
  }

  constexpr std::uint8_t pixel_code(std::size_t frame_index, std::size_t pixel_index) const {
    const auto fb = frame_bytes(frame_index);
    const auto byte_index = pixel_index / 4;
    const auto lane = pixel_index % 4;
    const auto shift = 6u - static_cast<unsigned>(lane * 2u);
    return static_cast<std::uint8_t>((fb[byte_index] >> shift) & 0b11u);
  }

  constexpr std::uint8_t pixel_code(std::size_t frame_index, std::size_t x, std::size_t y) const {
    const auto h = header();
    return pixel_code(frame_index, y * static_cast<std::size_t>(h.width) + x);
  }

  static constexpr char decode_pixel(std::uint8_t code) {
    switch(code & 0b11u) {
      case 0:
        return '#';
      case 1:
        return '=';
      case 2:
        return '+';
      default:
        return '_';
    }
  }

  constexpr char pixel_char(std::size_t frame_index, std::size_t pixel_index) const {
    return decode_pixel(pixel_code(frame_index, pixel_index));
  }

  constexpr char pixel_char(std::size_t frame_index, std::size_t x, std::size_t y) const {
    return decode_pixel(pixel_code(frame_index, x, y));
  }

  constexpr auto pixel_chars(std::size_t frame_index) const {
    return std::views::iota(std::size_t{0}, pixel_count_per_frame()) | std::views::transform([this, frame_index](std::size_t i) constexpr {
             return pixel_char(frame_index, i);
           });
  }

  constexpr auto row_chars(std::size_t frame_index, std::size_t y) const {
    const auto w = static_cast<std::size_t>(header().width);
    return std::views::iota(std::size_t{0}, w) | std::views::transform([this, frame_index, y](std::size_t x) constexpr {
             return pixel_char(frame_index, x, y);
           });
  }

  constexpr auto frame_indices() const {
    return std::views::iota(std::size_t{0}, static_cast<std::size_t>(header().frame_count));
  }
  constexpr auto frame(std::size_t frame_index) const {
    const auto h = static_cast<std::size_t>(header().height);
    return std::views::iota(std::size_t{}, h) | std::views::transform([this, frame_index](std::size_t y) {
             return row_chars(frame_index, y);
           });
  }
  constexpr auto frames() const {
    return frame_indices() | std::views::transform([&](auto i) {
             return frame(i);
           });
  }
};

constexpr Movie movie{bytes};

} // namespace bad_apple

Однако сырые символы ещё не готовы к отправке клиенту. Их нужно обернуть в валидные CSS-чанки. Более того, строки в content требуют экранирования перевода строки через \A. Можно было бы формировать чанки на лету, внутри корутины, но куда эффективнее подготовить всё заранее.

Потому в модуль чтения добавляется генерация массива готовых чанков:

// ..
constexpr std::size_t frame_size = movie.header().height * movie.header().width;

static const std::array chunk_array = [] {
  static constexpr std::string_view chunk_begin = "<style>\n.badapple::before{\ncontent:\"";
  static constexpr std::string_view chunk_end = "\";\n}</style>";
  using chunk = std::array<char, frame_size + movie.header().height * 2 + chunk_begin.size() + chunk_end.size()>;  //+ height for \A
  std::array<chunk, movie.header().frame_count> response{};
  for(auto&& [chunk, frame] : std::views::zip(response, movie.frames())) {
    auto out = chunk.begin();
    out = std::ranges::copy(chunk_begin, out).out;
    for(auto row : frame) {
      out = std::ranges::copy(row, out).out;
      out = std::ranges::copy(std::string_view{"\\A"}, out).out;
    }
    out = std::ranges::copy(chunk_end, out).out;
  }
  return response;
}();

export inline constexpr auto chunks = chunk_array | std::views::transform([](auto& arr) {
                                        return std::string_view{arr.data(), arr.size()};
                                      });

} // namespace bad_apple

Следует обратить внимание на то, что массив объявлен как const, а не constexpr. Дело в том, что вычисление всех кадров в интерпретаторе constexpr выражений потребовало бы огромное число шагов, а скорость компиляции бы оставляла желать лучшего. Если же оставить const, то данные будут высчитываться при старте сервера, а не на этапе компиляции. Но ничего не мешает заменить const на constexpr, предварительно убрав лимит на число шагов для constexpr в компиляторе. Впрочем, использовать constexpr даже необязательно, т.к. компилятор и с const попытается вычислить на этапе компиляции, но упрётся в лимит шагов constexpr вычислений.

Чтобы бинарный файл генерировался до сборки модуля, в CMake добавляется custom_command и явная зависимость:

find_package(OpenCV 4 REQUIRED COMPONENTS core videoio imgproc)
add_executable(video_to_2bpp ${CMAKE_CURRENT_SOURCE_DIR}/tools/video_to_2bpp.cc)
target_compile_features(video_to_2bpp PRIVATE cxx_std_26)
target_link_libraries(video_to_2bpp
  PRIVATE
        opencv_core
        opencv_videoio
        opencv_imgproc
)

set(FRAMES_BIN "${CMAKE_CURRENT_BINARY_DIR}/bad-apple.frames.2bpp.bin")

add_custom_command(
    OUTPUT ${FRAMES_BIN}
    COMMAND video_to_2bpp
            "${CMAKE_CURRENT_SOURCE_DIR}/assets/bad-apple.mp4"
            "${FRAMES_BIN}"
    DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/assets/bad-apple.mp4" video_to_2bpp
    COMMENT "Converting video to 2bpp frame data..."
    VERBATIM
)

set_property(SOURCE ${CMAKE_CURRENT_SOURCE_DIR}/src/read.cc APPEND PROPERTY
    OBJECT_DEPENDS ${FRAMES_BIN}
)
Полный CMakeLists.txt
cmake_minimum_required(VERSION 4.1.1 FATAL_ERROR)

set(CMAKE_EXPERIMENTAL_CXX_IMPORT_STD
  # This specific value changes as experimental support evolves. See
  # `Help/dev/experimental.rst` in the CMake source corresponding to
  # your CMake build for the exact value to use.
  "d0edc3af-4c50-42ea-a356-e2862fe7a444")

set(CMAKE_CXX_MODULE_STD 1)


project(
  bad_apple
  VERSION 1.0
  LANGUAGES CXX
)

if(PROJECT_SOURCE_DIR STREQUAL PROJECT_BINARY_DIR)
  message(
    FATAL_ERROR
      "In-source builds not allowed. Please make a new directory (called a build directory) and run CMake from there."
  )
endif()

find_package(Boost 1.84 REQUIRED)

find_package(OpenCV 4 REQUIRED COMPONENTS core videoio imgproc)
add_executable(video_to_2bpp ${CMAKE_CURRENT_SOURCE_DIR}/tools/video_to_2bpp.cc)
target_compile_features(video_to_2bpp PRIVATE cxx_std_26)
target_link_libraries(video_to_2bpp
  PRIVATE
        opencv_core
        opencv_videoio
        opencv_imgproc
)

set(FRAMES_BIN "${CMAKE_CURRENT_BINARY_DIR}/bad-apple.frames.2bpp.bin")

add_custom_command(
    OUTPUT ${FRAMES_BIN}
    COMMAND video_to_2bpp
            "${CMAKE_CURRENT_SOURCE_DIR}/assets/bad-apple.mp4"
            "${FRAMES_BIN}"
    DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/assets/bad-apple.mp4" video_to_2bpp
    COMMENT "Converting video to 2bpp frame data..."
    VERBATIM
)


file(GLOB_RECURSE sources CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/src/*.cc")


add_executable(${PROJECT_NAME} ${CMAKE_CURRENT_SOURCE_DIR}/main.cc)

target_compile_features(${PROJECT_NAME} PRIVATE cxx_std_26)

target_compile_options(${PROJECT_NAME} PRIVATE "-Wno-c23-extensions")


target_sources(${PROJECT_NAME} PUBLIC FILE_SET CXX_MODULES FILES ${sources})

# Link dependencies
target_link_libraries(${PROJECT_NAME} PUBLIC
  Boost::headers
)

target_include_directories(${PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_BINARY_DIR})

set_property(SOURCE ${CMAKE_CURRENT_SOURCE_DIR}/src/read.cc APPEND PROPERTY
    OBJECT_DEPENDS ${FRAMES_BIN}
)

❯ Boost в клетке модулей

Boost.Beast и Boost.Asio писались ещё до модулей C++20, потому их нужно дополнительно оборачивать в модуль. Для этого создаётся boost.cc с глобальным фрагментом module;, в котором подключаются заголовочные файлы, а затем экспортируются нужные имена.

Будет что-то вроде этого:

module;
#include <boost/asio/awaitable.hpp>
// ...
export module boost;

namespace boost {

namespace asio {

export using asio::awaitable;

// ...

} // namespace asio

// ...

} // namespace boost

Но тут кроется подвох. Из-за особенностей компиляторов ADL-находимые функции и специализации шаблонов, объявленные в module; блоке, не всегда видны коду, который подключает текущий модуль с блоком module;. Компилятор считает, что они не нужны, а потому и отбрасывает. В clang помогает инстанцирование шаблона, тогда как в gcc — нет.

Но есть универсальное решение: явно прокинуть export для таких функций. Понадобилось такое для std::coroutine_traits и boost::intrusive::detail::destructor_impl

Пример на годболте

Кусок ошибки:

/usr/include/boost/intrusive/detail/generic_hook.hpp:191:7: error: use of undeclared identifier 'destructor_impl'
  191 |       destructor_impl
      |       ^~~~~~~~~~~~~~~

❯ Реализация логики работы

Осталось реализовать саму логику отправки чанков. Чтобы не перегружать повествование деталями работы с Boost.Beast и Boost.Asio, вспомогательные функции для взаимодействия через HTTP и принятия соединений будут вынесены в src/core.cc.

module;
#include <cstdio>
export module bad_apple.core;

import std;
import boost;

namespace bad_apple {

namespace asio = boost::asio;
namespace beast = boost::beast;
namespace http = beast::http;
using asio::awaitable;

export struct ServerConfig {
  std::string bind_address;
  unsigned short port;
};

export using Request = http::request<http::string_body>;

export template <class Body, class Fields>
awaitable<void> send(beast::tcp_stream& stream, http::response<Body, Fields>&& res) {
  co_await http::async_write(stream, res, asio::use_awaitable);
}

export awaitable<void> send_text(
    beast::tcp_stream& stream, unsigned http_version, http::status status, std::string body, bool keep_alive = false) {
  http::response<http::string_body> res{status, http_version};
  res.set(http::field::server, "bad-apple");
  res.set(http::field::content_type, "text/plain; charset=utf-8");
  res.keep_alive(keep_alive);
  res.body() = std::move(body);
  res.prepare_payload();
  co_await send(stream, std::move(res));
}

export awaitable<void> send_binary(beast::tcp_stream& stream,
                                   unsigned http_version,
                                   http::status status,
                                   std::string_view content_type,
                                   std::span<const unsigned char> data,
                                   bool keep_alive = false) {
  http::response<http::vector_body<std::uint8_t>> res{status, http_version};
  res.set(http::field::server, "bad-apple");
  res.set(http::field::content_type, content_type);
  res.keep_alive(keep_alive);
  res.body() = std::vector<std::uint8_t>(data.begin(), data.end());
  res.prepare_payload();
  co_await send(stream, std::move(res));
}

export awaitable<void> send_not_found(beast::tcp_stream& stream, unsigned http_version) {
  co_await send_text(stream, http_version, http::status::not_found, "not found\n", false);
}

export awaitable<void> send_chunk(beast::tcp_stream& stream, std::string_view chunk) {
  co_await asio::async_write(stream.socket(), http::make_chunk(asio::buffer(chunk)), asio::use_awaitable);
}

export awaitable<void> send_last_chunk(beast::tcp_stream& stream) {
  co_await asio::async_write(stream.socket(), http::make_chunk_last(), asio::use_awaitable);
}

export awaitable<Request> read_request(beast::tcp_stream& stream, beast::flat_buffer& buffer) {
  http::request_parser<http::string_body> parser;
  parser.body_limit(1024 * 1024);
  co_await http::async_read(stream, buffer, parser, asio::use_awaitable);
  co_return parser.release();
}

export awaitable<void> serve_connection(auto&& handler, asio::ip::tcp::socket socket) {
  beast::tcp_stream stream(std::move(socket));
  beast::flat_buffer buffer;

  try {
    auto req = co_await read_request(stream, buffer);
    co_await handler(stream, std::move(req));
  } catch(const std::exception& e) {
    std::println(stderr, "[session] {}", e.what());
  }

  beast::error_code ec;
  stream.socket().shutdown(asio::ip::tcp::socket::shutdown_send, ec);
}

awaitable<void> accept_loop(auto&& handler, ServerConfig cfg) {
  auto ex = co_await asio::this_coro::executor;
  asio::ip::tcp::acceptor acceptor(ex, {asio::ip::make_address(cfg.bind_address), cfg.port});

  std::println("[listen] http://{}:{}/", cfg.bind_address, cfg.port);

  for(;;) {
    auto socket = co_await acceptor.async_accept(asio::use_awaitable);
    auto remote = socket.remote_endpoint();
    std::println("[accept] {}:{}", remote.address().to_string(), remote.port());

    asio::co_spawn(ex, serve_connection(handler, std::move(socket)), [](std::exception_ptr ep) {
      if(!ep)
        return;
      try {
        std::rethrow_exception(ep);
      } catch(const std::exception& e) {
        std::println(stderr, "[spawn] {}", e.what());
      }
    });
  }
}

export int run_server(auto handler, ServerConfig cfg) {
  try {
    asio::io_context io(8);
    asio::co_spawn(io, accept_loop(std::move(handler), std::move(cfg)), [](std::exception_ptr ep) {
      if(!ep)
        return;
      try {
        std::rethrow_exception(ep);
      } catch(const std::exception& e) {
        std::println(stderr, "[fatal] {}", e.what());
      }
    });
    io.run();
  } catch(const std::exception& e) {
    std::println(stderr, "[fatal] {}", e.what());
    return 1;
  }
  return 0;
}

}  // namespace bad_apple

Сервер — это самодостаточный бинарник: HTML-заглушки и OGG-аудио зашиты через #embed и обёрнуты в std::string_view, чтобы не тащить файловую систему в продакшн. В этом фрагменте будут данные и нужные константы:

import bad_apple.read;
import bad_apple.core;
import std;
import boost;

namespace {

inline constexpr unsigned char ogg_data[] = {
#embed "assets/bad-apple.ogg"
};

inline constexpr char html_begin_data[] = {
#embed "templates/page_begin.html"
};
inline constexpr std::string_view html_begin{html_begin_data, std::size(html_begin_data)};

inline constexpr char html_audio_data[] = {
#embed "templates/audio.html"
};
inline constexpr std::string_view html_audio{html_audio_data, std::size(html_audio_data)};

inline constexpr char html_end_data[] = {
#embed "templates/page_end.html"
};
inline constexpr std::string_view html_end{html_end_data, std::size(html_end_data)};

inline constexpr auto audio_preload_delay = std::chrono::seconds(3);
inline constexpr auto frame_interval      = std::chrono::milliseconds(250);

} // namespace

Корутина stream_movie формирует chunked-ответ. Сначала уходит заголовок и начало страницы. Затем сервер замирает ровно на три секунды — этого времени достаточно, чтобы браузер подгрузил и закэшировал аудиотрек. После «просыпания» в поток добавляется , и сразу за ним — кадры. Каждый кадр — это заранее сформированный CSS-чанк из модуля read; между ними steady_timer выдерживает паузу в 250 мс, что даёт заявленные 4 FPS. В конце — закрывающие теги и финальный чанк, разрывающий соединение.

namespace bad_apple {

namespace asio  = boost::asio;
namespace beast = boost::beast;
namespace http  = beast::http;
using asio::awaitable;

awaitable<void> stream_movie(beast::tcp_stream& stream, unsigned http_version)
{
    auto ex = co_await asio::this_coro::executor;
    asio::steady_timer timer(ex);

    http::response<http::empty_body> res{http::status::ok, http_version};
    res.set(http::field::server, "bad-apple");
    res.set(http::field::content_type, "text/html; charset=utf-8");
    res.chunked(true);
    res.keep_alive(false);

    http::response_serializer<http::empty_body> sr{res};
    co_await http::async_write_header(stream, sr, asio::use_awaitable);

    co_await send_chunk(stream, html_begin);

    timer.expires_after(audio_preload_delay);
    co_await timer.async_wait(asio::use_awaitable);

    co_await send_chunk(stream, html_audio);

    for (std::string_view frame_chunk : chunks)
    {
        co_await send_chunk(stream, frame_chunk);

        timer.expires_after(frame_interval);
        co_await timer.async_wait(asio::use_awaitable);
    }

    co_await send_chunk(stream, html_end);
    co_await send_last_chunk(stream);
}

Роутер разбирает путь. /bad_apple запускает потоковый показ, /bad_apple/bad-apple.ogg отдаёт встроенный аудиофайл, всё остальное — 404. Точка входа запускает io_context с восемью рабочими потоками и слушает 8001-й порт.

awaitable<void> handle_request(beast::tcp_stream& stream, Request req) {
  std::println("[request] {} {} HTTP/{}.{}", std::string_view(req.method_string()), req.target(), req.version() / 10, req.version() % 10);

  if(req.method() != http::verb::get) {
    co_await send_text(stream, req.version(), http::status::method_not_allowed, "Only GET is supported\n", false);
    co_return;
  }

  const auto target = req.target();

  if(target == "/bad_apple" || target == "/bad_apple/") {
    co_await stream_movie(stream, req.version());
    co_return;
  }

  if(target == "/bad_apple/bad-apple.ogg") {
    co_await send_binary(stream, req.version(), http::status::ok, "audio/ogg", std::span<const unsigned char>{ogg_data}, false);
    co_return;
  }

  co_await send_not_found(stream, req.version());
}

}  // namespace bad_apple

int main() {
  return bad_apple::run_server(bad_apple::handle_request, {.bind_address = "0.0.0.0", .port = 8001});
}
Полный код
import bad_apple.read;
import bad_apple.core;
import std;
import boost;

namespace {

inline constexpr unsigned char ogg_data[] = {
#embed "assets/bad-apple.ogg"
};

inline constexpr char html_begin_data[] = {
#embed "templates/page_begin.html"
};
inline constexpr std::string_view html_begin{html_begin_data, std::size(html_begin_data)};

inline constexpr char html_audio_data[] = {
#embed "templates/audio.html"
};
inline constexpr std::string_view html_audio{html_audio_data, std::size(html_audio_data)};

inline constexpr char html_end_data[] = {
#embed "templates/page_end.html"
};
inline constexpr std::string_view html_end{html_end_data, std::size(html_end_data)};

inline constexpr auto audio_preload_delay = std::chrono::seconds(3);
inline constexpr auto frame_interval = std::chrono::milliseconds(250);

}  // namespace

namespace bad_apple {

namespace asio = boost::asio;
namespace beast = boost::beast;
namespace http = beast::http;
using asio::awaitable;

awaitable<void> stream_movie(beast::tcp_stream& stream, unsigned http_version) {
  auto ex = co_await asio::this_coro::executor;
  asio::steady_timer timer(ex);

  http::response<http::empty_body> res{http::status::ok, http_version};
  res.set(http::field::server, "bad-apple");
  res.set(http::field::content_type, "text/html; charset=utf-8");
  res.chunked(true);
  res.keep_alive(false);

  http::response_serializer<http::empty_body> sr{res};
  co_await http::async_write_header(stream, sr, asio::use_awaitable);

  co_await send_chunk(stream, html_begin);

  timer.expires_after(audio_preload_delay);
  co_await timer.async_wait(asio::use_awaitable);

  co_await send_chunk(stream, html_audio);

  for(std::string_view frame_chunk : chunks) {
    co_await send_chunk(stream, frame_chunk);

    timer.expires_after(frame_interval);
    co_await timer.async_wait(asio::use_awaitable);
  }

  co_await send_chunk(stream, html_end);
  co_await send_last_chunk(stream);
}
awaitable<void> handle_request(beast::tcp_stream& stream, Request req) {
  std::println("[request] {} {} HTTP/{}.{}", std::string_view(req.method_string()), req.target(), req.version() / 10, req.version() % 10);

  if(req.method() != http::verb::get) {
    co_await send_text(stream, req.version(), http::status::method_not_allowed, "Only GET is supported\n", false);
    co_return;
  }

  const auto target = req.target();

  if(target == "/bad_apple" || target == "/bad_apple/") {
    co_await stream_movie(stream, req.version());
    co_return;
  }

  if(target == "/bad_apple/bad-apple.ogg") {
    co_await send_binary(stream, req.version(), http::status::ok, "audio/ogg", std::span<const unsigned char>{ogg_data}, false);
    co_return;
  }

  co_await send_not_found(stream, req.version());
}

}  // namespace bad_apple

int main() {
  return bad_apple::run_server(bad_apple::handle_request, {.bind_address = "0.0.0.0", .port = 8001});
}

❯ Починка автовоспроизведения

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

Исправить это можно даже не меняя код основного сервиса. Достаточно добавить кнопку в form, которая находится внутри iframe, которая отправит запрос на /bad_apple запишет результат в _self (этот же iframe, перезаписав себя):

<html lang="en"><head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Bad Apple</title>
    <style>
        html, body {
            margin: 0;
            padding: 0;
            width: 100%;
            height: 100%;
        }
        iframe {
            width: 100%;
            height: 100%;
            border: none;
        }
    </style>
</head>
<body>
    <iframe srcdoc="&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
    &lt;style&gt;
        body { margin: 20px; background-color: #fff; }
        button { background-color: #000; color: white; padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; }
        button:hover { background-color: #333; }
    &lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
    &lt;form method=&quot;GET&quot; action=&quot;/bad_apple&quot; target=&quot;_self&quot;&gt;
        &lt;button type=&quot;submit&quot;&gt;Load&lt;/button&gt;
    &lt;/form&gt;
&lt;/body&gt;
&lt;/html&gt;"></iframe>


</body></html>

❯ Заключение

В каком-то смысле этот проект не про CSS и даже не про C++26. Это про то, как технические ограничения могут стать источником креативности. HTTP был придуман для передачи гипертекста, CSS — для стилизации документов, а C++ — для того, чтобы писать быстрый системный софт. Но строгие границы между инструментами существуют лишь в наших головах и документации.

Разумеется, у такого подхода масса ограничений. Нельзя удалять старые элементы DOM: с каждым новым <style> дерево документа растёт, даже если прошлые кадры уже не видны. За полный ролик в памяти браузера оседает почти тысяча перекрывающих друг друга стилей.

Но именно в этих ограничениях и проявляется характер проекта. Он не претендует на замену <video> и не советует писать веб-приложения на CSS-чанках. Это proof-of-concept, демонстрирующий, что HTML + CSS — гораздо более гибкая среда, чем кажется на первый взгляд.

Полный код, инструкции по сборке и все шаблоны доступны в репозитории.


Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале