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:
@@ -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;
|
||||
}, {});
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user