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 ? (
) : (
);
}
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 (
{entry.label || entry.assetId}
{entry.assetId}
{sourceEntry ? (
) : null}
);
}
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 ? (
) : (
序列数据损坏,无法预览
);
}
return (
);
}
function AdminEditorStandardAssetPreviewMedia({
entry,
mediaKind,
token,
}: {
entry: AdminPreviewableEditorAsset;
mediaKind: Exclude;
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 (

{mediaSrc ? (
) : (
)}
);
}
if (isVideo) {
return mediaSrc ? (
) : (
);
}
return mediaSrc ? (
) : (
);
}
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 (
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();
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 (
{mountedFrameItems.map((item) => {
const cachedFrame = frameCache.get(item.key);
return (
);
})}
{`${currentFrameItem ? currentFrameItem.index + 1 : 0}/${frames.length}`}
{failedFrameKeys.size ? (
<>
{`${failedFrameKeys.size} 帧加载失败`}
>
) : null}
);
}
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;
};
const EMPTY_ADMIN_IMAGE_SEQUENCE_FRAME_CACHE = new Map<
string,
AdminImageSequenceFrameCacheEntry
>();
function useAdminImageSequenceFrameCache(
token: string,
ownerKey: string,
maximumEntryCount: number,
) {
const [cacheState, setCacheState] =
useState(() => ({
ownerKey,
entries: new Map(),
}));
const resolutionControllersRef = useRef(new Map());
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();
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) => {
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,
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(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 | 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((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() : '';
}