192 lines
8.6 KiB
C++
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
|