feat: multi-material overlays, presentation guides, and release prebuilds

Align control/presentation with presentation screen rect and darkness z-order; sync window titles and session window cleanup. Pack Win/Mac/Linux with npmRebuild disabled and release-native-prep for classic-level and sharp.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-08-03 11:28:18 +08:00
parent 61446dacfc
commit 2979d06f1c
41 changed files with 1060 additions and 252 deletions
+31 -23
View File
@@ -56,11 +56,21 @@ export function PresentationView({
);
const scene =
session?.project && session.currentSceneId ? session.project.scenes[session.currentSceneId] : undefined;
const activeMaterial =
session?.project && materialsOverlay?.activeMaterialId
? (session.project.materials ?? []).find((m) => m.id === materialsOverlay.activeMaterialId)
: 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 ?? [])
@@ -156,7 +166,7 @@ export function PresentationView({
src={originalUrl}
muted
playsInline
loop={false}
loop={Boolean(scene?.settings?.loopVideo)}
preload="auto"
onError={() => {
// noop: status surfaced in control app; keep presentation clean
@@ -200,25 +210,23 @@ export function PresentationView({
<ExplosionVideoOverlay state={fxState} viewport={contentRect} />
) : null}
{showEffects && scene?.previewAssetType === 'image' && scene.darkenScene && contentRect ? (
<SceneDarknessOverlay state={sdState} overlayAlpha={1} viewport={contentRect} />
<SceneDarknessOverlay state={sdState} overlayAlpha={1} viewport={contentRect} style={{ zIndex: 30 }} />
) : null}
<SceneOverlayHost active={Boolean(activeMaterial) || activeNpcItems.length > 0}>
{activeMaterial ? (
<MaterialOverlay
embedded
assetId={activeMaterial.assetId}
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
{...(activeMaterial.legend?.enabled
? { legendMarkers: activeMaterial.legend.markers ?? [] }
: {})}
/>
) : null}
{activeMaterial?.legend?.enabled ? (
<MaterialLegendPanel
legend={activeMaterial.legend}
layout={materialsOverlay?.legendLayout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
/>
) : 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 ? (
@@ -17,6 +17,15 @@
pointer-events: none;
}
.viewportGuide {
position: absolute;
inset: 0;
border: 2px solid rgba(245, 197, 66, 0.55);
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35);
pointer-events: none;
z-index: 0;
}
.captureZoom {
pointer-events: auto;
}
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import type { AssetId, MaterialsOverlayLayout, MaterialsZoomTool, NpcsZoomTool } from '../../../shared/types';
import type { AssetId, MaterialId, MaterialsOverlayLayout, MaterialsZoomTool, NpcsZoomTool } from '../../../shared/types';
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT } from '../../../shared/types';
import { useSceneOverlayView } from '../sceneOverlay/SceneOverlayViewContext';
import { useAssetUrl } from '../useAssetImageUrl';
@@ -12,6 +12,7 @@ type Corner = 'nw' | 'ne' | 'sw' | 'se';
type MaterialOverlayProps = {
assetId: AssetId | null;
layout: MaterialsOverlayLayout;
materialId?: MaterialId;
editable?: boolean;
zoomTool?: MaterialsZoomTool | NpcsZoomTool;
showClose?: boolean;
@@ -92,6 +93,7 @@ function localToScreenOffset(localX: number, localY: number, rotationDeg: number
export function MaterialOverlay({
assetId,
layout,
materialId,
editable = false,
zoomTool = null,
showClose = false,
@@ -366,6 +368,7 @@ export function MaterialOverlay({
<div
className={[styles.frame, editable && !zoomTool ? styles.frameEditable : ''].filter(Boolean).join(' ')}
data-overlay-kind="material"
{...(materialId ? { 'data-material-id': materialId } : {})}
style={{
left,
top,
@@ -3,6 +3,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import type { MaterialsZoomTool, NpcsZoomTool } from '../../../shared/types';
import styles from '../materials/MaterialOverlay.module.css';
import { overlayRootStyle, type SceneOverlayViewport } from './overlayViewport';
import { SceneOverlayViewContext } from './SceneOverlayViewContext';
export type SceneOverlayCloseAction = {
@@ -14,6 +15,10 @@ export type SceneOverlayCloseAction = {
type SceneOverlayHostProps = {
/** Есть ли что показывать (материал и/или NPC). */
active: boolean;
/** Область картинки сцены (contain); координаты относительно родителя. */
viewport?: SceneOverlayViewport | null;
/** Рамка видимой области (предпросмотр пульта). */
showViewportGuide?: boolean;
zoomTool?: MaterialsZoomTool | NpcsZoomTool;
onZoomAt?: (nx: number, ny: number, target: EventTarget | null) => void;
closes?: readonly SceneOverlayCloseAction[];
@@ -26,6 +31,8 @@ type SceneOverlayHostProps = {
*/
export function SceneOverlayHost({
active,
viewport = null,
showViewportGuide = false,
zoomTool = null,
onZoomAt,
closes = [],
@@ -58,7 +65,7 @@ export function SceneOverlayHost({
const ctx = useMemo(() => ({ rootRef, view }), [view]);
if (!active) return null;
if (!active && !showViewportGuide) return null;
const captureZoom = Boolean(zoomTool && onZoomAt);
const zoomCursor =
@@ -81,6 +88,7 @@ export function SceneOverlayHost({
className={[styles.root, styles.hostHitThrough, captureZoom ? styles.captureZoom : '', zoomCursor]
.filter(Boolean)
.join(' ')}
style={overlayRootStyle(viewport)}
role="presentation"
onClick={(e) => {
if (!captureZoom || !onZoomAt) return;
@@ -89,6 +97,7 @@ export function SceneOverlayHost({
onZoomAt(nx, ny, e.target);
}}
>
{showViewportGuide ? <div className={styles.viewportGuide} aria-hidden /> : null}
<div className={styles.dim} />
{children}
{closes.length > 0 ? (
@@ -0,0 +1,21 @@
import type { CSSProperties } from 'react';
export type SceneOverlayViewport = {
x: number;
y: number;
w: number;
h: number;
};
export function overlayRootStyle(viewport: SceneOverlayViewport | null | undefined): CSSProperties | undefined {
if (!viewport || viewport.w <= 0 || viewport.h <= 0) return undefined;
return {
left: viewport.x,
top: viewport.y,
width: viewport.w,
height: viewport.h,
right: 'auto',
bottom: 'auto',
overflow: 'hidden',
};
}
+33
View File
@@ -0,0 +1,33 @@
import React, { useCallback, useRef, useState } from 'react';
type Props = {
text: string;
className?: string;
};
/** Однострочный текст с ellipsis; полный title при обрезке. */
export function EllipsisText({ text, className }: Props) {
const ref = useRef<HTMLDivElement | null>(null);
const [title, setTitle] = useState<string | undefined>(undefined);
const syncTitle = useCallback(() => {
const el = ref.current;
if (!el) {
setTitle(undefined);
return;
}
setTitle(el.scrollWidth > el.clientWidth + 1 ? text : undefined);
}, [text]);
return (
<div
ref={ref}
className={className}
title={title}
onMouseEnter={syncTitle}
onMouseLeave={() => setTitle(undefined)}
>
{text}
</div>
);
}
@@ -0,0 +1,6 @@
.root {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}