完善角色动作精选与后台预览
精选与后台素材查询透传正式序列帧和毫秒时长。 列表仅加载首帧,激活预览后再逐帧换签并按完整时长播放。 创作首页与后台复用各自现有媒体组件,损坏动作不回退普通图片。 补齐精选模型、公开卡片和后台审核预览测试。
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { Pause, Play, X } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import type { AdminAssetReadUrlResponse } from '../api/adminApiClient';
|
||||
import { getAdminAssetReadUrl, isAdminApiError } from '../api/adminApiClient';
|
||||
import type { AdminEditorImageSequenceFramePayload } from '../api/adminApiTypes';
|
||||
|
||||
const ADMIN_ASSET_READ_EXPIRE_SECONDS = 300;
|
||||
const ADMIN_ASSET_READ_DISPATCH_SPACING_MS = 40;
|
||||
@@ -18,6 +19,8 @@ export interface AdminPreviewableEditorAsset {
|
||||
objectKey?: string | null;
|
||||
assetKind?: string | null;
|
||||
thumbnailSrc?: string | null;
|
||||
imageSequenceFrames?: AdminEditorImageSequenceFramePayload[] | null;
|
||||
imageSequenceDurationMs?: number | null;
|
||||
}
|
||||
|
||||
export function AdminEditorAssetThumbnail({
|
||||
@@ -98,6 +101,45 @@ function AdminEditorAssetPreviewMedia({
|
||||
token: string;
|
||||
}) {
|
||||
const mediaKind = resolveAdminAssetMediaKind(entry);
|
||||
const label = entry.label || entry.assetId;
|
||||
|
||||
if (mediaKind === 'image-sequence') {
|
||||
const sequence = normalizeAdminImageSequence(entry);
|
||||
return sequence ? (
|
||||
<AdminEditorImageSequencePreview
|
||||
entry={entry}
|
||||
frames={sequence.frames}
|
||||
durationMs={sequence.durationMs}
|
||||
token={token}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
aria-label={`角色动作序列损坏:${label}`}
|
||||
className="admin-asset-query-preview-placeholder admin-asset-query-sequence-error"
|
||||
>
|
||||
序列数据损坏,无法预览
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminEditorStandardAssetPreviewMedia
|
||||
entry={entry}
|
||||
mediaKind={mediaKind}
|
||||
token={token}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminEditorStandardAssetPreviewMedia({
|
||||
entry,
|
||||
mediaKind,
|
||||
token,
|
||||
}: {
|
||||
entry: AdminPreviewableEditorAsset;
|
||||
mediaKind: Exclude<AdminAssetMediaKind, 'image-sequence'>;
|
||||
token: string;
|
||||
}) {
|
||||
const isAudio = mediaKind === 'audio';
|
||||
const isVideo = mediaKind === 'video';
|
||||
const mediaSrc = useAdminResolvedAssetUrl(
|
||||
@@ -162,8 +204,156 @@ function AdminEditorAssetPreviewMedia({
|
||||
);
|
||||
}
|
||||
|
||||
function AdminEditorImageSequenceFrame({
|
||||
entry,
|
||||
frame,
|
||||
frameKey,
|
||||
visible,
|
||||
loaded,
|
||||
token,
|
||||
onReady,
|
||||
}: {
|
||||
entry: AdminPreviewableEditorAsset;
|
||||
frame: AdminEditorImageSequenceFramePayload;
|
||||
frameKey: string;
|
||||
visible: boolean;
|
||||
loaded: boolean;
|
||||
token: string;
|
||||
onReady: (frameKey: string) => void;
|
||||
}) {
|
||||
const resolvedUrl = useAdminResolvedAssetUrl(
|
||||
token,
|
||||
frame.imageSrc,
|
||||
frame.objectKey,
|
||||
);
|
||||
if (!resolvedUrl) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={resolvedUrl}
|
||||
alt={visible ? `角色动作预览:${entry.label || entry.assetId}` : ''}
|
||||
aria-hidden={visible ? undefined : true}
|
||||
className="admin-asset-query-sequence-frame"
|
||||
decoding="async"
|
||||
style={{ opacity: visible && loaded ? 1 : 0, transition: 'none' }}
|
||||
onLoad={() => onReady(frameKey)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminEditorImageSequencePreview({
|
||||
entry,
|
||||
frames,
|
||||
durationMs,
|
||||
token,
|
||||
}: {
|
||||
entry: AdminPreviewableEditorAsset;
|
||||
frames: AdminEditorImageSequenceFramePayload[];
|
||||
durationMs: number;
|
||||
token: string;
|
||||
}) {
|
||||
const frameItems = frames.map((frame, index) => ({
|
||||
frame,
|
||||
index,
|
||||
key: [entry.assetId, frame.objectKey ?? '', frame.imageSrc, index].join(':'),
|
||||
}));
|
||||
const sequenceKey = frameItems.map((item) => item.key).join('|');
|
||||
const firstFrameKey = frameItems[0]?.key ?? '';
|
||||
const [frameIndex, setFrameIndex] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(true);
|
||||
const [loadedFrameKeys, setLoadedFrameKeys] = useState<ReadonlySet<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [visibleFrameKey, setVisibleFrameKey] = useState(firstFrameKey);
|
||||
const currentFrameItem =
|
||||
frameItems[Math.min(frameIndex, frameItems.length - 1)];
|
||||
const currentFrameKey = currentFrameItem?.key ?? '';
|
||||
|
||||
const handleFrameReady = useCallback((frameKey: string) => {
|
||||
setLoadedFrameKeys((currentKeys) => {
|
||||
if (currentKeys.has(frameKey)) {
|
||||
return currentKeys;
|
||||
}
|
||||
const nextKeys = new Set(currentKeys);
|
||||
nextKeys.add(frameKey);
|
||||
return nextKeys;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setFrameIndex(0);
|
||||
setIsPlaying(true);
|
||||
setLoadedFrameKeys(new Set());
|
||||
setVisibleFrameKey(firstFrameKey);
|
||||
}, [firstFrameKey, sequenceKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentFrameKey) {
|
||||
setVisibleFrameKey('');
|
||||
return;
|
||||
}
|
||||
if (!loadedFrameKeys.has(currentFrameKey) && visibleFrameKey) {
|
||||
return;
|
||||
}
|
||||
setVisibleFrameKey(currentFrameKey);
|
||||
}, [currentFrameKey, loadedFrameKeys, visibleFrameKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlaying) {
|
||||
return undefined;
|
||||
}
|
||||
const frameIntervalMs = durationMs / frames.length;
|
||||
const timer = window.setInterval(() => {
|
||||
setFrameIndex((currentIndex) => (currentIndex + 1) % frames.length);
|
||||
}, frameIntervalMs);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [durationMs, frames.length, isPlaying]);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label={`角色动作预览:${entry.label || entry.assetId}`}
|
||||
className="admin-asset-query-preview-media admin-asset-query-sequence-preview"
|
||||
>
|
||||
{frameItems.map((item) => (
|
||||
<AdminEditorImageSequenceFrame
|
||||
key={item.key}
|
||||
entry={entry}
|
||||
frame={item.frame}
|
||||
frameKey={item.key}
|
||||
visible={item.key === visibleFrameKey}
|
||||
loaded={loadedFrameKeys.has(item.key)}
|
||||
token={token}
|
||||
onReady={handleFrameReady}
|
||||
/>
|
||||
))}
|
||||
<div className="admin-asset-query-sequence-controls">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isPlaying ? '暂停角色动作' : '播放角色动作'}
|
||||
onClick={() => setIsPlaying((current) => !current)}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause size={16} aria-hidden="true" />
|
||||
) : (
|
||||
<Play size={16} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
<span>{`${currentFrameItem ? currentFrameItem.index + 1 : 0}/${frames.length}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAdminAssetThumbnailSource(entry: AdminPreviewableEditorAsset) {
|
||||
const mediaKind = resolveAdminAssetMediaKind(entry);
|
||||
if (mediaKind === 'image-sequence') {
|
||||
const sequence = normalizeAdminImageSequence(entry);
|
||||
const firstFrame = sequence?.frames[0];
|
||||
return firstFrame
|
||||
? { src: firstFrame.imageSrc, objectKey: firstFrame.objectKey ?? null }
|
||||
: { src: '', objectKey: null };
|
||||
}
|
||||
if (mediaKind === 'audio') {
|
||||
return { src: AUDIO_ASSET_COVER_SRC, objectKey: null };
|
||||
}
|
||||
@@ -216,19 +406,58 @@ function useAdminAssetThumbnailVisibility() {
|
||||
return { observeElement, shouldLoad };
|
||||
}
|
||||
|
||||
type AdminAssetMediaKind = 'image' | 'audio' | 'video';
|
||||
type AdminAssetMediaKind = 'image' | 'audio' | 'video' | 'image-sequence';
|
||||
|
||||
function normalizeAdminImageSequence(entry: AdminPreviewableEditorAsset) {
|
||||
const frames = entry.imageSequenceFrames;
|
||||
const durationMs = entry.imageSequenceDurationMs;
|
||||
if (
|
||||
!Array.isArray(frames) ||
|
||||
frames.length < 2 ||
|
||||
typeof durationMs !== 'number' ||
|
||||
!Number.isFinite(durationMs) ||
|
||||
durationMs <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const normalizedFrames = frames.flatMap((frame) => {
|
||||
const imageSrc = frame.imageSrc?.trim() ?? '';
|
||||
if (
|
||||
!imageSrc ||
|
||||
!Number.isFinite(frame.width) ||
|
||||
frame.width <= 0 ||
|
||||
!Number.isFinite(frame.height) ||
|
||||
frame.height <= 0
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
...frame,
|
||||
imageSrc,
|
||||
objectKey: frame.objectKey?.trim() || null,
|
||||
assetObjectId: frame.assetObjectId?.trim() || null,
|
||||
},
|
||||
];
|
||||
});
|
||||
return normalizedFrames.length === frames.length
|
||||
? { frames: normalizedFrames, durationMs }
|
||||
: null;
|
||||
}
|
||||
|
||||
function resolveAdminAssetMediaKind(
|
||||
entry: AdminPreviewableEditorAsset,
|
||||
): AdminAssetMediaKind {
|
||||
const assetKind = entry.assetKind?.trim() ?? '';
|
||||
if (assetKind === 'character-animation') {
|
||||
return 'image-sequence';
|
||||
}
|
||||
const pathMediaKind =
|
||||
resolveAdminAssetMediaKindFromPath(entry.imageSrc) ??
|
||||
resolveAdminAssetMediaKindFromPath(entry.objectKey ?? '');
|
||||
if (pathMediaKind) {
|
||||
return pathMediaKind;
|
||||
}
|
||||
|
||||
const assetKind = entry.assetKind?.trim() ?? '';
|
||||
if (
|
||||
assetKind === 'sound-effect' ||
|
||||
assetKind === 'background-music' ||
|
||||
|
||||
Reference in New Issue
Block a user