Есть проекты, которые рождаются не из практической необходимости, а из глубоко нездорового вопроса: а можно ли сделать это совсем не тем инструментом?
Обычно, если человек хочет показать в браузере видео, он берёт 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:
#include <opencv2/opencv.hpp> 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<char>& out) { std::uint8_t byte = 0; int packed = 0; for(int y = 0; y < gray.rows; ++y) { const auto* row = gray.ptr<std::uint8_t>(y); for(int x = 0; x < gray.cols; ++x) { const auto shift = 6 - packed * 2; byte |= static_cast<std::uint8_t>(encode_pixel(row[x]) << shift); if(++packed == 4) { out.push_back(static_cast<char>(byte)); byte = 0; packed = 0; } } } if(packed != 0) { out.push_back(static_cast<char>(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_cast<std::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_cast<std::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<char> 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_cast<std::streamsize>(hdr_bytes.size())); out.write(payload.data(), static_cast<std::streamsize>(payload.size())); std::println("Done: {} frames, {} KiB", out_frame_count, payload.size() / 1024); }
❯ Компилятор как киностудия
Теперь нам нужно превратить этот бинарник в HTTP-чанки прямо внутри исполняемого файла. Здесь на сцену выходит #embed из C++26: он позволяет встроить бинарный файл как массив байт, с которым можно работать на этапе компиляции.
Модуль src/read.cc разбирает заголовок, предоставляет интерфейс диапазонов (ranges) и декодирует 2bpp обратно в символы:
src/read.cc
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="<!DOCTYPE html> <html> <head> <style> 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; } </style> </head> <body> <form method="GET" action="/bad_apple" target="_self"> <button type="submit">Load</button> </form> </body> </html>"></iframe> </body></html>
❯ Заключение
В каком-то смысле этот проект не про CSS и даже не про C++26. Это про то, как технические ограничения могут стать источником креативности. HTTP был придуман для передачи гипертекста, CSS — для стилизации документов, а C++ — для того, чтобы писать быстрый системный софт. Но строгие границы между инструментами существуют лишь в наших головах и документации.
Разумеется, у такого подхода масса ограничений. Нельзя удалять старые элементы DOM: с каждым новым <style> дерево документа растёт, даже если прошлые кадры уже не видны. За полный ролик в памяти браузера оседает почти тысяча перекрывающих друг друга стилей.
Но именно в этих ограничениях и проявляется характер проекта. Он не претендует на замену <video> и не советует писать веб-приложения на CSS-чанках. Это proof-of-concept, демонстрирующий, что HTML + CSS — гораздо более гибкая среда, чем кажется на первый взгляд.
Полный код, инструкции по сборке и все шаблоны доступны в репозитории.
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩

