2 Commits
Author SHA1 Message Date
justme d048fedc5e feat: keep every frame in animated avatar thumbnails and raise the thumbnail limit to 800k chars [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
2026-10-17 21:37:35 +08:00
justme ea7895d85c feat(web): upload large avatars in two steps so the thumbnail applies immediately 2026-10-17 21:37:30 +08:00
3 changed files with 35 additions and 7 deletions

No files matched your search

+1 -1
View File
@@ -12,7 +12,7 @@ public static class AvatarData
/// <summary>缩略图(列表、聊天里默认分发的有损小图)的上限:静态约 75 KB,动图约 370 KB</summary>
public const int MaxThumbStaticChars = 100_000;
public const int MaxThumbGifChars = 500_000;
public const int MaxThumbGifChars = 800_000;
private static readonly string[] Prefixes = ["data:image/png;base64,", "data:image/jpeg;base64,", "data:image/webp;base64,", "data:image/gif;base64,"];
+27 -2
View File
@@ -233,13 +233,21 @@ export async function clearSavedLogin(): Promise<void> {
}
await forgetAccount(); await forgetLoginFields();
}
/** 原图超过这个大小(字符数)就分两步传:先传缩略图,头像立刻生效,原图在后台慢慢传(弱网下 MB 级的动图一次传完要等很久) */
const SPLIT_UPLOAD_CHARS = 600_000;
export async function updatePersonal(displayName: string, change?: AvatarChange): Promise<void> {
if (!account) throw new Error('请先登录账号');
const session = account;
const send = async (request: AccountProfileRequest) => {
const { body, headers: encoding } = await jsonBody(request);
return apiFetch('/accounts/profile', { method: 'PATCH', headers: { ...headers(session), ...encoding }, body }).then(r => r.json() as Promise<AccountProfile>);
};
const split = change && change !== 'keep' && change.avatar.length > SPLIT_UPLOAD_CHARS;
const request: AccountProfileRequest = change === 'keep' ? { displayName, keepAvatar: true }
: split ? { displayName, keepAvatar: false, avatar: change.thumb, avatarThumb: change.thumb }
: change ? { displayName, keepAvatar: false, avatar: change.avatar, avatarThumb: change.thumb } : { displayName, keepAvatar: false };
const { body, headers: encoding } = await jsonBody(request);
const value = await apiFetch('/accounts/profile', { method: 'PATCH', headers: { ...headers(session), ...encoding }, body }).then(r => r.json() as Promise<AccountProfile>);
const value = await send(request);
stillActive(session);
const previous = session.personal?.avatar;
session.personal = personalProfile(value);
@@ -249,6 +257,23 @@ export async function updatePersonal(displayName: string, change?: AvatarChange)
primeAvatar(value.username, value.modifiedAt, shown);
setAccountIdentity(session, session.profile.keys.publicKey, session.personal);
await persistSession(session, { vault: await sealJson(session.profile, session.key), revision: session.revision });
if (split) void uploadOriginal(session, displayName, change, send);
}
/** 后台补传原图;失败就保留缩略图当头像(预览时看到的是缩略图),不打扰用户。重试几次,间隔逐步拉长 */
async function uploadOriginal(session: NonNullable<typeof account>, displayName: string, change: { avatar: string; thumb: string }, send: (request: AccountProfileRequest) => Promise<AccountProfile>): Promise<void> {
for (const wait of [0, 5_000, 20_000, 60_000]) {
if (wait) await new Promise((resolve) => setTimeout(resolve, wait));
if (account !== session) return;
try {
const value = await send({ displayName, keepAvatar: false, avatar: change.avatar, avatarThumb: change.thumb });
if (account !== session) return;
session.personal = { ...personalProfile(value), avatar: change.thumb, hasAvatar: true };
primeAvatar(value.username, value.modifiedAt, change.thumb);
setAccountIdentity(session, session.profile.keys.publicKey, session.personal);
return;
} catch { /* 弱网或暂时失败:稍后重试 */ }
}
}
export async function listAccountDevices(): Promise<AccountDeviceBody[]> { requireAccount(); return apiFetch('/accounts/devices').then((r) => r.json() as Promise<AccountDeviceBody[]>); }
export async function renameAccountDevice(id: string, name: string): Promise<void> {
+7 -4
View File
@@ -87,12 +87,15 @@ export async function exportGifAvatar(frames: readonly GifFrame[], crop: Crop, f
throw new Error('动图压缩后仍然过大,请换一张更短或更小的 GIF');
}
/** 动图的有损缩略图:帧数、颜色数和尺寸都压小(最多约 30 帧,64 色,80 像素),控制在 40 万字符以内。列表、聊天里默认用它 */
/**
* 动图的有损缩略图:尺寸和颜色压小,帧数尽量保持和原图一样(隔帧是最后的手段,会丢掉流畅度)。
* 控制在服务端上限(80 万字符)以内。列表、聊天里默认用它
*/
export async function exportGifThumb(frames: readonly GifFrame[], crop: Crop, from: number): Promise<string> {
for (const [size, maxFrames, colors] of [[80, 30, 64], [64, 24, 32], [48, 16, 32]] as const) {
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, Math.max(1, Math.ceil(frames.length / maxFrames)), colors);
if (url.length <= 400_000) return url;
const url = encode(frames, crop, from, size, step, colors);
if (url.length <= 780_000) return url;
}
throw new Error('动图缩略图生成失败');
}