fix(space): 배경 asset fallback 경로와 scene alias 해석 보강

맥락:
- /space 에서 forest 배경이 remote manifest asset 대신 기본 이미지로 조용히 fallback 될 수 있었다.
- scene key alias 와 manifest 실패 상태가 코드상 드러나지 않아 원인 추적이 어려웠다.

변경사항:
- media scene asset key 를 alias-aware 하게 정규화하고 asset source(fallback|remote) 메타를 추가했다.
- useMediaCatalog 가 remote manifest 실패와 fallback 사용 여부를 노출하도록 보강했다.
- SpaceWorkspaceWidget 에서 manifest 실패와 scene fallback 사용을 진단 로그/상태 메시지로 남기도록 정리했다.
- docs/work.md, docs/90_current_state.md, docs/session_brief.md 를 이번 작업 기준으로 갱신했다.

검증:
- npx tsc --noEmit

세션-상태: /space 배경 asset lookup 과 manifest fallback 진단을 보강했다.
세션-다음: forest/green-forest manifest 변형을 실제 브라우저에서 QA 한다.
세션-리스크: alias 목록 밖의 legacy scene id 는 추가 정규화가 필요할 수 있다.
This commit is contained in:
2026-03-11 13:35:44 +09:00
parent 9811134d8a
commit 4717bb3a1a
7 changed files with 163 additions and 123 deletions

View File

