catcrafts.net/shared/interfaces/Catcrafts.Shared-Media.cppm
Jorijn van der Graaf 6841623e23
Some checks failed
Deploy / build-deploy (push) Failing after 5m49s
posts and avif
2026-08-10 01:37:26 +02:00

192 lines
8.6 KiB
C++

/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// The markup for one mirrored file — image or video.
//
// This exists as its own partition because two callers need identical output:
// :Views renders a post's headline media on the card and at the top of the post
// page, and :Markdown renders whatever the body embeds. They were separate
// copies of the same element-building code, which is how the AV1 fallback logic
// came to be written twice; a format tier added to one and forgotten in the
// other shows up as the same picture served two different ways on two pages.
//
// FORMAT LADDER. Every file the mirror stores is served in the best form the
// browser will take, with a form every browser will take underneath it:
//
// video AV1 in MP4, falling back to H.264 in MP4. Two <source> elements;
// the codecs parameter on the first is what lets a browser without
// AV1 skip it rather than fail on a file it cannot decode.
// image AVIF, falling back to the mirrored original (usually WebP), falling
// back to PNG. A <picture>, so the browser fetches exactly one of
// them — the tiers cost nothing to the visitors who do not need them.
//
// The middle image tier is free: it is the file the mirror already downloaded,
// so it adds no encode and no disk. It matters because it is what stands
// between "no AVIF" and the PNG, and PNG is lossless — for a photograph that is
// an order of magnitude larger than the WebP beside it. With the middle tier
// the PNG is reached only by a browser that supports neither AVIF nor WebP.
//
// Any tier can be absent (the transcode was unavailable, or the file was never
// mirrored at all) and the markup degrades a step at a time, down to a bare
// <img src> — which is what the site emitted before any of this existed.
export module Catcrafts.Shared:Media;
import std;
import :Html;
import :Model;
namespace Catcrafts::Media {
using Html::SafeHtml;
using Html::Escape;
using Html::Attr;
using Html::Format;
using Html::Join;
using Html::Url;
bool EndsWithNoCase(std::string_view s, std::string_view suffix) {
if (s.size() < suffix.size()) return false;
const std::size_t off = s.size() - suffix.size();
for (std::size_t i = 0; i < suffix.size(); ++i) {
char a = s[off + i];
if (a >= 'A' && a <= 'Z') a = static_cast<char>(a - 'A' + 'a');
if (a != suffix[i]) return false;
}
return true;
}
// True when the path names something the <video> path should handle. Used only
// to guess for a file that was never mirrored, so there is no record to ask.
export bool LooksLikeVideo(std::string_view src) {
return EndsWithNoCase(src, ".mp4") || EndsWithNoCase(src, ".webm")
|| EndsWithNoCase(src, ".mov");
}
// The type= a <source> should advertise. Empty for anything not recognised, in
// which case the tier is dropped rather than guessed: a wrong type is worse
// than a missing source, because the browser believes it.
std::string_view MimeFor(std::string_view src) {
if (EndsWithNoCase(src, ".avif")) return "image/avif";
if (EndsWithNoCase(src, ".webp")) return "image/webp";
if (EndsWithNoCase(src, ".png")) return "image/png";
if (EndsWithNoCase(src, ".jpg") || EndsWithNoCase(src, ".jpeg")) return "image/jpeg";
if (EndsWithNoCase(src, ".gif")) return "image/gif";
return {};
}
// Width and height, or nothing when the mirror could not probe them. Both or
// neither: a lone dimension is worse than none, because the browser derives the
// other from it and gets the aspect ratio wrong.
SafeHtml Dimensions(const PostMedia& m) {
if (m.width <= 0 || m.height <= 0) return SafeHtml{};
return Format("{}{}", Attr("width", std::to_string(m.width)),
Attr("height", std::to_string(m.height)));
}
SafeHtml ImageTag(const PostMedia& m, std::string_view alt) {
// What every browser reads, and the only element that is always present.
// The PNG when there is one, because that is the tier nothing can refuse.
const std::string_view imgSrc = m.fallback.empty() ? m.src : m.fallback;
std::vector<SafeHtml> sources;
if (!m.avif.empty()) {
sources.push_back(Format(R"(<source{} type="image/avif">)", Url("srcset", m.avif)));
}
// The mirrored original, sitting between the AVIF and the PNG. Skipped when
// it IS what the <img> points at or what the AVIF tier already offered
// (nothing left to say), or when its type cannot be named.
if (m.src != imgSrc && m.src != m.avif) {
if (const std::string_view mime = MimeFor(m.src); !mime.empty()) {
sources.push_back(Format(R"(<source{}{}>)",
Url("srcset", m.src), Attr("type", mime)));
}
}
// alt is whatever the author wrote, which for a post's headline media is
// nothing: those are screenshots whose meaning is already in the title and
// the excerpt, and inventing descriptive alt text here would be making up
// what the picture shows. Emitted explicitly even when empty — alt="" is
// skipped cleanly by a screen reader, a missing alt makes it read the file
// name out loud.
const SafeHtml img = Format(
R"(<img class="post-media__item" loading="lazy" decoding="async" alt="{}"{}{}>)",
Escape(alt), Url("src", imgSrc), Dimensions(m));
if (sources.empty()) return img;
return Format("<picture>{}{}</picture>", Join(sources), img);
}
SafeHtml VideoTag(const PostMedia& m) {
// preload="metadata", not "auto": a page with several 5 MB recordings must
// not pull them all on load.
const SafeHtml poster = m.poster.empty() ? SafeHtml{} : Url("poster", m.poster);
const SafeHtml dims = Dimensions(m);
if (m.fallback.empty() || m.fallback == m.src) {
return Format(
R"(<video class="post-media__item" controls preload="metadata" playsinline{}{}{}></video>)",
Url("src", m.src), poster, dims);
}
// An AV1 video with its H.264 rendition. Both are .mp4, so the container
// alone cannot tell them apart: the codecs parameter on the first <source>
// is what lets a browser without AV1 (Safari before 17, Apple hardware
// without the decoder) skip it and take the H.264 instead of failing on a
// file it cannot decode. The string is advisory and used only for
// selection — once a source is picked the browser reads the actual stream —
// so the canonical profile-0 8-bit form is right for anything
// publish-media.sh emits (yuv420p is pinned there).
return Format(
R"(<video class="post-media__item" controls preload="metadata" playsinline{}{}>)"
R"(<source{} type="video/mp4; codecs=av01.0.08M.08">)"
R"(<source{} type="video/mp4">)"
R"(</video>)",
poster, dims, Url("src", m.src), Url("src", m.fallback));
}
// The element for one mirrored file.
export SafeHtml Tag(const PostMedia& m, std::string_view alt = {}) {
return m.kind == "video" ? VideoTag(m) : ImageTag(m, alt);
}
// The mirrored record for `src`, or nullptr when this path was never mirrored.
//
// Matching is on the exact string because tools/fetch-media.sh rewrites the
// body text and the body_media list from the same mapping in the same pass —
// they cannot disagree about a path without the mirror step itself being wrong.
export const PostMedia* Find(std::span<const PostMedia> media, std::string_view src) {
for (const PostMedia& m : media) {
if (m.src == src) return &m;
}
return nullptr;
}
// The record for `src`, or one invented from the path alone.
//
// The invented case is a file whose download failed, so the body still points
// at its original URL: there are no dimensions and no renditions to offer, and
// the markup degrades to the bare element. Rendering something beats dropping
// the picture the paragraph is talking about.
export PostMedia Describe(std::span<const PostMedia> media, std::string_view src) {
if (const PostMedia* hit = Find(media, src)) return *hit;
PostMedia m;
m.src = std::string(src);
m.kind = LooksLikeVideo(src) ? "video" : "image";
return m;
}
// A post's media as one block. More than one file gets a two-up grid via CSS,
// so a post with four screenshots does not become a mile of scrolling.
export SafeHtml Block(std::span<const PostMedia> media) {
if (media.empty()) return SafeHtml{};
std::vector<SafeHtml> items;
items.reserve(media.size());
for (const PostMedia& m : media) items.push_back(Tag(m));
return Format(R"(<div class="post-media">{}</div>)", Join(items));
}
} // namespace Catcrafts::Media