Files
WebDrop/web/src/features/avatar/avatarGif.ts
T
justme 5489407241 refactor(web): restructure into api/domain/services/worker; capabilities, file domains, video covers
- 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
2026-10-19 13:38:51 +08:00

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('动图缩略图生成失败');
}