This commit is contained in:
parent
2fa6e70af1
commit
6841623e23
17 changed files with 2306 additions and 148 deletions
192
shared/interfaces/Catcrafts.Shared-Media.cppm
Normal file
192
shared/interfaces/Catcrafts.Shared-Media.cppm
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
/*
|
||||
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
|
||||
Loading…
Reference in a new issue