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 {