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
+64
View File
@@ -0,0 +1,64 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Credits — Human Experience Filter</title>
<link rel="stylesheet" href="style.css" />
</head>
<body class="credits-page">
<main class="credits-wrap">
<header>
<h1>Credits &amp; licenses</h1>
<p><a href="index.html" class="back-link">← Back to the experience</a></p>
</header>
<!-- Option D1: the altered work's own license + the "modified" statement that
CC BY / CC BY-SA attribution requires. -->
<section class="declaration">
<h2>About this work</h2>
<p>
The <em>Human Experience Filter</em> alters its source footage in real time
(overlays, an emotion/affect channel, and a WebGL “dream” shader), so every
frame you see is a <strong>derivative</strong> of the original clip.
</p>
<p>
This altered work is offered under
<a href="https://creativecommons.org/licenses/by-sa/4.0/" rel="license">Creative
Commons Attribution-ShareAlike 4.0 (CC BY-SA 4.0)</a>. The original sources it
builds on are credited below. This is a non-commercial art installation.
</p>
</section>
<section>
<h2>Footage</h2>
<p class="note">
Original video sources and their licenses. Public-domain (US-government) and
CC0 clips need no attribution; the names below for CC BY / CC BY-SA clips are
required credit. All footage is altered as described above.
</p>
<div id="video-credits" class="credits-list">
<p class="loading">Loading credits…</p>
</div>
</section>
<!-- Audio is all public-domain / CC0, so attribution is courtesy, not required;
it isn't tracked per-clip in the API, so it's acknowledged here as a block. -->
<section>
<h2>Sound</h2>
<p class="note">
Soundtracks are public domain or CC0 — credit is courtesy, not required.
</p>
<ul class="audio-credits">
<li><strong>NASA / Chandra X-ray Observatory (CXC/SAO)</strong> — cosmos sonification (public domain).</li>
<li><strong>NOAA</strong> SanctSound &amp; PMEL hydrophone recordings — reef/abyss biological layers (public domain).</li>
<li><strong>freesound.org</strong> contributors — Sonicfreak (station room-tone) and DCSFX (underwater bed) (CC0).</li>
<li><strong>BigSoundBank</strong> — sea waves &amp; gull calls (CC0).</li>
</ul>
</section>
</main>
<script src="config.js"></script>
<script src="credits.js"></script>
</body>
</html>
+75
View File
@@ -0,0 +1,75 @@
// Credits/colophon data + render — no framework. UMD so the browser gets a
// `HEFCredits` global and `node --test` can require() the pure functions.
//
// The legally load-bearing attribution is per VIDEO clip (CC BY / CC BY-SA need
// the author + license shown to the viewer, and the footage is altered → a
// derivative). That data already ships in clips.json (baked from /api/clips):
// each clip carries free-text `license` + `source`. This module turns that into
// the credits list the public colophon (credits.html) renders. Audio is all
// PD/CC0 and credited as a static courtesy block in the page itself.
(function (root, factory) {
const api = factory();
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.HEFCredits = api;
})(typeof self !== "undefined" ? self : this, function () {
"use strict";
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// One attribution entry per clip, sorted by id (deterministic). A clip is
// NEVER silently dropped — even one with an empty license appears, so a
// missing credit is visible rather than hidden. License/source are the
// free-text strings authored in the manifest.
function creditEntries(clips) {
return (clips || [])
.map((c) => ({
id: c.id,
title: c.title || c.id,
license: c.license || "",
source: c.source || "",
}))
.sort((a, b) => String(a.id).localeCompare(String(b.id)));
}
function creditHtml(e) {
const lic = e.license
? `<span class="lic">${escapeHtml(e.license)}</span>`
: `<span class="lic missing">license not recorded</span>`;
const src = e.source ? `<div class="src">${escapeHtml(e.source)}</div>` : "";
return (
`<div class="credit">` +
`<div class="title">${escapeHtml(e.title)}</div>` +
lic + src +
`</div>`
);
}
function creditsListHtml(clips) {
return creditEntries(clips).map(creditHtml).join("");
}
// Browser bootstrap: fetch the same clips the experience uses (baked
// clips.json in the static build, the live API otherwise — the config.js
// flag, loaded before this script, decides) and fill the container.
function init(doc, cfg) {
const fetchUrl = cfg && cfg.static ? "clips.json" : "/api/clips";
const box = doc.getElementById("video-credits");
if (!box) return;
return fetch(fetchUrl)
.then((r) => r.json())
.then((d) => { box.innerHTML = creditsListHtml(d.clips || []); })
.catch((err) => { box.innerHTML = `<p class="err">Could not load credits: ${escapeHtml(err.message)}</p>`; });
}
if (typeof document !== "undefined") {
document.addEventListener("DOMContentLoaded", () => {
init(document, (typeof window !== "undefined" && window.HEF_CONFIG) || {});
});
}
return { escapeHtml, creditEntries, creditHtml, creditsListHtml, init };
});
+1
View File
@@ -20,6 +20,7 @@
"app.title": { en: "Human Experience Filter — Alteration Preview", es: "Filtro de Experiencia Humana — Vista previa de alteración", fr: "Filtre dExpérience Humaine — Aperçu daltération", ja: "ヒューマン・エクスペリエンス・フィルター — 変容プレビュー" },
"loading.title": { en: "Loading Universe", es: "Cargando el universo", fr: "Chargement de lunivers", ja: "宇宙を読み込み中" },
"run.button": { en: "Run simulation", es: "Iniciar simulación", fr: "Lancer la simulation", ja: "シミュレーションを開始" },
"credits.link": { en: "ⓘ Credits & licenses", es: "ⓘ Créditos y licencias", fr: "ⓘ Crédits et licences", ja: "ⓘ クレジットとライセンス" },
"output.legend": { en: "Output", es: "Salida", fr: "Sortie", ja: "出力" },
"output.video": { en: "Video", es: "Vídeo", fr: "Vidéo", ja: "映像" },
"output.audio": { en: "Audio", es: "Audio", fr: "Audio", ja: "音声" },
+4 -1
View File
@@ -13,7 +13,10 @@
<div class="loading-bar"><div id="loading-fill"></div></div>
</div>
</div>
<header><h1 data-i18n="app.title">Human Experience Filter — Alteration Preview</h1></header>
<header>
<h1 data-i18n="app.title">Human Experience Filter — Alteration Preview</h1>
<a href="credits.html" class="credits-link" data-i18n="credits.link">ⓘ Credits &amp; licenses</a>
</header>
<main>
<section class="stage" id="stage">
<div class="screen">
+26 -1
View File
@@ -1,7 +1,32 @@
* { box-sizing: border-box; }
body { margin: 0; font: 14px/1.4 system-ui, sans-serif; background: #111; color: #eee; }
header { padding: 0.6rem 1rem; background: #000; }
header { padding: 0.6rem 1rem; background: #000; display: flex; align-items: baseline;
justify-content: space-between; gap: 1rem; }
h1 { font-size: 1rem; margin: 0; font-weight: 600; }
/* Always-visible link to the credits/colophon — reachable from the work, which is
what CC BY / BY-SA attribution "reasonable to the medium" calls for. */
.credits-link { color: #9af; text-decoration: none; font-size: 12px; white-space: nowrap; flex: none; }
.credits-link:hover { text-decoration: underline; }
/* --- Credits / colophon page (credits.html) --- */
.credits-page { background: #0c0f14; }
.credits-wrap { max-width: 760px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
.credits-wrap h1 { font-size: 1.6rem; }
.credits-wrap h2 { font-size: 1.05rem; color: #9af; margin: 1.8rem 0 0.5rem; }
.credits-wrap a { color: #9af; }
.credits-wrap .back-link { font-size: 13px; }
.credits-wrap .note { color: #9ab; font-size: 13px; }
.declaration { background: #131a24; border: 1px solid #233; border-radius: 6px;
padding: 0.8rem 1rem; margin-top: 1rem; }
.credits-list { display: grid; gap: 0.7rem; margin-top: 0.8rem; }
.credit { background: #131820; border: 1px solid #1f2a3a; border-radius: 5px; padding: 0.6rem 0.8rem; }
.credit .title { font-weight: 600; color: #dfe7f2; }
.credit .lic { display: inline-block; margin-top: 0.2rem; font-size: 12px; color: #cbd; }
.credit .lic.missing { color: #f97; }
.credit .src { margin-top: 0.3rem; font-size: 12px; color: #8aa; line-height: 1.5; }
.audio-credits { color: #b9c4d2; font-size: 13px; line-height: 1.6; padding-left: 1.2rem; }
.credits-list .loading, .credits-list .err { color: #9ab; }
.credits-list .err { color: #f97; }
main { display: flex; gap: 1rem; padding: 1rem; flex-wrap: wrap; align-items: flex-start; }
/* Stage stays pinned in view while the right pane scrolls on its own. */
.stage { flex: 1 1 640px; position: sticky; top: 1rem; }
+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
});
+3
View File
@@ -19,6 +19,9 @@ def test_build_emits_frontend_baked_json_and_only_referenced_media(tmp_path):
assert (app_dir / "scrub.js").exists()
assert (app_dir / "alteration.js").exists()
assert (app_dir / "config.js").exists()
# Credits/colophon ships so CC-BY / CC-BY-SA attribution is reachable from the public site.
assert (app_dir / "credits.html").exists()
assert (app_dir / "credits.js").exists()
assert not (app_dir / "author.html").exists()
assert not list(app_dir.glob("review*.html"))
+81
View File
@@ -0,0 +1,81 @@
"""E2E (Playwright) — the public credits/colophon page actually surfaces the
CC-BY / CC-BY-SA video attribution to a visitor.
Unit tests pin the credits *logic* (creditEntries/creditsListHtml) and the build
ships the files; this confirms the live integration: credits.js fetches the clips
and fills the page in a real browser, and the experience links to it. The suite
skips cleanly when Playwright/Chromium is absent (headless box)."""
import socket
import threading
import time
from contextlib import closing
import pytest
pytest.importorskip("playwright.sync_api")
def _free_port() -> int:
with closing(socket.socket()) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
@pytest.fixture(scope="module")
def app_url():
import uvicorn
from simulator.app import app
port = _free_port()
server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error"))
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
for _ in range(50):
if server.started:
break
time.sleep(0.1)
if not server.started:
pytest.skip("uvicorn did not start")
yield f"http://127.0.0.1:{port}"
server.should_exit = True
thread.join(timeout=5)
@pytest.fixture
def browser_page(app_url):
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
try:
browser = p.chromium.launch()
except Exception as exc: # no browser binary installed
pytest.skip(f"chromium not available: {exc}")
page = browser.new_page()
page._app_url = app_url # stash for the tests
yield page
browser.close()
def test_credits_page_lists_video_attributions(browser_page):
page = browser_page
page.goto(page._app_url + "/credits.html")
# credits.js fetches /api/clips and fills the container with one block per clip.
page.wait_for_selector("#video-credits .credit", timeout=30000)
count = page.eval_on_selector_all("#video-credits .credit", "els => els.length")
assert count >= 40, f"expected the full clip pool credited, got {count}"
# the required CC-BY / CC-BY-SA credits are visible to the viewer
body = page.inner_text("body")
assert "NASA, ESA, CSA, STScI" in body
assert "Joe Mabel" in body # a CC BY-SA clip's required author credit
assert "CC BY-SA 4.0" in body # the Option-D1 derivative declaration
def test_experience_links_to_credits(browser_page):
page = browser_page
page.goto(page._app_url + "/")
page.wait_for_selector("#loading", state="detached", timeout=60000)
href = page.get_attribute(".credits-link", "href")
assert href == "credits.html"
page.click(".credits-link")
page.wait_for_selector("#video-credits .credit", timeout=30000)
+2 -1
View File
@@ -35,7 +35,8 @@ from simulator.app import MEDIA_DIR, create_app
STATIC = Path(__file__).resolve().parent.parent / "simulator" / "static"
# Frontend files that ship; everything else in static/ (author*, review*) is dev-only.
PUBLIC_ASSETS = ["index.html", "app.js", "scrub.js", "i18n.js", "alteration.js", "style.css"]
PUBLIC_ASSETS = ["index.html", "app.js", "scrub.js", "i18n.js", "alteration.js", "style.css",
"credits.html", "credits.js"]
def _bake_api(app_dir: Path) -> dict: