修复后台角色动作预览换签缓存
将序列帧 signed URL、解析与加载状态统一上移到预览父组件。 按当前序列限制缓存并在序列、token 或弹窗生命周期结束时清理在途请求。 新增跨三帧窗口延迟换签与回绕复用的回归测试,并同步架构文档。
This commit is contained in:
@@ -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
Reference in New Issue
Block a user