feat(web): animated GIF avatars that refresh across chat and member list [CI]
This commit is contained in:
1 parent
3b062687cd
commit
a08aa7568f
10 files changed
+163
-19
No files matched your search
@@ -15,6 +15,7 @@ import { serial } from './worker/serial';
|
|||||||
import { errorMessage } from './errors';
|
import { errorMessage } from './errors';
|
||||||
import { accountStatus, loginAccount, logoutAccount, registerAccount, syncAccount, listAccountInvitations, acceptInvitation, inviteChannelUser, listChannelInvitations, revokeChannelInvitation } from './worker/account';
|
import { accountStatus, loginAccount, logoutAccount, registerAccount, syncAccount, listAccountInvitations, acceptInvitation, inviteChannelUser, listChannelInvitations, revokeChannelInvitation } from './worker/account';
|
||||||
import { createFolder, createThread, createGroup, renameGroup, deleteGroup, createRole, updateRole, deleteRole, setRoleMembers, groupThread, moveItems, renameFolder, renameThread, setChannelHistory } from './worker/channelThreads';
|
import { createFolder, createThread, createGroup, renameGroup, deleteGroup, createRole, updateRole, deleteRole, setRoleMembers, groupThread, moveItems, renameFolder, renameThread, setChannelHistory } from './worker/channelThreads';
|
||||||
|
import { refreshLoadedWorkspaces } from './worker/workspaceStructure';
|
||||||
import { restoreAccount, updatePersonal, listAccountDevices, renameAccountDevice, revokeAccountDevice, getSavedLogin, clearSavedLogin } from './worker/account';
|
import { restoreAccount, updatePersonal, listAccountDevices, renameAccountDevice, revokeAccountDevice, getSavedLogin, clearSavedLogin } from './worker/account';
|
||||||
|
|
||||||
interface WorkerRequest { requestId: number; operation: string; payload: unknown }
|
interface WorkerRequest { requestId: number; operation: string; payload: unknown }
|
||||||
@@ -44,7 +45,7 @@ async function dispatch(operation: string, input: unknown): Promise<unknown> {
|
|||||||
case 'beginPasskeyLogin': return beginPasskeyLogin(String(payload.username ?? ''));
|
case 'beginPasskeyLogin': return beginPasskeyLogin(String(payload.username ?? ''));
|
||||||
case 'finishPasskeyLogin': return finishPasskeyLogin(String(payload.username ?? ''), id, String(payload.credential ?? ''), payload.prf as ArrayBuffer | undefined, payload.remember !== false);
|
case 'finishPasskeyLogin': return finishPasskeyLogin(String(payload.username ?? ''), id, String(payload.credential ?? ''), payload.prf as ArrayBuffer | undefined, payload.remember !== false);
|
||||||
case 'unlockPasskeyLogin': return unlockPasskeyLogin(String(payload.password ?? ''), payload.remember !== false);
|
case 'unlockPasskeyLogin': return unlockPasskeyLogin(String(payload.password ?? ''), payload.remember !== false);
|
||||||
case 'updatePersonal': await updatePersonal(String(payload.displayName ?? ''), typeof payload.avatar === 'string' ? payload.avatar : undefined); return accountStatus();
|
case 'updatePersonal': await updatePersonal(String(payload.displayName ?? ''), typeof payload.avatar === 'string' ? payload.avatar : undefined); void refreshLoadedWorkspaces(); return accountStatus();
|
||||||
case 'accountDevices': return listAccountDevices();
|
case 'accountDevices': return listAccountDevices();
|
||||||
case 'renameAccountDevice': await renameAccountDevice(id, String(payload.name ?? '')); return true;
|
case 'renameAccountDevice': await renameAccountDevice(id, String(payload.name ?? '')); return true;
|
||||||
case 'revokeAccountDevice': await revokeAccountDevice(id); return accountStatus();
|
case 'revokeAccountDevice': await revokeAccountDevice(id); return accountStatus();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { canDecryptRecord, objectAuthorAccount, objectEventProjection, readDataK
|
|||||||
import { getResource, cacheResource, type SavedResource } from '../database';
|
import { getResource, cacheResource, type SavedResource } from '../database';
|
||||||
import { channelCache } from './resourceMetadata';
|
import { channelCache } from './resourceMetadata';
|
||||||
import { cachedPerson, loadWorkspace } from './workspaceStructure';
|
import { cachedPerson, loadWorkspace } from './workspaceStructure';
|
||||||
import { avatarThumbnail } from './senderSnapshot';
|
import { displayAvatar } from './senderSnapshot';
|
||||||
import type { FileItem, Item, SenderInfo, Snapshot } from '../types';
|
import type { FileItem, Item, SenderInfo, Snapshot } from '../types';
|
||||||
import { previewKindOf } from '@/lib/fileKind';
|
import { previewKindOf } from '@/lib/fileKind';
|
||||||
import { blobs, readers } from './sessions';
|
import { blobs, readers } from './sessions';
|
||||||
@@ -37,7 +37,7 @@ async function authorOf(resourceId: string, itemId: string): Promise<SenderInfo
|
|||||||
const username = objectAuthorAccount(resourceId, itemId);
|
const username = objectAuthorAccount(resourceId, itemId);
|
||||||
if (!username) return undefined;
|
if (!username) return undefined;
|
||||||
const person = cachedPerson(resourceId, username);
|
const person = cachedPerson(resourceId, username);
|
||||||
const avatar = person?.avatar ? await avatarThumbnail(person.avatar) : undefined;
|
const avatar = person?.avatar ? await displayAvatar(person.avatar) : undefined;
|
||||||
return { username, displayName: person?.displayName || username, ...(avatar ? { avatar } : {}) };
|
return { username, displayName: person?.displayName || username, ...(avatar ? { avatar } : {}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ export async function senderSnapshot(): Promise<SenderInfo | undefined> {
|
|||||||
return { username: sender.username, displayName: sender.displayName, ...(avatar ? { avatar } : {}) };
|
return { username: sender.username, displayName: sender.displayName, ...(avatar ? { avatar } : {}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 本机显示用:动图头像原样保留(会动);其他压成小缩略图。发进消息里的快照仍用 avatarThumbnail,不能把几 MB 的动图塞进每条消息 */
|
||||||
|
export function displayAvatar(value: string): Promise<string | undefined> {
|
||||||
|
return value.startsWith('data:image/gif') ? Promise.resolve(value) : avatarThumbnail(value);
|
||||||
|
}
|
||||||
|
|
||||||
export function avatarThumbnail(value: string): Promise<string | undefined> {
|
export function avatarThumbnail(value: string): Promise<string | undefined> {
|
||||||
let thumbnail = thumbnails.get(value);
|
let thumbnail = thumbnails.get(value);
|
||||||
if (!thumbnail) {
|
if (!thumbnail) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getResource, cacheResource } from '../database';
|
|||||||
import { getSession } from './sessions';
|
import { getSession } from './sessions';
|
||||||
import { apiFetch } from './http';
|
import { apiFetch } from './http';
|
||||||
import { publishWorkspace } from './presentation';
|
import { publishWorkspace } from './presentation';
|
||||||
import { avatarThumbnail } from './senderSnapshot';
|
import { displayAvatar } from './senderSnapshot';
|
||||||
|
|
||||||
const current = new Map<string, WorkspaceBody>();
|
const current = new Map<string, WorkspaceBody>();
|
||||||
export function clearWorkspaceStructures(): void { current.clear(); }
|
export function clearWorkspaceStructures(): void { current.clear(); }
|
||||||
@@ -19,7 +19,7 @@ async function publish(id: string, body: WorkspaceBody): Promise<WorkspaceBody>
|
|||||||
current.set(id, body);
|
current.set(id, body);
|
||||||
const resource = await getResource(id);
|
const resource = await getResource(id);
|
||||||
if (resource) await cacheResource(resource, { workspaceMetadata: body });
|
if (resource) await cacheResource(resource, { workspaceMetadata: body });
|
||||||
const people = await Promise.all(body.people.map(async person => ({ ...person, avatar: person.avatar ? await avatarThumbnail(person.avatar) : undefined })));
|
const people = await Promise.all(body.people.map(async person => ({ ...person, avatar: person.avatar ? await displayAvatar(person.avatar) : undefined })));
|
||||||
if (current.get(id) !== body) return current.get(id) ?? body;
|
if (current.get(id) !== body) return current.get(id) ?? body;
|
||||||
publishWorkspace(id, { t: 'structure', state: body.structure, people });
|
publishWorkspace(id, { t: 'structure', state: body.structure, people });
|
||||||
return body;
|
return body;
|
||||||
@@ -37,6 +37,10 @@ export async function loadWorkspace(id: string): Promise<WorkspaceBody> {
|
|||||||
const body = await apiFetch(`/spaces/${id}/structure`, { headers: session.authHeaders('read') }).then(r => r.json() as Promise<WorkspaceBody>);
|
const body = await apiFetch(`/spaces/${id}/structure`, { headers: session.authHeaders('read') }).then(r => r.json() as Promise<WorkspaceBody>);
|
||||||
return publish(id, body);
|
return publish(id, body);
|
||||||
}
|
}
|
||||||
|
/** 自己改了头像或名字后立刻重新拉取所有已打开空间的成员资料(其他人靠服务端的设置变更通知) */
|
||||||
|
export async function refreshLoadedWorkspaces(): Promise<void> {
|
||||||
|
await Promise.all([...current.keys()].map((id) => loadWorkspace(id).catch(() => undefined)));
|
||||||
|
}
|
||||||
export async function changeWorkspace(id: string, path: string, method: 'POST' | 'PATCH' | 'PUT' | 'DELETE', value?: unknown): Promise<WorkspaceBody> {
|
export async function changeWorkspace(id: string, path: string, method: 'POST' | 'PATCH' | 'PUT' | 'DELETE', value?: unknown): Promise<WorkspaceBody> {
|
||||||
const { session } = await getSession(id);
|
const { session } = await getSession(id);
|
||||||
const body = await apiFetch(`/spaces/${id}/${path}`, {
|
const body = await apiFetch(`/spaces/${id}/${path}`, {
|
||||||
|
|||||||
@@ -135,3 +135,9 @@
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
|
||||||
import { Button } from '@/ui/Button';
|
import { Button } from '@/ui/Button';
|
||||||
import { clampCrop, exportAvatar, paintCrop, type Crop } from './avatarCrop';
|
import { clampCrop, exportAvatar, paintCrop, type Crop } from './avatarCrop';
|
||||||
|
import { canDecodeGif, decodeGif, exportGifAvatar, type GifFrame } from './avatarGif';
|
||||||
import styles from './AvatarEditor.module.css';
|
import styles from './AvatarEditor.module.css';
|
||||||
|
|
||||||
const VIEW = 260;
|
const VIEW = 260;
|
||||||
@@ -31,7 +32,11 @@ interface Props {
|
|||||||
|
|
||||||
/** 在浏览器里裁剪头像:拖动、缩放、旋转,右侧按聊天消息和成员列表的样子实时预览 */
|
/** 在浏览器里裁剪头像:拖动、缩放、旋转,右侧按聊天消息和成员列表的样子实时预览 */
|
||||||
export function AvatarEditor({ file, name, onCancel, onDone }: Props) {
|
export function AvatarEditor({ file, name, onCancel, onDone }: Props) {
|
||||||
const [image, setImage] = useState<ImageBitmap>();
|
const [still, setStill] = useState<ImageBitmap>();
|
||||||
|
// 动图:各帧(已取样)和当前播放到哪一帧;所有帧尺寸相同,裁剪几何只看尺寸
|
||||||
|
const [frames, setFrames] = useState<GifFrame[]>([]);
|
||||||
|
const [tick, setTick] = useState(0);
|
||||||
|
const image = frames.length ? frames[tick % frames.length]!.bitmap : still;
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
const [view, setView] = useState<View>(FRESH);
|
const [view, setView] = useState<View>(FRESH);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -42,13 +47,29 @@ export function AvatarEditor({ file, name, onCancel, onDone }: Props) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
let bitmap: ImageBitmap | undefined;
|
let bitmap: ImageBitmap | undefined;
|
||||||
createImageBitmap(file).then((value) => {
|
let decoded: GifFrame[] | undefined;
|
||||||
if (!active) { value.close(); return; }
|
(async () => {
|
||||||
bitmap = value; setImage(value); setView(FRESH);
|
try {
|
||||||
}).catch(() => { if (active) setFailed(true); });
|
decoded = await decodeGif(file);
|
||||||
return () => { active = false; bitmap?.close(); };
|
if (decoded) {
|
||||||
|
if (!active) { for (const frame of decoded) frame.bitmap.close(); return; }
|
||||||
|
setFrames(decoded); setStill(decoded[0]!.bitmap); setTick(0); setView(FRESH);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = await createImageBitmap(file);
|
||||||
|
if (!active) { value.close(); return; }
|
||||||
|
bitmap = value; setFrames([]); setStill(value); setView(FRESH);
|
||||||
|
} catch { if (active) setFailed(true); }
|
||||||
|
})();
|
||||||
|
return () => { active = false; bitmap?.close(); for (const frame of decoded ?? []) frame.bitmap.close(); };
|
||||||
}, [file]);
|
}, [file]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (frames.length < 2) return;
|
||||||
|
const timer = setTimeout(() => setTick((value) => value + 1), frames[tick % frames.length]!.delayMs);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [frames, tick]);
|
||||||
|
|
||||||
const update = useCallback((next: (value: View) => View) => {
|
const update = useCallback((next: (value: View) => View) => {
|
||||||
if (!image) return;
|
if (!image) return;
|
||||||
setView((value) => {
|
setView((value) => {
|
||||||
@@ -94,12 +115,12 @@ export function AvatarEditor({ file, name, onCancel, onDone }: Props) {
|
|||||||
async function confirm() {
|
async function confirm() {
|
||||||
if (!image || busy) return;
|
if (!image || busy) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try { onDone(await exportAvatar(image, crop, VIEW)); }
|
try { onDone(frames.length ? await exportGifAvatar(frames, crop, VIEW) : await exportAvatar(image, crop, VIEW)); }
|
||||||
catch { setFailed(true); setBusy(false); }
|
catch { setFailed(true); setBusy(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (failed) return <div className={styles.editor}>
|
if (failed) return <div className={styles.editor}>
|
||||||
<p className={styles.error} role="alert">这张图片无法处理,请换一张 PNG、JPEG 或 WebP 图片。</p>
|
<p className={styles.error} role="alert">这张图片无法处理,请换一张 PNG、JPEG、WebP 或 GIF 图片。</p>
|
||||||
<div className={styles.actions}><Button onClick={onCancel}>返回</Button></div>
|
<div className={styles.actions}><Button onClick={onCancel}>返回</Button></div>
|
||||||
</div>;
|
</div>;
|
||||||
|
|
||||||
@@ -112,6 +133,7 @@ export function AvatarEditor({ file, name, onCancel, onDone }: Props) {
|
|||||||
onPointerDown={down} onPointerMove={move} onPointerUp={() => { drag.current = null; }} onPointerCancel={() => { drag.current = null; }} />
|
onPointerDown={down} onPointerMove={move} onPointerUp={() => { drag.current = null; }} onPointerCancel={() => { drag.current = null; }} />
|
||||||
<span className={styles.mask} aria-hidden="true" />
|
<span className={styles.mask} aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
|
{file.type === 'image/gif' && !canDecodeGif() ? <p className={styles.note}>当前浏览器不支持动图头像,只会使用第一帧。</p> : null}
|
||||||
<label className={styles.slider}><span>缩放</span>
|
<label className={styles.slider}><span>缩放</span>
|
||||||
<input type="range" min={1} max={4} step={0.01} value={view.zoom} disabled={!ready}
|
<input type="range" min={1} max={4} step={0.01} value={view.zoom} disabled={!ready}
|
||||||
onChange={(event) => update((value) => ({ ...value, zoom: Number(event.target.value) }))} /></label>
|
onChange={(event) => update((value) => ({ ...value, zoom: Number(event.target.value) }))} /></label>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import styles from './AccountSettings.module.css';
|
|||||||
const MAX_SOURCE_BYTES = 20_000_000;
|
const MAX_SOURCE_BYTES = 20_000_000;
|
||||||
|
|
||||||
async function checkAvatarSource(file: File): Promise<void> {
|
async function checkAvatarSource(file: File): Promise<void> {
|
||||||
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type) || file.size > MAX_SOURCE_BYTES) throw new Error('请选择 20 MB 以内的 PNG、JPEG 或 WebP 图片');
|
if (!['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(file.type) || file.size > MAX_SOURCE_BYTES) throw new Error('请选择 20 MB 以内的 PNG、JPEG、WebP 或 GIF 图片');
|
||||||
const bitmap = await createImageBitmap(file);
|
const bitmap = await createImageBitmap(file);
|
||||||
const valid = bitmap.width > 0 && bitmap.height > 0 && bitmap.width <= 8192 && bitmap.height <= 8192;
|
const valid = bitmap.width > 0 && bitmap.height > 0 && bitmap.width <= 8192 && bitmap.height <= 8192;
|
||||||
bitmap.close();
|
bitmap.close();
|
||||||
@@ -54,7 +54,7 @@ export function ProfileSettings() {
|
|||||||
{avatar ? <img className={styles.avatarLarge} src={avatar} alt="个人头像" />
|
{avatar ? <img className={styles.avatarLarge} src={avatar} alt="个人头像" />
|
||||||
: <span className={`${styles.avatarLarge} ${styles.avatarFallback}`} aria-hidden="true">{Array.from(shown)[0]?.toLocaleUpperCase() ?? '?'}</span>}
|
: <span className={`${styles.avatarLarge} ${styles.avatarFallback}`} aria-hidden="true">{Array.from(shown)[0]?.toLocaleUpperCase() ?? '?'}</span>}
|
||||||
<div className={styles.avatarSide}>
|
<div className={styles.avatarSide}>
|
||||||
<input ref={fileInput} type="file" accept="image/png,image/jpeg,image/webp" aria-label="选择头像文件" hidden onChange={(event) => { void choose(event.target.files?.[0]); event.target.value = ''; }} />
|
<input ref={fileInput} type="file" accept="image/png,image/jpeg,image/webp,image/gif" aria-label="选择头像文件" hidden onChange={(event) => { void choose(event.target.files?.[0]); event.target.value = ''; }} />
|
||||||
<div className={styles.row}>
|
<div className={styles.row}>
|
||||||
<Button icon="image" onClick={() => fileInput.current?.click()}>更换头像</Button>
|
<Button icon="image" onClick={() => fileInput.current?.click()}>更换头像</Button>
|
||||||
{avatar ? <Button variant="ghost" onClick={() => setAvatar(undefined)}>移除</Button> : null}
|
{avatar ? <Button variant="ghost" onClick={() => setAvatar(undefined)}>移除</Button> : null}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
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): 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, 256, { 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): 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 <= MAX_GIF_DATA_URL) return url;
|
||||||
|
}
|
||||||
|
throw new Error('动图压缩后仍然过大,请换一张更短或更小的 GIF');
|
||||||
|
}
|
||||||
Vendored
+13
@@ -0,0 +1,13 @@
|
|||||||
|
declare module 'gifenc' {
|
||||||
|
export type Palette = number[][];
|
||||||
|
export interface QuantizeOptions { format?: 'rgb565' | 'rgb444' | 'rgba4444'; oneBitAlpha?: boolean | number; clearAlpha?: boolean }
|
||||||
|
export interface FrameOptions { palette?: Palette; delay?: number; repeat?: number; transparent?: boolean; transparentIndex?: number }
|
||||||
|
export interface Encoder {
|
||||||
|
writeFrame(index: Uint8Array, width: number, height: number, options?: FrameOptions): void;
|
||||||
|
finish(): void;
|
||||||
|
bytes(): Uint8Array;
|
||||||
|
}
|
||||||
|
export function GIFEncoder(): Encoder;
|
||||||
|
export function quantize(rgba: Uint8Array | Uint8ClampedArray, maxColors: number, options?: QuantizeOptions): Palette;
|
||||||
|
export function applyPalette(rgba: Uint8Array | Uint8ClampedArray, palette: Palette, format?: 'rgb565' | 'rgb444' | 'rgba4444'): Uint8Array;
|
||||||
|
}
|
||||||
@@ -70,10 +70,14 @@ export function reduce(s: WorkspaceData, e: WorkspaceEvent): WorkspaceData {
|
|||||||
const person = location.authorAccount ? people.get(location.authorAccount) : undefined;
|
const person = location.authorAccount ? people.get(location.authorAccount) : undefined;
|
||||||
if (person) authors[location.itemId] = { username: person.username, displayName: person.displayName || person.username, avatar: person.avatar ?? undefined };
|
if (person) authors[location.itemId] = { username: person.username, displayName: person.displayName || person.username, avatar: person.avatar ?? undefined };
|
||||||
}
|
}
|
||||||
const byId = Object.fromEntries(Object.entries(s.byId).map(([id, item]) => [id, {
|
|
||||||
...item, ...(locations[id] ?? {}), ...(authors[id] ? { author: authors[id] } : {}),
|
|
||||||
}]));
|
|
||||||
const profiles = Object.fromEntries(e.people.map(person => [person.username, { username: person.username, displayName: person.displayName || person.username, ...(person.avatar ? { avatar: person.avatar } : {}) }]));
|
const profiles = Object.fromEntries(e.people.map(person => [person.username, { username: person.username, displayName: person.displayName || person.username, ...(person.avatar ? { avatar: person.avatar } : {}) }]));
|
||||||
|
// 资料改了(头像、名字),已经显示的消息也要跟着换:作者按账号取最新的资料
|
||||||
|
const fresh = (author: SenderInfo | undefined) => (author && profiles[author.username]) || author;
|
||||||
|
for (const id of Object.keys(authors)) authors[id] = fresh(authors[id])!;
|
||||||
|
const byId = Object.fromEntries(Object.entries(s.byId).map(([id, item]) => {
|
||||||
|
const author = fresh(authors[id] ?? item.author);
|
||||||
|
return [id, { ...item, ...(locations[id] ?? {}), ...(author ? { author } : {}) }];
|
||||||
|
}));
|
||||||
return { ...s, people: profiles, threads: e.state.channels.map(c => ({ ...c, groupId: c.groupId ?? undefined })), groups: e.state.groups, roles: e.state.roles ?? [],
|
return { ...s, people: profiles, threads: e.state.channels.map(c => ({ ...c, groupId: c.groupId ?? undefined })), groups: e.state.groups, roles: e.state.roles ?? [],
|
||||||
folders: e.state.folders.map(f => ({ ...f, parentId: f.parentId ?? undefined })), locations, authors, byId };
|
folders: e.state.folders.map(f => ({ ...f, parentId: f.parentId ?? undefined })), locations, authors, byId };
|
||||||
}
|
}
|
||||||
@@ -96,7 +100,8 @@ export function reduce(s: WorkspaceData, e: WorkspaceEvent): WorkspaceData {
|
|||||||
const known = e.item.id in s.byId;
|
const known = e.item.id in s.byId;
|
||||||
let item = e.item;
|
let item = e.item;
|
||||||
const current = s.byId[item.id];
|
const current = s.byId[item.id];
|
||||||
const author = s.authors[item.id] ?? item.author ?? current?.author;
|
const known0 = s.authors[item.id] ?? item.author ?? current?.author;
|
||||||
|
const author = (known0 && s.people[known0.username]) || known0;
|
||||||
if (author) item = { ...item, author };
|
if (author) item = { ...item, author };
|
||||||
if (e.t === 'item' && item.kind === 'file' && item.metadataHidden && current?.kind === 'file' && !current.metadataHidden) return s;
|
if (e.t === 'item' && item.kind === 'file' && item.metadataHidden && current?.kind === 'file' && !current.metadataHidden) return s;
|
||||||
const location = s.locations[item.id];
|
const location = s.locations[item.id];
|
||||||
|
|||||||
Reference in new issue
Block a user