修复后台角色动作预览换签缓存

将序列帧 signed URL、解析与加载状态统一上移到预览父组件。

按当前序列限制缓存并在序列、token 或弹窗生命周期结束时清理在途请求。

新增跨三帧窗口延迟换签与回绕复用的回归测试,并同步架构文档。
This commit is contained in:
2026-08-05 14:25:59 +08:00
parent ff582404d1
commit a3d3a1b8ba
3 changed files with 475 additions and 85 deletions
@@ -0,0 +1,166 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { getAdminAssetReadUrl } from '../api/adminApiClient';
import { AdminEditorAssetPreviewDialog } from './AdminEditorAssetMedia';
vi.mock('../api/adminApiClient', () => ({
getAdminAssetReadUrl: vi.fn(),
isAdminApiError: vi.fn(
(error: unknown) =>
typeof error === 'object' &&
error !== null &&
'status' in error &&
typeof error.status === 'number',
),
}));
afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
});
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
test('角色动作预览跨窗口回播时复用父级换签缓存并等待目标帧就绪', async () => {
vi.useFakeTimers();
const frameObjectKeys = [
'generated-animations/editor/task-cache/frame00.png',
'generated-animations/editor/task-cache/frame01.png',
'generated-animations/editor/task-cache/frame02.png',
'generated-animations/editor/task-cache/frame03.png',
'generated-animations/editor/task-cache/frame04.png',
] as const;
const delayedFrame = createDeferred<{
read: {
objectKey: string;
signedUrl: string;
expiresAt: string;
};
}>();
vi.mocked(getAdminAssetReadUrl).mockImplementation((_token, request) => {
const objectKey = request.objectKey ?? '';
if (objectKey === frameObjectKeys[3]) {
return delayedFrame.promise;
}
return Promise.resolve({
read: {
objectKey,
signedUrl: `https://signed.example.com/${objectKey}`,
expiresAt: '2026-07-04T11:00:00Z',
},
});
});
render(
<AdminEditorAssetPreviewDialog
entry={{
assetId: 'asset-character-animation-cache',
label: '缓存回播动作',
imageSrc: `/${frameObjectKeys[0]}`,
objectKey: frameObjectKeys[0],
assetKind: 'character-animation',
imageSequenceFrames: frameObjectKeys.map((objectKey) => ({
imageSrc: `/${objectKey}`,
objectKey,
width: 192,
height: 256,
})),
imageSequenceDurationMs: 500,
}}
token="admin-token"
onClose={vi.fn()}
/>,
);
const dialog = screen.getByRole('dialog', { name: '素材预览' });
fireEvent.click(within(dialog).getByRole('button', { name: '暂停角色动作' }));
await act(async () => {
await vi.advanceTimersByTimeAsync(120);
});
const initialFrames = Array.from(
dialog.querySelectorAll<HTMLImageElement>(
'.admin-asset-query-sequence-frame',
),
);
expect(initialFrames).toHaveLength(3);
initialFrames.forEach((frame) => fireEvent.load(frame));
fireEvent.click(within(dialog).getByRole('button', { name: '播放角色动作' }));
for (const elapsedMs of [100, 40, 60, 40, 60]) {
await act(async () => {
await vi.advanceTimersByTimeAsync(elapsedMs);
});
}
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
objectKey: frameObjectKeys[3],
expireSeconds: 300,
});
expect(resolveVisibleFrameSrc(dialog)).toBe(
`https://signed.example.com/${frameObjectKeys[2]}`,
);
await act(async () => {
delayedFrame.resolve({
read: {
objectKey: frameObjectKeys[3],
signedUrl: `https://signed.example.com/${frameObjectKeys[3]}`,
expiresAt: '2026-07-04T11:00:00Z',
},
});
await Promise.resolve();
});
const fourthFrame = resolveFrameByObjectKey(dialog, frameObjectKeys[3]);
expect(fourthFrame?.style.opacity).toBe('0');
expect(resolveVisibleFrameSrc(dialog)).toBe(
`https://signed.example.com/${frameObjectKeys[2]}`,
);
fireEvent.load(fourthFrame!);
fireEvent.load(resolveFrameByObjectKey(dialog, frameObjectKeys[4])!);
for (let index = 0; index < 3; index += 1) {
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
});
}
expect(within(dialog).getByText('1/5')).toBeTruthy();
expect(resolveVisibleFrameSrc(dialog)).toBe(
`https://signed.example.com/${frameObjectKeys[0]}`,
);
expect(
vi
.mocked(getAdminAssetReadUrl)
.mock.calls.filter(
([, request]) => request.objectKey === frameObjectKeys[0],
),
).toHaveLength(1);
expect(
dialog.querySelectorAll('.admin-asset-query-sequence-frame').length,
).toBeLessThanOrEqual(3);
});
function resolveFrameByObjectKey(dialog: HTMLElement, objectKey: string) {
return Array.from(
dialog.querySelectorAll<HTMLImageElement>(
'.admin-asset-query-sequence-frame',
),
).find((frame) => frame.src.endsWith(objectKey));
}
function resolveVisibleFrameSrc(dialog: HTMLElement) {
return Array.from(
dialog.querySelectorAll<HTMLImageElement>(
'.admin-asset-query-sequence-frame',
),
).find((frame) => frame.style.opacity === '1')?.src;
}
@@ -206,33 +206,22 @@ function AdminEditorStandardAssetPreviewMedia({
function AdminEditorImageSequenceFrame({
entry,
frame,
frameKey,
resolvedUrl,
visible,
loaded,
token,
retryGeneration,
ready,
onReady,
onFailed,
}: {
entry: AdminPreviewableEditorAsset;
frame: AdminEditorImageSequenceFramePayload;
frameKey: string;
resolvedUrl: string;
visible: boolean;
loaded: boolean;
token: string;
retryGeneration: number;
ready: boolean;
onReady: (frameKey: string) => void;
onFailed: (frameKey: string) => void;
}) {
const failureReportedRef = useRef(false);
const { resolvedUrl, hasError } = useAdminResolvedAssetUrl(
token,
frame.imageSrc,
frame.objectKey,
true,
`${frameKey}:${retryGeneration}`,
);
const reportFailure = useCallback(() => {
if (failureReportedRef.current) {
return;
@@ -241,12 +230,6 @@ function AdminEditorImageSequenceFrame({
onFailed(frameKey);
}, [frameKey, onFailed]);
useEffect(() => {
if (hasError) {
reportFailure();
}
}, [hasError, reportFailure]);
if (!resolvedUrl) {
return null;
}
@@ -257,7 +240,7 @@ function AdminEditorImageSequenceFrame({
aria-hidden={visible ? undefined : true}
className="admin-asset-query-sequence-frame"
decoding="async"
style={{ opacity: visible && loaded ? 1 : 0, transition: 'none' }}
style={{ opacity: visible && ready ? 1 : 0, transition: 'none' }}
onLoad={() => onReady(frameKey)}
onError={reportFailure}
/>
@@ -287,75 +270,73 @@ function AdminEditorImageSequencePreview({
[entry.assetId, frames],
);
const sequenceKey = frameItems.map((item) => item.key).join('|');
const frameCacheOwnerKey = `${token}\u0000${sequenceKey}`;
const firstFrameKey = frameItems[0]?.key ?? '';
const [frameIndex, setFrameIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(true);
const [loadedFrameKeys, setLoadedFrameKeys] = useState<ReadonlySet<string>>(
() => new Set(),
const {
frameCache,
resolveFrames,
markFrameReady,
markFrameFailed,
retryFailedFrames: clearFailedFrameCache,
} = useAdminImageSequenceFrameCache(
token,
frameCacheOwnerKey,
frameItems.length,
);
const [failedFrameKeys, setFailedFrameKeys] = useState<ReadonlySet<string>>(
() => new Set(),
);
const [retryGeneration, setRetryGeneration] = useState(0);
const [visibleFrameKey, setVisibleFrameKey] = useState(firstFrameKey);
const failedFrameKeys = useMemo(
() =>
new Set(
Array.from(frameCache.entries()).flatMap(([frameKey, cached]) =>
cached.status === 'failed' ? [frameKey] : [],
),
),
[frameCache],
);
const currentFrameItem =
frameItems[Math.min(frameIndex, frameItems.length - 1)];
const currentFrameKey = currentFrameItem?.key ?? '';
const mountedFrameKeys = new Set<string>();
if (visibleFrameKey && !failedFrameKeys.has(visibleFrameKey)) {
mountedFrameKeys.add(visibleFrameKey);
}
for (
let offset = 0;
offset < frameItems.length && mountedFrameKeys.size < 3;
offset += 1
) {
const item = frameItems[(frameIndex + offset) % frameItems.length];
if (item && !failedFrameKeys.has(item.key)) {
mountedFrameKeys.add(item.key);
const mountedFrameKeys = useMemo(() => {
const nextMountedFrameKeys = new Set<string>();
if (visibleFrameKey && !failedFrameKeys.has(visibleFrameKey)) {
nextMountedFrameKeys.add(visibleFrameKey);
}
}
for (
let offset = 0;
offset < frameItems.length && nextMountedFrameKeys.size < 3;
offset += 1
) {
const item = frameItems[(frameIndex + offset) % frameItems.length];
if (item && !failedFrameKeys.has(item.key)) {
nextMountedFrameKeys.add(item.key);
}
}
return nextMountedFrameKeys;
}, [failedFrameKeys, frameIndex, frameItems, visibleFrameKey]);
const mountedFrameItems = useMemo(
() => frameItems.filter((item) => mountedFrameKeys.has(item.key)),
[frameItems, mountedFrameKeys],
);
const availableFrameCount = frameItems.length - failedFrameKeys.size;
const handleFrameReady = useCallback((frameKey: string) => {
setLoadedFrameKeys((currentKeys) => {
if (currentKeys.has(frameKey)) {
return currentKeys;
}
const nextKeys = new Set(currentKeys);
nextKeys.add(frameKey);
return nextKeys;
});
}, []);
const handleFrameFailed = useCallback((frameKey: string) => {
setFailedFrameKeys((currentKeys) => {
if (currentKeys.has(frameKey)) {
return currentKeys;
}
const nextKeys = new Set(currentKeys);
nextKeys.add(frameKey);
return nextKeys;
});
}, []);
const retryFailedFrames = useCallback(() => {
setFrameIndex(0);
setIsPlaying(true);
setLoadedFrameKeys(new Set());
setFailedFrameKeys(new Set());
setVisibleFrameKey(firstFrameKey);
setRetryGeneration((current) => current + 1);
}, [firstFrameKey]);
clearFailedFrameCache();
}, [clearFailedFrameCache]);
useEffect(() => {
setFrameIndex(0);
setIsPlaying(true);
setLoadedFrameKeys(new Set());
setFailedFrameKeys(new Set());
setVisibleFrameKey(firstFrameKey);
}, [firstFrameKey, sequenceKey]);
useEffect(() => {
resolveFrames(mountedFrameItems);
}, [mountedFrameItems, resolveFrames]);
useEffect(() => {
if (!currentFrameKey || !failedFrameKeys.has(currentFrameKey)) {
return;
@@ -378,11 +359,14 @@ function AdminEditorImageSequencePreview({
setVisibleFrameKey('');
return;
}
if (!loadedFrameKeys.has(currentFrameKey) && visibleFrameKey) {
if (
frameCache.get(currentFrameKey)?.status !== 'ready' &&
visibleFrameKey
) {
return;
}
setVisibleFrameKey(currentFrameKey);
}, [currentFrameKey, loadedFrameKeys, visibleFrameKey]);
}, [currentFrameKey, frameCache, visibleFrameKey]);
useEffect(() => {
if (!isPlaying || availableFrameCount < 2) {
@@ -414,22 +398,21 @@ function AdminEditorImageSequencePreview({
aria-label={`角色动作预览:${entry.label || entry.assetId}`}
className="admin-asset-query-preview-media admin-asset-query-sequence-preview"
>
{frameItems
.filter((item) => mountedFrameKeys.has(item.key))
.map((item) => (
{mountedFrameItems.map((item) => {
const cachedFrame = frameCache.get(item.key);
return (
<AdminEditorImageSequenceFrame
key={`${item.key}:${retryGeneration}`}
key={item.key}
entry={entry}
frame={item.frame}
frameKey={item.key}
resolvedUrl={cachedFrame?.resolvedUrl ?? ''}
visible={item.key === visibleFrameKey}
loaded={loadedFrameKeys.has(item.key)}
token={token}
retryGeneration={retryGeneration}
onReady={handleFrameReady}
onFailed={handleFrameFailed}
ready={cachedFrame?.status === 'ready'}
onReady={markFrameReady}
onFailed={markFrameFailed}
/>
))}
);
})}
<div className="admin-asset-query-sequence-controls">
<button
type="button"
@@ -456,6 +439,182 @@ function AdminEditorImageSequencePreview({
);
}
type AdminImageSequenceFrameItem = {
frame: AdminEditorImageSequenceFramePayload;
index: number;
key: string;
};
type AdminImageSequenceFrameCacheEntry = {
resolvedUrl: string;
status: 'resolving' | 'loading' | 'ready' | 'failed';
};
type AdminImageSequenceFrameCacheState = {
ownerKey: string;
entries: ReadonlyMap<string, AdminImageSequenceFrameCacheEntry>;
};
const EMPTY_ADMIN_IMAGE_SEQUENCE_FRAME_CACHE = new Map<
string,
AdminImageSequenceFrameCacheEntry
>();
function useAdminImageSequenceFrameCache(
token: string,
ownerKey: string,
maximumEntryCount: number,
) {
const [cacheState, setCacheState] =
useState<AdminImageSequenceFrameCacheState>(() => ({
ownerKey,
entries: new Map(),
}));
const resolutionControllersRef = useRef(new Map<string, AbortController>());
const frameCache =
cacheState.ownerKey === ownerKey
? cacheState.entries
: EMPTY_ADMIN_IMAGE_SEQUENCE_FRAME_CACHE;
useEffect(() => {
const resolutionControllers = resolutionControllersRef.current;
resolutionControllers.forEach((controller) => controller.abort());
resolutionControllers.clear();
setCacheState({ ownerKey, entries: new Map() });
return () => {
resolutionControllers.forEach((controller) => controller.abort());
resolutionControllers.clear();
};
}, [ownerKey]);
const updateFrameCacheEntry = useCallback(
(
frameKey: string,
update: (
current: AdminImageSequenceFrameCacheEntry | undefined,
) => AdminImageSequenceFrameCacheEntry | null,
) => {
setCacheState((currentState) => {
const currentEntries =
currentState.ownerKey === ownerKey
? currentState.entries
: new Map<string, AdminImageSequenceFrameCacheEntry>();
const nextEntry = update(currentEntries.get(frameKey));
if (nextEntry === currentEntries.get(frameKey)) {
return currentState;
}
const nextEntries = new Map(currentEntries);
if (nextEntry) {
if (
!nextEntries.has(frameKey) &&
nextEntries.size >= maximumEntryCount
) {
return currentState;
}
nextEntries.set(frameKey, nextEntry);
} else {
nextEntries.delete(frameKey);
}
return { ownerKey, entries: nextEntries };
});
},
[maximumEntryCount, ownerKey],
);
const resolveFrames = useCallback(
(frameItemsToResolve: ReadonlyArray<AdminImageSequenceFrameItem>) => {
for (const item of frameItemsToResolve) {
if (
frameCache.has(item.key) ||
resolutionControllersRef.current.has(item.key)
) {
continue;
}
const controller = new AbortController();
resolutionControllersRef.current.set(item.key, controller);
updateFrameCacheEntry(item.key, () => ({
resolvedUrl: '',
status: 'resolving',
}));
void resolveAdminAssetUrl(
token,
item.frame.imageSrc,
item.frame.objectKey,
controller.signal,
)
.then((resolvedUrl) => {
if (!resolvedUrl) {
throw new Error('素材读取地址为空');
}
updateFrameCacheEntry(item.key, () => ({
resolvedUrl,
status: 'loading',
}));
})
.catch(() => {
if (!controller.signal.aborted) {
updateFrameCacheEntry(item.key, () => ({
resolvedUrl: '',
status: 'failed',
}));
}
})
.finally(() => {
if (resolutionControllersRef.current.get(item.key) === controller) {
resolutionControllersRef.current.delete(item.key);
}
});
}
},
[frameCache, token, updateFrameCacheEntry],
);
const markFrameReady = useCallback(
(frameKey: string) => {
updateFrameCacheEntry(frameKey, (current) =>
current?.resolvedUrl
? { ...current, status: 'ready' }
: (current ?? null),
);
},
[updateFrameCacheEntry],
);
const markFrameFailed = useCallback(
(frameKey: string) => {
resolutionControllersRef.current.get(frameKey)?.abort();
resolutionControllersRef.current.delete(frameKey);
updateFrameCacheEntry(frameKey, () => ({
resolvedUrl: '',
status: 'failed',
}));
},
[updateFrameCacheEntry],
);
const retryFailedFrames = useCallback(() => {
setCacheState((currentState) => {
if (currentState.ownerKey !== ownerKey) {
return { ownerKey, entries: new Map() };
}
const nextEntries = new Map(
Array.from(currentState.entries).filter(
([, cached]) => cached.status !== 'failed',
),
);
return { ownerKey, entries: nextEntries };
});
}, [ownerKey]);
return {
frameCache,
resolveFrames,
markFrameReady,
markFrameFailed,
retryFailedFrames,
};
}
function findNextAvailableFrameIndex(
frameItems: ReadonlyArray<{ key: string }>,
failedFrameKeys: ReadonlySet<string>,
@@ -739,6 +898,61 @@ function useAdminResolvedAssetUrl(
};
}
async function resolveAdminAssetUrl(
token: string,
imageSrc: string | null | undefined,
objectKey: string | null | undefined,
signal: AbortSignal,
) {
const normalizedImageSrc = imageSrc?.trim() ?? '';
const normalizedObjectKey = normalizeAdminObjectKey(objectKey);
const normalizedLegacyPublicPath = isGeneratedLegacyPath(normalizedImageSrc)
? normalizedImageSrc
: resolveAdminGeneratedLegacyPathFromUrl(normalizedImageSrc);
if (!normalizedObjectKey && !normalizedLegacyPublicPath) {
return normalizedImageSrc;
}
let retryIndex = 0;
while (!signal.aborted) {
try {
await waitForAdminAssetReadDispatch(signal);
const response = await getAdminAssetReadUrl(
token,
normalizedObjectKey
? {
objectKey: normalizedObjectKey,
expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS,
}
: {
legacyPublicPath: normalizedLegacyPublicPath,
expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS,
},
);
if (signal.aborted) {
throw new DOMException('The operation was aborted.', 'AbortError');
}
return resolveAdminAssetReadSignedUrl(response);
} catch (error: unknown) {
if (signal.aborted) {
throw error;
}
const retryDelay = ADMIN_ASSET_READ_RETRY_DELAYS_MS[retryIndex];
if (
isAdminApiError(error) &&
error.status === 429 &&
typeof retryDelay === 'number'
) {
retryIndex += 1;
await waitForAdminAssetReadDelay(retryDelay, signal);
continue;
}
throw error;
}
}
throw new DOMException('The operation was aborted.', 'AbortError');
}
async function waitForAdminAssetReadDispatch(signal: AbortSignal) {
const dispatch = adminAssetReadDispatchTail.then(
() => waitForAdminAssetReadDispatchSpacing(signal),
@@ -749,6 +963,16 @@ async function waitForAdminAssetReadDispatch(signal: AbortSignal) {
}
async function waitForAdminAssetReadDispatchSpacing(signal: AbortSignal) {
await waitForAdminAssetReadDelay(
ADMIN_ASSET_READ_DISPATCH_SPACING_MS,
signal,
);
}
async function waitForAdminAssetReadDelay(
delayMs: number,
signal: AbortSignal,
) {
if (signal.aborted) {
throw new DOMException('The operation was aborted.', 'AbortError');
}
@@ -756,7 +980,7 @@ async function waitForAdminAssetReadDispatchSpacing(signal: AbortSignal) {
const timer = setTimeout(() => {
signal.removeEventListener('abort', handleAbort);
resolve();
}, ADMIN_ASSET_READ_DISPATCH_SPACING_MS);
}, delayMs);
function handleAbort() {
clearTimeout(timer);
File diff suppressed because one or more lines are too long