diff --git a/package-lock.json b/package-lock.json
index e5e8344f03d06c86bbd8c97a499f55d88dd7a16f..4a11ecdc42106e514ed3d5a02716d8753d77db8a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4,6 +4,7 @@
"requires": true,
"packages": {
"": {
+ "name": "sitegen",
"dependencies": {
"@hono/node-server": "1.14.3",
"@mdx-js/mdx": "3.1.0",
@@ -58,6 +59,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz",
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -1168,11 +1170,129 @@
"node": ">=10"
}
},
- "node_modules/@svta/common-media-library": {
- "version": "0.12.4",
- "resolved": "https://registry.npmjs.org/@svta/common-media-library/-/common-media-library-0.12.4.tgz",
- "integrity": "sha512-9EuOoaNmz7JrfGwjsrD9SxF9otU5TNMnbLu1yU4BeLK0W5cDxVXXR58Z89q9u2AnHjIctscjMTYdlqQ1gojTuw==",
- "license": "Apache-2.0"
+ "node_modules/@svta/cml-608": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@svta/cml-608/-/cml-608-1.0.1.tgz",
+ "integrity": "sha512-Y/Ier9VPUSOBnf0bJqdDyTlPrt4dDB+jk5mYHa1bnD2kcRl8qn7KkW3PRuj4w1aVN+BS2eHmsLxodt7P2hylUg==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@svta/cml-cmcd": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-1.0.1.tgz",
+ "integrity": "sha512-eox305g+QUJgXqOLVrbgxeQHCgl90ewwQ9O2bIoo7m+hanR8Xswu5CknFnT5qqIbLOHfw80ug+raycoAFHTQ+w==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "@svta/cml-cta": "1.0.1",
+ "@svta/cml-structured-field-values": "1.0.1",
+ "@svta/cml-utils": "1.0.1"
+ }
+ },
+ "node_modules/@svta/cml-cmsd": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@svta/cml-cmsd/-/cml-cmsd-1.0.1.tgz",
+ "integrity": "sha512-+nIB8PuSfb/qw+xGaArPhNqPm84tBJUbe3H1DnPL5QUsjSUI7mUIUQwAtRV1ZdEu0+80g9i0op79woB0OIwr/g==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "@svta/cml-cta": "1.0.1",
+ "@svta/cml-structured-field-values": "1.0.1",
+ "@svta/cml-utils": "1.0.1"
+ }
+ },
+ "node_modules/@svta/cml-cta": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@svta/cml-cta/-/cml-cta-1.0.1.tgz",
+ "integrity": "sha512-jcXqNIPv26bmFxIOFh8/c3+6WLH4qBjKpq9qTQcggDPoHuV1YBydMsJLOnYPDeK8rNMKcAkFLbnDRvyJthu5yw==",
+ "license": "Apache-2.0",
+ "peer": true,
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "@svta/cml-structured-field-values": "1.0.1",
+ "@svta/cml-utils": "1.0.1"
+ }
+ },
+ "node_modules/@svta/cml-dash": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@svta/cml-dash/-/cml-dash-1.0.1.tgz",
+ "integrity": "sha512-lYnD1I7FUbbQND+xICI+kcRaRXuT+whKk27R8m8me5VMVu2sMsAMc7Yui6l9sxw2cBKt8pSETPYRm/1+n4LZkw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "@svta/cml-utils": "1.0.1"
+ }
+ },
+ "node_modules/@svta/cml-id3": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@svta/cml-id3/-/cml-id3-1.0.1.tgz",
+ "integrity": "sha512-90fGlL1qRI88CcaB89k6NG6cC3kky4Eu2jwqU4HefqK+S5k2OASUxf8JXkGz+DsdaiY7sh51vGPYdolfBZS7ug==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "@svta/cml-utils": "1.0.1"
+ }
+ },
+ "node_modules/@svta/cml-request": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@svta/cml-request/-/cml-request-1.0.1.tgz",
+ "integrity": "sha512-enL19BuXUjFkDDDF9jdNwUclMNPRsagnjGAetVC7xcmpDMpEx+ZLgsDip6BFNg5p6izSEk/OyujTWW1r8bDNiA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "@svta/cml-utils": "1.0.1",
+ "@svta/cml-xml": "1.0.1"
+ }
+ },
+ "node_modules/@svta/cml-structured-field-values": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@svta/cml-structured-field-values/-/cml-structured-field-values-1.0.1.tgz",
+ "integrity": "sha512-Kibciki59Pon3Pn/sl5uyrbJcSpZQDKqdCfDrokBvOdLoqqcd0oFrkEPsZBiuuIODX1CB80612xe8hopeFDyBA==",
+ "license": "Apache-2.0",
+ "peer": true,
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "@svta/cml-utils": "1.0.1"
+ }
+ },
+ "node_modules/@svta/cml-utils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@svta/cml-utils/-/cml-utils-1.0.1.tgz",
+ "integrity": "sha512-kso3curTJfp00I1mKFoBliBApjn4aPE+wF8cPucf7TrSDVWZDeLLuF14ASmUE9m7rnrqTTK4878VvmXaXcCCfQ==",
+ "license": "Apache-2.0",
+ "peer": true,
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@svta/cml-xml": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@svta/cml-xml/-/cml-xml-1.0.1.tgz",
+ "integrity": "sha512-11LkJa5kDEcsRMWkVI1ABH3KLCxGoiSVe4kQ293ItVj8ncTTQ7htmCGiJDjS+Cmy35UgF3e/vc0ysJIiWRTx2g==",
+ "license": "Apache-2.0",
+ "peer": true,
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "@svta/cml-utils": "1.0.1"
+ }
},
"node_modules/@tootallnate/quickjs-emscripten": {
"version": "0.23.0",
@@ -1271,6 +1391,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1530,6 +1651,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746",
@@ -1778,12 +1900,18 @@
"license": "MIT"
},
"node_modules/dashjs": {
- "version": "5.0.3",
- "resolved": "https://registry.npmjs.org/dashjs/-/dashjs-5.0.3.tgz",
- "integrity": "sha512-TXndNnCUjFjF2nYBxDVba+hWRpVkadkQ8flLp7kHkem+5+wZTfRShJCnVkPUosmjS0YPE9fVNLbYPJxHBeQZvA==",
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/dashjs/-/dashjs-5.1.1.tgz",
+ "integrity": "sha512-BzNXlUgzEjhuZ5M5hlSp1qIyQHZ7NpXAR0loP9DAAFVZj/ntL1DHeZ7qp/L3bvI4rq50X5indkAZQ3zEHWJoCA==",
"license": "BSD-3-Clause",
"dependencies": {
- "@svta/common-media-library": "^0.12.4",
+ "@svta/cml-608": "1.0.1",
+ "@svta/cml-cmcd": "1.0.1",
+ "@svta/cml-cmsd": "1.0.1",
+ "@svta/cml-dash": "1.0.1",
+ "@svta/cml-id3": "1.0.1",
+ "@svta/cml-request": "1.0.1",
+ "@svta/cml-xml": "1.0.1",
"bcp-47-match": "^2.0.3",
"bcp-47-normalize": "^2.3.0",
"codem-isoboxer": "0.3.10",
@@ -1868,9 +1996,9 @@
}
},
"node_modules/devalue": {
- "version": "5.3.2",
- "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.3.2.tgz",
- "integrity": "sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw==",
+ "version": "5.6.2",
+ "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz",
+ "integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==",
"license": "MIT"
},
"node_modules/devlop": {
@@ -1890,7 +2018,8 @@
"version": "0.0.1508733",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1508733.tgz",
"integrity": "sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==",
- "license": "BSD-3-Clause"
+ "license": "BSD-3-Clause",
+ "peer": true
},
"node_modules/electron-to-chromium": {
"version": "1.5.229",
@@ -2392,10 +2521,11 @@
"license": "Apache-2.0"
},
"node_modules/hono": {
- "version": "4.10.3",
- "resolved": "https://registry.npmjs.org/hono/-/hono-4.10.3.tgz",
- "integrity": "sha512-2LOYWUbnhdxdL8MNbNg9XZig6k+cZXm5IjHn2Aviv7honhBMOHb+jxrKIeJRZJRmn+htUCKhaicxwXuUDlchRA==",
+ "version": "4.11.7",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz",
+ "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -2581,9 +2711,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
- "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
+ "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -2847,9 +2977,9 @@
}
},
"node_modules/mdast-util-to-hast": {
- "version": "13.2.0",
- "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz",
- "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==",
+ "version": "13.2.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
+ "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
@@ -4225,6 +4355,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
diff --git a/src/file-viewer/backend.tsx b/src/file-viewer/backend.tsx
index 7ea041ee08a316548e1864390b7ec1ddffd64876..d744127ecd7194d8fffa276b878772dabef17ec8 100644
--- a/src/file-viewer/backend.tsx
+++ b/src/file-viewer/backend.tsx
@@ -13,11 +13,18 @@ app.get("/file/*", async (c, next) => {
const ua = c.req.header("User-Agent")?.toLowerCase() ?? "";
const lofi = ua.includes("msie") || false;
+ let rawFilePath = c.req.path.slice(5) || "/";
+
// Some robots ignore 'robots.txt' which violates the license agreement.
if (
- (!ua.startsWith("mozilla/")) ||
- ua.includes("headless") ||
- ua.includes("discord")
+ !rawFilePath.endsWith("thumbnail.jpeg") && (
+ (!ua.startsWith("mozilla/") &&
+ // these two are for AirPlay
+ !ua.includes("airplay") &&
+ !ua.includes("applecoremedia")) ||
+ ua.includes("headless") ||
+ ua.includes("discord")
+ )
) {
return c.body(
"Forbidden\n\nYour user agent, " + JSON.stringify(ua) +
@@ -28,8 +35,6 @@ app.get("/file/*", async (c, next) => {
);
}
- let rawFilePath = c.req.path.slice(5) || "/";
-
let derivedKey = "";
if (rawFilePath.includes(":/")) {
[rawFilePath, derivedKey] = rawFilePath.split(":/") as [string, string];
@@ -102,6 +107,14 @@ app.get("/file/*", async (c, next) => {
return next();
}
}
+
+ // If an asset is too big, just redirect to the direct server URL.
+ if (size >= 1_000_000_000 && !derivedKey) {
+ return c.redirect(
+ `https://file.paperclover.net/public${escapeUri(file.path)}`,
+ );
+ }
+
const etag = `"${hash}"`;
const headers = new Headers({
@@ -313,3 +326,4 @@ import { Speedbump } from "@/file-viewer/cotyledon.tsx";
import * as cache from "@/file-viewer/cache.ts";
import * as derived from "@/file-viewer/models/derived.ts";
import { UNWRAP } from "lib/assert.ts";
+import { escapeUri } from "./format.ts";
diff --git a/src/file-viewer/cache.ts b/src/file-viewer/cache.ts
index b6ce512d4487793e53a2365ecc7f82c1ffec4e4d..14e38202aa19265a5d787631b39162ba01c19770 100644
--- a/src/file-viewer/cache.ts
+++ b/src/file-viewer/cache.ts
@@ -145,6 +145,9 @@ export async function read(
if (localOnly) return null;
+ const suffix = derived ? ":/" + derived.split("/").slice(1).join("/") : "";
+ const url = `${sotUrl}/file${file.path}${suffix}`;
+
// Disk cache
const diskPath = diskCacheRoot.join(hash);
let diskFile = diskCache.get(diskPath);
@@ -158,6 +161,17 @@ export async function read(
});
if (diskStream) {
const isPending = diskFile.written.value !== diskFile.size;
+
+ // TODO: fix this? i'm suspicious
+ if (isPending) {
+ diskStream.close();
+ return {
+ stream: await fetchAndSlice(rel, url, start, end),
+ size,
+ src: "contended",
+ };
+ }
+
console.info(`from ${isPending ? "pending " : ""}disk cache: ${rel}`);
return {
stream: stream.Readable.toWeb(diskStream),
@@ -169,15 +183,11 @@ export async function read(
}
}
- // Fetch the source of truth
- const suffix = derived ? ":/" + derived.split("/").slice(1).join("/") : "";
- const url = `${sotUrl}/file${file.path}${suffix}`;
-
// Do not cache large assets
if (size > 1_000_000_000) {
console.info(`skipping disk cache due to large asset: ${rel}`);
return {
- stream: fetchAndSlice(rel, url, start, end),
+ stream: await fetchAndSlice(rel, url, start, end),
size,
src: "ignored",
};
@@ -186,7 +196,7 @@ export async function read(
if (diskCache.unlockedCapacity < size) {
console.info(`skipping disk cache due to contention: ${rel}`);
return {
- stream: fetchAndSlice(rel, url, start, end),
+ stream: await fetchAndSlice(rel, url, start, end),
size,
src: "contended",
};
@@ -196,7 +206,7 @@ export async function read(
if (noWrite) {
console.info(`skipping disk cache due to config: ${rel}`);
return {
- stream: fetchAndSlice(rel, url, start, end),
+ stream: await fetchAndSlice(rel, url, start, end),
size,
src: "ratelimit",
};
@@ -207,7 +217,16 @@ export async function read(
diskFile = { size, written: new async.Watch(0), memory: new Map() };
diskCache.set(diskPath, diskFile);
const lock = diskCache.lock(diskPath);
- return fetchAndStore(rel, url, size, diskPath, diskFile, start, end, lock);
+ return await fetchAndStore(
+ rel,
+ url,
+ size,
+ diskPath,
+ diskFile,
+ start,
+ end,
+ lock,
+ );
}
async function fetchAndStore(
@@ -218,7 +237,7 @@ async function fetchAndStore(
diskFile: DiskFile,
start: number,
end: number,
- lock: Lock,
+ lock: ts.Dispose,
) {
try {
let response = await fetch(rel, url);
@@ -236,7 +255,11 @@ async function fetchAndStore(
diskFile.written.value = Infinity;
});
- return { stream: sliceStream(response, start, end), size, src: "miss" };
+ return {
+ stream: sliceStream(response, start, end),
+ size,
+ src: "miss",
+ } as const;
} catch (e) {
lock();
throw e;
@@ -590,3 +613,4 @@ import type { ReadableStream } from "node:stream/web";
import { Lru } from "lib/Lru.ts";
import { Path } from "#sitegen/path";
import { ASSERT, UNWRAP } from "lib/assert.ts";
+import * as ts from "lib/ts.ts";
diff --git a/src/pages/history-of-japan-reanimated.client.ts b/src/pages/history-of-japan-reanimated.client.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2d053ec6dd77136c34766e0529c91d8beda2f2a2
--- /dev/null
+++ b/src/pages/history-of-japan-reanimated.client.ts
@@ -0,0 +1,137 @@
+// this script is mostly written by ai
+
+const urls: Record = {
+ "Kappacap": "https://www.youtube.com/channel/UClywJ-scBa8hcCywQ_7gWbg",
+ "atamai": "https://www.tumblr.com/atamai-m8",
+ "moxley": "https://www.youtube.com/channel/UCNJ6EbYQcnqpB6cQLGYXQow",
+ "TheJustinator": "https://www.youtube.com/channel/UCdy0IJXbdKyTWyKajyEvodw",
+ "chaosyumi": "https://chaosyumi.net",
+ "LunaPogi": "https://www.youtube.com/channel/UC7NHQWu3awWcC0rm8nB5CfQ",
+ "zeccet": "https://www.youtube.com/channel/UChec240BfC1UKX9DvBBIyLQ",
+ "TheRealTinyWorld":
+ "https://www.youtube.com/channel/UCLfv-6_J0WLsA2jAsJxiLOg",
+ "Fred French VA": "https://www.castingcall.club/fredfrenchiii",
+ "rqfirqfo": "https://www.youtube.com/channel/UCqeAGMquQNtrSfd-quZX3KQ",
+ "krispykarim": "https://www.youtube.com/@krispykarim",
+ "SimplyDoodled Studios":
+ "https://www.youtube.com/channel/UCkhbgaqiIkliOKyEORnkhDg",
+ "paper clover": "",
+ "Coffee": "https://www.youtube.com/channel/UCftnPCmG6dfQ50YOMRMtkjg",
+ "harjjw": "https://www.youtube.com/channel/UCPQv3ueB-E771gnwcgALzxw",
+ "yellowmarkers": "https://www.youtube.com/channel/UCcR5G761gkEKvWskjEqWFkQ",
+ "TheNothingGuy07": "https://www.youtube.com/channel/UC8SZGQ32RQxiQeTnQ5hgYAg",
+ "bill wurtz": "https://billwurtz.com",
+};
+
+const animators: [string, number][] = [
+ ["atamai", 0 * 60 + 3.1],
+ ["TheJustinator", 1 * 60 + 2],
+ ["chaosyumi", 1 * 60 + 33],
+ ["TheJustinator", 2 * 60 + 1],
+ ["zeccet", 2 * 60 + 22],
+ ["TheRealTinyWorld", 3 * 60 + 10],
+ ["LunaPogi", 3 * 60 + 35],
+ ["moxley", 3 * 60 + 50],
+ ["rqfirqfo", 4 * 60 + 3],
+ ["krispykarim", 4 * 60 + 35],
+ ["SimplyDoodled Studios", 5 * 60 + 4],
+ ["chaosyumi", 5 * 60 + 33],
+ ["paper clover", 6 * 60 + 4],
+ ["harjjw", 6 * 60 + 37],
+ ["yellowmarkers", 7 * 60 + 3],
+ ["TheNothingGuy07", 7 * 60 + 36],
+ ["LunaPogi", 8 * 60 + 0],
+ ["paper clover", 9 * 60 + 11],
+];
+
+const narrators: [string, number][] = [
+ ["moxley", 0 * 60 + 3.1],
+ ["Fred French VA", 3 * 60 + 10],
+ ["krispykarim", 4 * 60 + 35],
+ ["LunaPogi", 8 * 60 + 0],
+];
+
+const musicians: [string, number][] = [
+ ["moxley", 0 * 60 + 3.1],
+ ["TheJustinator", 3 * 60 + 10],
+ ["LunaPogi", 6 * 60 + 4],
+ ["bill wurtz", 9 * 60 + 11],
+];
+
+// random colors for each creator
+const creatorHues: Record = {};
+for (const name of Object.keys(urls)) {
+ let hash = 0;
+ for (let i = 0; i < name.length; i++) {
+ hash = ((hash << 5) - hash) + name.charCodeAt(i);
+ hash |= 0;
+ }
+ creatorHues[name] = hash % 360;
+}
+
+function findCurrent(list: [string, number][], time: number): string | null {
+ let current: string | null = null;
+ for (const [name, start] of list) {
+ if (time >= start) {
+ current = name;
+ } else {
+ break;
+ }
+ }
+ return current;
+}
+
+function createCreditElement(name: string, role: string): string {
+ const hue = creatorHues[name] ?? 0;
+ const url = urls[name];
+ const style =
+ `background: hsl(${hue}, 60%, 25%); color: hsl(${hue}, 80%, 85%);`;
+
+ if (url) {
+ return `${name} (${role})`;
+ }
+ return `${name} (${role})`;
+}
+
+const creditsBox = document.getElementById("live-credits")!;
+
+let lastCreditsKey = "";
+
+function updateCredits(video) {
+ const time = video.currentTime;
+
+ const animator = time > 9 * 60 + 32 ? null : findCurrent(animators, time);
+ const narrator = time > 9 * 60 + 11 ? null : findCurrent(narrators, time);
+ const musician = time > 9 * 60 + 32 ? null : findCurrent(musicians, time);
+
+ // group roles by person
+ const rolesByPerson: Record = {};
+ if (animator) (rolesByPerson[animator] ??= []).push("animation");
+ if (narrator) (rolesByPerson[narrator] ??= []).push("narration");
+ if (musician) (rolesByPerson[musician] ??= []).push("music");
+
+ const creditsKey = JSON.stringify(rolesByPerson);
+ if (creditsKey === lastCreditsKey) return;
+ lastCreditsKey = creditsKey;
+
+ const html = Object.entries(rolesByPerson)
+ .map(([name, roles]) => createCreditElement(name, roles.join(", ")))
+ .join(" ");
+
+ creditsBox.innerHTML = html
+ ? "credits: " + html
+ : "";
+}
+
+globalThis.onCloverVideoInit = (id: string, video: HTMLVideoElement) => {
+ console.log(1, id, video);
+ if (id === "main-player") {
+ updateCredits(video);
+ video.addEventListener("timeupdate", () => {
+ updateCredits(video);
+ });
+ video.addEventListener("seeked", () => {
+ updateCredits(video);
+ });
+ }
+};
diff --git a/src/pages/history-of-japan-reanimated.css b/src/pages/history-of-japan-reanimated.css
new file mode 100644
index 0000000000000000000000000000000000000000..ac121ba69c05e70b67bb8b7180dd030088502924
--- /dev/null
+++ b/src/pages/history-of-japan-reanimated.css
@@ -0,0 +1,38 @@
+#live-credits {
+ background: #602945;
+ color: white;
+ height: 30px;
+ display: flex;
+ margin-top: -16px;
+ align-items: center;
+}
+#live-credits a {
+ flex: auto;
+ padding: 0 8px;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ gap: 5px;
+}
+
+@media (max-width: 768px) {
+ #live-credits {
+ flex-direction: column;
+ height: unset;
+
+ b {
+ flex: auto;
+ padding: 0 8px;
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ height: 30px;
+ }
+
+ a {
+ height: 30px;
+ width: 100%;
+ justify-content: center;
+ }
+ }
+}
diff --git a/src/pages/history-of-japan-reanimated.marko b/src/pages/history-of-japan-reanimated.marko
new file mode 100644
index 0000000000000000000000000000000000000000..52f8e0c8b2c90e9ff22c67d2dbaa7d3456aef6db
--- /dev/null
+++ b/src/pages/history-of-japan-reanimated.marko
@@ -0,0 +1,104 @@
+import "./history-of-japan-reanimated.css";
+
+client import "./history-of-japan-reanimated.client.ts";
+
+export const meta = {
+ title: "history of japan reanimated",
+ description: "17 artists and musicians come together to remake bill wurtz's legendary \"history of japan\" to celebrate its 10 year anniversary.",
+ embed: {
+ thumbnail: "https://paperclover.net/file/2026/history%20of%20japan%20reanimated/thumbnail.jpeg"
+ },
+ canonical: "/history-of-japan-reanimated",
+ twitter: {
+ image: "https://paperclover.net/file/2026/history%20of%20japan%20reanimated/thumbnail.jpeg"
+ },
+};
+export const theme = {
+ fg: "#fff",
+ bg: "#442233",
+ primary: "#ff8b47"
+};
+
+
+ history of japan reanimated
+
+ me and 16 other artists (music, narration, and animation) have been
+ spending the past couple months on a recreation of bill wurtz's legendary
+ "history of japan" to celebrate the 10 year anniversary of his release. i
+ contributed two animation sections. the canonical release is on
+ LunaPogi's YouTube page, but i
+ am providing this ad-free video player.
+
+
+
+ <@header>history of japan reanimated>
+>
+
+
+
+
+
+
+
+ the wonderful souls who worked on the project, in order of appearance.
+
+
+
+
+ the music during the credits is an instrumental by bill wurtz: hey mom i beat the scale!
+
+
+ what's next
+
+ from the success of this project, a much more ambitous project is being
+ planned: the reanimation for 'history of the entire world, i guess'. while
+ some planning between co-hosts is in progress, i am putting out an
+ interest form if this project is something you're interested in working
+ on. by submitting the form, i'll be able to reach out when it's actually time to start cooking.
+
+
+ behind the scenes
+
+ here is a 55 minute dive into my process, as well as nearly every little trick i used on this project. additionally, for one of the first times from me, a free download for all of the project files.
+
+
+ <@header>behind the scenes session with clover>
+ >
+
+
+ more links
+
+ more:
+ fragments
+
+ paper clover q&a page
+ paper clover's mailing list
+
+
+ mentions on the q&a
+
+
diff --git a/src/pages/index.marko b/src/pages/index.marko
index 35db1efdc1a97160c5f3e4db23844da5724186d2..5a93f36788a481699d7f4ca54737429933663ddf 100644
--- a/src/pages/index.marko
+++ b/src/pages/index.marko
@@ -21,6 +21,7 @@ export const meta = {
this site contains my art.
posts
+ video collab: history of japan reanimated (2026-02-02)
blog: One Year with Next.js App Router — why we're moving on (2025-10-21)
song: in the summer (2025-08-08)
song: waterfalls (2025-01-01)
diff --git a/src/tags/clover-video.client.ts b/src/tags/clover-video.client.ts
index 7a64b19dfe52555ec6b618053c18cec056bfc659..0c33bd467afa2e79298b8e0456d6db6d753a7721 100644
--- a/src/tags/clover-video.client.ts
+++ b/src/tags/clover-video.client.ts
@@ -2,6 +2,9 @@
// Polyfill for old browsers.
type PlaybackMode = "av1" | "hls-polyfill" | "hls-native" | "none";
+const forceMode = location.search.match(/(?:\?|&)force-player=(.*)/)?.[1] as
+ | PlaybackMode
+ | undefined;
let mode: Promise | null = null;
let hls: Promise | null = null;
@@ -13,6 +16,7 @@ Promise.allSettled ??= (promises: Iterable>) =>
(reason) => ({ status: "rejected" as const, reason }),
)));
+declare function hydrateVideo(container: HTMLElement): void;
globalThis.hydrateVideo = function hydrateVideo(container) {
mode ??= decidePlaybackMode();
mode.then((mode) => hydrateVideoInner(container, mode));
@@ -23,9 +27,21 @@ Array.from(
(container) => hydrateVideo(container),
);
+// TODO: be less stupid about this
+const onCloverVideoInit = window.onCloverVideoInit;
+
function decidePlaybackMode(): Promise {
+ if (forceMode) {
+ if (forceMode === "hls-polyfill") {
+ // @ts-expect-error
+ return (hls ??= import("/js/scripts/vendor/hls.js"))
+ .then(() => forceMode);
+ }
+ return Promise.resolve(forceMode);
+ }
+
return shouldPlayAv1().catch(() => ({ hardware: false, supported: false }))
- .then((av1): Awaitable => {
+ .then((av1): PlaybackMode | Promise => {
// Native AV1 with DASH Polyfill
// (Chrome v70, Safari v17 on M3/A17 chip, Firefox v67)
if (av1.hardware) return "av1";
@@ -43,6 +59,7 @@ function decidePlaybackMode(): Promise {
return "hls-native";
}
+ // @ts-expect-error
return (hls ??= import("/js/scripts/vendor/hls.js"))
.then((hls): PlaybackMode => {
// Polyfill HLS (Chrome 23, Firefox 42, IE11 on Win8)
@@ -59,80 +76,165 @@ function decidePlaybackMode(): Promise {
});
}
-declare global {
- function hydrateVideo(container: HTMLElement): void;
-}
-
function hydrateVideoInner(
container: HTMLElement,
mode: PlaybackMode,
) {
- const video = container instanceof HTMLVideoElement
+ let video = container instanceof HTMLVideoElement
? container
: container.querySelector("video")!;
+ const id = video.id;
if (video.hasAttribute("controls")) return;
let src = video.getAttribute("data-src");
if (!src) return;
src = `/file${src}`;
+ src = new URL(src, location.origin).href;
const dashFile = `${src}:/dash.mpd`;
const hlsFile = `${src}:/master.m3u8`;
+ if (forceMode) {
+ const figcaption = container.querySelector("figcaption");
+ if (figcaption) {
+ figcaption.innerHTML += " (force player backend: " + forceMode + ")";
+ }
+ }
+
if (mode === "av1") {
+ // @ts-expect-error
import("/js/scripts/vendor/dash.js").then((dashjs) => {
+ let mainSource: HTMLSourceElement | null = null;
+ let mediaSource: MediaSource | null = null, objectUrl = "";
+ const hasThumbnail = video.hasAttribute("poster");
+
const player = dashjs.MediaPlayer().create();
+
+ // the world is so simple when there is no thumbnail because we are not
+ // working around a billion sharp edges.
+ if (!hasThumbnail) {
+ enableSafariAirplay();
+ player.initialize(video, dashFile, false);
+ video.controls = true;
+ onCloverVideoInit?.(id, video);
+ return;
+ }
+
+ // With Dash.js, we get pranked. No matter if preloading is on or off,
+ // the first frame is always preloaded and will override the poster.
+ // To avoid this, the viewer is attached later. However, on Chrome the
+ // video is still not playable until metadata has loaded in, so a fake
+ // MediaSource with one invisible frame is used.
+ player.initialize(null, dashFile, false);
+ player.updateSettings({
+ streaming: { cacheInitSegments: true },
+ });
+ player.preload();
+
+ if (typeof MediaSource !== "undefined") {
+ const duration = new Promise((resolve) => {
+ player.on(dashjs.MediaPlayer.events.MANIFEST_LOADED, (e) => {
+ const duration = e.data.mediaPresentationDuration;
+ resolve(duration); // seconds
+ });
+ });
+ mediaSource = new MediaSource();
+ mediaSource.addEventListener("sourceopen", () => {
+ duration.then((duration) => {
+ mediaSource = mediaSource!;
+ mediaSource.duration = duration;
+ const sb = mediaSource.addSourceBuffer('video/webm; codecs="vp8"');
+ const oneFrame = Uint8Array.from(
+ atob(
+ "GkXfo59ChoEBQveBAULygQRC84EIQoKEd2VibUKHgQJChYECGFOAZwEAAAAAAAITEU2bdLpNu4tTq4QVSalmU6yBoU27i1OrhBZUrmtTrIHWTbuMU6uEElTDZ1OsggEjTbuMU6uEHFO7a1OsggH97AEAAAAAAABZ" +
+ "A".repeat(119) +
+ "VSalmsCrXsYMPQkBNgIxMYXZmNjIuMy4xMDBXQYxMYXZmNjIuMy4xMDBEiYhAXgAAAAAAABZUrmvIrgEAAAAAAAA/14EBc8WIDV7YG1KxA/CcgQAitZyDdW5kiIEAhoVWX1ZQOIOBASPjg4QCYloA4JCwgQG6gQGagQJVsIRVuYEBElTDZ/tzc59jwIBnyJlFo4dFTkNPREVSRIeMTGF2ZjYyLjMuMTAwc3PWY8CLY8WIDV7YG1KxA/BnyKFFo4dFTkNPREVSRIeUTGF2YzYyLjExLjEwMCBsaWJ2cHhnyKFFo4hEVVJBVElPTkSHkzAwOjAwOjAwLjEyMDAwMDAwMAAfQ7Z11eeBAKOigQAAgBACAJ0BKgEAAQALxwiFhYiFhIg/ggAMDWAA/ua1AKOVgQAoALEBAC8R/AAYABhYL/QAJAAAo5WBAFAAsQEALxH8ABgAGFgv9AAkAAAcU7trkbuPs4EAt4r3gQHxggGj8IED",
+ ),
+ (x) => x.charCodeAt(0),
+ );
+ sb.appendBuffer(oneFrame);
+ });
+ });
+ objectUrl = URL.createObjectURL(mediaSource);
+
+ mainSource = document.createElement("source");
+ mainSource.src = objectUrl;
+ video.appendChild(mainSource);
+ }
+ video.addEventListener("play", function play() {
+ video.removeEventListener("play", play);
+
+ // Revoke Fake MediaSource
+ if (objectUrl) {
+ mainSource?.remove();
+ mainSource = null;
+ mediaSource?.endOfStream();
+ mediaSource = null;
+ URL.revokeObjectURL(objectUrl);
+
+ const newVideo = document.createElement("video");
+ newVideo.poster = video.poster;
+ newVideo.controls = true;
+ video.parentElement!.insertBefore(newVideo, video);
+ video.remove();
+ video = newVideo;
+ }
+
+ enableSafariAirplay();
+ player.attachView(video);
+ if (objectUrl) video.play();
+ onCloverVideoInit?.(id, video);
+ });
+ video.controls = true;
+ onCloverVideoInit?.(id, video);
+
// This proxying is for ManagedMediaSource, exposing the HLS stream for AirPlay
- let mainSource: HTMLSourceElement | null = null;
- Object.defineProperty(video, "src", {
- get: () => false,
- set: (v) => {
- if (!mainSource) {
- mainSource = document.createElement("source");
- mainSource.src = v;
- video.appendChild(mainSource);
- } else {
- mainSource.src = v;
- }
- },
- });
- Object.defineProperty(video, "disableRemotePlayback", {
- get: () => false,
- set: () => {
- const source = document.createElement("source");
- source.src = hlsFile;
- video.appendChild(source);
- },
- });
- player.initialize(
- video,
- dashFile,
- false, // autoplay
- 0, // start
- );
- video.controls = true;
+ function enableSafariAirplay() {
+ Object.defineProperty(video, "src", {
+ get: () => false,
+ set: (v) => {
+ if (!mainSource) {
+ mainSource = document.createElement("source");
+ mainSource.src = v;
+ video.appendChild(mainSource);
+ } else {
+ mainSource.src = v;
+ }
+ },
+ });
+ Object.defineProperty(video, "disableRemotePlayback", {
+ get: () => false,
+ set: () => {
+ const source = document.createElement("source");
+ source.src = hlsFile;
+ video.appendChild(source);
+ },
+ });
+ }
});
} else if (mode === "hls-native") {
const hlsSource = document.createElement("source");
- hlsSource.src = `${src}:/hls.m3u8`;
+ hlsSource.src = hlsFile;
video.appendChild(hlsSource);
video.controls = true;
+ onCloverVideoInit?.(id, video);
} else if (mode === "hls-polyfill") {
hls!.then((hls) => {
- const player = new hls.default();
+ console.log(hls);
+ // @ts-expect-error suspicious
+ const player = new (hls.default ?? hls.Hls)();
player.loadSource(hlsFile);
player.attachMedia(video);
video.controls = true;
+ onCloverVideoInit?.(id, video);
});
} else {
video.src = src;
video.controls = true;
+ onCloverVideoInit?.(id, video);
}
}
-declare global {
- var ManagedMediaSource: typeof MediaSource | null;
-}
-
+declare var ManagedMediaSource: typeof MediaSource | null;
function shouldPlayAv1(): Promise<{ hardware: boolean; supported: boolean }> {
const MediaSource = globalThis.ManagedMediaSource ?? globalThis.MediaSource;
if (!MediaSource.isTypeSupported('audio/webm;codecs="opus"')) {
diff --git a/src/tags/clover-video.css b/src/tags/clover-video.css
index b49e98ebf3dff80f5d9a4082867eacde9c42a342..b77b05560c7dd58e8ef046f57dbe8b911a48bd51 100644
--- a/src/tags/clover-video.css
+++ b/src/tags/clover-video.css
@@ -18,6 +18,7 @@
}
.clover-video video {
width: 100%;
+ height: 100%;
}
.clover-video h2 {
margin-top: 0;
diff --git a/src/tags/clover-video.marko b/src/tags/clover-video.marko
index 81d2d442f4adafc7f0cff6fbd9e5835b27c3a3b9..09c27d9310e3527308a87afa6cad5292c9ad678c 100644
--- a/src/tags/clover-video.marko
+++ b/src/tags/clover-video.marko
@@ -1,6 +1,7 @@
import "./clover-video.css";
export interface Input {
+ id?: string;
file: MediaFile | string;
poster?: MediaFile | string;
header?: Marko.AttrTag<{ content: Marko.Body }>;
@@ -8,7 +9,7 @@ export interface Input {
noFigure?: boolean
}
-
+
@@ -22,6 +23,7 @@ export interface Input {