feat(web): stream video and audio while decrypting, paste images, 16px inputs on touch

This commit is contained in:
justme committed 2026-10-17 14:35:41 +08:00
1 parent 31ae3bc39e
commit 283196938e
12 files changed
+376 -16

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
import { postToWorker } from './workerClient';
/**
* 页面这一侧的「边下边播」:把条目登记成一个 /__wd_stream/<id> 地址交给 <video>/<audio>,
* 播放器的每个 Range 请求由 Service Worker(public/stream-sw.js)转给这里,
* 这里把 SW 给的 MessagePort 直接交给加密线程,解密后的数据不再经过页面。
*/
interface Entry { resourceId: string; itemId: string; size: number; mime: string }
interface SwMessage { type: 'wd-stream'; id: string; range: { start?: number; end?: number; suffix?: number }; port: MessagePort }
const entries = new Map<string, Entry>();
let ready: Promise<boolean> | undefined;
function setup(): Promise<boolean> {
if (!('serviceWorker' in navigator)) return Promise.resolve(false);
return (async () => {
navigator.serviceWorker.addEventListener('message', (event: MessageEvent<SwMessage>) => {
const message = event.data;
if (message?.type !== 'wd-stream') return;
const entry = entries.get(message.id);
if (!entry) { message.port.postMessage({ error: 'gone', status: 404 }); message.port.close(); return; }
postToWorker({ operation: 'streamRange', payload: { ...entry, ...message.range } }, [message.port]);
});
await navigator.serviceWorker.register(`${import.meta.env.BASE_URL}stream-sw.js`);
await navigator.serviceWorker.ready;
if (!navigator.serviceWorker.controller) {
// 第一次安装:等 SW 接管当前页面(clients.claim)
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, 2000);
navigator.serviceWorker.addEventListener('controllerchange', () => { clearTimeout(timer); resolve(); }, { once: true });
});
}
return navigator.serviceWorker.controller !== null;
})().catch(() => false);
}
/** 登记一个可以边下边播的条目,返回播放地址;浏览器或环境不支持时返回 undefined(调用方退回整个下载) */
export async function playbackUrl(resourceId: string, itemId: string, size: number, mime: string): Promise<string | undefined> {
ready ??= setup();
if (!(await ready)) return undefined;
const id = `${crypto.randomUUID()}`;
entries.set(id, { resourceId, itemId, size, mime });
return `${import.meta.env.BASE_URL}__wd_stream/${id}`;
}
export function releasePlayback(url: string): void {
entries.delete(url.slice(url.lastIndexOf('/') + 1));
}
@@ -0,0 +1,125 @@
import { fromBase64Url, IntegrityError } from '@webdrop/core';
import { apiFetch } from './http';
import { getSession } from './sessions';
/**
* 边下边播:按字节范围把加密条目解密后推给 Service Worker(stream-sw.js)。
*
* 条目的帧是定长的:第 0 帧内容 cap0 字节,之后每帧 maxData 字节,最后一帧不足。
* 读完第 0、1 帧就知道这两个数,于是任意字节位置都能算出在第几帧,从那一帧开始拉取(from 参数),
* 拖进度条不用从头下载。每一帧都有 AEAD 认证(绑定空间、条目和帧序号),被篡改的帧直接报错;
* 整个条目的清单校验需要读完全部帧,边播不做(下载保存时仍然会做)。
*/
interface Geometry { cap0: number; maxData: number; head: Uint8Array[] }
const geometries = new Map<string, Promise<Geometry>>();
interface PlaybackRequest { resourceId: string; itemId: string; size: number; mime: string; start?: number; end?: number; suffix?: number }
async function framesFrom(resourceId: string, itemId: string, from: number): Promise<{ next: () => Promise<Uint8Array | undefined>; close: () => Promise<void> }> {
const { session, resource } = await getSession(resourceId);
if (resource.kind !== 'channel') throw new Error('资源类型不匹配');
const role = session.can('read') ? 'read' : 'owner';
const response = await apiFetch(`/channels/${resourceId}/items/${itemId}/frames?from=${from}`, { headers: session.authHeaders(role) });
if (!response.body) throw new Error('服务端没有返回文件内容');
const expectedId = fromBase64Url(itemId, 16);
if (!expectedId) throw new IntegrityError('bad_meta');
// 每一帧解出来就是一块内容,块的边界就是帧的边界
const { content } = session.decryptItemStream(response.body, { itemId: expectedId, fromIndex: from });
const reader = content.getReader();
return {
next: async () => { const { done, value } = await reader.read(); return done ? undefined : value; },
close: () => reader.cancel().catch(() => undefined),
};
}
function geometryOf(resourceId: string, itemId: string, size: number): Promise<Geometry> {
const key = `${resourceId}:${itemId}`;
let known = geometries.get(key);
if (!known) {
if (geometries.size >= 8) geometries.clear();
known = (async () => {
const frames = await framesFrom(resourceId, itemId, 0);
try {
const first = await frames.next();
if (!first) throw new Error('文件内容为空');
if (first.length >= size) return { cap0: first.length, maxData: Math.max(1, first.length), head: [first] };
const second = await frames.next();
if (!second) throw new Error('文件不完整');
return { cap0: first.length, maxData: Math.max(1, second.length), head: [first, second] };
} finally { await frames.close(); }
})();
known.catch(() => geometries.delete(key));
geometries.set(key, known);
}
return known;
}
/** 字节位置 → 所在的帧序号和帧内偏移 */
function locate(g: Geometry, position: number): { index: number; offset: number } {
if (position < g.cap0) return { index: 0, offset: position };
const rest = position - g.cap0;
return { index: 1 + Math.floor(rest / g.maxData), offset: rest % g.maxData };
}
/** 流控:对方(Service Worker)每消费一块才给一个额度,避免把整个文件一口气解密进内存 */
function flow(port: MessagePort) {
let credit = 0; let cancelled = false; let wake: (() => void) | undefined;
port.onmessage = (message: MessageEvent<{ credit?: number; cancel?: boolean }>) => {
if (message.data.cancel) cancelled = true;
if (message.data.credit) credit += message.data.credit;
wake?.(); wake = undefined;
};
return {
get cancelled() { return cancelled; },
async take(): Promise<boolean> {
while (credit <= 0 && !cancelled) await new Promise<void>((resolve) => { wake = resolve; });
if (cancelled) return false;
credit -= 1; return true;
},
};
}
export async function servePlaybackRange(port: MessagePort, request: PlaybackRequest): Promise<void> {
const { resourceId, itemId, size, mime } = request;
const control = flow(port);
try {
if (request.suffix !== undefined && request.suffix <= 0) { port.postMessage({ error: 'bad range', status: 416 }); return; }
const start = request.suffix !== undefined ? Math.max(0, size - request.suffix) : request.start ?? 0;
const end = Math.min(size - 1, request.end ?? size - 1);
if (start >= size || start > end) { port.postMessage({ error: 'range not satisfiable', status: 416 }); return; }
const geometry = await geometryOf(resourceId, itemId, size);
port.postMessage({ total: size, mime, start, end });
let position = start;
const send = async (chunk: Uint8Array, offset: number): Promise<boolean> => {
if (!(await control.take())) return false;
const part = chunk.slice(offset, offset + (end + 1 - position));
const length = part.length; // 转移之后 part 就被分离了,长度会变成 0
port.postMessage({ data: part.buffer }, [part.buffer]);
position += length;
return true;
};
let { index, offset } = locate(geometry, position);
// 第 0、1 帧已经在手上
while (position <= end && index < geometry.head.length) {
if (!(await send(geometry.head[index]!, offset))) return;
index += 1; offset = 0;
}
if (position <= end) {
const frames = await framesFrom(resourceId, itemId, index);
try {
while (position <= end) {
const chunk = await frames.next();
if (!chunk) throw new Error('文件不完整');
if (!(await send(chunk, offset))) return;
offset = 0;
}
} finally { await frames.close(); }
}
port.postMessage({ done: true });
} catch (error) {
port.postMessage({ error: error instanceof Error ? error.message : '播放失败' });
}
// 端口由 Service Worker 读完后关闭:这里立刻关掉会让还没送达的最后几条消息丢失
}
+5 -1
View File
@@ -17,7 +17,11 @@ let clientHintsPromise: Promise<ClientHints> | undefined;
async function readClientHints(): Promise<ClientHints> {
const hints = (navigator as Navigator & { userAgentData?: ClientHints }).userAgentData;
if (!hints?.getHighEntropyValues) return hints ?? {};
clientHintsPromise ??= hints.getHighEntropyValues(['model', 'platform', 'platformVersion']).catch(() => hints);
// 个别手机浏览器的 getHighEntropyValues 会很久没有响应,不能因此卡住上传:最多等 1.5 秒,超时就用基础信息
clientHintsPromise ??= Promise.race([
hints.getHighEntropyValues(['model', 'platform', 'platformVersion']).catch(() => hints),
new Promise<ClientHints>((resolve) => setTimeout(() => resolve(hints), 1500)),
]);
return clientHintsPromise;
}