Merge remote-tracking branch 'github/claude/friendly-dirac-hz5pje' into resume-downloads

This commit is contained in:
Jonathan Sykes
2026-10-03 00:28:45 +08:00
10 changed files with 189 additions and 9 deletions

View File

@@ -36,7 +36,7 @@ JSON shapes mirror the Tauri Rust bridge exactly — don't change one side alone
sends the `File` itself); cover images go to `PUT /api/admin/uploads/:id/art`. The multipart
`POST /api/admin/uploads` parses the whole body in memory — scripts with small files only.
- Videos over `transcode.maxSeconds` are never re-encoded (lane would be tied up for hours).
- Deferred ideas (Cloudflare Worker fetch, Android device download): `docs/deferred-ideas.md`.
- Deferred ideas (Cloudflare Worker fetch, Android device download, ffmpeg.wasm client editing): `docs/deferred-ideas.md`.
## Save to device + "On other devices"
- `exportToDevice(video)` (app.js, helpers in `frontend/export.js`): puts the file in Photos / Files / Downloads.
@@ -76,6 +76,15 @@ JSON shapes mirror the Tauri Rust bridge exactly — don't change one side alone
`ignoreSearch` against the plain-URL precache. A new build = new URLs, so old ones simply get evicted.
`/fonts` and `/icons` are cached 30 days.
## SHA-256 in WebAssembly (`frontend/sha256.js`)
- `Sha256.create()` runs its block function as WebAssembly (`frontend/wasm/sha256.c` → `scripts/build-sha256-wasm.sh`
→ generated `frontend/sha256-wasm.js`, base64, ~2 KB) and falls back to pure JS if WebAssembly is missing, the CSP
refuses it, or the load-time self-test ("abc") fails. ~150 MB/s vs ~65 MB/s in JS (Node/Chromium; phones are slower
but the ratio holds). It is synchronous on purpose (callers aren't async) — fine because the module is under 4 KB.
- Needs `'wasm-unsafe-eval'` in index.html's CSP `script-src`. Workers pull the blob in with `importScripts('/sha256-wasm.js')`
from inside sha256.js; the file is in the SW `SHELL` list. After editing the C file, re-run the build script and commit both.
`Sha256.engine()` says which one is active; `create({js:true})` forces JS (tests).
## Server media cache (`server/media-cache.js`)
- Every played (`/api/streams`, LOW priority, ≤ `MEDIA_AUTO_MAX_SECONDS`, default 3 h) or saved
(`/api/download`, HIGH, ≤ 3 h) video gets ONE copy: `$MEDIA_DIR/<id>.<gen>.mp4`

View File

@@ -32,3 +32,13 @@ yt-dlp (or an equivalent extractor) on the phone's own IP, checks the server
cache first, then uploads the finished file in the background through the
device intake (`POST /api/p2p/intake`, `server/p2p-intake.js`). The server's
yt-dlp stays as the fallback when the device fails.
## Client-side video editing with ffmpeg.wasm (deferred 2026-10-02)
Skipped on purpose. ffmpeg.wasm is about 25–30 MB, and fast (multi-threaded)
use needs cross-origin isolation (COOP/COEP). That would break the YouTube
thumbnails and avatars unless every image host sends CORP headers, and we do not
control `i.ytimg.com` / `ggpht.com`. The server already trims video (edit & download
runs ffmpeg there), so the browser gains nothing it needs. Revisit only if offline
editing becomes a requirement; then load it lazily from a worker and measure the
isolation fallout first.

View File

@@ -3,7 +3,7 @@
* thread, read in 4 MiB slices (never the whole video in memory).
*
* In: { name } file name under OPFS videos/ (e.g. "abc.mp4")
* Out: { ok: true, sha256, size } | { ok: false, error }
^ * Out: { ok: true, sha256, size, engine } | { ok: false, error }
* ========================================================================== */
'use strict';
importScripts('/sha256.js');
@@ -19,7 +19,7 @@ self.onmessage = async (e) => {
for (let pos = 0; pos < file.size; pos += STEP) {
h.update(new Uint8Array(await file.slice(pos, pos + STEP).arrayBuffer()));
}
self.postMessage({ ok: true, sha256: h.hex(), size: file.size });
self.postMessage({ ok: true, sha256: h.hex(), size: file.size, engine: self.Sha256.engine() });
} catch (err) {
self.postMessage({ ok: false, error: err && err.message ? err.message : String(err) });
}

View File

@@ -13,7 +13,7 @@
<meta name="apple-mobile-web-app-title" content="YT Player" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; img-src 'self' https: data: asset: http://asset.localhost blob:; media-src 'self' https: blob: asset: http://asset.localhost; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; script-src 'self'; connect-src 'self' https: ipc: http://ipc.localhost blob:; worker-src 'self';"
content="default-src 'self'; img-src 'self' https: data: asset: http://asset.localhost blob:; media-src 'self' https: blob: asset: http://asset.localhost; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; script-src 'self' 'wasm-unsafe-eval'; connect-src 'self' https: ipc: http://ipc.localhost blob:; worker-src 'self';"
/>
<title>YT Player</title>
<link rel="manifest" href="/manifest.webmanifest" />
@@ -632,6 +632,7 @@
<script src="flag-ui.js"></script>
<script src="export.js"></script>
<script src="stats-core.js"></script>
<script src="sha256-wasm.js"></script>
<script src="sha256.js"></script>
<script src="device-db.js"></script>
<script src="p2p-client.js"></script>

7
frontend/sha256-wasm.js Normal file
View File

@@ -0,0 +1,7 @@
/* GENERATED by scripts/build-sha256-wasm.sh from frontend/wasm/sha256.c — do not edit.
* WebAssembly SHA-256 block compression, embedded as base64 so it loads with the
* page / worker that already loads sha256.js (no extra request, no MIME or SW change). */
(function (root) {
root.SHA256_WASM_B64 = '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';
if (typeof module !== 'undefined' && module.exports) module.exports = root.SHA256_WASM_B64;
})(typeof globalThis !== 'undefined' ? globalThis : this);

View File

@@ -1,6 +1,10 @@
/* ============================================================================
* sha256.js — incremental SHA-256 (pure JS; window.Sha256 / worker / node)
*
* The block function runs as WebAssembly (wasm/sha256.c → sha256-wasm.js) when the
* browser allows it, with the pure-JS version below as the fallback — a failed
* load or a failed self-test silently keeps hashing in JS.
*
* WebCrypto's digest() needs the whole input at once, which would pull a
* multi-hundred-MB video into memory. This hasher takes chunks as they stream
* past (downloads, peer transfers, OPFS reads) and keeps ~100 bytes of state.
@@ -22,7 +26,41 @@
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]);
function create() {
// ---- WebAssembly engine (optional) -------------------------------------
// Memory layout shared with wasm/sha256.c: state at STATE, input blocks at DATA.
const STATE = 16384, DATA = 65536, CHUNK = 1 << 20; // 1 MiB staging window
const IV = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
let wasm; // undefined = not tried yet, null = unavailable, else { compress, state, data }
function loadWasm() {
if (wasm !== undefined) return wasm;
wasm = null;
try {
if (typeof WebAssembly === 'undefined') return wasm;
if (!root.SHA256_WASM_B64 && typeof importScripts === 'function') importScripts('/sha256-wasm.js');
let b64 = root.SHA256_WASM_B64;
if (!b64 && typeof require === 'function') b64 = require('./sha256-wasm.js');
if (!b64) return wasm;
const bin = typeof Buffer !== 'undefined' ? Uint8Array.from(Buffer.from(b64, 'base64'))
: Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const memory = new WebAssembly.Memory({ initial: (DATA + CHUNK) / 65536, maximum: (DATA + CHUNK) / 65536 });
const inst = new WebAssembly.Instance(new WebAssembly.Module(bin), { env: { memory } });
const e = {
compress: inst.exports.compress,
state: new Uint32Array(memory.buffer, STATE, 8),
data: new Uint8Array(memory.buffer, DATA, CHUNK),
};
// Self-test: one padded block of "abc" must give the well-known digest.
e.state.set(IV);
e.data.fill(0, 0, 64); e.data.set([0x61, 0x62, 0x63, 0x80], 0); e.data[63] = 24;
e.compress(STATE, DATA, 1);
if (e.state[0] !== 0xba7816bf || e.state[7] !== 0xf20015ad) return wasm;
wasm = e;
} catch { wasm = null; }
return wasm;
}
function create(opts) {
const useWasm = !(opts && opts.js) && loadWasm();
const H = new Uint32Array([
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
]);
@@ -58,6 +96,21 @@
H[4] = (H[4] + e) | 0; H[5] = (H[5] + f) | 0; H[6] = (H[6] + g) | 0; H[7] = (H[7] + h) | 0;
}
// Hash n whole 64-byte blocks of buf starting at off — wasm in 1 MiB windows, else JS.
function blocks(buf, off, n) {
if (!useWasm) { for (let k = 0; k < n; k++) compress(buf, off + k * 64); return; }
const w = useWasm;
w.state.set(H);
const per = CHUNK / 64;
for (let done = 0; done < n;) {
const m = Math.min(per, n - done);
w.data.set(buf.subarray(off + done * 64, off + (done + m) * 64));
w.compress(STATE, DATA, m);
done += m;
}
H.set(w.state);
}
function update(data) {
if (done) throw new Error('sha256: update() after digest');
const u8 = data instanceof Uint8Array ? data : new Uint8Array(data);
@@ -68,9 +121,10 @@
block.set(u8.subarray(0, take), blockLen);
blockLen += take;
i = take;
if (blockLen === 64) { compress(block, 0); blockLen = 0; }
if (blockLen === 64) { blocks(block, 0, 1); blockLen = 0; }
}
for (; i + 64 <= u8.length; i += 64) compress(u8, i);
const whole = Math.floor((u8.length - i) / 64);
if (whole) { blocks(u8, i, whole); i += whole * 64; }
if (i < u8.length) { block.set(u8.subarray(i), 0); blockLen = u8.length - i; }
return api;
}
@@ -80,12 +134,12 @@
done = true;
const bits = total * 8;
block[blockLen++] = 0x80;
if (blockLen > 56) { block.fill(0, blockLen); compress(block, 0); blockLen = 0; }
if (blockLen > 56) { block.fill(0, blockLen); blocks(block, 0, 1); blockLen = 0; }
block.fill(0, blockLen, 56);
const hi = Math.floor(bits / 0x100000000), lo = bits >>> 0;
block[56] = hi >>> 24; block[57] = hi >>> 16; block[58] = hi >>> 8; block[59] = hi;
block[60] = lo >>> 24; block[61] = lo >>> 16; block[62] = lo >>> 8; block[63] = lo;
compress(block, 0);
blocks(block, 0, 1);
}
const out = new Uint8Array(32);
for (let i = 0; i < 8; i++) {
@@ -107,6 +161,8 @@
const Sha256 = {
create,
hex: (data) => create().update(data).hex(),
// Which engine create() will use: 'wasm' or 'js'.
engine: () => (loadWasm() ? 'wasm' : 'js'),
isHex: (s) => typeof s === 'string' && /^[0-9a-f]{64}$/.test(s),
};
if (typeof module !== 'undefined' && module.exports) module.exports = Sha256;

View File

@@ -43,3 +43,32 @@ test('update after digest throws; isHex', () => {
assert.ok(Sha256.isHex(ref(Buffer.from('x'))));
assert.ok(!Sha256.isHex('ABC'));
});
// ---- WebAssembly engine ----
test('wasm engine is available in Node and matches the JS engine and node:crypto', () => {
const crypto = require('node:crypto');
assert.equal(Sha256.engine(), 'wasm');
const sizes = [0, 1, 55, 56, 63, 64, 65, 1000, (1 << 20) - 1, 1 << 20, (1 << 20) + 1, 3_000_001];
for (const n of sizes) {
const buf = Buffer.alloc(n).map((_, i) => (i * 7 + n) & 255);
const want = crypto.createHash('sha256').update(buf).digest('hex');
assert.equal(Sha256.create().update(buf).hex(), want, `wasm ${n}`);
assert.equal(Sha256.create({ js: true }).update(buf).hex(), want, `js ${n}`);
}
});
test('wasm engine hashes streamed odd-sized chunks like one-shot', () => {
const crypto = require('node:crypto');
const buf = Buffer.alloc(2_500_003).map((_, i) => (i * 13) & 255);
const h = Sha256.create();
for (let o = 0; o < buf.length; o += 77_777) h.update(buf.subarray(o, o + 77_777));
assert.equal(h.hex(), crypto.createHash('sha256').update(buf).digest('hex'));
});
test('falls back to the JS engine when WebAssembly is unavailable', () => {
const { execFileSync } = require('node:child_process');
const out = execFileSync(process.execPath, ['-e',
"delete globalThis.WebAssembly; const S=require('./frontend/sha256.js');" +
"console.log(S.engine(), S.hex(new TextEncoder().encode('abc')))"], { cwd: require('node:path').join(__dirname, '..') }).toString().trim();
assert.equal(out, 'js ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad');
});

View File

@@ -70,6 +70,7 @@ const SHELL = [
'/lyrics-core.js',
'/stats-core.js',
'/sha256.js',
'/sha256-wasm.js',
'/device-db.js',
'/hash-worker.js',
'/p2p-client.js',

46
frontend/wasm/sha256.c Normal file
View File

@@ -0,0 +1,46 @@
// SHA-256 block compression for frontend/sha256.js (built to WebAssembly).
// No libc, no allocation: JS owns the memory layout.
// state (8 x u32) at STATE
// input (N x 64 B) at DATA
// Build: scripts/build-sha256-wasm.sh (writes frontend/sha256-wasm.js)
typedef unsigned int u32;
typedef unsigned char u8;
static const u32 K[64] = {
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2 };
#define ROR(x,n) (((x) >> (n)) | ((x) << (32 - (n))))
__attribute__((export_name("compress")))
void compress(u32 *state, const u8 *data, u32 blocks) {
u32 W[64];
while (blocks--) {
for (int i = 0; i < 16; i++, data += 4)
W[i] = ((u32)data[0] << 24) | ((u32)data[1] << 16) | ((u32)data[2] << 8) | data[3];
for (int i = 16; i < 64; i++) {
u32 s0 = ROR(W[i-15],7) ^ ROR(W[i-15],18) ^ (W[i-15] >> 3);
u32 s1 = ROR(W[i-2],17) ^ ROR(W[i-2],19) ^ (W[i-2] >> 10);
W[i] = W[i-16] + s0 + W[i-7] + s1;
}
u32 a=state[0],b=state[1],c=state[2],d=state[3],e=state[4],f=state[5],g=state[6],h=state[7];
// 8 rounds per iteration with the variables renamed instead of shuffled.
#define RND(a,b,c,d,e,f,g,h,i) do { \
u32 t1 = h + (ROR(e,6) ^ ROR(e,11) ^ ROR(e,25)) + (g ^ (e & (f ^ g))) + K[i] + W[i]; \
u32 t2 = (ROR(a,2) ^ ROR(a,13) ^ ROR(a,22)) + ((a & b) | (c & (a | b))); \
d += t1; h = t1 + t2; } while (0)
for (int i = 0; i < 64; i += 8) {
RND(a,b,c,d,e,f,g,h,i); RND(h,a,b,c,d,e,f,g,i+1);
RND(g,h,a,b,c,d,e,f,i+2); RND(f,g,h,a,b,c,d,e,i+3);
RND(e,f,g,h,a,b,c,d,i+4); RND(d,e,f,g,h,a,b,c,i+5);
RND(c,d,e,f,g,h,a,b,i+6); RND(b,c,d,e,f,g,h,a,i+7);
}
state[0]+=a; state[1]+=b; state[2]+=c; state[3]+=d; state[4]+=e; state[5]+=f; state[6]+=g; state[7]+=h;
}
}

21
scripts/build-sha256-wasm.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Rebuild frontend/sha256-wasm.js (the base64-embedded WebAssembly SHA-256 block
# function) from frontend/wasm/sha256.c. Needs clang with wasm32 + wasm-ld.
set -euo pipefail
cd "$(dirname "$0")/.."
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
clang --target=wasm32 -O3 -nostdlib -fno-builtin -ffreestanding \
-Wl,--no-entry -Wl,--export=compress -Wl,--import-memory -Wl,--initial-memory=65536 -Wl,--max-memory=1114112 \
-Wl,-z,stack-size=4096 -Wl,--strip-all \
frontend/wasm/sha256.c -o "$tmp/sha256.wasm"
b64=$(base64 -w0 "$tmp/sha256.wasm")
cat > frontend/sha256-wasm.js <<JS
/* GENERATED by scripts/build-sha256-wasm.sh from frontend/wasm/sha256.c — do not edit.
* WebAssembly SHA-256 block compression, embedded as base64 so it loads with the
* page / worker that already loads sha256.js (no extra request, no MIME or SW change). */
(function (root) {
root.SHA256_WASM_B64 = '$b64';
if (typeof module !== 'undefined' && module.exports) module.exports = root.SHA256_WASM_B64;
})(typeof globalThis !== 'undefined' ? globalThis : this);
JS
echo "wasm: $(wc -c < "$tmp/sha256.wasm") bytes"