@@ -1,15 +1,18 @@
import type { CSSProperties } from 'react';
import type { SceneTheme } from '@/entities/scene';
import { normalizeSceneId, type SceneTheme } from '@/entities/scene';
import { DEFAULT_MEDIA_MANIFEST } from './mockMediaManifest';
import type {
MediaManifest,
SceneAssetManifestItem,
SceneAssetMap,
SoundAssetManifestItem,
SoundAssetMap,
} from './types';
const DEFAULT_STAGE_GRADIENT = 'linear-gradient(160deg, #1e293b 0%, #0f172a 100%)';
const normalizeSceneAssetId = (sceneId: string) => normalizeSceneId(sceneId) ?? sceneId;
const isAbsoluteUrl = (value: string) => /^(?:[a-z]+:)?\/\//i.test(value);
const resolveAssetUrl = (value: string | null | undefined, baseUrl?: string | null) => {
@@ -34,13 +37,28 @@ const resolveAssetUrl = (value: string | null | undefined, baseUrl?: string | nu
const mergeSceneAssets = (manifest: MediaManifest) => {
const bySceneId = new Map(
DEFAULT_MEDIA_MANIFEST.scenes.map((asset) => [asset.sceneId, asset]),
DEFAULT_MEDIA_MANIFEST.scenes.map((asset) => {
const normalizedSceneId = normalizeSceneAssetId(asset.sceneId);
return [
normalizedSceneId,
{
...asset,
sceneId: normalizedSceneId,
source: 'fallback',
} as SceneAssetManifestItem,
] as const;
}),
);
for (const asset of manifest.scenes) {
bySceneId.set(asset.sceneId, {
...bySceneId.get(asset.sceneId),
const normalizedSceneId = normalizeSceneAssetId(asset.sceneId);
bySceneId.set(normalizedSceneId, {
...bySceneId.get(normalizedSceneId),
...asset,
sceneId: normalizedSceneId,
source: 'remote',
});
}
@@ -55,13 +73,20 @@ const mergeSceneAssets = (manifest: MediaManifest) => {
const mergeSoundAssets = (manifest: MediaManifest) => {
const byPresetId = new Map(
DEFAULT_MEDIA_MANIFEST.sounds.map((asset) => [asset.presetId, asset]),
DEFAULT_MEDIA_MANIFEST.sounds.map((asset) => [
asset.presetId,
{
...asset,
source: 'fallback',
} as SoundAssetManifestItem,
]),
);
for (const asset of manifest.sounds) {
byPresetId.set(asset.presetId, {
...byPresetId.get(asset.presetId),
...asset,
source: 'remote',
});
}
@@ -91,7 +116,7 @@ export const normalizeMediaManifest = (manifest: Partial<MediaManifest> | null |
export const buildSceneAssetMap = (manifest: MediaManifest): SceneAssetMap => {
return manifest.scenes.reduce<SceneAssetMap>((accumulator, asset) => {
accumulator[asset.sceneId] = asset;
accumulator[normalizeSceneAssetId(asset.sceneId)] = asset;
return accumulator;
}, {});
};

View File

@@ -6,6 +6,7 @@ export interface SceneAssetManifestItem {
hdStageUrl?: string | null;
placeholderGradient?: string | null;
blurDataUrl?: string | null;
source?: 'fallback' | 'remote';
}
export interface SoundAssetManifestItem {
@@ -16,6 +17,7 @@ export interface SoundAssetManifestItem {
mimeType?: 'audio/mp4' | 'audio/mpeg' | 'audio/webm' | null;
durationSec?: number | null;
defaultVolume?: number | null;
source?: 'fallback' | 'remote';
}
export interface MediaManifest {

View File

@@ -10,12 +10,22 @@ import {
} from './resolveMediaAsset';
import type { MediaManifest } from './types';
let manifestCache = normalizeMediaManifest(DEFAULT_MEDIA_MANIFEST);
let manifestRequest: Promise<MediaManifest> | null = null;
type MediaCatalogLoadResult = {
manifest: MediaManifest;
error: string | null;
usedFallbackManifest: boolean;
};
const readMediaManifest = async (signal?: AbortSignal) => {
let manifestCache = normalizeMediaManifest(DEFAULT_MEDIA_MANIFEST);
let manifestRequest: Promise<MediaCatalogLoadResult> | null = null;
const readMediaManifest = async (signal?: AbortSignal): Promise<MediaCatalogLoadResult> => {
if (!MEDIA_MANIFEST_URL) {
return manifestCache;
return {
manifest: manifestCache,
error: null,
usedFallbackManifest: false,
};
}
if (!manifestRequest) {
@@ -23,9 +33,21 @@ const readMediaManifest = async (signal?: AbortSignal) => {
.getManifest(signal)
.then((manifest) => {
manifestCache = manifest;
return manifest;
return {
manifest,
error: null,
usedFallbackManifest: false,
};
})
.catch((error) => {
const nextError = error instanceof Error ? error.message : null;
return {
manifest: manifestCache,
error: nextError,
usedFallbackManifest: true,
};
})
.catch(() => manifestCache)
.finally(() => {
manifestRequest = null;
});
@@ -36,12 +58,18 @@ const readMediaManifest = async (signal?: AbortSignal) => {
export const useMediaCatalog = () => {
const [manifest, setManifest] = useState<MediaManifest>(manifestCache);
const [error, setError] = useState<string | null>(null);
const [usedFallbackManifest, setUsedFallbackManifest] = useState(false);
const [hasResolvedManifest, setHasResolvedManifest] = useState(!MEDIA_MANIFEST_URL);
useEffect(() => {
const controller = new AbortController();
void readMediaManifest(controller.signal).then((nextManifest) => {
setManifest(nextManifest);
void readMediaManifest(controller.signal).then((result) => {
setManifest(result.manifest);
setError(result.error);
setUsedFallbackManifest(result.usedFallbackManifest);
setHasResolvedManifest(true);
});
return () => {
@@ -56,5 +84,8 @@ export const useMediaCatalog = () => {
manifest,
sceneAssetMap,
soundAssetMap,
error,
usedFallbackManifest,
hasResolvedManifest,
};
};

View File

@@ -208,8 +208,16 @@ export const SpaceWorkspaceWidget = () => {
});
const queuedFocusStatusMessageRef = useRef<string | null>(null);
const lastSoundPlaybackErrorRef = useRef<string | null>(null);
const lastMediaManifestErrorRef = useRef<string | null>(null);
const lastFallbackSceneDiagnosticRef = useRef<string | null>(null);
const didHydrateServerPreferencesRef = useRef(false);
const { sceneAssetMap, soundAssetMap } = useMediaCatalog();
const {
sceneAssetMap,
soundAssetMap,
error: mediaCatalogError,
usedFallbackManifest,
hasResolvedManifest,
} = useMediaCatalog();
const {
selectedPresetId,
@@ -778,6 +786,49 @@ export const SpaceWorkspaceWidget = () => {
});
}, [pushStatusLine, soundPlaybackError]);
useEffect(() => {
if (!mediaCatalogError) {
lastMediaManifestErrorRef.current = null;
return;
}
if (mediaCatalogError === lastMediaManifestErrorRef.current) {
return;
}
lastMediaManifestErrorRef.current = mediaCatalogError;
console.error('[media] Failed to load remote media manifest.', {
error: mediaCatalogError,
sceneId: selectedScene.id,
});
pushStatusLine({
message: mediaCatalogError,
});
}, [mediaCatalogError, pushStatusLine, selectedScene.id]);
useEffect(() => {
if (!hasResolvedManifest || usedFallbackManifest) {
return;
}
const isUsingFallbackSceneAsset = !selectedSceneAsset || selectedSceneAsset.source === 'fallback';
if (!isUsingFallbackSceneAsset) {
lastFallbackSceneDiagnosticRef.current = null;
return;
}
if (lastFallbackSceneDiagnosticRef.current === selectedScene.id) {
return;
}
lastFallbackSceneDiagnosticRef.current = selectedScene.id;
console.warn('[space] Selected scene is using fallback asset data.', {
sceneId: selectedScene.id,
asset: selectedSceneAsset ?? null,
});
}, [hasResolvedManifest, selectedScene.id, selectedSceneAsset, usedFallbackManifest]);
return (
<div className="relative h-dvh overflow-hidden text-white">
<div