feat(web): stream video and audio while decrypting, paste images, 16px inputs on touch
This commit is contained in:
1 parent
31ae3bc39e
commit
283196938e
12 files changed
+376
-16
No files matched your search
@@ -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 读完后关闭:这里立刻关掉会让还没送达的最后几条消息丢失
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user