补齐角色动作预览失败重试

后台和创作主页序列播放器识别换签失败与图片解码失败并跳过故障帧。

全部帧失败时停止播放,弹窗显示失败帧数量并允许重新换签加载。

卡片全帧失败时引导打开预览重试,避免嵌套交互按钮。

补充故障帧跳过、手动重试和预读窗口恢复回归测试与项目记忆。
This commit is contained in:
2026-08-04 12:12:59 +08:00
parent 351f41fd12
commit da42876c63
6 changed files with 352 additions and 53 deletions
@@ -1,5 +1,5 @@
import { Pause, Play, X } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { AdminAssetReadUrlResponse } from '../api/adminApiClient';
import { getAdminAssetReadUrl, isAdminApiError } from '../api/adminApiClient';
@@ -34,7 +34,7 @@ export function AdminEditorAssetThumbnail({
}) {
const thumbnailSource = resolveAdminAssetThumbnailSource(entry);
const { observeElement, shouldLoad } = useAdminAssetThumbnailVisibility();
const imageSrc = useAdminResolvedAssetUrl(
const { resolvedUrl: imageSrc } = useAdminResolvedAssetUrl(
token,
thumbnailSource.src,
thumbnailSource.objectKey,
@@ -142,12 +142,12 @@ function AdminEditorStandardAssetPreviewMedia({
}) {
const isAudio = mediaKind === 'audio';
const isVideo = mediaKind === 'video';
const mediaSrc = useAdminResolvedAssetUrl(
const { resolvedUrl: mediaSrc } = useAdminResolvedAssetUrl(
token,
entry.imageSrc,
entry.objectKey,
);
const posterSrc = useAdminResolvedAssetUrl(
const { resolvedUrl: posterSrc } = useAdminResolvedAssetUrl(
token,
isVideo ? (entry.thumbnailSrc ?? '') : '',
null,
@@ -211,7 +211,9 @@ function AdminEditorImageSequenceFrame({
visible,
loaded,
token,
retryGeneration,
onReady,
onFailed,
}: {
entry: AdminPreviewableEditorAsset;
frame: AdminEditorImageSequenceFramePayload;
@@ -219,13 +221,32 @@ function AdminEditorImageSequenceFrame({
visible: boolean;
loaded: boolean;
token: string;
retryGeneration: number;
onReady: (frameKey: string) => void;
onFailed: (frameKey: string) => void;
}) {
const resolvedUrl = useAdminResolvedAssetUrl(
const failureReportedRef = useRef(false);
const { resolvedUrl, hasError } = useAdminResolvedAssetUrl(
token,
frame.imageSrc,
frame.objectKey,
true,
`${frameKey}:${retryGeneration}`,
);
const reportFailure = useCallback(() => {
if (failureReportedRef.current) {
return;
}
failureReportedRef.current = true;
onFailed(frameKey);
}, [frameKey, onFailed]);
useEffect(() => {
if (hasError) {
reportFailure();
}
}, [hasError, reportFailure]);
if (!resolvedUrl) {
return null;
}
@@ -238,6 +259,7 @@ function AdminEditorImageSequenceFrame({
decoding="async"
style={{ opacity: visible && loaded ? 1 : 0, transition: 'none' }}
onLoad={() => onReady(frameKey)}
onError={reportFailure}
/>
);
}
@@ -253,11 +275,17 @@ function AdminEditorImageSequencePreview({
durationMs: number;
token: string;
}) {
const frameItems = frames.map((frame, index) => ({
frame,
index,
key: [entry.assetId, frame.objectKey ?? '', frame.imageSrc, index].join(':'),
}));
const frameItems = useMemo(
() =>
frames.map((frame, index) => ({
frame,
index,
key: [entry.assetId, frame.objectKey ?? '', frame.imageSrc, index].join(
':',
),
})),
[entry.assetId, frames],
);
const sequenceKey = frameItems.map((item) => item.key).join('|');
const firstFrameKey = frameItems[0]?.key ?? '';
const [frameIndex, setFrameIndex] = useState(0);
@@ -265,17 +293,29 @@ function AdminEditorImageSequencePreview({
const [loadedFrameKeys, setLoadedFrameKeys] = useState<ReadonlySet<string>>(
() => new Set(),
);
const [failedFrameKeys, setFailedFrameKeys] = useState<ReadonlySet<string>>(
() => new Set(),
);
const [retryGeneration, setRetryGeneration] = useState(0);
const [visibleFrameKey, setVisibleFrameKey] = useState(firstFrameKey);
const currentFrameItem =
frameItems[Math.min(frameIndex, frameItems.length - 1)];
const currentFrameKey = currentFrameItem?.key ?? '';
const mountedFrameKeys = new Set([visibleFrameKey]);
for (let offset = 0; offset < Math.min(3, frameItems.length); offset += 1) {
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) {
if (item && !failedFrameKeys.has(item.key)) {
mountedFrameKeys.add(item.key);
}
}
const availableFrameCount = frameItems.length - failedFrameKeys.size;
const handleFrameReady = useCallback((frameKey: string) => {
setLoadedFrameKeys((currentKeys) => {
@@ -288,13 +328,51 @@ function AdminEditorImageSequencePreview({
});
}, []);
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]);
useEffect(() => {
setFrameIndex(0);
setIsPlaying(true);
setLoadedFrameKeys(new Set());
setFailedFrameKeys(new Set());
setVisibleFrameKey(firstFrameKey);
}, [firstFrameKey, sequenceKey]);
useEffect(() => {
if (!currentFrameKey || !failedFrameKeys.has(currentFrameKey)) {
return;
}
const nextIndex = findNextAvailableFrameIndex(
frameItems,
failedFrameKeys,
frameIndex,
);
if (nextIndex === null) {
setVisibleFrameKey('');
setIsPlaying(false);
return;
}
setFrameIndex(nextIndex);
}, [currentFrameKey, failedFrameKeys, frameIndex, frameItems]);
useEffect(() => {
if (!currentFrameKey) {
setVisibleFrameKey('');
@@ -307,15 +385,29 @@ function AdminEditorImageSequencePreview({
}, [currentFrameKey, loadedFrameKeys, visibleFrameKey]);
useEffect(() => {
if (!isPlaying) {
if (!isPlaying || availableFrameCount < 2) {
return undefined;
}
const frameIntervalMs = Math.min(2_147_483_647, durationMs / frames.length);
const timer = window.setInterval(() => {
setFrameIndex((currentIndex) => (currentIndex + 1) % frames.length);
setFrameIndex(
(currentIndex) =>
findNextAvailableFrameIndex(
frameItems,
failedFrameKeys,
currentIndex,
) ?? currentIndex,
);
}, frameIntervalMs);
return () => window.clearInterval(timer);
}, [durationMs, frames.length, isPlaying]);
}, [
availableFrameCount,
durationMs,
failedFrameKeys,
frameItems,
frames.length,
isPlaying,
]);
return (
<div
@@ -326,14 +418,16 @@ function AdminEditorImageSequencePreview({
.filter((item) => mountedFrameKeys.has(item.key))
.map((item) => (
<AdminEditorImageSequenceFrame
key={item.key}
key={`${item.key}:${retryGeneration}`}
entry={entry}
frame={item.frame}
frameKey={item.key}
visible={item.key === visibleFrameKey}
loaded={loadedFrameKeys.has(item.key)}
token={token}
retryGeneration={retryGeneration}
onReady={handleFrameReady}
onFailed={handleFrameFailed}
/>
))}
<div className="admin-asset-query-sequence-controls">
@@ -349,11 +443,34 @@ function AdminEditorImageSequencePreview({
)}
</button>
<span>{`${currentFrameItem ? currentFrameItem.index + 1 : 0}/${frames.length}`}</span>
{failedFrameKeys.size ? (
<>
<span aria-live="polite">{`${failedFrameKeys.size} 帧加载失败`}</span>
<button type="button" onClick={retryFailedFrames}>
重试失败帧
</button>
</>
) : null}
</div>
</div>
);
}
function findNextAvailableFrameIndex(
frameItems: ReadonlyArray<{ key: string }>,
failedFrameKeys: ReadonlySet<string>,
currentIndex: number,
) {
for (let offset = 1; offset <= frameItems.length; offset += 1) {
const nextIndex = (currentIndex + offset) % frameItems.length;
const item = frameItems[nextIndex];
if (item && !failedFrameKeys.has(item.key)) {
return nextIndex;
}
}
return null;
}
function resolveAdminAssetThumbnailSource(entry: AdminPreviewableEditorAsset) {
const mediaKind = resolveAdminAssetMediaKind(entry);
if (mediaKind === 'image-sequence') {
@@ -517,6 +634,7 @@ function useAdminResolvedAssetUrl(
imageSrc: string | null | undefined,
objectKey: string | null | undefined,
enabled = true,
refreshKey: string | number | null = null,
) {
const normalizedImageSrc = imageSrc?.trim() ?? '';
const normalizedObjectKey = normalizeAdminObjectKey(objectKey);
@@ -528,18 +646,26 @@ function useAdminResolvedAssetUrl(
const [resolvedImageSrc, setResolvedImageSrc] = useState(
shouldResolve ? '' : normalizedImageSrc,
);
const [isResolving, setIsResolving] = useState(enabled && shouldResolve);
const [hasError, setHasError] = useState(false);
useEffect(() => {
if (!normalizedImageSrc && !normalizedObjectKey) {
setResolvedImageSrc('');
setIsResolving(false);
setHasError(false);
return;
}
if (!shouldResolve) {
setResolvedImageSrc(normalizedImageSrc);
setIsResolving(false);
setHasError(false);
return;
}
if (!enabled) {
setResolvedImageSrc('');
setIsResolving(false);
setHasError(false);
return;
}
@@ -548,6 +674,8 @@ function useAdminResolvedAssetUrl(
let retryIndex = 0;
const dispatchController = new AbortController();
setResolvedImageSrc('');
setIsResolving(true);
setHasError(false);
const resolveReadUrl = async () => {
try {
@@ -568,7 +696,10 @@ function useAdminResolvedAssetUrl(
},
);
if (!cancelled) {
setResolvedImageSrc(resolveAdminAssetReadSignedUrl(response));
const nextUrl = resolveAdminAssetReadSignedUrl(response);
setResolvedImageSrc(nextUrl);
setIsResolving(false);
setHasError(!nextUrl);
}
} catch (error: unknown) {
if (cancelled) {
@@ -585,6 +716,8 @@ function useAdminResolvedAssetUrl(
return;
}
setResolvedImageSrc('');
setIsResolving(false);
setHasError(true);
}
};
@@ -602,11 +735,16 @@ function useAdminResolvedAssetUrl(
normalizedImageSrc,
normalizedLegacyPublicPath,
normalizedObjectKey,
refreshKey,
shouldResolve,
token,
]);
return resolvedImageSrc;
return {
resolvedUrl: resolvedImageSrc,
isResolving,
hasError,
};
}
async function waitForAdminAssetReadDispatch(signal: AbortSignal) {
@@ -1177,8 +1177,28 @@ test('后台素材查询在现有预览弹窗播放完整角色动作序列', as
expireSeconds: 300,
});
const failedFrame = dialog.querySelector(
'.admin-asset-query-sequence-frame',
) as HTMLImageElement;
fireEvent.error(failedFrame);
expect(await within(dialog).findByText('1 帧加载失败')).toBeTruthy();
await waitFor(() => {
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
objectKey: 'generated-animations/editor/source-1/task-1/frame03.png',
expireSeconds: 300,
});
});
const callsBeforeRetry = vi.mocked(getAdminAssetReadUrl).mock.calls.length;
await user.click(
within(dialog).getByRole('button', { name: '重试失败帧' }),
);
await waitFor(() => {
expect(vi.mocked(getAdminAssetReadUrl).mock.calls.length).toBeGreaterThan(
callsBeforeRetry,
);
});
expect(
within(dialog).getByRole('button', { name: '播放角色动作' }),
within(dialog).getByRole('button', { name: '暂停角色动作' }),
).toBeTruthy();
});