diff --git a/simulator/static/credits.html b/simulator/static/credits.html new file mode 100644 index 0000000..95cd2d5 --- /dev/null +++ b/simulator/static/credits.html @@ -0,0 +1,64 @@ + + + + + + Credits — Human Experience Filter + + + +
+
+

Credits & licenses

+

← Back to the experience

+
+ + +
+

About this work

+

+ The Human Experience Filter alters its source footage in real time + (overlays, an emotion/affect channel, and a WebGL “dream” shader), so every + frame you see is a derivative of the original clip. +

+

+ This altered work is offered under + Creative + Commons Attribution-ShareAlike 4.0 (CC BY-SA 4.0). The original sources it + builds on are credited below. This is a non-commercial art installation. +

+
+ +
+

Footage

+

+ 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. +

+
+

Loading credits…

+
+
+ + +
+

Sound

+

+ Soundtracks are public domain or CC0 — credit is courtesy, not required. +

+ +
+
+ + + + + diff --git a/simulator/static/credits.js b/simulator/static/credits.js new file mode 100644 index 0000000..b9b2705 --- /dev/null +++ b/simulator/static/credits.js @@ -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, "&").replace(//g, ">") + .replace(/"/g, """); + } + + // 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 + ? `${escapeHtml(e.license)}` + : `license not recorded`; + const src = e.source ? `
${escapeHtml(e.source)}
` : ""; + return ( + `
` + + `
${escapeHtml(e.title)}
` + + lic + src + + `
` + ); + } + + 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 = `

Could not load credits: ${escapeHtml(err.message)}

`; }); + } + + if (typeof document !== "undefined") { + document.addEventListener("DOMContentLoaded", () => { + init(document, (typeof window !== "undefined" && window.HEF_CONFIG) || {}); + }); + } + + return { escapeHtml, creditEntries, creditHtml, creditsListHtml, init }; +}); diff --git a/simulator/static/i18n.js b/simulator/static/i18n.js index f50ac29..864ce6d 100644 --- a/simulator/static/i18n.js +++ b/simulator/static/i18n.js @@ -20,6 +20,7 @@ "app.title": { en: "Human Experience Filter — Alteration Preview", es: "Filtro de Experiencia Humana — Vista previa de alteración", fr: "Filtre d’Expérience Humaine — Aperçu d’altération", ja: "ヒューマン・エクスペリエンス・フィルター — 変容プレビュー" }, "loading.title": { en: "Loading Universe", es: "Cargando el universo", fr: "Chargement de l’univers", 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: "音声" }, diff --git a/simulator/static/index.html b/simulator/static/index.html index 4f6d190..469e09f 100644 --- a/simulator/static/index.html +++ b/simulator/static/index.html @@ -13,7 +13,10 @@
-

Human Experience Filter — Alteration Preview

+
+

Human Experience Filter — Alteration Preview

+ ⓘ Credits & licenses +
diff --git a/simulator/static/style.css b/simulator/static/style.css index c92b1fd..849acbd 100644 --- a/simulator/static/style.css +++ b/simulator/static/style.css @@ -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; } diff --git a/simulator/unit/credits.test.js b/simulator/unit/credits.test.js new file mode 100644 index 0000000..9d9cf9e --- /dev/null +++ b/simulator/unit/credits.test.js @@ -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 " }, + { 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 "); + 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 " must be escaped, not injected as a tag + assert.match(html, /Wikimedia <reef>/); + assert.doesNotMatch(html, //); + // 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, /