补齐角色动作预览失败重试
后台和创作主页序列播放器识别换签失败与图片解码失败并跳过故障帧。 全部帧失败时停止播放,弹窗显示失败帧数量并允许重新换签加载。 卡片全帧失败时引导打开预览重试,避免嵌套交互按钮。 补充故障帧跳过、手动重试和预读窗口恢复回归测试与项目记忆。
This commit is contained in:
@@ -569,6 +569,18 @@ describe('CreationLandingView', () => {
|
||||
),
|
||||
).toHaveLength(3);
|
||||
|
||||
const failedModalFrame = document.querySelector(
|
||||
'.creation-landing__showcase-current .creation-landing__image-sequence-frame',
|
||||
) as HTMLImageElement;
|
||||
await user.click(screen.getByRole('button', { name: '暂停角色动作' }));
|
||||
fireEvent.error(failedModalFrame);
|
||||
expect(await screen.findByText('1 帧加载失败')).toBeTruthy();
|
||||
expect(await screen.findByText('2/5')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '重试失败帧' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('1 帧加载失败')).toBeNull();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '暂停角色动作' }));
|
||||
expect(screen.getByRole('button', { name: '播放角色动作' })).toBeTruthy();
|
||||
setIntervalSpy.mockRestore();
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
Film,
|
||||
Image as ImageIcon,
|
||||
Music2,
|
||||
Pause,
|
||||
Play,
|
||||
} from 'lucide-react';
|
||||
import { Film, Image as ImageIcon, Music2, Pause, Play } from 'lucide-react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -269,10 +263,7 @@ function getPreviewIcon(preview: ShowcaseAssetPreview) {
|
||||
if (preview.mediaType === 'audio') {
|
||||
return <Music2 aria-hidden="true" />;
|
||||
}
|
||||
if (
|
||||
preview.mediaType === 'video' ||
|
||||
preview.mediaType === 'image-sequence'
|
||||
) {
|
||||
if (preview.mediaType === 'video' || preview.mediaType === 'image-sequence') {
|
||||
return <Film aria-hidden="true" />;
|
||||
}
|
||||
return <ImageIcon aria-hidden="true" />;
|
||||
@@ -347,19 +338,40 @@ function ShowcaseImageSequenceFrame({
|
||||
frameKey,
|
||||
visible,
|
||||
loaded,
|
||||
retryGeneration,
|
||||
onReady,
|
||||
onFailed,
|
||||
}: {
|
||||
preview: ShowcaseAssetPreview;
|
||||
frame: NonNullable<ShowcaseAssetPreview['imageSequenceFrames']>[number];
|
||||
frameKey: string;
|
||||
visible: boolean;
|
||||
loaded: boolean;
|
||||
retryGeneration: number;
|
||||
onReady: (frameKey: string) => void;
|
||||
onFailed: (frameKey: string) => void;
|
||||
}) {
|
||||
const { resolvedUrl } = useResolvedAssetReadUrl(frame.imageSrc, {
|
||||
objectKey: frame.objectKey,
|
||||
refreshKey: `${preview.id}:${frameKey}`,
|
||||
});
|
||||
const failureReportedRef = useRef(false);
|
||||
const { resolvedUrl, isResolving, shouldResolve } = useResolvedAssetReadUrl(
|
||||
frame.imageSrc,
|
||||
{
|
||||
objectKey: frame.objectKey,
|
||||
refreshKey: `${preview.id}:${frameKey}:${retryGeneration}`,
|
||||
},
|
||||
);
|
||||
const reportFailure = useCallback(() => {
|
||||
if (failureReportedRef.current) {
|
||||
return;
|
||||
}
|
||||
failureReportedRef.current = true;
|
||||
onFailed(frameKey);
|
||||
}, [frameKey, onFailed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldResolve && !isResolving && !resolvedUrl) {
|
||||
reportFailure();
|
||||
}
|
||||
}, [isResolving, reportFailure, resolvedUrl, shouldResolve]);
|
||||
|
||||
if (!resolvedUrl) {
|
||||
return null;
|
||||
@@ -377,6 +389,7 @@ function ShowcaseImageSequenceFrame({
|
||||
transition: 'none',
|
||||
}}
|
||||
onLoad={() => onReady(frameKey)}
|
||||
onError={reportFailure}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -390,14 +403,23 @@ function ShowcaseImageSequencePreview({
|
||||
variant: 'card' | 'modal' | 'thumb';
|
||||
playing: boolean;
|
||||
}) {
|
||||
const frames = preview.imageSequenceFrames ?? [];
|
||||
const frames = useMemo(
|
||||
() => preview.imageSequenceFrames ?? [],
|
||||
[preview.imageSequenceFrames],
|
||||
);
|
||||
const durationMs = preview.imageSequenceDurationMs ?? 0;
|
||||
const shouldMountSequence = variant === 'modal' || playing;
|
||||
const frameItems = frames.map((frame, index) => ({
|
||||
frame,
|
||||
index,
|
||||
key: [preview.id, frame.objectKey ?? '', frame.imageSrc, index].join(':'),
|
||||
}));
|
||||
const frameItems = useMemo(
|
||||
() =>
|
||||
frames.map((frame, index) => ({
|
||||
frame,
|
||||
index,
|
||||
key: [preview.id, frame.objectKey ?? '', frame.imageSrc, index].join(
|
||||
':',
|
||||
),
|
||||
})),
|
||||
[frames, preview.id],
|
||||
);
|
||||
const frameSequenceKey = frameItems.map((item) => item.key).join('|');
|
||||
const firstFrameKey = frameItems[0]?.key ?? '';
|
||||
const [frameIndex, setFrameIndex] = useState(0);
|
||||
@@ -405,17 +427,29 @@ function ShowcaseImageSequencePreview({
|
||||
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) => {
|
||||
@@ -428,13 +462,51 @@ function ShowcaseImageSequencePreview({
|
||||
});
|
||||
}, []);
|
||||
|
||||
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(playing);
|
||||
setLoadedFrameKeys(new Set());
|
||||
setFailedFrameKeys(new Set());
|
||||
setVisibleFrameKey(firstFrameKey);
|
||||
setRetryGeneration((current) => current + 1);
|
||||
}, [firstFrameKey, playing]);
|
||||
|
||||
useEffect(() => {
|
||||
setFrameIndex(0);
|
||||
setLoadedFrameKeys(new Set());
|
||||
setFailedFrameKeys(new Set());
|
||||
setVisibleFrameKey(firstFrameKey);
|
||||
setIsPlaying(playing);
|
||||
}, [firstFrameKey, frameSequenceKey, playing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentFrameKey || !failedFrameKeys.has(currentFrameKey)) {
|
||||
return;
|
||||
}
|
||||
const nextIndex = findNextShowcaseFrameIndex(
|
||||
frameItems,
|
||||
failedFrameKeys,
|
||||
frameIndex,
|
||||
);
|
||||
if (nextIndex === null) {
|
||||
setVisibleFrameKey('');
|
||||
setIsPlaying(false);
|
||||
return;
|
||||
}
|
||||
setFrameIndex(nextIndex);
|
||||
}, [currentFrameKey, failedFrameKeys, frameIndex, frameItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentFrameKey) {
|
||||
setVisibleFrameKey('');
|
||||
@@ -447,15 +519,29 @@ function ShowcaseImageSequencePreview({
|
||||
}, [currentFrameKey, loadedFrameKeys, visibleFrameKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlaying || frames.length < 2 || durationMs <= 0) {
|
||||
if (!isPlaying || availableFrameCount < 2 || durationMs <= 0) {
|
||||
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) =>
|
||||
findNextShowcaseFrameIndex(
|
||||
frameItems,
|
||||
failedFrameKeys,
|
||||
currentIndex,
|
||||
) ?? currentIndex,
|
||||
);
|
||||
}, frameIntervalMs);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [durationMs, frames.length, isPlaying]);
|
||||
}, [
|
||||
availableFrameCount,
|
||||
durationMs,
|
||||
failedFrameKeys,
|
||||
frameItems,
|
||||
frames.length,
|
||||
isPlaying,
|
||||
]);
|
||||
|
||||
if (!shouldMountSequence) {
|
||||
return <ShowcaseImagePreview preview={preview} variant={variant} />;
|
||||
@@ -475,13 +561,15 @@ function ShowcaseImageSequencePreview({
|
||||
.filter((item) => mountedFrameKeys.has(item.key))
|
||||
.map((item) => (
|
||||
<ShowcaseImageSequenceFrame
|
||||
key={item.key}
|
||||
key={`${item.key}:${retryGeneration}`}
|
||||
preview={preview}
|
||||
frame={item.frame}
|
||||
frameKey={item.key}
|
||||
visible={item.key === visibleFrameKey}
|
||||
loaded={loadedFrameKeys.has(item.key)}
|
||||
retryGeneration={retryGeneration}
|
||||
onReady={handleFrameReady}
|
||||
onFailed={handleFrameFailed}
|
||||
/>
|
||||
))}
|
||||
{variant === 'modal' ? (
|
||||
@@ -498,12 +586,42 @@ function ShowcaseImageSequencePreview({
|
||||
)}
|
||||
</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>
|
||||
) : availableFrameCount === 0 ? (
|
||||
<span
|
||||
aria-live="polite"
|
||||
className="creation-landing__image-sequence-error"
|
||||
>
|
||||
角色动作加载失败,打开预览可重试
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function findNextShowcaseFrameIndex(
|
||||
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 ShowcaseImagePreview({
|
||||
preview,
|
||||
variant,
|
||||
@@ -585,10 +703,7 @@ function ShowcaseBundlePreview({
|
||||
className="creation-landing__asset-preview-tile"
|
||||
aria-label={preview.label}
|
||||
>
|
||||
<ShowcasePreview
|
||||
preview={preview}
|
||||
sequencePlaying={playSequences}
|
||||
/>
|
||||
<ShowcasePreview preview={preview} sequencePlaying={playSequences} />
|
||||
</span>
|
||||
))}
|
||||
{item.previews.length > visiblePreviews.length ? (
|
||||
|
||||
@@ -3468,6 +3468,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
|
||||
.creation-landing__image-sequence-controls button {
|
||||
display: grid;
|
||||
min-height: 44px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
@@ -3476,6 +3477,19 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.creation-landing__image-sequence-error {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(24, 18, 14, 0.72);
|
||||
color: #fff;
|
||||
padding: 1rem;
|
||||
font-size: 0.78rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.creation-landing__asset-preview--campaign {
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user