- dissolve core-bridge into api (wire contract), domain (models), services (what the UI calls) and worker (everything that runs in the Web Worker, grouped by account, crypto, space, files, shares, runtime); regroup features, lib and store by responsibility and delete unused components - the UI only reads backend capabilities (domain/capabilities); one tokenRole picks the token, hasCode/isRetryable handle errors; pre-checks that decided permissions on the client are gone; tests fail if those rules are reimplemented - files go straight to the file domain (create, upload, download, delete, commit, cancel); the host sees one registration per file; creating a channel allocates a file domain and works even when the file service is down - file panel shows which channel the files belong to and its space usage; unlimited files preview in chat; videos get a stored cover; the file service status is shown - fix: console flooded by Retry-After reads on every upload frame
102 lines
5.0 KiB
TypeScript
102 lines
5.0 KiB
TypeScript
import { applyPalette, GIFEncoder, quantize } from 'gifenc';
|
|
import { paintCrop, type Crop } from './avatarCrop';
|
|
|
|
export interface GifFrame { bitmap: ImageBitmap; delayMs: number }
|
|
|
|
/** 编辑器里保留的帧:尽量保留全部。太长的动图才隔帧取样(时长不变),避免把几百张大图都放进内存 */
|
|
const MAX_FRAMES = 400;
|
|
const MAX_PIXELS = 120_000_000;
|
|
const MIN_DELAY_MS = 20;
|
|
/** 服务端对头像 data URL 的上限(字符数),留一点余量 */
|
|
export const MAX_GIF_DATA_URL = 5_900_000;
|
|
|
|
/** 浏览器是否能把 GIF 拆成帧(WebCodecs ImageDecoder)。不能就退回成静态头像 */
|
|
export const canDecodeGif = (): boolean => typeof ImageDecoder !== 'undefined';
|
|
|
|
/** 解出 GIF 的各帧;不是动图、不支持或解码失败时返回 undefined(调用方按静态图处理) */
|
|
export async function decodeGif(file: File): Promise<GifFrame[] | undefined> {
|
|
if (file.type !== 'image/gif' || !canDecodeGif()) return undefined;
|
|
const frames: GifFrame[] = [];
|
|
const decoder = new ImageDecoder({ data: await file.arrayBuffer(), type: 'image/gif' });
|
|
try {
|
|
await decoder.tracks.ready;
|
|
const total = decoder.tracks.selectedTrack?.frameCount ?? 1;
|
|
const { displayWidth, displayHeight } = decoder.tracks.selectedTrack?.frameCount ? await sizeOf(decoder) : { displayWidth: 0, displayHeight: 0 };
|
|
const stride = Math.max(1, Math.ceil(total / MAX_FRAMES), Math.ceil((total * displayWidth * displayHeight) / MAX_PIXELS));
|
|
let pending = 0;
|
|
for (let index = 0; index < total; index += 1) {
|
|
const { image } = await decoder.decode({ frameIndex: index });
|
|
pending += Math.max(MIN_DELAY_MS, (image.duration ?? 100_000) / 1000);
|
|
if (index % stride === 0 || index === total - 1) {
|
|
frames.push({ bitmap: await createImageBitmap(image), delayMs: pending }); pending = 0;
|
|
}
|
|
image.close();
|
|
}
|
|
} catch {
|
|
for (const frame of frames) frame.bitmap.close();
|
|
return undefined;
|
|
} finally { decoder.close(); }
|
|
if (frames.length > 1) return frames;
|
|
for (const frame of frames) frame.bitmap.close();
|
|
return undefined;
|
|
}
|
|
|
|
async function sizeOf(decoder: ImageDecoder): Promise<{ displayWidth: number; displayHeight: number }> {
|
|
const { image } = await decoder.decode({ frameIndex: 0 });
|
|
const size = { displayWidth: image.displayWidth, displayHeight: image.displayHeight };
|
|
image.close();
|
|
return size;
|
|
}
|
|
|
|
function toDataUrl(bytes: Uint8Array): string {
|
|
let binary = '';
|
|
for (let at = 0; at < bytes.length; at += 0x8000) binary += String.fromCharCode(...bytes.subarray(at, at + 0x8000));
|
|
return `data:image/gif;base64,${btoa(binary)}`;
|
|
}
|
|
|
|
function encode(frames: readonly GifFrame[], crop: Crop, from: number, size: number, step: number, colors = 256): string {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = canvas.height = size;
|
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
if (!ctx) throw new Error('当前浏览器不支持图片处理');
|
|
const encoder = GIFEncoder();
|
|
let delay = 0;
|
|
frames.forEach((frame, at) => {
|
|
delay += frame.delayMs;
|
|
if (at % step !== 0 && at !== frames.length - 1) return;
|
|
paintCrop(ctx, frame.bitmap, crop, from, size);
|
|
const { data } = ctx.getImageData(0, 0, size, size);
|
|
const palette = quantize(data, colors, { format: 'rgba4444', oneBitAlpha: true });
|
|
const transparentIndex = palette.findIndex((color) => color.length > 3 && color[3] === 0);
|
|
encoder.writeFrame(applyPalette(data, palette, 'rgba4444'), size, size,
|
|
{ palette, delay: Math.round(delay), transparent: transparentIndex >= 0, transparentIndex: Math.max(0, transparentIndex) });
|
|
delay = 0;
|
|
});
|
|
encoder.finish();
|
|
return toDataUrl(encoder.bytes());
|
|
}
|
|
|
|
/** 把每一帧按同样的裁剪导出成动图;默认 256 像素、保留每一帧;只有超过上限(约 4.5 MB)才逐步缩小尺寸、再隔帧 */
|
|
export async function exportGifAvatar(frames: readonly GifFrame[], crop: Crop, from: number, maxChars = MAX_GIF_DATA_URL): Promise<string> {
|
|
for (const [size, step] of [[256, 1], [192, 1], [128, 1], [192, 2], [128, 2], [96, 2]] as const) {
|
|
// 让出线程,编辑器的「处理中」能先画出来
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
const url = encode(frames, crop, from, size, step);
|
|
if (url.length <= maxChars) return url;
|
|
}
|
|
throw new Error('动图压缩后仍然过大,请换一张更短或更小的 GIF');
|
|
}
|
|
|
|
/**
|
|
* 动图的有损缩略图:尺寸和颜色压小,帧数尽量保持和原图一样(隔帧是最后的手段,会丢掉流畅度)。
|
|
* 控制在服务端上限(80 万字符)以内。列表、聊天里默认用它
|
|
*/
|
|
export async function exportGifThumb(frames: readonly GifFrame[], crop: Crop, from: number): Promise<string> {
|
|
for (const [size, step, colors] of [[80, 1, 64], [64, 1, 48], [64, 1, 32], [48, 1, 32], [64, 2, 32], [48, 2, 32], [40, 3, 16]] as const) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
const url = encode(frames, crop, from, size, step, colors);
|
|
if (url.length <= 780_000) return url;
|
|
}
|
|
throw new Error('动图缩略图生成失败');
|
|
}
|