feat(credits): public CC-BY/BY-SA attribution colophon + CC BY-SA work license

CC BY / CC BY-SA footage needs its author + license shown to the viewer, and the
installation alters every frame (a derivative). Per-clip license/source already
shipped in clips.json but only surfaced in the dev panel and internal review
pages — the public experience showed nothing, so the static benstull.art site
was not attribution-compliant.

Option A — add a manifest-driven credits/colophon (credits.html + credits.js):
the page fetches the same clips the experience uses (baked clips.json in static,
the live API otherwise) and renders one attribution block per clip (title,
license, source), HTML-escaped, sorted, never silently dropping a clip. An
"ⓘ Credits & licenses" link in the header (localised en/es/fr/ja) makes it
reachable from the work — what CC's "reasonable to the medium" calls for. Audio
is all PD/CC0, credited as a courtesy block.

Option D1 — the colophon declares the altered work itself is offered under
CC BY-SA 4.0 and states the footage is modified, satisfying the ShareAlike +
"indicate changes" obligations for the BY-SA sources. Non-commercial.

Ships in the static build (added to PUBLIC_ASSETS). Verified: 41/41 clips
credited incl. all 7 CC-BY/BY-SA clips, 0 missing a license. Tests: node units
for creditEntries/creditsListHtml + escaping + page structure/declaration;
pytest build-output assertion; Playwright e2e (page lists attributions, header
links through).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-06-30 08:39:26 -07:00
parent 3a45e338f3
commit 1c7f2b7785
9 changed files with 318 additions and 3 deletions
+62
View File
@@ -0,0 +1,62 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const C = require("../static/credits.js");
const CLIPS = [
{ id: "reef_x", title: "Reef X", license: "CC-BY-SA 4.0 — credit Joe Mabel", source: "Wikimedia <reef>" },
{ id: "cosmos", title: "Cosmic Cliffs", license: "CC-BY-class — credit NASA, ESA, CSA, STScI", source: "NASA SVS 31348" },
{ id: "pd_only", title: "PD clip", license: "public-domain (US Gov, 17 U.S.C. §105)", source: "NASA" },
];
test("creditEntries returns one entry per clip, preserving license/source", () => {
const out = C.creditEntries(CLIPS);
assert.equal(out.length, CLIPS.length, "no clip is silently dropped from the credits");
const reef = out.find((e) => e.id === "reef_x");
assert.equal(reef.license, "CC-BY-SA 4.0 — credit Joe Mabel");
assert.equal(reef.source, "Wikimedia <reef>");
assert.equal(reef.title, "Reef X");
});
test("creditEntries is sorted by id for deterministic output", () => {
const ids = C.creditEntries(CLIPS).map((e) => e.id);
assert.deepEqual(ids, ["cosmos", "pd_only", "reef_x"]);
});
test("creditEntries falls back title->id and tolerates missing fields", () => {
const out = C.creditEntries([{ id: "bare" }]);
assert.deepEqual(out, [{ id: "bare", title: "bare", license: "", source: "" }]);
});
test("creditEntries handles null/empty input", () => {
assert.deepEqual(C.creditEntries(null), []);
assert.deepEqual(C.creditEntries([]), []);
});
test("creditsListHtml renders every clip's attribution and escapes HTML", () => {
const html = C.creditsListHtml(CLIPS);
assert.match(html, /Cosmic Cliffs/);
assert.match(html, /Joe Mabel/);
// free-text source "Wikimedia <reef>" must be escaped, not injected as a tag
assert.match(html, /Wikimedia &lt;reef&gt;/);
assert.doesNotMatch(html, /<reef>/);
// one block per clip
const blocks = html.match(/class="credit"/g) || [];
assert.equal(blocks.length, CLIPS.length);
});
test("credits.html wires config.js + credits.js, the attributions container, and a back link", () => {
const html = fs.readFileSync(path.join(__dirname, "../static/credits.html"), "utf8");
assert.match(html, /<script src="config\.js">/); // static/live media-base + flag
assert.match(html, /<script src="credits\.js">/);
assert.match(html, /id="video-credits"/); // JS fills this
assert.match(html, /href="index\.html"/); // back into the experience
});
test("credits.html carries the CC BY-SA derivative declaration (Option D1)", () => {
const html = fs.readFileSync(path.join(__dirname, "../static/credits.html"), "utf8");
assert.match(html, /CC BY-SA 4\.0/); // the altered work's own license
assert.match(html, /altered/i); // states the footage is modified
});