7362a36fe5
Reuse previewRotationDeg for video scenes across editor, control, presentation and overlays via ContainedVideo layout parity. Co-authored-by: Cursor <cursoragent@cursor.com>
283 lines
11 KiB
TypeScript
283 lines
11 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
|
|
import { computeTimeSec } from '../../main/video/videoPlaybackStore';
|
|
import type { SessionState } from '../../shared/ipc/contracts';
|
|
import { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranchFeatures';
|
|
|
|
import { ExplosionVideoOverlay } from './effects/ExplosionVideoOverlay';
|
|
import { PixiEffectsOverlay } from './effects/PxiEffectsOverlay';
|
|
import { SceneDarknessOverlay } from './effects/SceneDarknessOverlay';
|
|
import { useEffectsState } from './effects/useEffectsState';
|
|
import { useSceneDarknessState } from './effects/useSceneDarknessState';
|
|
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../shared/types';
|
|
import { SceneGridOverlay } from './grid/SceneGridOverlay';
|
|
import { MaterialLegendPanel } from './materials/MaterialLegendPanel';
|
|
import { MaterialOverlay } from './materials/MaterialOverlay';
|
|
import { useMaterialsOverlayState } from './materials/useMaterialsOverlayState';
|
|
import { NpcsSceneOverlay } from './npcs/NpcsSceneOverlay';
|
|
import { useNpcsOverlayState } from './npcs/useNpcsOverlayState';
|
|
import { SceneNpcTokensOverlay } from './playerToken/SceneNpcTokensOverlay';
|
|
import { ScenePlayerTokensOverlay } from './playerToken/ScenePlayerTokensOverlay';
|
|
import { useAppPlayers } from './playerToken/useAppPlayers';
|
|
import { useSceneNpcTokensSession } from './playerToken/useSceneNpcTokensSession';
|
|
import { useScenePlayerTokensSession } from './playerToken/useScenePlayerTokensSession';
|
|
import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost';
|
|
import { useSceneViewState } from './sceneView/useSceneViewState';
|
|
import { SceneTokensOverlay } from './tokens/SceneTokensOverlay';
|
|
import { useAppTokens } from './tokens/useAppTokens';
|
|
import { useSceneTokensSession } from './tokens/useSceneTokensSession';
|
|
import { SceneTrapsOverlay } from './traps/SceneTrapsOverlay';
|
|
import { useSceneTrapsState } from './traps/useSceneTrapsState';
|
|
import styles from './PresentationView.module.css';
|
|
import { ContainedVideo } from './ContainedVideo';
|
|
import { RotatedImage } from './RotatedImage';
|
|
import { useAssetUrl } from './useAssetImageUrl';
|
|
import { useVideoPlaybackState } from './video/useVideoPlaybackState';
|
|
import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../shared/types/appPlayers';
|
|
|
|
export type PresentationViewProps = {
|
|
session: SessionState | null;
|
|
/** Если true — показываем укороченный заголовок/оверлей для предпросмотра. */
|
|
compact?: boolean;
|
|
showTitle?: boolean;
|
|
showEffects?: boolean;
|
|
};
|
|
|
|
export function PresentationView({
|
|
session,
|
|
compact = false,
|
|
showTitle = true,
|
|
showEffects = true,
|
|
}: PresentationViewProps) {
|
|
const [fxState] = useEffectsState();
|
|
const [sdState] = useSceneDarknessState();
|
|
const [sceneTraps] = useSceneTrapsState();
|
|
const [sceneView] = useSceneViewState();
|
|
const [materialsOverlay] = useMaterialsOverlayState();
|
|
const [npcsOverlay] = useNpcsOverlayState();
|
|
const appTokens = useAppTokens();
|
|
const appPlayersResult = useAppPlayers();
|
|
const appPlayers = USERS_BRANCH_FEATURES_ENABLED ? appPlayersResult.players : [];
|
|
const [sceneTokensSession] = useSceneTokensSession();
|
|
const [sceneNpcTokensSession] = useSceneNpcTokensSession();
|
|
const [scenePlayerTokensSession] = useScenePlayerTokensSession();
|
|
const [vp] = useVideoPlaybackState();
|
|
const videoElRef = useRef<HTMLVideoElement | null>(null);
|
|
const [contentRect, setContentRect] = React.useState<{ x: number; y: number; w: number; h: number } | null>(
|
|
null,
|
|
);
|
|
const scene =
|
|
session?.project && session.currentSceneId ? session.project.scenes[session.currentSceneId] : undefined;
|
|
const project = session?.project;
|
|
const activeMaterialItems =
|
|
project && (materialsOverlay?.activeMaterialIds?.length ?? 0) > 0
|
|
? (materialsOverlay?.activeMaterialIds ?? [])
|
|
.map((id) => {
|
|
const material = (project.materials ?? []).find((m) => m.id === id);
|
|
if (!material) return null;
|
|
return {
|
|
material,
|
|
layout: materialsOverlay?.layouts[id] ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT,
|
|
legendLayout: materialsOverlay?.legendLayouts[id] ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT,
|
|
};
|
|
})
|
|
.filter((x): x is NonNullable<typeof x> => x !== null)
|
|
: [];
|
|
const activeNpcItems =
|
|
project && (npcsOverlay?.activeNpcIds?.length ?? 0) > 0
|
|
? (npcsOverlay?.activeNpcIds ?? [])
|
|
.map((id) => {
|
|
const npc = (project.npcs ?? []).find((n) => n.id === id);
|
|
if (!npc) return null;
|
|
return {
|
|
npcId: npc.id,
|
|
assetId: npc.avatarAssetId,
|
|
layout: npcsOverlay?.layouts[id] ?? DEFAULT_NPCS_OVERLAY_LAYOUT,
|
|
};
|
|
})
|
|
.filter((x): x is NonNullable<typeof x> => x !== null)
|
|
: [];
|
|
const originalUrl = useAssetUrl(scene?.previewAssetId ?? null);
|
|
const thumbUrl = useAssetUrl(scene?.previewThumbAssetId ?? null);
|
|
const [shownImageUrl, setShownImageUrl] = useState<string | null>(null);
|
|
const rot = scene?.previewRotationDeg ?? 0;
|
|
|
|
useEffect(() => {
|
|
if (!scene) {
|
|
setShownImageUrl(null);
|
|
return;
|
|
}
|
|
if (scene.previewAssetType !== 'image') {
|
|
setShownImageUrl(null);
|
|
return;
|
|
}
|
|
// Show thumbnail instantly (if exists) to avoid stutter on slide switch, then swap to original when loaded.
|
|
setShownImageUrl(thumbUrl ?? originalUrl);
|
|
if (!thumbUrl || !originalUrl || thumbUrl === originalUrl) return;
|
|
let cancelled = false;
|
|
const img = new Image();
|
|
img.decoding = 'async';
|
|
img.onload = () => {
|
|
if (cancelled) return;
|
|
setShownImageUrl(originalUrl);
|
|
};
|
|
img.onerror = () => {
|
|
// keep thumbnail
|
|
};
|
|
img.src = originalUrl;
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [originalUrl, scene, thumbUrl]);
|
|
|
|
useEffect(() => {
|
|
const el = videoElRef.current;
|
|
if (!el) return;
|
|
if (!vp) return;
|
|
if (!scene?.previewAssetId) return;
|
|
if (scene.previewAssetType !== 'video') return;
|
|
if (vp.targetAssetId !== scene.previewAssetId) return;
|
|
|
|
el.playbackRate = vp.playbackRate;
|
|
const desired = computeTimeSec(vp, vp.serverNowMs);
|
|
if (Number.isFinite(desired) && Math.abs(el.currentTime - desired) > 0.35) {
|
|
el.currentTime = Math.max(0, desired);
|
|
}
|
|
if (vp.playing) {
|
|
void el.play().catch(() => undefined);
|
|
} else {
|
|
el.pause();
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- avoid reruns on 500ms heartbeats (serverNowMs-only updates)
|
|
}, [
|
|
scene?.previewAssetId,
|
|
scene?.previewAssetType,
|
|
originalUrl,
|
|
vp?.revision,
|
|
vp?.targetAssetId,
|
|
vp?.playing,
|
|
vp?.playbackRate,
|
|
]);
|
|
|
|
return (
|
|
<div className={styles.root}>
|
|
{shownImageUrl && scene?.previewAssetType === 'image' ? (
|
|
<div className={styles.fill}>
|
|
<RotatedImage
|
|
url={shownImageUrl}
|
|
rotationDeg={rot}
|
|
mode="contain"
|
|
viewCamera={sceneView}
|
|
onContentRectChange={setContentRect}
|
|
/>
|
|
</div>
|
|
) : originalUrl && scene?.previewAssetType === 'video' ? (
|
|
<div className={styles.fill}>
|
|
<ContainedVideo
|
|
url={originalUrl}
|
|
rotationDeg={rot}
|
|
videoRef={videoElRef}
|
|
muted
|
|
playsInline
|
|
loop={Boolean(scene?.settings?.loopVideo)}
|
|
preload="auto"
|
|
viewCamera={sceneView}
|
|
onContentRectChange={setContentRect}
|
|
onError={() => {
|
|
// noop: status surfaced in control app; keep presentation clean
|
|
}}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className={styles.placeholderBg} />
|
|
)}
|
|
{scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video' ? (
|
|
<SceneGridOverlay grid={scene.grid} viewport={contentRect} />
|
|
) : null}
|
|
<div className={styles.vignette} />
|
|
{(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') && contentRect ? (
|
|
<SceneTokensOverlay
|
|
placements={scene.tokens ?? []}
|
|
library={appTokens}
|
|
session={sceneTokensSession}
|
|
viewport={contentRect}
|
|
/>
|
|
) : null}
|
|
{USERS_BRANCH_FEATURES_ENABLED &&
|
|
(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') &&
|
|
contentRect ? (
|
|
<SceneNpcTokensOverlay
|
|
placements={scene.npcTokens ?? []}
|
|
library={project?.npcs ?? []}
|
|
session={sceneNpcTokensSession}
|
|
viewport={contentRect}
|
|
grid={scene.grid}
|
|
/>
|
|
) : null}
|
|
{USERS_BRANCH_FEATURES_ENABLED &&
|
|
(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') &&
|
|
contentRect ? (
|
|
<ScenePlayerTokensOverlay
|
|
library={appPlayers}
|
|
session={scenePlayerTokensSession}
|
|
displayScale={sceneNpcTokensSession.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE}
|
|
viewport={contentRect}
|
|
grid={scene.grid}
|
|
/>
|
|
) : null}
|
|
{(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') && contentRect ? (
|
|
<SceneTrapsOverlay
|
|
traps={scene.traps ?? []}
|
|
session={sceneTraps}
|
|
viewport={contentRect}
|
|
mode="presentation"
|
|
/>
|
|
) : null}
|
|
{showEffects && (scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') ? (
|
|
<PixiEffectsOverlay
|
|
state={fxState}
|
|
style={{ zIndex: 6 }}
|
|
viewport={
|
|
contentRect
|
|
? { x: contentRect.x, y: contentRect.y, w: contentRect.w, h: contentRect.h }
|
|
: undefined
|
|
}
|
|
/>
|
|
) : null}
|
|
{showEffects && (scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') ? (
|
|
<ExplosionVideoOverlay state={fxState} viewport={contentRect} />
|
|
) : null}
|
|
{showEffects &&
|
|
(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') &&
|
|
scene.darkenScene &&
|
|
contentRect ? (
|
|
<SceneDarknessOverlay state={sdState} overlayAlpha={1} viewport={contentRect} style={{ zIndex: 30 }} />
|
|
) : null}
|
|
<SceneOverlayHost active={activeMaterialItems.length > 0 || activeNpcItems.length > 0}>
|
|
{activeMaterialItems.map(({ material, layout, legendLayout }) => (
|
|
<React.Fragment key={material.id}>
|
|
<MaterialOverlay
|
|
embedded
|
|
assetId={material.assetId}
|
|
layout={layout}
|
|
materialId={material.id}
|
|
{...(material.legend?.enabled ? { legendMarkers: material.legend.markers ?? [] } : {})}
|
|
/>
|
|
{material.legend?.enabled ? (
|
|
<MaterialLegendPanel legend={material.legend} layout={legendLayout} />
|
|
) : null}
|
|
</React.Fragment>
|
|
))}
|
|
{activeNpcItems.length > 0 ? <NpcsSceneOverlay embedded items={activeNpcItems} /> : null}
|
|
</SceneOverlayHost>
|
|
{showTitle ? (
|
|
<div className={styles.titleWrap}>
|
|
<div className={compact ? styles.titleCompact : styles.titleFull}>
|
|
{scene?.title ?? 'Выберите сцену в редакторе'}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|