feat: 前端改为通过签名地址读取生成资源

This commit is contained in:
2026-04-21 16:45:05 +08:00
parent fcaf7bdb38
commit 78dcad1222
26 changed files with 779 additions and 76 deletions

View File

@@ -1,11 +1,19 @@
// @vitest-environment jsdom
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AnimationState, type Character } from '../types';
import { CharacterAnimator } from './CharacterAnimator';
vi.mock('../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: vi.fn((source: string | null | undefined) => ({
resolvedUrl: source?.trim() ?? '',
isResolving: false,
shouldResolve: false,
})),
}));
function buildCharacter(overrides: Partial<Character> = {}): Character {
return {
id: 'generated-role',
@@ -26,6 +34,10 @@ function buildCharacter(overrides: Partial<Character> = {}): Character {
}
describe('CharacterAnimator portrait fallbacks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('keeps idle fallback static on the portrait when idle animation is missing', () => {
render(
<CharacterAnimator

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl';
import { AnimationState, Character, CharacterAnimationConfig } from '../types';
interface CharacterAnimatorProps {
@@ -208,6 +209,13 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
const imagePath = fallbackToPortrait
? character.portrait
: generatedImagePath;
const {
resolvedUrl: resolvedImagePath,
shouldResolve: shouldResolveImagePath,
} = useResolvedAssetReadUrl(imagePath);
// 私有 OSS 资源必须等签名地址返回后再渲染,不能先落回原始 generated-* 路径。
const displayImagePath =
resolvedImagePath || (!shouldResolveImagePath ? imagePath : '');
const resolvedImageClassName =
`h-full w-full object-contain pixelated ${imageClassName ?? ''}`.trim();
const imageStyle =
@@ -215,10 +223,14 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
? FALLEN_PORTRAIT_STYLE
: DEFAULT_IMAGE_STYLE;
if (!displayImagePath) {
return <div className={`relative ${className ?? ''}`} style={style} />;
}
return (
<div className={`relative ${className ?? ''}`} style={style}>
<img
src={imagePath}
src={displayImagePath}
alt={`${character.name} ${state} animation`}
className={resolvedImageClassName}
style={imageStyle}

View File

@@ -67,6 +67,7 @@ import {
import type { GameCanvasEntitySelection } from './GameCanvas';
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
import { PixelIcon } from './PixelIcon';
import { ResolvedAssetImage } from './ResolvedAssetImage';
interface CharacterPanelProps {
worldType: WorldType | null;
@@ -407,7 +408,7 @@ export function CharacterPanel({
>
<div className="flex items-start gap-3 rounded-xl border border-white/6 bg-black/18 px-3 py-3">
<div className="flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-transparent sm:h-16 sm:w-16">
<img
<ResolvedAssetImage
src={member.character.portrait}
alt={member.character.name}
className="h-full w-full scale-125 object-contain object-bottom"

View File

@@ -6,6 +6,7 @@ import { MAX_COMPANIONS } from '../data/npcInteractions';
import { Character, CompanionState } from '../types';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PixelIcon } from './PixelIcon';
import { ResolvedAssetImage } from './ResolvedAssetImage';
interface CompanionCampModalProps {
isOpen: boolean;
@@ -180,7 +181,7 @@ export function CompanionCampModal({
>
<div className="flex items-center gap-3">
<div className="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-black/25">
<img
<ResolvedAssetImage
src={character.portrait}
alt={character.name}
className="h-full w-full scale-125 object-contain"
@@ -243,7 +244,7 @@ export function CompanionCampModal({
<div key={companion.npcId} className="rounded-xl border border-white/8 bg-black/20 px-3 py-3">
<div className="flex items-center gap-3">
<div className="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-black/25">
<img
<ResolvedAssetImage
src={character.portrait}
alt={character.name}
className="h-full w-full scale-125 object-contain"

View File

@@ -1,5 +1,6 @@
import type { ReactNode } from 'react';
import { ResolvedAssetImage } from './ResolvedAssetImage';
import type { CustomWorldCoverRenderMode } from '../services/customWorldCover';
const COVER_PORTRAIT_CLASS_NAMES = [
@@ -34,7 +35,7 @@ export function CustomWorldCoverArtwork({
className={`relative overflow-hidden bg-[radial-gradient(circle_at_top,rgba(255,244,214,0.3),transparent_38%),linear-gradient(180deg,rgba(34,40,55,0.92),rgba(10,12,18,0.96))] ${className}`}
>
{imageSrc ? (
<img
<ResolvedAssetImage
src={imageSrc}
alt={title}
loading="lazy"
@@ -56,7 +57,7 @@ export function CustomWorldCoverArtwork({
key={`${title}-cover-character-${index}-${characterImageSrc}`}
className={`overflow-hidden rounded-[1rem] border border-white/16 bg-[linear-gradient(180deg,rgba(255,255,255,0.14),rgba(255,255,255,0.04))] shadow-[0_12px_28px_rgba(0,0,0,0.4)] ${COVER_PORTRAIT_CLASS_NAMES[index] ?? COVER_PORTRAIT_CLASS_NAMES[1]}`}
>
<img
<ResolvedAssetImage
src={characterImageSrc}
alt=""
loading="lazy"

View File

@@ -31,6 +31,7 @@ import { CharacterAnimator } from './CharacterAnimator';
import { CustomWorldCoverArtwork } from './CustomWorldCoverArtwork';
import type { CustomWorldEditorTarget } from './CustomWorldEntityEditorModal';
import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
import { ResolvedAssetImage } from './ResolvedAssetImage';
export type ResultTab = 'world' | 'playable' | 'story' | 'landmarks';
@@ -171,7 +172,11 @@ function ImageFrame({
className={`overflow-hidden rounded-2xl border border-[var(--platform-subpanel-border)] bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.22),transparent_42%),linear-gradient(180deg,rgba(255,96,147,0.92),rgba(255,146,109,0.84))] ${tone === 'landscape' ? 'aspect-[16/9]' : 'aspect-square'}`}
>
{src ? (
<img src={src} alt={alt} className="h-full w-full object-cover" />
<ResolvedAssetImage
src={src}
alt={alt}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center px-4 text-center text-sm font-semibold tracking-[0.18em] text-zinc-400">
{fallbackLabel}
@@ -1427,7 +1432,7 @@ export function CustomWorldEntityCatalog({
}
media={
role.imageSrc?.trim() ? (
<img
<ResolvedAssetImage
src={role.imageSrc}
alt={role.name}
className="h-full w-full object-cover object-top"
@@ -1440,7 +1445,7 @@ export function CustomWorldEntityCatalog({
imageClassName="object-bottom"
/>
) : previewImageSrc ? (
<img
<ResolvedAssetImage
src={previewImageSrc}
alt={role.name}
className="h-full w-full object-cover object-top"

View File

@@ -24,6 +24,7 @@ import { fetchJson } from '../editor/shared/jsonClient';
import { useCombatFlow } from '../hooks/useCombatFlow';
import { useGameFlow } from '../hooks/useGameFlow';
import { useNpcInteractionFlow } from '../hooks/useNpcInteractionFlow';
import { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl';
import { useStoryGeneration } from '../hooks/useStoryGeneration';
import { buildSkillActionPrompt } from '../prompts/customWorldEntityActionPrompts';
import {
@@ -85,6 +86,7 @@ import {
} from './game-canvas/GameCanvasShared';
import { GameShellRuntime } from './game-shell/GameShellRuntime';
import { PixelIcon } from './PixelIcon';
import { ResolvedAssetImage } from './ResolvedAssetImage';
export type CustomWorldEditorTarget =
| { kind: 'world' }
@@ -1153,13 +1155,19 @@ function ImagePreview({
children?: ReactNode;
overlayInteractive?: boolean;
}) {
const {
resolvedUrl: resolvedSrc,
shouldResolve,
} = useResolvedAssetReadUrl(src);
const displaySrc = resolvedSrc || (!shouldResolve ? src : '');
return (
<div
className={`relative overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.95),rgba(8,10,17,0.92))] ${tone === 'landscape' ? 'aspect-[16/9]' : 'aspect-square'}`}
>
{src ? (
{displaySrc ? (
<img
src={src}
src={displaySrc}
alt={alt}
loading="lazy"
className="h-full w-full object-cover"
@@ -3881,7 +3889,7 @@ function RoleSkillEditorModal({
/>
</div>
) : role.imageSrc ? (
<img
<ResolvedAssetImage
src={role.imageSrc}
alt={role.name}
className="max-h-40 w-full object-contain"
@@ -4013,7 +4021,7 @@ function SkillListEditor({
/>
</div>
) : role.imageSrc ? (
<img
<ResolvedAssetImage
src={role.imageSrc}
alt={skill.name}
className="max-h-20 w-full object-contain"
@@ -4589,7 +4597,7 @@ function PlayableNpcEditor({
</div>
<div className="mt-3 grid gap-4 sm:grid-cols-[7rem_minmax(0,1fr)]">
<div className="overflow-hidden rounded-2xl border border-white/10 bg-black/30">
<img
<ResolvedAssetImage
src={draft.imageSrc || selectedTemplate.portrait}
alt={selectedTemplate.name}
className="h-28 w-full object-cover object-top"

View File

@@ -31,6 +31,7 @@ import {
import { buildDefaultCustomWorldNpcVisual } from './customWorldNpcVisualDefaults';
import { HostileNpcAnimator } from './HostileNpcAnimator';
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
import { ResolvedAssetImage } from './ResolvedAssetImage';
type EditableNpcSource = Pick<
CustomWorldNpc,
@@ -333,7 +334,7 @@ export function CustomWorldNpcPortrait({
className={`relative flex h-full items-center justify-center ${contentClassName}`}
>
{preferredImageSrc ? (
<img
<ResolvedAssetImage
src={preferredImageSrc}
alt={npc.name}
className="h-full w-full object-contain drop-shadow-[0_12px_18px_rgba(0,0,0,0.38)]"

View File

@@ -29,6 +29,17 @@ vi.mock('./CustomWorldEntityEditorModal', () => ({
default: () => null,
}));
vi.mock('../services/assetReadUrlService', () => ({
resolveAssetReadUrl: vi.fn(async (source: string | null | undefined) => {
const value = source?.trim() ?? '';
return value ? `https://signed.example${value}` : '';
}),
isGeneratedLegacyPath: vi.fn((source: string | null | undefined) => {
const value = source?.trim() ?? '';
return /^\/generated-[^/?#]+\/.+/u.test(value);
}),
}));
async function loadAiService() {
return import('../services/aiService');
}
@@ -315,9 +326,13 @@ test('clicking新增可扮演角色 shows pending item, disables button, and mar
expect(screen.getAllByText('新').length).toBeGreaterThan(0);
});
test('world basic setting renders eight anchor fields and hides legacy parsed/source copy', () => {
test('world basic setting renders eight anchor fields and hides legacy parsed/source copy', async () => {
render(<ResultViewHarness />);
await waitFor(() => {
expect(screen.getByText('世界承诺')).toBeTruthy();
});
expect(screen.getByText('世界承诺')).toBeTruthy();
expect(screen.getByText('玩家幻想')).toBeTruthy();
expect(screen.getByText('主题边界')).toBeTruthy();
@@ -378,7 +393,7 @@ test('playable tab prefers generated portrait over runtime preview placeholder',
const portrait = screen.getByRole('img', { name: '云止' });
expect((portrait as HTMLImageElement).getAttribute('src')).toBe(
'/generated-characters/playable-portrait/master.png',
'https://signed.example/generated-characters/playable-portrait/master.png',
);
expect(screen.getByText('已生成主图')).toBeTruthy();
});
@@ -395,6 +410,59 @@ test('landmark tab uses first act image as scene card preview and keeps chapter
const sceneImage = screen.getByRole('img', { name: '沉钟栈桥' });
expect((sceneImage as HTMLImageElement).getAttribute('src')).toBe(
'/generated-custom-world-scenes/scene-act-1.png',
'https://signed.example/generated-custom-world-scenes/scene-act-1.png',
);
});
test('asset debug panel opens signed image link in dev mode', async () => {
const user = userEvent.setup();
const originalImportMetaEnv = import.meta.env;
const originalUrl = window.location.href;
const originalImage = globalThis.Image;
Object.defineProperty(import.meta, 'env', {
value: {
...originalImportMetaEnv,
DEV: true,
},
configurable: true,
});
window.history.pushState({}, '', '/?debugCustomWorldAssets=1');
class MockImage {
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
set src(_value: string) {
queueMicrotask(() => {
this.onload?.();
});
}
}
Object.defineProperty(globalThis, 'Image', {
value: MockImage,
configurable: true,
});
try {
render(<ResultViewHarness />);
await user.click(screen.getByRole('button', { name: /\s*2/u }));
const signedLink = await screen.findByRole('link', {
name: / \/ /u,
});
expect((signedLink as HTMLAnchorElement).href).toBe(
'https://signed.example/generated-custom-world-scenes/scene-act-1.png',
);
} finally {
Object.defineProperty(import.meta, 'env', {
value: originalImportMetaEnv,
configurable: true,
});
window.history.pushState({}, '', originalUrl);
Object.defineProperty(globalThis, 'Image', {
value: originalImage,
configurable: true,
});
}
});

View File

@@ -6,6 +6,7 @@ import {
generateCustomWorldPlayableNpc,
generateCustomWorldStoryNpc,
} from '../services/aiService';
import { resolveAssetReadUrl } from '../services/assetReadUrlService';
import {
Character,
CustomWorldLandmark,
@@ -405,6 +406,29 @@ export function CustomWorldResultView({
const [assetDebugStatusMap, setAssetDebugStatusMap] = useState<
Record<string, AssetDebugLoadStatus>
>({});
const [assetDebugResolvedImageMap, setAssetDebugResolvedImageMap] = useState<
Record<string, string>
>({});
const assetDebugResolvedEntries = useMemo(
() =>
assetDebugEntries.map((entry) => ({
...entry,
hasResolvedImageSrc: Object.prototype.hasOwnProperty.call(
assetDebugResolvedImageMap,
entry.id,
),
resolvedImageSrc: assetDebugResolvedImageMap[entry.id] || entry.imageSrc,
})),
[assetDebugEntries, assetDebugResolvedImageMap],
);
const assetDebugDetectableEntries = useMemo(
() =>
assetDebugResolvedEntries.filter(
(entry) =>
entry.hasResolvedImageSrc && Boolean(entry.resolvedImageSrc.trim()),
),
[assetDebugResolvedEntries],
);
const createTarget = useMemo(
() => getCreateTargetByTab(activeTab),
@@ -425,11 +449,42 @@ export function CustomWorldResultView({
useEffect(() => {
if (!assetDebugEnabled) {
setAssetDebugStatusMap({});
setAssetDebugResolvedImageMap({});
return;
}
if (assetDebugEntries.length === 0) {
setAssetDebugResolvedImageMap({});
return;
}
let cancelled = false;
void Promise.all(
assetDebugEntries.map(async (entry) => [
entry.id,
await resolveAssetReadUrl(entry.imageSrc),
] as const),
).then((resolvedEntries) => {
if (cancelled) {
return;
}
setAssetDebugResolvedImageMap(Object.fromEntries(resolvedEntries));
});
return () => {
cancelled = true;
};
}, [assetDebugEnabled, assetDebugEntries]);
useEffect(() => {
if (!assetDebugEnabled) {
setAssetDebugStatusMap({});
return;
}
if (assetDebugDetectableEntries.length === 0) {
setAssetDebugStatusMap({});
return;
}
@@ -437,13 +492,14 @@ export function CustomWorldResultView({
let cancelled = false;
const cleanupList: Array<() => void> = [];
// 诊断面板只根据已解析地址做探测,避免状态流里反复访问原始 generated-* 路径。
setAssetDebugStatusMap(
Object.fromEntries(
assetDebugEntries.map((entry) => [entry.id, 'loading' as const]),
assetDebugDetectableEntries.map((entry) => [entry.id, 'loading' as const]),
),
);
assetDebugEntries.forEach((entry) => {
assetDebugDetectableEntries.forEach((entry) => {
const image = new Image();
const updateStatus = (status: AssetDebugLoadStatus) => {
if (cancelled) {
@@ -463,7 +519,7 @@ export function CustomWorldResultView({
image.onload = () => updateStatus('loaded');
image.onerror = () => updateStatus('error');
image.src = entry.imageSrc;
image.src = entry.resolvedImageSrc;
cleanupList.push(() => {
image.onload = null;
image.onerror = null;
@@ -474,7 +530,7 @@ export function CustomWorldResultView({
cancelled = true;
cleanupList.forEach((cleanup) => cleanup());
};
}, [assetDebugEnabled, assetDebugEntries]);
}, [assetDebugDetectableEntries, assetDebugEnabled]);
const startPendingProgress = (kind: EntityGenerationKind) => {
stopPendingProgressTimer();
@@ -679,7 +735,7 @@ export function CustomWorldResultView({
</div>
</div>
<div className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
{assetDebugEntries.length}
{assetDebugResolvedEntries.length}
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 xl:grid-cols-4">
@@ -696,8 +752,8 @@ export function CustomWorldResultView({
))}
</div>
<div className="mt-3 space-y-2">
{assetDebugEntries.length > 0 ? (
assetDebugEntries.map((entry) => (
{assetDebugResolvedEntries.length > 0 ? (
assetDebugResolvedEntries.map((entry) => (
<div
key={entry.id}
className="platform-subpanel rounded-2xl px-3 py-2"
@@ -718,15 +774,21 @@ export function CustomWorldResultView({
</div>
</div>
<div className="mt-2">
<a
href={entry.imageSrc}
target="_blank"
rel="noreferrer"
aria-label={`打开 ${entry.label}`}
className="text-xs font-semibold text-amber-200 underline decoration-white/20 underline-offset-2"
>
</a>
{entry.hasResolvedImageSrc ? (
<a
href={entry.resolvedImageSrc}
target="_blank"
rel="noreferrer"
aria-label={`打开 ${entry.label}签名图`}
className="text-xs font-semibold text-amber-200 underline decoration-white/20 underline-offset-2"
>
</a>
) : (
<div className="text-xs text-zinc-500">
...
</div>
)}
</div>
</div>
))

View File

@@ -34,6 +34,7 @@ import { buildDefaultRolePromptBundle } from './asset-studio/customWorldRoleProm
import { buildProjectPixelStyleReferenceBoard } from './asset-studio/projectPixelStyleReference';
import { useAuthUi } from './auth/AuthUiContext';
import { CharacterAnimator } from './CharacterAnimator';
import { ResolvedAssetImage } from './ResolvedAssetImage';
type EditableCustomWorldRole = {
id: string;
@@ -1173,13 +1174,13 @@ export function CustomWorldRoleAssetStudioModal({
<div className="platform-role-studio__preview overflow-hidden rounded-3xl">
<div className="flex min-h-[18rem] items-center justify-center p-4 sm:min-h-[22rem]">
{previewImageSrc ? (
<img
<ResolvedAssetImage
src={previewImageSrc}
alt={workingRole.name}
className="max-h-[28rem] w-full object-contain"
/>
) : selectedTemplate ? (
<img
<ResolvedAssetImage
src={selectedTemplate.portrait}
alt={selectedTemplate.name}
className="max-h-[20rem] w-full object-contain"
@@ -1289,7 +1290,7 @@ export function CustomWorldRoleAssetStudioModal({
</div>
</div>
) : previewImageSrc ? (
<img
<ResolvedAssetImage
src={previewImageSrc}
alt={workingRole.name}
className="max-h-[28rem] w-full object-contain pixelated"

View File

@@ -3,6 +3,7 @@ import { type CSSProperties, useEffect, useMemo, useState } from 'react';
import { getCustomWorldSceneRelativePositionLabel } from '../data/customWorldSceneGraph';
import { getConnectedScenePresets } from '../data/scenePresets';
import { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl';
import { ScenePresetInfo, WorldType } from '../types';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PixelIcon } from './PixelIcon';
@@ -153,6 +154,10 @@ export function MapModal({
canTravel = true,
}: MapModalProps) {
const [pendingScene, setPendingScene] = useState<MapConnectionEntry | null>(null);
const {
resolvedUrl: resolvedBackdropImageSrc,
shouldResolve: shouldResolveBackdropImage,
} = useResolvedAssetReadUrl(currentScenePreset?.imageSrc);
const connectedScenes = useMemo(
() =>
@@ -186,7 +191,10 @@ export function MapModal({
return buildFallbackConnectionEntries(currentScenePreset, connectedScenes);
}, [connectedScenes, currentScenePreset]);
const sceneBackdropStyle = buildSceneBackdropStyle(currentScenePreset?.imageSrc);
const sceneBackdropStyle = buildSceneBackdropStyle(
resolvedBackdropImageSrc
|| (!shouldResolveBackdropImage ? currentScenePreset?.imageSrc : ''),
);
const destinationStackHeightPx = getMapDestinationStackHeight(connectionEntries.length);
useEffect(() => {

View File

@@ -0,0 +1,27 @@
import type { ImgHTMLAttributes } from 'react';
import { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl';
type ResolvedAssetImageProps = Omit<
ImgHTMLAttributes<HTMLImageElement>,
'src'
> & {
src?: string | null;
fallbackSrc?: string | null;
};
export function ResolvedAssetImage({
src,
fallbackSrc,
alt,
...rest
}: ResolvedAssetImageProps) {
const { resolvedUrl } = useResolvedAssetReadUrl(src);
const finalSrc = resolvedUrl || fallbackSrc?.trim() || '';
if (!finalSrc) {
return null;
}
return <img {...rest} src={finalSrc} alt={alt} />;
}

View File

@@ -1,4 +1,5 @@
import type { CustomWorldDraftCardDetail } from '../../../packages/shared/src/contracts/customWorldAgent';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { CustomWorldDraftEditPanel } from './CustomWorldDraftEditPanel';
type CustomWorldAgentDraftDetailPanelProps = {
@@ -179,7 +180,7 @@ export function CustomWorldAgentDraftDetailPanel({
{section.label}
</div>
{shouldRenderImagePreview(detail.kind, section.id, section.value) ? (
<img
<ResolvedAssetImage
src={section.value}
alt={section.label}
className="mt-3 h-40 w-full rounded-[1rem] border border-white/10 object-cover"

View File

@@ -15,6 +15,7 @@ import {
import {HostileNpcAnimator} from '../HostileNpcAnimator';
import {MedievalNpcAnimator} from '../MedievalNpcAnimator';
import {getRenderableNpcFacing} from '../npcRenderUtils';
import {ResolvedAssetImage} from '../ResolvedAssetImage';
import {NpcAffinityEffectBadge} from './NpcAffinityEffectBadge';
import {
DialogueBubbleIcon,
@@ -408,7 +409,7 @@ export function GameCanvasEntityLayer({
<div className={ROLE_CHARACTER_FRAME_CLASS}>
{encounter.kind === 'treasure' ? (
<div className="flex h-20 w-20 items-center justify-center rounded-2xl border border-amber-400/30 bg-amber-500/15 shadow-[0_0_20px_rgba(255,255,255,0.12)]">
<img
<ResolvedAssetImage
src={encounter.npcAvatar || '/Icons/47_treasure.png'}
alt={encounter.npcName}
className="h-12 w-12 object-contain"

View File

@@ -1,5 +1,6 @@
import {AnimatePresence, motion} from 'motion/react';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import {type ScenePresetInfo, WorldType} from '../../types';
import {CHROME_ICONS, getNineSliceStyle, UI_CHROME} from '../../uiAssets';
import {PixelIcon} from '../PixelIcon';
@@ -24,11 +25,19 @@ export function GameCanvasSceneLayer({
onSceneNameClick = null,
onBackgroundLoadError,
}: GameCanvasSceneLayerProps) {
const {
resolvedUrl: resolvedBackgroundSrc,
shouldResolve: shouldResolveBackground,
} = useResolvedAssetReadUrl(backgroundSrc);
// 签名地址未返回前先显示渐变底色,避免浏览器直接访问私有原图触发 403。
const displayBackgroundSrc =
resolvedBackgroundSrc || (!shouldResolveBackground ? backgroundSrc : '');
return (
<>
{!backgroundLoadFailed ? (
{!backgroundLoadFailed && displayBackgroundSrc ? (
<img
src={backgroundSrc}
src={displayBackgroundSrc}
alt={currentScenePreset?.name || 'Scene background'}
className="absolute inset-0 h-full w-full object-cover"
style={{imageRendering: 'pixelated'}}

View File

@@ -2,6 +2,7 @@ import React, {useEffect, useState} from 'react';
import {getCharacterById} from '../../data/characterPresets';
import {METERS_TO_PIXELS} from '../../data/hostileNpcs';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import {
buildMedievalNpcVisual,
buildMedievalNpcVisualFromCustomWorldVisual,
@@ -266,6 +267,15 @@ export function SceneEncounterNpcSprite({
facing: 'left' | 'right';
className?: string;
}) {
const rawEncounterImageSrc = encounter.imageSrc?.trim() ?? '';
const {
resolvedUrl: resolvedEncounterImageSrc,
shouldResolve: shouldResolveEncounterImage,
} = useResolvedAssetReadUrl(rawEncounterImageSrc);
const displayEncounterImageSrc =
resolvedEncounterImageSrc
|| (!shouldResolveEncounterImage ? rawEncounterImageSrc : '');
if (encounter.visual) {
return (
<MedievalNpcAnimator
@@ -277,10 +287,14 @@ export function SceneEncounterNpcSprite({
);
}
if (encounter.imageSrc?.trim()) {
if (rawEncounterImageSrc && shouldResolveEncounterImage && !displayEncounterImageSrc) {
return <div className={`h-full w-full ${className ?? ''}`.trim()} />;
}
if (displayEncounterImageSrc) {
return (
<img
src={encounter.imageSrc.trim()}
src={displayEncounterImageSrc}
alt={encounter.npcName}
className={`h-full w-full object-contain ${className ?? ''}`.trim()}
style={{

View File

@@ -15,6 +15,7 @@ import {AnimationState, type Character, type CustomWorldProfile, WorldType} from
import {getNineSliceStyle, UI_CHROME} from '../../uiAssets';
import {CharacterAnimator} from '../CharacterAnimator';
import {CharacterDetailModal} from '../CharacterDetailModal';
import {ResolvedAssetImage} from '../ResolvedAssetImage';
import {CharacterDraftModal} from '../SelectionCustomizationModals';
type CharacterSelectionDraft = {
@@ -346,7 +347,12 @@ export function CharacterSelectionFlow({
className="character-carousel__portrait character-carousel__portrait--animated"
/>
) : (
<img src={character.portrait} alt={meta.name} className="character-carousel__portrait" style={{imageRendering: 'pixelated'}} />
<ResolvedAssetImage
src={character.portrait}
alt={meta.name}
className="character-carousel__portrait"
style={{imageRendering: 'pixelated'}}
/>
)}
</span>
{selected ? (

View File

@@ -32,7 +32,9 @@ import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapsho
import type { AuthUser } from '../../services/authService';
import type { PlatformBrowseHistoryEntry } from '../../services/platformBrowseHistory';
import type { CustomWorldProfile } from '../../types';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import { useAuthUi } from '../auth/AuthUiContext';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { PlatformBrandLogo } from './PlatformBrandLogo';
import {
buildPlatformWorldTags,
@@ -53,6 +55,35 @@ const MOBILE_PAGE_STAGE_CLASS =
const DESKTOP_PAGE_STAGE_CLASS =
'platform-page-stage platform-remap-surface space-y-5 pb-4';
function ResolvedAssetBackdrop({
src,
alt,
className,
ariaHidden = false,
}: {
src?: string | null;
alt: string;
className: string;
ariaHidden?: boolean;
}) {
const { resolvedUrl, shouldResolve } = useResolvedAssetReadUrl(src);
// 私有 OSS 封面在签名地址返回前保持现有底色层,避免浏览器直接访问旧 generated-* 路径。
const displaySrc = resolvedUrl || (!shouldResolve ? src?.trim() ?? '' : '');
if (!displaySrc) {
return null;
}
return (
<img
src={displaySrc}
alt={alt}
aria-hidden={ariaHidden}
className={className}
/>
);
}
function SectionHeader({ title, detail }: { title: string; detail: string }) {
return (
<div className="mb-3">
@@ -91,7 +122,7 @@ function SaveArchivePreview({
className={`platform-remap-surface relative shrink-0 overflow-hidden rounded-[1.35rem] border border-white/12 bg-black/18 shadow-[var(--platform-desktop-hover-shadow)] ${className}`}
>
{entry.coverImageSrc ? (
<img
<ResolvedAssetBackdrop
src={entry.coverImageSrc}
alt=""
className="absolute inset-0 h-full w-full object-cover"
@@ -139,14 +170,14 @@ function WorldCard({
className={`platform-surface platform-interactive-card relative flex h-[15rem] w-[15.25rem] shrink-0 flex-col overflow-hidden px-3.5 py-3.5 text-left ${className ?? ''}`}
>
{coverImage ? (
<img
<ResolvedAssetBackdrop
src={coverImage}
alt={entry.worldName}
className="absolute inset-0 h-full w-full object-cover opacity-40"
/>
) : null}
{leadPortrait ? (
<img
<ResolvedAssetImage
src={leadPortrait}
alt=""
aria-hidden="true"
@@ -223,14 +254,14 @@ function CreationLibraryCard({
className="platform-surface platform-interactive-card relative flex min-h-[13rem] w-full min-w-0 flex-col overflow-hidden px-3 py-3 text-left sm:min-h-[14rem] sm:px-3.5 sm:py-3.5"
>
{coverImage ? (
<img
<ResolvedAssetBackdrop
src={coverImage}
alt={entry.worldName}
className="absolute inset-0 h-full w-full object-cover opacity-38"
/>
) : null}
{leadPortrait ? (
<img
<ResolvedAssetImage
src={leadPortrait}
alt=""
aria-hidden="true"
@@ -413,7 +444,7 @@ function DesktopTrendingItem({
>
<div className="relative h-[5.5rem] w-[4.3rem] shrink-0 overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-[rgba(255,255,255,0.66)]">
{coverImage ? (
<img
<ResolvedAssetBackdrop
src={coverImage}
alt={entry.worldName}
className="h-full w-full object-cover"
@@ -1140,7 +1171,7 @@ export function PlatformHomeView({
className={`${HERO_SURFACE_CLASS} relative block overflow-hidden px-7 py-6 text-left`}
>
{desktopHeroCover ? (
<img
<ResolvedAssetBackdrop
src={desktopHeroCover}
alt=""
aria-hidden="true"
@@ -1188,7 +1219,7 @@ export function PlatformHomeView({
>
<div className="relative aspect-[1.35/1] overflow-hidden">
{coverImage ? (
<img
<ResolvedAssetBackdrop
src={coverImage}
alt=""
aria-hidden="true"

View File

@@ -3,6 +3,7 @@ import { ArrowLeft } from 'lucide-react';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import type { CustomWorldProfile } from '../../types';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import {
buildPlatformWorldTags,
describePlatformThemeLabel,
@@ -96,14 +97,14 @@ export function PlatformWorldDetailView({
<div className="space-y-4 pb-2">
<div className="platform-surface platform-surface--hero relative overflow-hidden px-[18px] py-4">
{coverImage ? (
<img
<ResolvedAssetImage
src={coverImage}
alt={entry.worldName}
className="absolute inset-0 h-full w-full object-cover opacity-38"
/>
) : null}
{leadPortrait ? (
<img
<ResolvedAssetImage
src={leadPortrait}
alt=""
aria-hidden="true"

View File

@@ -38,6 +38,17 @@ import {
type SelectionStage,
} from './PreGameSelectionFlow';
vi.mock('../../services/assetReadUrlService', () => ({
resolveAssetReadUrl: vi.fn(async (source: string | null | undefined) => {
const value = source?.trim() ?? '';
return value ? `https://signed.example${value}` : '';
}),
isGeneratedLegacyPath: vi.fn((source: string | null | undefined) => {
const value = source?.trim() ?? '';
return /^\/generated-[^/?#]+\/.+/u.test(value);
}),
}));
async function clickFirstButtonByName(
user: ReturnType<typeof userEvent.setup>,
name: string | RegExp,
@@ -434,6 +445,39 @@ test('clicking a public work while logged out routes through requireAuth', async
expect(getCustomWorldGalleryDetail).not.toHaveBeenCalled();
});
test('platform home cards resolve generated cover images through signed read urls', async () => {
vi.mocked(listCustomWorldGallery).mockResolvedValue([
{
ownerUserId: 'author-1',
profileId: 'world-public-1',
visibility: 'published',
publishedAt: '2026-04-16T12:00:00.000Z',
updatedAt: '2026-04-16T12:00:00.000Z',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '最近公开发布的世界。',
coverImageSrc: '/generated-custom-world-covers/world-public-1/cover.webp',
themeMode: 'tide',
authorDisplayName: '潮汐作者',
playableNpcCount: 3,
landmarkCount: 4,
},
]);
render(<TestWrapper withAuth />);
await waitFor(() => {
const coverImages = screen.getAllByAltText('潮雾列岛');
expect(
coverImages.some(
(image) =>
image.getAttribute('src') ===
'https://signed.example/generated-custom-world-covers/world-public-1/cover.webp',
),
).toBe(true);
});
});
test('selecting RPG creation while logged out routes through requireAuth', async () => {
const user = userEvent.setup();
const requireAuth = vi.fn();
@@ -626,14 +670,11 @@ test('existing draft sessions enter the legacy result layout directly', async ()
await clickFirstButtonByName(user, //u);
await user.click(screen.getByRole('button', { name: / RPG/u }));
await waitFor(
async () => {
expect(await screen.findByText('世界档案')).toBeTruthy();
expect(screen.getByText('已自动保存')).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
},
{ timeout: 2500 },
);
expect(
await screen.findByText('世界档案', undefined, { timeout: 10000 }),
).toBeTruthy();
expect(screen.getByText('已自动保存')).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.queryByText(/Agent/u)).toBeNull();
expect(screen.queryByRole('button', { name: /^/u })).toBeNull();

View File

@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react';
import {
isGeneratedLegacyPath,
resolveAssetReadUrl,
} from '../services/assetReadUrlService';
type UseResolvedAssetReadUrlOptions = {
enabled?: boolean;
expireSeconds?: number;
};
export function useResolvedAssetReadUrl(
source: string | null | undefined,
options: UseResolvedAssetReadUrlOptions = {},
) {
const enabled = options.enabled !== false;
const normalizedSource = source?.trim() ?? '';
const shouldResolve =
enabled && Boolean(normalizedSource) && isGeneratedLegacyPath(normalizedSource);
const [resolvedUrl, setResolvedUrl] = useState(
shouldResolve ? '' : normalizedSource,
);
useEffect(() => {
if (!normalizedSource) {
setResolvedUrl('');
return;
}
if (!shouldResolve) {
setResolvedUrl(normalizedSource);
return;
}
let cancelled = false;
setResolvedUrl('');
void resolveAssetReadUrl(normalizedSource, {
expireSeconds: options.expireSeconds,
})
.then((nextUrl) => {
if (!cancelled) {
setResolvedUrl(nextUrl);
}
})
.catch(() => {
if (!cancelled) {
// 读取签名失败时回退原始路径,至少保持现有 UI 可见错误表象。
setResolvedUrl(normalizedSource);
}
});
return () => {
cancelled = true;
};
}, [normalizedSource, options.expireSeconds, shouldResolve]);
return {
resolvedUrl,
isResolving: shouldResolve && !resolvedUrl,
shouldResolve,
};
}

View File

@@ -0,0 +1,113 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import {
clearSignedAssetReadUrlCache,
getSignedAssetReadUrl,
resolveAssetReadUrl,
} from './assetReadUrlService';
describe('assetReadUrlService', () => {
beforeEach(() => {
clearSignedAssetReadUrlCache();
vi.restoreAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
test('resolveAssetReadUrl returns passthrough for absolute url', async () => {
await expect(resolveAssetReadUrl('https://example.com/demo.png')).resolves.toBe(
'https://example.com/demo.png',
);
});
test('resolveAssetReadUrl returns passthrough for data url', async () => {
await expect(resolveAssetReadUrl('data:image/png;base64,abc')).resolves.toBe(
'data:image/png;base64,abc',
);
});
test('resolveAssetReadUrl exchanges legacy generated path for signed url', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({
ok: true,
data: {
read: {
objectKey: 'generated-characters/hero/visual/asset-01/master.png',
signedUrl: 'https://signed.example.com/master.png',
expiresAt: '2099-01-01T00:10:00Z',
},
},
error: null,
meta: {
apiVersion: '2026-04-08',
routeVersion: '2026-04-08',
latencyMs: 1,
timestamp: '2099-01-01T00:00:00Z',
},
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
},
),
);
await expect(
resolveAssetReadUrl('/generated-characters/hero/visual/asset-01/master.png'),
).resolves.toBe('https://signed.example.com/master.png');
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
'/api/assets/read-url?',
);
});
test('getSignedAssetReadUrl reuses cached signed url before expiry', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2099-01-01T00:00:00Z'));
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({
ok: true,
data: {
read: {
objectKey: 'generated-custom-world-scenes/profile-1/landmark-1/scene.png',
signedUrl: 'https://signed.example.com/scene.png',
expiresAt: '2099-01-01T00:10:00Z',
},
},
error: null,
meta: {
apiVersion: '2026-04-08',
routeVersion: '2026-04-08',
latencyMs: 1,
timestamp: '2099-01-01T00:00:00Z',
},
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
},
),
);
const first = await getSignedAssetReadUrl({
legacyPublicPath: '/generated-custom-world-scenes/profile-1/landmark-1/scene.png',
});
const second = await getSignedAssetReadUrl({
legacyPublicPath: '/generated-custom-world-scenes/profile-1/landmark-1/scene.png',
});
expect(first).toBe('https://signed.example.com/scene.png');
expect(second).toBe(first);
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,191 @@
import { requestJson } from './apiClient';
export type AssetReadUrlRequest = {
objectKey?: string;
legacyPublicPath?: string;
expireSeconds?: number;
};
export type AssetReadUrlResponse = {
read?: {
objectKey?: string;
signedUrl?: string;
expiresAt?: string;
};
signedUrl?: string;
objectKey?: string;
expiresAt?: string;
};
type CachedReadUrlEntry = {
signedUrl: string;
expiresAtMs: number;
};
const ASSET_READ_URL_API_PATH = '/api/assets/read-url';
const DEFAULT_CACHE_SAFETY_WINDOW_MS = 30 * 1000;
const signedReadUrlCache = new Map<string, CachedReadUrlEntry>();
const pendingSignedReadUrlRequests = new Map<string, Promise<string>>();
export function isGeneratedLegacyPath(value: string) {
return /^\/generated-[^/?#]+\/.+/u.test(value.trim());
}
function normalizeLegacyPublicPath(value: string) {
return `/${value.trim().replace(/^\/+/u, '')}`;
}
function buildCacheKey(request: AssetReadUrlRequest) {
if (request.objectKey?.trim()) {
return `object:${request.objectKey.trim().replace(/^\/+/u, '')}`;
}
if (request.legacyPublicPath?.trim()) {
return `legacy:${normalizeLegacyPublicPath(request.legacyPublicPath)}`;
}
return '';
}
function resolveSignedReadPayload(response: AssetReadUrlResponse) {
const read = response.read ?? response;
const signedUrl = typeof read.signedUrl === 'string' ? read.signedUrl.trim() : '';
const expiresAt = typeof read.expiresAt === 'string' ? read.expiresAt.trim() : '';
const objectKey = typeof read.objectKey === 'string' ? read.objectKey.trim() : '';
if (!signedUrl) {
throw new Error('资源访问地址缺失');
}
return {
signedUrl,
expiresAt,
objectKey,
};
}
function parseExpiresAtMs(expiresAt: string) {
if (!expiresAt) {
return 0;
}
const parsed = Date.parse(expiresAt);
return Number.isFinite(parsed) ? parsed : 0;
}
function shouldReuseCachedReadUrl(entry: CachedReadUrlEntry | undefined) {
if (!entry) {
return false;
}
return entry.expiresAtMs - DEFAULT_CACHE_SAFETY_WINDOW_MS > Date.now();
}
export async function getSignedAssetReadUrl(
request: AssetReadUrlRequest,
signal?: AbortSignal,
) {
const cacheKey = buildCacheKey(request);
const cached = cacheKey ? signedReadUrlCache.get(cacheKey) : undefined;
if (cached && shouldReuseCachedReadUrl(cached)) {
return cached.signedUrl;
}
if (cacheKey) {
const pendingRequest = pendingSignedReadUrlRequests.get(cacheKey);
if (pendingRequest) {
return pendingRequest;
}
}
const requestPromise = (async () => {
const searchParams = new URLSearchParams();
if (request.objectKey?.trim()) {
searchParams.set('objectKey', request.objectKey.trim().replace(/^\/+/u, ''));
}
if (request.legacyPublicPath?.trim()) {
searchParams.set(
'legacyPublicPath',
normalizeLegacyPublicPath(request.legacyPublicPath),
);
}
if (
typeof request.expireSeconds === 'number' &&
Number.isFinite(request.expireSeconds) &&
request.expireSeconds > 0
) {
searchParams.set('expireSeconds', String(Math.floor(request.expireSeconds)));
}
const response = await requestJson<AssetReadUrlResponse>(
`${ASSET_READ_URL_API_PATH}?${searchParams.toString()}`,
{
method: 'GET',
signal,
},
'获取资源访问地址失败',
);
const payload = resolveSignedReadPayload(response);
const expiresAtMs = parseExpiresAtMs(payload.expiresAt);
if (cacheKey && expiresAtMs > 0) {
signedReadUrlCache.set(cacheKey, {
signedUrl: payload.signedUrl,
expiresAtMs,
});
}
return payload.signedUrl;
})();
if (cacheKey) {
pendingSignedReadUrlRequests.set(cacheKey, requestPromise);
}
try {
return await requestPromise;
} finally {
if (cacheKey) {
pendingSignedReadUrlRequests.delete(cacheKey);
}
}
}
// 兼容层:普通 http(s)/data/blob 路径原样返回;历史 generated-* 路径自动换签名读 URL。
export async function resolveAssetReadUrl(
source: string | null | undefined,
options: {
signal?: AbortSignal;
expireSeconds?: number;
} = {},
) {
const value = source?.trim() ?? '';
if (!value) {
return '';
}
if (
/^(?:https?:)?\/\//u.test(value) ||
value.startsWith('data:') ||
value.startsWith('blob:')
) {
return value;
}
if (isGeneratedLegacyPath(value)) {
return getSignedAssetReadUrl(
{
legacyPublicPath: value,
expireSeconds: options.expireSeconds,
},
options.signal,
);
}
return value;
}
export function clearSignedAssetReadUrlCache() {
signedReadUrlCache.clear();
pendingSignedReadUrlRequests.clear();
}

View File

@@ -18,6 +18,7 @@ import {
GENERATED_FRAME_WIDTH,
} from '../components/asset-studio/characterAssetWorkflowModel';
import { generateCharacterAnimationDraft } from '../components/asset-studio/characterAssetWorkflowPersistence';
import { ResolvedAssetImage } from '../components/ResolvedAssetImage';
import {
NumberField,
SelectField,
@@ -124,7 +125,7 @@ function DraftStrip({
}`}
>
<div className="flex h-[220px] items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-[linear-gradient(180deg,#171b23,#0d1117)] p-2">
<img
<ResolvedAssetImage
src={draft.imageSrc}
alt={draft.label}
className="h-full w-full object-contain"
@@ -714,7 +715,7 @@ export default function QwenSpriteSheetTool() {
</div>
<div className="flex h-[260px] items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-[linear-gradient(180deg,#171b23,#0d1117)] p-2">
<img
<ResolvedAssetImage
src={selectedMasterSource}
alt="当前主图"
className="h-full w-full object-contain"