fix(space): 미디어 재진입 복원 안정화

This commit is contained in:
2026-03-18 20:17:36 +09:00
parent 9b013f1843
commit 6df34a0eb7
5 changed files with 469 additions and 49 deletions

View File

@@ -20,11 +20,15 @@ const resolveMediaManifestUrl = () => {
const resolveManifestFetchCache = (): RequestCache => { const resolveManifestFetchCache = (): RequestCache => {
const configuredCacheMode = process.env.NEXT_PUBLIC_MEDIA_MANIFEST_FETCH_CACHE; const configuredCacheMode = process.env.NEXT_PUBLIC_MEDIA_MANIFEST_FETCH_CACHE;
if (configuredCacheMode === 'no-store' || configuredCacheMode === 'force-cache') { if (configuredCacheMode === 'no-store') {
return configuredCacheMode; return configuredCacheMode;
} }
return process.env.NODE_ENV === 'development' ? 'no-store' : 'force-cache'; if (configuredCacheMode === 'force-cache') {
return process.env.NODE_ENV === 'development' ? 'force-cache' : 'no-store';
}
return 'no-store';
}; };
export const MEDIA_MANIFEST_URL = resolveMediaManifestUrl(); export const MEDIA_MANIFEST_URL = resolveMediaManifestUrl();

View File

@@ -15,27 +15,59 @@ const normalizeSceneAssetId = (sceneId: string) => normalizeSceneId(sceneId) ??
const isAbsoluteUrl = (value: string) => /^(?:[a-z]+:)?\/\//i.test(value); const isAbsoluteUrl = (value: string) => /^(?:[a-z]+:)?\/\//i.test(value);
const resolveAssetUrl = (value: string | null | undefined, baseUrl?: string | null) => { const appendCacheBustParam = (value: string, cacheBustKey?: string | null) => {
if (!value) { if (!cacheBustKey) {
return null;
}
if (isAbsoluteUrl(value) || value.startsWith('/')) {
return value;
}
if (!baseUrl) {
return value; return value;
} }
try { try {
return new URL(value, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString(); const url = value.startsWith('/')
? new URL(value, 'http://localhost')
: new URL(value);
if (!url.searchParams.has('v')) {
url.searchParams.set('v', cacheBustKey);
}
if (value.startsWith('/')) {
return `${url.pathname}${url.search}${url.hash}`;
}
return url.toString();
} catch { } catch {
return value; return value;
} }
}; };
const resolveAssetUrl = (
value: string | null | undefined,
baseUrl?: string | null,
cacheBustKey?: string | null,
) => {
if (!value) {
return null;
}
if (isAbsoluteUrl(value) || value.startsWith('/')) {
return appendCacheBustParam(value, cacheBustKey);
}
if (!baseUrl) {
return appendCacheBustParam(value, cacheBustKey);
}
try {
return appendCacheBustParam(
new URL(value, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString(),
cacheBustKey,
);
} catch {
return appendCacheBustParam(value, cacheBustKey);
}
};
const mergeSceneAssets = (manifest: MediaManifest) => { const mergeSceneAssets = (manifest: MediaManifest) => {
const cacheBustKey = manifest.updatedAt || manifest.version;
const bySceneId = new Map( const bySceneId = new Map(
DEFAULT_MEDIA_MANIFEST.scenes.map((asset) => { DEFAULT_MEDIA_MANIFEST.scenes.map((asset) => {
const normalizedSceneId = normalizeSceneAssetId(asset.sceneId); const normalizedSceneId = normalizeSceneAssetId(asset.sceneId);
@@ -64,14 +96,15 @@ const mergeSceneAssets = (manifest: MediaManifest) => {
return Array.from(bySceneId.values()).map((asset) => ({ return Array.from(bySceneId.values()).map((asset) => ({
...asset, ...asset,
cardUrl: resolveAssetUrl(asset.cardUrl, manifest.cdnBaseUrl) ?? asset.cardUrl, cardUrl: resolveAssetUrl(asset.cardUrl, manifest.cdnBaseUrl, cacheBustKey) ?? asset.cardUrl,
stageUrl: resolveAssetUrl(asset.stageUrl, manifest.cdnBaseUrl), stageUrl: resolveAssetUrl(asset.stageUrl, manifest.cdnBaseUrl, cacheBustKey),
mobileStageUrl: resolveAssetUrl(asset.mobileStageUrl, manifest.cdnBaseUrl), mobileStageUrl: resolveAssetUrl(asset.mobileStageUrl, manifest.cdnBaseUrl, cacheBustKey),
hdStageUrl: resolveAssetUrl(asset.hdStageUrl, manifest.cdnBaseUrl), hdStageUrl: resolveAssetUrl(asset.hdStageUrl, manifest.cdnBaseUrl, cacheBustKey),
})); }));
}; };
const mergeSoundAssets = (manifest: MediaManifest) => { const mergeSoundAssets = (manifest: MediaManifest) => {
const cacheBustKey = manifest.updatedAt || manifest.version;
const byPresetId = new Map( const byPresetId = new Map(
DEFAULT_MEDIA_MANIFEST.sounds.map((asset) => [ DEFAULT_MEDIA_MANIFEST.sounds.map((asset) => [
asset.presetId, asset.presetId,
@@ -92,9 +125,9 @@ const mergeSoundAssets = (manifest: MediaManifest) => {
return Array.from(byPresetId.values()).map((asset) => ({ return Array.from(byPresetId.values()).map((asset) => ({
...asset, ...asset,
previewUrl: resolveAssetUrl(asset.previewUrl, manifest.cdnBaseUrl), previewUrl: resolveAssetUrl(asset.previewUrl, manifest.cdnBaseUrl, cacheBustKey),
loopUrl: resolveAssetUrl(asset.loopUrl, manifest.cdnBaseUrl), loopUrl: resolveAssetUrl(asset.loopUrl, manifest.cdnBaseUrl, cacheBustKey),
fallbackLoopUrl: resolveAssetUrl(asset.fallbackLoopUrl, manifest.cdnBaseUrl), fallbackLoopUrl: resolveAssetUrl(asset.fallbackLoopUrl, manifest.cdnBaseUrl, cacheBustKey),
})); }));
}; };
@@ -150,6 +183,44 @@ export const getSceneStagePhotoUrl = (
return getSceneCardPhotoUrl(scene, asset); return getSceneCardPhotoUrl(scene, asset);
}; };
export interface SceneStageLayerSources {
placeholderGradient: string;
underpaintUrl: string | null;
baseImageUrl: string | null;
finalImageUrl: string | null;
}
export const resolveSceneStageLayerSources = (
scene: SceneTheme,
asset?: SceneAssetManifestItem | null,
options?: { preferMobile?: boolean; preferHd?: boolean },
): SceneStageLayerSources => {
const placeholderGradient = asset?.placeholderGradient ?? scene.previewGradient ?? DEFAULT_STAGE_GRADIENT;
const baseImageUrl =
(options?.preferMobile ? asset?.mobileStageUrl : null) ??
asset?.mobileStageUrl ??
asset?.cardUrl ??
scene.managedCardPhotoUrl ??
scene.cardPhotoUrl;
const preferredFinalImageUrl =
(options?.preferHd ? asset?.hdStageUrl : null) ??
asset?.stageUrl ??
asset?.hdStageUrl ??
null;
const finalImageUrl =
preferredFinalImageUrl && preferredFinalImageUrl !== baseImageUrl
? preferredFinalImageUrl
: null;
const underpaintUrl = baseImageUrl ? null : asset?.blurDataUrl ?? null;
return {
placeholderGradient,
underpaintUrl,
baseImageUrl,
finalImageUrl,
};
};
export const getSceneCardBackgroundStyle = ( export const getSceneCardBackgroundStyle = (
scene: SceneTheme, scene: SceneTheme,
asset?: SceneAssetManifestItem | null, asset?: SceneAssetManifestItem | null,
@@ -186,3 +257,24 @@ export const preloadAssetImage = (url: string | null | undefined) => {
image.decoding = 'async'; image.decoding = 'async';
image.src = url; image.src = url;
}; };
export const loadAssetImage = (url: string | null | undefined) => {
if (!url || typeof window === 'undefined') {
return Promise.resolve(false);
}
return new Promise<boolean>((resolve) => {
const image = new window.Image();
image.decoding = 'async';
image.onload = () => {
resolve(true);
};
image.onerror = () => {
resolve(false);
};
image.src = url;
});
};

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { MEDIA_MANIFEST_URL, mediaManifestApi } from '../api/mediaManifestApi'; import { MEDIA_MANIFEST_URL, mediaManifestApi } from '../api/mediaManifestApi';
import { DEFAULT_MEDIA_MANIFEST } from './mockMediaManifest'; import { DEFAULT_MEDIA_MANIFEST } from './mockMediaManifest';
import { import {
@@ -18,6 +18,7 @@ type MediaCatalogLoadResult = {
let manifestCache = normalizeMediaManifest(DEFAULT_MEDIA_MANIFEST); let manifestCache = normalizeMediaManifest(DEFAULT_MEDIA_MANIFEST);
let manifestRequest: Promise<MediaCatalogLoadResult> | null = null; let manifestRequest: Promise<MediaCatalogLoadResult> | null = null;
const MANIFEST_REVALIDATE_INTERVAL_MS = 60_000;
const readMediaManifest = async (signal?: AbortSignal): Promise<MediaCatalogLoadResult> => { const readMediaManifest = async (signal?: AbortSignal): Promise<MediaCatalogLoadResult> => {
if (!MEDIA_MANIFEST_URL) { if (!MEDIA_MANIFEST_URL) {
@@ -69,17 +70,42 @@ export const useMediaCatalog = () => {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [usedFallbackManifest, setUsedFallbackManifest] = useState(false); const [usedFallbackManifest, setUsedFallbackManifest] = useState(false);
const [hasResolvedManifest, setHasResolvedManifest] = useState(!MEDIA_MANIFEST_URL); const [hasResolvedManifest, setHasResolvedManifest] = useState(!MEDIA_MANIFEST_URL);
const isMountedRef = useRef(false);
const lastRefreshAtRef = useRef(0);
const applyLoadResult = useCallback((result: MediaCatalogLoadResult) => {
if (!isMountedRef.current) {
return;
}
setManifest(result.manifest);
setError(result.error);
setUsedFallbackManifest(result.usedFallbackManifest);
setHasResolvedManifest(true);
}, []);
const refreshManifest = useCallback(async () => {
try {
lastRefreshAtRef.current = Date.now();
const result = await readMediaManifest();
applyLoadResult(result);
return result;
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError' && isMountedRef.current) {
console.error('Failed to refresh media manifest:', err);
}
return null;
}
}, [applyLoadResult]);
useEffect(() => { useEffect(() => {
isMountedRef.current = true;
const controller = new AbortController(); const controller = new AbortController();
readMediaManifest(controller.signal) readMediaManifest(controller.signal)
.then((result) => { .then((result) => {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
setManifest(result.manifest); applyLoadResult(result);
setError(result.error);
setUsedFallbackManifest(result.usedFallbackManifest);
setHasResolvedManifest(true);
} }
}) })
.catch((err) => { .catch((err) => {
@@ -89,9 +115,69 @@ export const useMediaCatalog = () => {
}); });
return () => { return () => {
isMountedRef.current = false;
controller.abort(); controller.abort();
}; };
}, []); }, [applyLoadResult]);
useEffect(() => {
if (!MEDIA_MANIFEST_URL || (!usedFallbackManifest && !error)) {
return;
}
const timeoutId = window.setTimeout(() => {
void refreshManifest();
}, 4_000);
return () => {
window.clearTimeout(timeoutId);
};
}, [error, refreshManifest, usedFallbackManifest]);
useEffect(() => {
if (!MEDIA_MANIFEST_URL) {
return;
}
const maybeRefresh = () => {
if (document.visibilityState === 'hidden') {
return;
}
if (Date.now() - lastRefreshAtRef.current < 15_000) {
return;
}
void refreshManifest();
};
const handleVisibilityChange = () => {
if (document.visibilityState !== 'visible') {
return;
}
maybeRefresh();
};
const intervalId = window.setInterval(() => {
if (document.visibilityState !== 'visible') {
return;
}
maybeRefresh();
}, MANIFEST_REVALIDATE_INTERVAL_MS);
window.addEventListener('focus', maybeRefresh);
window.addEventListener('online', maybeRefresh);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.clearInterval(intervalId);
window.removeEventListener('focus', maybeRefresh);
window.removeEventListener('online', maybeRefresh);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [refreshManifest]);
const sceneAssetMap = useMemo(() => buildSceneAssetMap(manifest), [manifest]); const sceneAssetMap = useMemo(() => buildSceneAssetMap(manifest), [manifest]);
const soundAssetMap = useMemo(() => buildSoundAssetMap(manifest), [manifest]); const soundAssetMap = useMemo(() => buildSoundAssetMap(manifest), [manifest]);
@@ -103,5 +189,6 @@ export const useMediaCatalog = () => {
error, error,
usedFallbackManifest, usedFallbackManifest,
hasResolvedManifest, hasResolvedManifest,
refreshManifest,
}; };
}; };

View File

@@ -24,6 +24,18 @@ const resolvePlaybackErrorMessage = (error: unknown) => {
return copy.soundPlayback.loadFailed; return copy.soundPlayback.loadFailed;
}; };
const normalizeMediaUrl = (value: string | null | undefined) => {
if (!value || typeof window === 'undefined') {
return value ?? null;
}
try {
return new URL(value, window.location.href).toString();
} catch {
return value;
}
};
export const useSoundPlayback = ({ export const useSoundPlayback = ({
selectedPresetId, selectedPresetId,
soundAsset, soundAsset,
@@ -33,6 +45,8 @@ export const useSoundPlayback = ({
}: UseSoundPlaybackOptions) => { }: UseSoundPlaybackOptions) => {
const audioRef = useRef<HTMLAudioElement | null>(null); const audioRef = useRef<HTMLAudioElement | null>(null);
const isPlaybackUnlockedRef = useRef(false); const isPlaybackUnlockedRef = useRef(false);
const hasUserInteractedRef = useRef(false);
const recoveryAttemptedUrlRef = useRef<string | null>(null);
const requestedUrlRef = useRef<string | null>(null); const requestedUrlRef = useRef<string | null>(null);
const [isReady, setReady] = useState(false); const [isReady, setReady] = useState(false);
const [isPlaying, setPlaying] = useState(false); const [isPlaying, setPlaying] = useState(false);
@@ -46,6 +60,10 @@ export const useSoundPlayback = ({
return soundAsset?.loopUrl ?? soundAsset?.fallbackLoopUrl ?? null; return soundAsset?.loopUrl ?? soundAsset?.fallbackLoopUrl ?? null;
}, [selectedPresetId, soundAsset?.fallbackLoopUrl, soundAsset?.loopUrl]); }, [selectedPresetId, soundAsset?.fallbackLoopUrl, soundAsset?.loopUrl]);
const normalizedActiveUrl = useMemo(() => {
return normalizeMediaUrl(activeUrl);
}, [activeUrl]);
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return; return;
@@ -55,6 +73,8 @@ export const useSoundPlayback = ({
audio.loop = true; audio.loop = true;
audio.preload = 'auto'; audio.preload = 'auto';
isPlaybackUnlockedRef.current = false; isPlaybackUnlockedRef.current = false;
hasUserInteractedRef.current = false;
recoveryAttemptedUrlRef.current = null;
const handleCanPlay = () => { const handleCanPlay = () => {
setReady(true); setReady(true);
@@ -121,8 +141,9 @@ export const useSoundPlayback = ({
selectedPresetId === 'silent' selectedPresetId === 'silent'
? null ? null
: requestedUrl ?? activeUrl; : requestedUrl ?? activeUrl;
const normalizedNextUrl = normalizeMediaUrl(nextUrl);
if (!audio || !nextUrl) { if (!audio || !nextUrl || !normalizedNextUrl) {
isPlaybackUnlockedRef.current = true; isPlaybackUnlockedRef.current = true;
return true; return true;
} }
@@ -135,8 +156,8 @@ export const useSoundPlayback = ({
const previousVolume = audio.volume; const previousVolume = audio.volume;
try { try {
if (audio.src !== nextUrl) { if (audio.src !== normalizedNextUrl) {
audio.src = nextUrl; audio.src = normalizedNextUrl;
audio.load(); audio.load();
} }
@@ -160,6 +181,24 @@ export const useSoundPlayback = ({
} }
}, [activeUrl, selectedPresetId]); }, [activeUrl, selectedPresetId]);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const markInteraction = () => {
hasUserInteractedRef.current = true;
};
window.addEventListener('pointerdown', markInteraction, { passive: true });
window.addEventListener('keydown', markInteraction, { passive: true });
return () => {
window.removeEventListener('pointerdown', markInteraction);
window.removeEventListener('keydown', markInteraction);
};
}, []);
useEffect(() => { useEffect(() => {
const audio = audioRef.current; const audio = audioRef.current;
@@ -167,21 +206,71 @@ export const useSoundPlayback = ({
return; return;
} }
if (!activeUrl) { if (!activeUrl || !normalizedActiveUrl) {
audio.pause(); audio.pause();
audio.removeAttribute('src'); audio.removeAttribute('src');
requestedUrlRef.current = null; requestedUrlRef.current = null;
recoveryAttemptedUrlRef.current = null;
return; return;
} }
if (audio.src === activeUrl) { if (audio.src === normalizedActiveUrl) {
return; return;
} }
requestedUrlRef.current = activeUrl; requestedUrlRef.current = normalizedActiveUrl;
audio.src = activeUrl; audio.src = normalizedActiveUrl;
audio.load(); audio.load();
}, [activeUrl]); }, [activeUrl, normalizedActiveUrl]);
useEffect(() => {
if (!shouldPlay || !activeUrl || isPlaybackUnlockedRef.current) {
return;
}
const attemptRecovery = () => {
if (recoveryAttemptedUrlRef.current === activeUrl) {
return;
}
recoveryAttemptedUrlRef.current = activeUrl;
void unlockPlayback(activeUrl).then((didUnlock) => {
if (!didUnlock && recoveryAttemptedUrlRef.current === activeUrl) {
recoveryAttemptedUrlRef.current = null;
}
});
};
attemptRecovery();
if (isPlaybackUnlockedRef.current) {
return;
}
if (hasUserInteractedRef.current) {
return;
}
let cancelled = false;
const handleInteraction = () => {
if (cancelled) {
return;
}
hasUserInteractedRef.current = true;
attemptRecovery();
};
window.addEventListener('pointerdown', handleInteraction, { passive: true, once: true });
window.addEventListener('keydown', handleInteraction, { passive: true, once: true });
return () => {
cancelled = true;
window.removeEventListener('pointerdown', handleInteraction);
window.removeEventListener('keydown', handleInteraction);
};
}, [activeUrl, shouldPlay, unlockPlayback]);
useEffect(() => { useEffect(() => {
const audio = audioRef.current; const audio = audioRef.current;

View File

@@ -1,9 +1,10 @@
"use client"; "use client";
import { import {
getSceneStageBackgroundStyle,
getSceneStagePhotoUrl, getSceneStagePhotoUrl,
loadAssetImage,
preloadAssetImage, preloadAssetImage,
resolveSceneStageLayerSources,
useMediaCatalog, useMediaCatalog,
} from "@/entities/media"; } from "@/entities/media";
import { getSceneById, SCENE_THEMES } from "@/entities/scene"; import { getSceneById, SCENE_THEMES } from "@/entities/scene";
@@ -82,6 +83,9 @@ export const SpaceWorkspaceWidget = () => {
useState<SessionEntryPoint>("space-setup"); useState<SessionEntryPoint>("space-setup");
const [, setCurrentSessionThoughts] = useState<CurrentSessionThought[]>([]); const [, setCurrentSessionThoughts] = useState<CurrentSessionThought[]>([]);
const [pendingCompletionResult, setPendingCompletionResult] = useState<CompletionResult | null>(null); const [pendingCompletionResult, setPendingCompletionResult] = useState<CompletionResult | null>(null);
const [preferMobileStage, setPreferMobileStage] = useState(false);
const [preferHdStage, setPreferHdStage] = useState(false);
const [resolvedFinalStageUrl, setResolvedFinalStageUrl] = useState<string | null>(null);
const { const {
selectedPresetId, selectedPresetId,
@@ -183,6 +187,37 @@ export const SpaceWorkspaceWidget = () => {
}); });
const didResolveEntryRouteRef = useRef(false); const didResolveEntryRouteRef = useRef(false);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const mobileQuery = window.matchMedia("(max-width: 767px)");
const wideQuery = window.matchMedia("(min-width: 1536px)");
const updateViewportPreference = () => {
const nextPreferMobile = mobileQuery.matches;
const nextPreferHd =
!nextPreferMobile &&
wideQuery.matches &&
window.devicePixelRatio >= 1.5;
setPreferMobileStage(nextPreferMobile);
setPreferHdStage(nextPreferHd);
};
updateViewportPreference();
mobileQuery.addEventListener("change", updateViewportPreference);
wideQuery.addEventListener("change", updateViewportPreference);
window.addEventListener("resize", updateViewportPreference);
return () => {
mobileQuery.removeEventListener("change", updateViewportPreference);
wideQuery.removeEventListener("change", updateViewportPreference);
window.removeEventListener("resize", updateViewportPreference);
};
}, []);
useEffect(() => { useEffect(() => {
if (!isBootstrapping && !currentSession && !pendingCompletionResult) { if (!isBootstrapping && !currentSession && !pendingCompletionResult) {
router.replace("/app"); router.replace("/app");
@@ -237,18 +272,125 @@ export const SpaceWorkspaceWidget = () => {
}, [currentSessionId, pendingCompletionResult]); }, [currentSessionId, pendingCompletionResult]);
useEffect(() => { useEffect(() => {
const preferMobile =
typeof window !== "undefined"
? window.matchMedia("(max-width: 767px)").matches
: false;
preloadAssetImage( preloadAssetImage(
getSceneStagePhotoUrl( getSceneStagePhotoUrl(
selection.selectedScene, selection.selectedScene,
selection.selectedSceneAsset, selection.selectedSceneAsset,
{ preferMobile }, { preferMobile: preferMobileStage },
), ),
); );
}, [selection.selectedScene, selection.selectedSceneAsset]); }, [preferMobileStage, selection.selectedScene, selection.selectedSceneAsset]);
const stageLayerSources = useMemo(() => {
return resolveSceneStageLayerSources(
selection.selectedScene,
selection.selectedSceneAsset,
{
preferMobile: preferMobileStage,
preferHd: preferHdStage,
},
);
}, [
preferHdStage,
preferMobileStage,
selection.selectedScene,
selection.selectedSceneAsset,
]);
useEffect(() => {
preloadAssetImage(stageLayerSources.baseImageUrl);
}, [stageLayerSources.baseImageUrl]);
useEffect(() => {
const nextFinalUrl = stageLayerSources.finalImageUrl;
if (!nextFinalUrl) {
if (!resolvedFinalStageUrl) {
return;
}
const resetRafId = window.requestAnimationFrame(() => {
setResolvedFinalStageUrl(null);
});
return () => {
window.cancelAnimationFrame(resetRafId);
};
}
if (resolvedFinalStageUrl === nextFinalUrl) {
return;
}
let active = true;
preloadAssetImage(nextFinalUrl);
void loadAssetImage(nextFinalUrl).then((didLoad) => {
if (!active || !didLoad) {
return;
}
setResolvedFinalStageUrl((currentUrl) => {
if (!active) {
return currentUrl;
}
return currentUrl === nextFinalUrl ? currentUrl : nextFinalUrl;
});
});
return () => {
active = false;
};
}, [resolvedFinalStageUrl, selection.selectedScene.id, stageLayerSources.finalImageUrl]);
const stageBackdropMotionClassName =
"absolute -inset-8 will-change-transform animate-[space-stage-pan_42s_ease-in-out_infinite_alternate] motion-reduce:animate-none";
const stageBackdropLayerClassName =
"absolute inset-0 bg-cover bg-center";
const underpaintStageStyle = useMemo(() => {
return {
backgroundImage: stageLayerSources.baseImageUrl
? stageLayerSources.placeholderGradient
: stageLayerSources.underpaintUrl
? `url('${stageLayerSources.underpaintUrl}'), ${stageLayerSources.placeholderGradient}`
: stageLayerSources.placeholderGradient,
backgroundSize:
!stageLayerSources.baseImageUrl && stageLayerSources.underpaintUrl ? "cover, cover" : "cover",
backgroundPosition:
!stageLayerSources.baseImageUrl && stageLayerSources.underpaintUrl ? "center, center" : "center",
backgroundRepeat:
!stageLayerSources.baseImageUrl && stageLayerSources.underpaintUrl ? "no-repeat, no-repeat" : "no-repeat",
};
}, [stageLayerSources.baseImageUrl, stageLayerSources.placeholderGradient, stageLayerSources.underpaintUrl]);
const baseStageStyle = useMemo(() => {
return {
backgroundImage: stageLayerSources.baseImageUrl
? `url('${stageLayerSources.baseImageUrl}'), ${stageLayerSources.placeholderGradient}`
: stageLayerSources.placeholderGradient,
backgroundSize: stageLayerSources.baseImageUrl ? "cover, cover" : "cover",
backgroundPosition: stageLayerSources.baseImageUrl ? "center, center" : "center",
backgroundRepeat: stageLayerSources.baseImageUrl ? "no-repeat, no-repeat" : "no-repeat",
};
}, [stageLayerSources.baseImageUrl, stageLayerSources.placeholderGradient]);
const finalStageStyle = useMemo(() => {
if (!resolvedFinalStageUrl) {
return null;
}
return {
backgroundImage: `url('${resolvedFinalStageUrl}'), ${stageLayerSources.placeholderGradient}`,
backgroundSize: "cover, cover",
backgroundPosition: "center, center",
backgroundRepeat: "no-repeat, no-repeat",
};
}, [resolvedFinalStageUrl, stageLayerSources.placeholderGradient]);
const isFinalStageVisible =
Boolean(resolvedFinalStageUrl) &&
resolvedFinalStageUrl === stageLayerSources.finalImageUrl;
const resolvedTimeDisplay = const resolvedTimeDisplay =
timeDisplay ?? timeDisplay ??
@@ -256,14 +398,20 @@ export const SpaceWorkspaceWidget = () => {
return ( return (
<div className="relative h-dvh overflow-hidden text-white"> <div className="relative h-dvh overflow-hidden text-white">
<div aria-hidden className={stageBackdropMotionClassName}>
<div <div
aria-hidden className={stageBackdropLayerClassName}
className="absolute -inset-8 bg-cover bg-center will-change-transform animate-[space-stage-pan_42s_ease-in-out_infinite_alternate] motion-reduce:animate-none" style={underpaintStageStyle}
style={getSceneStageBackgroundStyle(
selection.selectedScene,
selection.selectedSceneAsset,
)}
/> />
<div
className={stageBackdropLayerClassName}
style={baseStageStyle}
/>
<div
className={`${stageBackdropLayerClassName} transition-opacity duration-[320ms] ease-[cubic-bezier(0.16,1,0.3,1)] ${isFinalStageVisible ? "opacity-100" : "opacity-0"}`}
style={finalStageStyle ?? undefined}
/>
</div>
<div className="relative z-10 flex h-full flex-col"> <div className="relative z-10 flex h-full flex-col">
<main className="relative flex-1" /> <main className="relative flex-1" />