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
+141 -50
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { pickEraseTargetId } from '../../shared/effectEraserHitTest';
import { fitAspectRect } from '../../shared/geometry/fitAspectRect';
import { ipcChannels } from '../../shared/ipc/contracts';
import type { SessionState } from '../../shared/ipc/contracts';
import {
@@ -8,7 +9,7 @@ import {
isNodeInSideStoryline,
listSideStoryStarts,
} from '../../shared/graph/sceneGraphLineage';
import type { GraphNodeId, Scene, SceneId, SceneViewCamera } from '../../shared/types';
import type { GraphNodeId, MaterialId, Scene, SceneId, SceneViewCamera } from '../../shared/types';
import {
DEFAULT_SCENE_VIEW_CAMERA,
sceneViewPanBy,
@@ -42,6 +43,8 @@ import { useSceneTokensSession } from '../shared/tokens/useSceneTokensSession';
import { SceneTrapsOverlay } from '../shared/traps/SceneTrapsOverlay';
import { useSceneTrapsState } from '../shared/traps/useSceneTrapsState';
import { Button } from '../shared/ui/controls';
import { EllipsisText } from '../shared/ui/EllipsisText';
import ellipsisStyles from '../shared/ui/ellipsisText.module.css';
import { Surface } from '../shared/ui/Surface';
import { useAssetUrl } from '../shared/useAssetImageUrl';
@@ -191,10 +194,25 @@ export function ControlApp() {
w: number;
h: number;
} | null>(null);
const [presentationContentSize, setPresentationContentSize] = useState<{
width: number;
height: number;
} | null>(null);
const previewContentRectRef = useRef(previewContentRect);
previewContentRectRef.current = previewContentRect;
const previewSizeRef = useRef(previewSize);
previewSizeRef.current = previewSize;
const presentationScreenRect = useMemo(() => {
if (!presentationContentSize) return null;
if (previewSize.w <= 1 || previewSize.h <= 1) return null;
return fitAspectRect(
previewSize.w,
previewSize.h,
presentationContentSize.width,
presentationContentSize.height,
);
}, [presentationContentSize, previewSize.h, previewSize.w]);
const brushCursorElRef = useRef<HTMLDivElement | null>(null);
const cursorPosRef = useRef<{ x: number; y: number } | null>(null);
const draftPaintRafRef = useRef(0);
@@ -229,12 +247,32 @@ export function ControlApp() {
}, [api]);
useEffect(() => {
return api.on(ipcChannels.windows.multiWindowStateChanged, ({ open }) => {
const refreshPresentationSize = () => {
void api.invoke(ipcChannels.windows.getPresentationContentSize, {}).then((size) => {
if (size.width == null || size.height == null) {
setPresentationContentSize(null);
return;
}
setPresentationContentSize({ width: size.width, height: size.height });
});
};
refreshPresentationSize();
const offSize = api.on(ipcChannels.windows.presentationContentSizeChanged, (size) => {
setPresentationContentSize({ width: size.width, height: size.height });
});
const offMw = api.on(ipcChannels.windows.multiWindowStateChanged, ({ open }) => {
if (!open) {
mainStoryReturnRef.current = null;
setMainStoryReturnGraphNodeId(null);
setPresentationContentSize(null);
return;
}
refreshPresentationSize();
});
return () => {
offSize();
offMw();
};
}, [api]);
useEffect(() => {
@@ -1695,7 +1733,10 @@ export function ControlApp() {
) : (
<div className={styles.historyMuted}>{t('control.passed')}</div>
)}
<div className={styles.historyTitle}>{s?.title ?? (gn ? String(gn.sceneId) : gnId)}</div>
<EllipsisText
text={s?.title ?? (gn ? String(gn.sceneId) : gnId)}
className={[styles.historyTitle, ellipsisStyles.root].join(' ')}
/>
</button>
);
})}
@@ -1759,9 +1800,6 @@ export function ControlApp() {
draft={explosionDraft}
viewport={previewContentRect}
/>
{previewContentRect ? (
<SceneDarknessOverlay state={sdState} overlayAlpha={0.5} viewport={previewContentRect} />
) : null}
<div
ref={brushCursorElRef}
className={styles.brushCursor}
@@ -1987,11 +2025,28 @@ export function ControlApp() {
</>
) : null}
{(() => {
const activeMaterial =
session?.project && materialsOverlay?.activeMaterialId
? (session.project.materials ?? []).find((m) => m.id === materialsOverlay.activeMaterialId)
: undefined;
const project = session?.project;
const materialIds = materialsOverlay?.activeMaterialIds ?? [];
const materialItems =
project && materialIds.length > 0
? materialIds
.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,
cx: 0.82,
cy: 0.5,
scale: 0.85,
},
};
})
.filter((x): x is NonNullable<typeof x> => x !== null)
: [];
const activeIds = npcsOverlay?.activeNpcIds ?? [];
const npcItems =
project && activeIds.length > 0
@@ -2007,9 +2062,11 @@ export function ControlApp() {
})
.filter((x): x is NonNullable<typeof x> => x !== null)
: [];
const showMaterial = Boolean(activeMaterial);
const showMaterial = materialItems.length > 0;
const showNpcs = npcItems.length > 0;
if (!showMaterial && !showNpcs) return null;
const screenRect = presentationScreenRect;
const showGuide = Boolean(screenRect) && !isVideoPreviewScene;
if (!showMaterial && !showNpcs && !showGuide) return null;
const closes = [
...(showMaterial
? [
@@ -2035,52 +2092,79 @@ export function ControlApp() {
: []),
];
const materialsZoom = materialsOverlay?.zoomTool ?? null;
const materialIdFromTarget = (target: EventTarget | null) => {
if (!(target instanceof Element)) return undefined;
const frame = target.closest('[data-material-id]');
const raw = frame?.getAttribute('data-material-id');
return raw ? (raw as MaterialId) : undefined;
};
return (
<>
{previewContentRect && currentScene?.darkenScene ? (
<SceneDarknessOverlay
state={sdState}
overlayAlpha={0.5}
viewport={previewContentRect}
style={{ zIndex: 30 }}
/>
) : null}
<SceneOverlayHost
active
active={showMaterial || showNpcs}
viewport={screenRect}
showViewportGuide={showGuide}
zoomTool={materialsZoom}
{...(materialsZoom
? {
onZoomAt: (nx: number, ny: number) => {
void materialsApi.dispatch({ kind: 'zoomAt', nx, ny });
onZoomAt: (nx: number, ny: number, evTarget?: EventTarget | null) => {
const mid = materialIdFromTarget(evTarget ?? null);
void materialsApi.dispatch({
kind: 'zoomAt',
nx,
ny,
...(mid ? { materialId: mid } : {}),
});
},
}
: {})}
closes={closes}
>
{showMaterial && activeMaterial ? (
<MaterialOverlay
embedded
assetId={activeMaterial.assetId}
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
editable
zoomTool={materialsZoom}
rotateLabel={t('materials.rotateOverlay')}
onLayoutChange={(layout) => {
void materialsApi.dispatch({ kind: 'layout.set', layout });
}}
{...(activeMaterial.legend?.enabled
? { legendMarkers: activeMaterial.legend.markers ?? [] }
: {})}
/>
) : null}
{showMaterial && activeMaterial?.legend?.enabled ? (
<MaterialLegendPanel
legend={activeMaterial.legend}
layout={
materialsOverlay?.legendLayout ?? {
...DEFAULT_MATERIALS_OVERLAY_LAYOUT,
cx: 0.82,
cy: 0.5,
scale: 0.85,
}
}
editable
onLayoutChange={(layout) => {
void materialsApi.dispatch({ kind: 'legendLayout.set', layout });
}}
/>
) : null}
{materialItems.map(({ material, layout, legendLayout }) => (
<React.Fragment key={material.id}>
<MaterialOverlay
embedded
assetId={material.assetId}
materialId={material.id}
layout={layout}
editable
zoomTool={materialsZoom}
rotateLabel={t('materials.rotateOverlay')}
onLayoutChange={(nextLayout) => {
void materialsApi.dispatch({
kind: 'layout.set',
materialId: material.id,
layout: nextLayout,
});
}}
{...(material.legend?.enabled
? { legendMarkers: material.legend.markers ?? [] }
: {})}
/>
{material.legend?.enabled ? (
<MaterialLegendPanel
legend={material.legend}
layout={legendLayout}
editable
onLayoutChange={(nextLayout) => {
void materialsApi.dispatch({
kind: 'legendLayout.set',
materialId: material.id,
layout: nextLayout,
});
}}
/>
) : null}
</React.Fragment>
))}
{showNpcs ? (
<NpcsSceneOverlay
embedded
@@ -2093,6 +2177,7 @@ export function ControlApp() {
/>
) : null}
</SceneOverlayHost>
</>
);
})()}
</div>
@@ -2106,7 +2191,10 @@ export function ControlApp() {
<div className={styles.branchCardHeader}>
<div className={styles.branchOption}>{t('control.option', { n: '1' })}</div>
</div>
<div className={styles.branchName}>{returnSceneTitle}</div>
<EllipsisText
text={returnSceneTitle}
className={[styles.branchName, ellipsisStyles.root].join(' ')}
/>
<Button variant="primary" onClick={returnToMainStoryline}>
{t('control.returnToMainStory')}
</Button>
@@ -2119,7 +2207,10 @@ export function ControlApp() {
{t('control.option', { n: String(i + 1 + branchOptionOffset) })}
</div>
</div>
<div className={styles.branchName}>{o.scene.title || t('control.unnamed')}</div>
<EllipsisText
text={o.scene.title || t('control.unnamed')}
className={[styles.branchName, ellipsisStyles.root].join(' ')}
/>
<Button
variant="primary"
onClick={() =>