c5200c7d45
## 变更内容 - 新增基于 shadcn open-code 模式的共享 UI canonical 组件、样式与导出。 - 新增 `/components` 共享组件展示页,并覆盖平台组件、Token、状态和移动端布局。 - 补齐平台展示页的筛选、排序、上传预览、标签、开关和异步状态交互。 - 修复排序导致预览主题变化、筛选弹窗误关闭和入口状态不更新的问题。 - 同步网站与客户端构建别名、依赖、路由测试和项目文档。 ## 验证 - `npm run test -- src/components/shared-components/SharedComponentsShowcasePage.test.tsx` - `npm run typecheck` - `npm run check:encoding` - `npm run build:raw` - `git diff --check` - pre-push 门禁通过 Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/202 Co-authored-by: kdletters <kdletters@qq.com> Co-committed-by: kdletters <kdletters@qq.com>
1168 lines
33 KiB
TypeScript
1168 lines
33 KiB
TypeScript
import { Pause, Play, X } from 'lucide-react';
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
|
||
import {
|
||
parseSignedReadUrlExpiresAtMs,
|
||
SIGNED_READ_URL_CACHE_SAFETY_WINDOW_MS,
|
||
} from '../../../../packages/shared/src/utils/signedReadUrlCache';
|
||
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;
|
||
const ADMIN_ASSET_READ_RETRY_DELAYS_MS = [400, 1_200, 3_000] as const;
|
||
const ADMIN_ASSET_THUMBNAIL_ROOT_MARGIN = '240px 0px';
|
||
const AUDIO_ASSET_COVER_SRC = `${import.meta.env.DEV ? import.meta.env.BASE_URL : '/'}creation-home/audio-asset-cover.png`;
|
||
let adminAssetReadDispatchTail = Promise.resolve();
|
||
|
||
export interface AdminPreviewableEditorAsset {
|
||
assetId: string;
|
||
label: string;
|
||
imageSrc: string;
|
||
objectKey?: string | null;
|
||
assetKind?: string | null;
|
||
thumbnailSrc?: string | null;
|
||
imageSequenceFrames?: AdminEditorImageSequenceFramePayload[] | null;
|
||
imageSequenceDurationMs?: number | null;
|
||
sourceResourceId?: string | null;
|
||
sourceImageSrc?: string | null;
|
||
sourceObjectKey?: string | null;
|
||
sourceAssetObjectId?: string | null;
|
||
sourceLabel?: string | null;
|
||
}
|
||
|
||
export function AdminEditorAssetThumbnail({
|
||
entry,
|
||
token,
|
||
altPrefix = '素材',
|
||
}: {
|
||
entry: AdminPreviewableEditorAsset;
|
||
token: string;
|
||
altPrefix?: string;
|
||
}) {
|
||
const thumbnailSource = resolveAdminAssetThumbnailSource(entry);
|
||
const { observeElement, shouldLoad } = useAdminAssetThumbnailVisibility();
|
||
const { resolvedUrl: imageSrc } = useAdminResolvedAssetUrl(
|
||
token,
|
||
thumbnailSource.src,
|
||
thumbnailSource.objectKey,
|
||
shouldLoad,
|
||
);
|
||
const alt = `${altPrefix}:${entry.label || entry.assetId}`;
|
||
|
||
return imageSrc ? (
|
||
<img
|
||
ref={observeElement}
|
||
alt={alt}
|
||
className="admin-asset-query-thumb"
|
||
src={imageSrc}
|
||
/>
|
||
) : (
|
||
<div
|
||
ref={observeElement}
|
||
className="admin-asset-query-thumb admin-asset-query-thumb-placeholder"
|
||
/>
|
||
);
|
||
}
|
||
|
||
export function AdminEditorAssetPreviewDialog({
|
||
entry,
|
||
token,
|
||
onClose,
|
||
}: {
|
||
entry: AdminPreviewableEditorAsset;
|
||
token: string;
|
||
onClose: () => void;
|
||
}) {
|
||
const sourceEntry = entry.sourceImageSrc?.trim()
|
||
? {
|
||
assetId: entry.sourceResourceId ?? `${entry.assetId}-source`,
|
||
label: entry.sourceLabel?.trim() || '原图',
|
||
imageSrc: entry.sourceImageSrc,
|
||
objectKey: entry.sourceObjectKey,
|
||
}
|
||
: null;
|
||
|
||
return (
|
||
<div className="admin-confirm-backdrop" role="presentation">
|
||
<section
|
||
aria-label="素材预览"
|
||
className="admin-detail-panel admin-asset-query-preview-dialog"
|
||
role="dialog"
|
||
>
|
||
<div className="admin-panel-heading">
|
||
<div>
|
||
<h3>{entry.label || entry.assetId}</h3>
|
||
<span>{entry.assetId}</span>
|
||
</div>
|
||
<button
|
||
aria-label="关闭素材预览"
|
||
className="admin-ghost-button"
|
||
type="button"
|
||
onClick={onClose}
|
||
>
|
||
<X size={17} aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
{sourceEntry ? (
|
||
<div className="admin-asset-query-source-preview">
|
||
<h4>原图</h4>
|
||
<AdminEditorAssetPreviewMedia entry={sourceEntry} token={token} />
|
||
</div>
|
||
) : null}
|
||
<AdminEditorAssetPreviewMedia entry={entry} token={token} />
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function AdminEditorAssetPreviewMedia({
|
||
entry,
|
||
token,
|
||
}: {
|
||
entry: AdminPreviewableEditorAsset;
|
||
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 { resolvedUrl: mediaSrc } = useAdminResolvedAssetUrl(
|
||
token,
|
||
entry.imageSrc,
|
||
entry.objectKey,
|
||
);
|
||
const { resolvedUrl: posterSrc } = useAdminResolvedAssetUrl(
|
||
token,
|
||
isVideo ? (entry.thumbnailSrc ?? '') : '',
|
||
null,
|
||
);
|
||
const label = entry.label || entry.assetId;
|
||
|
||
if (isAudio) {
|
||
return (
|
||
<div className="admin-asset-query-preview-audio">
|
||
<img
|
||
alt={`音频封面:${label}`}
|
||
className="admin-asset-query-preview-cover"
|
||
src={AUDIO_ASSET_COVER_SRC}
|
||
/>
|
||
{mediaSrc ? (
|
||
<audio
|
||
aria-label={`音频预览:${label}`}
|
||
className="admin-asset-query-preview-player"
|
||
controls
|
||
preload="metadata"
|
||
src={mediaSrc}
|
||
/>
|
||
) : (
|
||
<div className="admin-asset-query-preview-placeholder" />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (isVideo) {
|
||
return mediaSrc ? (
|
||
<video
|
||
aria-label={`视频预览:${label}`}
|
||
className="admin-asset-query-preview-media"
|
||
controls
|
||
playsInline
|
||
poster={posterSrc || undefined}
|
||
preload="metadata"
|
||
src={mediaSrc}
|
||
/>
|
||
) : (
|
||
<div className="admin-asset-query-preview-placeholder" />
|
||
);
|
||
}
|
||
|
||
return mediaSrc ? (
|
||
<img
|
||
alt={`图片预览:${label}`}
|
||
className="admin-asset-query-preview-media"
|
||
src={mediaSrc}
|
||
/>
|
||
) : (
|
||
<div className="admin-asset-query-preview-placeholder" />
|
||
);
|
||
}
|
||
|
||
function AdminEditorImageSequenceFrame({
|
||
entry,
|
||
frameKey,
|
||
resolvedUrl,
|
||
visible,
|
||
ready,
|
||
onReady,
|
||
onFailed,
|
||
}: {
|
||
entry: AdminPreviewableEditorAsset;
|
||
frameKey: string;
|
||
resolvedUrl: string;
|
||
visible: boolean;
|
||
ready: boolean;
|
||
onReady: (frameKey: string) => void;
|
||
onFailed: (frameKey: string) => void;
|
||
}) {
|
||
const failureReportedRef = useRef(false);
|
||
const reportFailure = useCallback(() => {
|
||
if (failureReportedRef.current) {
|
||
return;
|
||
}
|
||
failureReportedRef.current = true;
|
||
onFailed(frameKey);
|
||
}, [frameKey, onFailed]);
|
||
|
||
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 && ready ? 1 : 0, transition: 'none' }}
|
||
onLoad={() => onReady(frameKey)}
|
||
onError={reportFailure}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function AdminEditorImageSequencePreview({
|
||
entry,
|
||
frames,
|
||
durationMs,
|
||
token,
|
||
}: {
|
||
entry: AdminPreviewableEditorAsset;
|
||
frames: AdminEditorImageSequenceFramePayload[];
|
||
durationMs: number;
|
||
token: string;
|
||
}) {
|
||
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 frameCacheOwnerKey = `${token}\u0000${sequenceKey}`;
|
||
const firstFrameKey = frameItems[0]?.key ?? '';
|
||
const [frameIndex, setFrameIndex] = useState(0);
|
||
const [isPlaying, setIsPlaying] = useState(true);
|
||
const {
|
||
frameCache,
|
||
resolveFrames,
|
||
markFrameReady,
|
||
markFrameFailed,
|
||
retryFailedFrames: clearFailedFrameCache,
|
||
} = useAdminImageSequenceFrameCache(
|
||
token,
|
||
frameCacheOwnerKey,
|
||
frameItems.length,
|
||
);
|
||
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 = 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 retryFailedFrames = useCallback(() => {
|
||
setFrameIndex(0);
|
||
setIsPlaying(true);
|
||
clearFailedFrameCache();
|
||
}, [clearFailedFrameCache]);
|
||
|
||
useEffect(() => {
|
||
setFrameIndex(0);
|
||
setIsPlaying(true);
|
||
setVisibleFrameKey(firstFrameKey);
|
||
}, [firstFrameKey, sequenceKey]);
|
||
|
||
useEffect(() => {
|
||
resolveFrames(mountedFrameItems);
|
||
}, [mountedFrameItems, resolveFrames]);
|
||
|
||
useEffect(() => {
|
||
const refreshAtMs = mountedFrameItems.reduce(
|
||
(earliest, item) => {
|
||
const cached = frameCache.get(item.key);
|
||
if (
|
||
!cached ||
|
||
cached.expiresAtMs === null ||
|
||
cached.status === 'resolving' ||
|
||
cached.status === 'refreshing' ||
|
||
cached.status === 'failed'
|
||
) {
|
||
return earliest;
|
||
}
|
||
const candidate = cached.nextRefreshAtMs;
|
||
if (candidate === null) {
|
||
return earliest;
|
||
}
|
||
return earliest === null || candidate < earliest ? candidate : earliest;
|
||
},
|
||
null as number | null,
|
||
);
|
||
if (refreshAtMs === null) {
|
||
return undefined;
|
||
}
|
||
const timer = window.setTimeout(
|
||
() => resolveFrames(mountedFrameItems),
|
||
Math.min(2_147_483_647, Math.max(0, refreshAtMs - Date.now())),
|
||
);
|
||
return () => window.clearTimeout(timer);
|
||
}, [frameCache, mountedFrameItems, resolveFrames]);
|
||
|
||
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('');
|
||
return;
|
||
}
|
||
if (
|
||
!isAdminImageSequenceFrameReady(frameCache.get(currentFrameKey)) &&
|
||
visibleFrameKey
|
||
) {
|
||
return;
|
||
}
|
||
setVisibleFrameKey(currentFrameKey);
|
||
}, [currentFrameKey, frameCache, visibleFrameKey]);
|
||
|
||
useEffect(() => {
|
||
if (!isPlaying || availableFrameCount < 2) {
|
||
return undefined;
|
||
}
|
||
const frameIntervalMs = Math.min(2_147_483_647, durationMs / frames.length);
|
||
const timer = window.setInterval(() => {
|
||
setFrameIndex(
|
||
(currentIndex) =>
|
||
findNextAvailableFrameIndex(
|
||
frameItems,
|
||
failedFrameKeys,
|
||
currentIndex,
|
||
) ?? currentIndex,
|
||
);
|
||
}, frameIntervalMs);
|
||
return () => window.clearInterval(timer);
|
||
}, [
|
||
availableFrameCount,
|
||
durationMs,
|
||
failedFrameKeys,
|
||
frameItems,
|
||
frames.length,
|
||
isPlaying,
|
||
]);
|
||
|
||
return (
|
||
<div
|
||
aria-label={`角色动作预览:${entry.label || entry.assetId}`}
|
||
className="admin-asset-query-preview-media admin-asset-query-sequence-preview"
|
||
>
|
||
{mountedFrameItems.map((item) => {
|
||
const cachedFrame = frameCache.get(item.key);
|
||
return (
|
||
<AdminEditorImageSequenceFrame
|
||
key={item.key}
|
||
entry={entry}
|
||
frameKey={item.key}
|
||
resolvedUrl={cachedFrame?.resolvedUrl ?? ''}
|
||
visible={item.key === visibleFrameKey}
|
||
ready={isAdminImageSequenceFrameReady(cachedFrame)}
|
||
onReady={markFrameReady}
|
||
onFailed={markFrameFailed}
|
||
/>
|
||
);
|
||
})}
|
||
<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>
|
||
{failedFrameKeys.size ? (
|
||
<>
|
||
<span aria-live="polite">{`${failedFrameKeys.size} 帧加载失败`}</span>
|
||
<button type="button" onClick={retryFailedFrames}>
|
||
重试失败帧
|
||
</button>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
type AdminImageSequenceFrameItem = {
|
||
frame: AdminEditorImageSequenceFramePayload;
|
||
index: number;
|
||
key: string;
|
||
};
|
||
|
||
type AdminImageSequenceFrameCacheEntry = {
|
||
expiresAtMs: number | null;
|
||
nextRefreshAtMs: number | null;
|
||
refreshRetryIndex: number;
|
||
resolvedUrl: string;
|
||
status: 'resolving' | 'refreshing' | '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) {
|
||
const cached = frameCache.get(item.key);
|
||
if (resolutionControllersRef.current.has(item.key)) {
|
||
continue;
|
||
}
|
||
if (cached && shouldReuseAdminImageSequenceFrameCacheEntry(cached)) {
|
||
continue;
|
||
}
|
||
const refreshReadyFrame = isAdminImageSequenceFrameReady(cached);
|
||
const controller = new AbortController();
|
||
resolutionControllersRef.current.set(item.key, controller);
|
||
updateFrameCacheEntry(item.key, (current) =>
|
||
refreshReadyFrame && current?.resolvedUrl
|
||
? { ...current, status: 'refreshing' }
|
||
: {
|
||
expiresAtMs: null,
|
||
nextRefreshAtMs: null,
|
||
refreshRetryIndex: 0,
|
||
resolvedUrl: '',
|
||
status: 'resolving',
|
||
},
|
||
);
|
||
void resolveAdminAssetUrl(
|
||
token,
|
||
item.frame.imageSrc,
|
||
item.frame.objectKey,
|
||
controller.signal,
|
||
)
|
||
.then((resolved) => {
|
||
updateFrameCacheEntry(item.key, () => ({
|
||
...resolved,
|
||
nextRefreshAtMs:
|
||
resolved.expiresAtMs === null
|
||
? null
|
||
: resolved.expiresAtMs -
|
||
SIGNED_READ_URL_CACHE_SAFETY_WINDOW_MS,
|
||
refreshRetryIndex: 0,
|
||
status: refreshReadyFrame ? 'refreshing' : 'loading',
|
||
}));
|
||
})
|
||
.catch((error: unknown) => {
|
||
if (!controller.signal.aborted) {
|
||
updateFrameCacheEntry(item.key, (current) => {
|
||
if (!isAdminImageSequenceFrameUrlUsable(current)) {
|
||
return {
|
||
expiresAtMs: null,
|
||
nextRefreshAtMs: null,
|
||
refreshRetryIndex: 0,
|
||
resolvedUrl: '',
|
||
status: 'failed',
|
||
};
|
||
}
|
||
const retryDelay =
|
||
isAdminApiError(error) && error.status === 429
|
||
? undefined
|
||
: ADMIN_ASSET_READ_RETRY_DELAYS_MS[
|
||
current.refreshRetryIndex
|
||
];
|
||
return {
|
||
...current,
|
||
nextRefreshAtMs:
|
||
typeof retryDelay === 'number'
|
||
? Math.min(
|
||
current.expiresAtMs ?? Number.POSITIVE_INFINITY,
|
||
Date.now() + retryDelay,
|
||
)
|
||
: current.expiresAtMs,
|
||
refreshRetryIndex:
|
||
typeof retryDelay === 'number'
|
||
? current.refreshRetryIndex + 1
|
||
: current.refreshRetryIndex,
|
||
status: 'ready',
|
||
};
|
||
});
|
||
}
|
||
})
|
||
.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, () => ({
|
||
expiresAtMs: null,
|
||
nextRefreshAtMs: null,
|
||
refreshRetryIndex: 0,
|
||
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 isAdminImageSequenceFrameReady(
|
||
cached: AdminImageSequenceFrameCacheEntry | undefined,
|
||
) {
|
||
return cached?.status === 'ready' || cached?.status === 'refreshing';
|
||
}
|
||
|
||
function shouldReuseAdminImageSequenceFrameCacheEntry(
|
||
cached: AdminImageSequenceFrameCacheEntry,
|
||
) {
|
||
if (cached.status === 'failed') {
|
||
return false;
|
||
}
|
||
return cached.nextRefreshAtMs === null || cached.nextRefreshAtMs > Date.now();
|
||
}
|
||
|
||
function isAdminImageSequenceFrameUrlUsable(
|
||
cached: AdminImageSequenceFrameCacheEntry | undefined,
|
||
): cached is AdminImageSequenceFrameCacheEntry {
|
||
return Boolean(
|
||
cached?.resolvedUrl &&
|
||
(cached.expiresAtMs === null || cached.expiresAtMs > Date.now()),
|
||
);
|
||
}
|
||
|
||
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') {
|
||
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 };
|
||
}
|
||
if (mediaKind === 'video') {
|
||
return { src: entry.thumbnailSrc || '', objectKey: null };
|
||
}
|
||
if (entry.thumbnailSrc?.trim()) {
|
||
return {
|
||
src: entry.thumbnailSrc,
|
||
objectKey: adminAssetPathsMatch(entry.thumbnailSrc, entry.imageSrc)
|
||
? entry.objectKey
|
||
: null,
|
||
};
|
||
}
|
||
return {
|
||
src: entry.imageSrc,
|
||
objectKey: entry.objectKey,
|
||
};
|
||
}
|
||
|
||
function useAdminAssetThumbnailVisibility() {
|
||
const [element, setElement] = useState<HTMLElement | null>(null);
|
||
const [shouldLoad, setShouldLoad] = useState(false);
|
||
const observeElement = useCallback((nextElement: HTMLElement | null) => {
|
||
setElement(nextElement);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (shouldLoad || !element) {
|
||
return;
|
||
}
|
||
if (typeof IntersectionObserver === 'undefined') {
|
||
setShouldLoad(true);
|
||
return;
|
||
}
|
||
|
||
const observer = new IntersectionObserver(
|
||
(entries) => {
|
||
if (entries.some((entry) => entry.isIntersecting)) {
|
||
setShouldLoad(true);
|
||
observer.disconnect();
|
||
}
|
||
},
|
||
{ rootMargin: ADMIN_ASSET_THUMBNAIL_ROOT_MARGIN },
|
||
);
|
||
observer.observe(element);
|
||
return () => observer.disconnect();
|
||
}, [element, shouldLoad]);
|
||
|
||
return { observeElement, shouldLoad };
|
||
}
|
||
|
||
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;
|
||
}
|
||
if (
|
||
assetKind === 'sound-effect' ||
|
||
assetKind === 'background-music' ||
|
||
assetKind === 'editor_uploaded_audio'
|
||
) {
|
||
return 'audio';
|
||
}
|
||
if (
|
||
assetKind === 'video' ||
|
||
assetKind === 'editor_video' ||
|
||
assetKind === 'editor-video' ||
|
||
assetKind === 'editor_uploaded_video'
|
||
) {
|
||
return 'video';
|
||
}
|
||
return 'image';
|
||
}
|
||
|
||
function resolveAdminAssetMediaKindFromPath(
|
||
value: string,
|
||
): AdminAssetMediaKind | null {
|
||
const normalizedValue = value.trim();
|
||
if (/^data:image\//iu.test(normalizedValue)) {
|
||
return 'image';
|
||
}
|
||
if (/^data:audio\//iu.test(normalizedValue)) {
|
||
return 'audio';
|
||
}
|
||
if (/^data:video\//iu.test(normalizedValue)) {
|
||
return 'video';
|
||
}
|
||
if (
|
||
/\.(?:avif|bmp|gif|jpe?g|png|svg|webp)(?:$|[?#])/iu.test(normalizedValue)
|
||
) {
|
||
return 'image';
|
||
}
|
||
if (/\.(?:aac|flac|m4a|mp3|ogg|opus|wav)(?:$|[?#])/iu.test(normalizedValue)) {
|
||
return 'audio';
|
||
}
|
||
if (/\.(?:m4v|mov|mp4|ogv|webm)(?:$|[?#])/iu.test(normalizedValue)) {
|
||
return 'video';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function useAdminResolvedAssetUrl(
|
||
token: string,
|
||
imageSrc: string | null | undefined,
|
||
objectKey: string | null | undefined,
|
||
enabled = true,
|
||
refreshKey: string | number | null = null,
|
||
) {
|
||
const normalizedImageSrc = imageSrc?.trim() ?? '';
|
||
const normalizedObjectKey = normalizeAdminObjectKey(objectKey);
|
||
const normalizedLegacyPublicPath = isGeneratedLegacyPath(normalizedImageSrc)
|
||
? normalizedImageSrc
|
||
: resolveAdminGeneratedLegacyPathFromUrl(normalizedImageSrc);
|
||
const shouldResolve =
|
||
Boolean(normalizedObjectKey) || Boolean(normalizedLegacyPublicPath);
|
||
const [resolvedImageSrc, setResolvedImageSrc] = useState(
|
||
shouldResolve ? '' : normalizedImageSrc,
|
||
);
|
||
const [hasError, setHasError] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (!normalizedImageSrc && !normalizedObjectKey) {
|
||
setResolvedImageSrc('');
|
||
setHasError(false);
|
||
return;
|
||
}
|
||
if (!shouldResolve) {
|
||
setResolvedImageSrc(normalizedImageSrc);
|
||
setHasError(false);
|
||
return;
|
||
}
|
||
if (!enabled) {
|
||
setResolvedImageSrc('');
|
||
setHasError(false);
|
||
return;
|
||
}
|
||
|
||
let cancelled = false;
|
||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||
let retryIndex = 0;
|
||
const dispatchController = new AbortController();
|
||
setResolvedImageSrc('');
|
||
setHasError(false);
|
||
|
||
const resolveReadUrl = async () => {
|
||
try {
|
||
await waitForAdminAssetReadDispatch(dispatchController.signal);
|
||
if (cancelled) {
|
||
return;
|
||
}
|
||
const response = await getAdminAssetReadUrl(
|
||
token,
|
||
normalizedObjectKey
|
||
? {
|
||
objectKey: normalizedObjectKey,
|
||
expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS,
|
||
}
|
||
: {
|
||
legacyPublicPath: normalizedLegacyPublicPath,
|
||
expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS,
|
||
},
|
||
);
|
||
if (!cancelled) {
|
||
const nextUrl = resolveAdminAssetReadSignedUrl(response);
|
||
setResolvedImageSrc(nextUrl);
|
||
setHasError(!nextUrl);
|
||
}
|
||
} catch (error: unknown) {
|
||
if (cancelled) {
|
||
return;
|
||
}
|
||
const retryDelay = ADMIN_ASSET_READ_RETRY_DELAYS_MS[retryIndex];
|
||
if (
|
||
isAdminApiError(error) &&
|
||
error.status === 429 &&
|
||
typeof retryDelay === 'number'
|
||
) {
|
||
retryIndex += 1;
|
||
retryTimer = setTimeout(() => void resolveReadUrl(), retryDelay);
|
||
return;
|
||
}
|
||
setResolvedImageSrc('');
|
||
setHasError(true);
|
||
}
|
||
};
|
||
|
||
void resolveReadUrl();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
dispatchController.abort();
|
||
if (retryTimer !== null) {
|
||
clearTimeout(retryTimer);
|
||
}
|
||
};
|
||
}, [
|
||
enabled,
|
||
normalizedImageSrc,
|
||
normalizedLegacyPublicPath,
|
||
normalizedObjectKey,
|
||
refreshKey,
|
||
shouldResolve,
|
||
token,
|
||
]);
|
||
|
||
return {
|
||
resolvedUrl: resolvedImageSrc,
|
||
hasError,
|
||
};
|
||
}
|
||
|
||
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 {
|
||
expiresAtMs: null,
|
||
resolvedUrl: 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');
|
||
}
|
||
const read = response.read ?? response;
|
||
const resolvedUrl = resolveAdminAssetReadSignedUrl(response);
|
||
const expiresAt =
|
||
typeof read.expiresAt === 'string' ? read.expiresAt.trim() : '';
|
||
const expiresAtMs = parseSignedReadUrlExpiresAtMs(expiresAt);
|
||
if (!resolvedUrl) {
|
||
throw new Error('素材读取地址为空');
|
||
}
|
||
if (expiresAtMs <= Date.now()) {
|
||
throw new Error('素材读取地址过期时间无效');
|
||
}
|
||
return { expiresAtMs, resolvedUrl };
|
||
} 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),
|
||
() => waitForAdminAssetReadDispatchSpacing(signal),
|
||
);
|
||
adminAssetReadDispatchTail = dispatch.catch(() => undefined);
|
||
await dispatch;
|
||
}
|
||
|
||
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');
|
||
}
|
||
await new Promise<void>((resolve, reject) => {
|
||
const timer = setTimeout(() => {
|
||
signal.removeEventListener('abort', handleAbort);
|
||
resolve();
|
||
}, delayMs);
|
||
|
||
function handleAbort() {
|
||
clearTimeout(timer);
|
||
reject(new DOMException('The operation was aborted.', 'AbortError'));
|
||
}
|
||
|
||
signal.addEventListener('abort', handleAbort, { once: true });
|
||
});
|
||
}
|
||
|
||
function normalizeAdminObjectKey(value: string | null | undefined) {
|
||
return value?.trim().replace(/^\/+/u, '') ?? '';
|
||
}
|
||
|
||
function adminAssetPathsMatch(left: string, right: string) {
|
||
return (
|
||
left.trim().replace(/^\/+|[?#].*$/gu, '') ===
|
||
right.trim().replace(/^\/+|[?#].*$/gu, '')
|
||
);
|
||
}
|
||
|
||
function isGeneratedLegacyPath(value: string) {
|
||
return /^\/?generated-[^/?#]+\/.+/u.test(value.trim());
|
||
}
|
||
|
||
function resolveAdminGeneratedLegacyPathFromUrl(value: string) {
|
||
try {
|
||
const parsedUrl = new URL(value);
|
||
if (
|
||
parsedUrl.protocol !== 'https:' ||
|
||
!/^[^.]+\.oss-[^.]+\.aliyuncs\.com$/iu.test(parsedUrl.hostname)
|
||
) {
|
||
return '';
|
||
}
|
||
const legacyPublicPath = decodeURIComponent(parsedUrl.pathname);
|
||
return isGeneratedLegacyPath(legacyPublicPath) ? legacyPublicPath : '';
|
||
} catch {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function resolveAdminAssetReadSignedUrl(response: AdminAssetReadUrlResponse) {
|
||
const read = response.read ?? response;
|
||
return typeof read.signedUrl === 'string' ? read.signedUrl.trim() : '';
|
||
}
|