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
+5 -1
View File
@@ -271,6 +271,7 @@
.historyTitle {
font-weight: 800;
min-width: 0;
}
.emptyStory {
@@ -356,7 +357,7 @@
.branchGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
@@ -367,6 +368,8 @@
padding: 12px;
display: grid;
gap: 10px;
min-width: 0;
max-width: 100%;
}
.branchCardHeader {
@@ -383,6 +386,7 @@
.branchName {
font-weight: 900;
min-width: 0;
}
.branchCardReturn {
+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={() =>
+10 -4
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { computeTimeSec } from '../../main/video/videoPlaybackStore';
import type { SessionState } from '../../shared/ipc/contracts';
@@ -35,6 +35,7 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
const isVideo = scene?.previewAssetType === 'video';
const assetId = scene?.previewAssetType === 'video' ? scene.previewAssetId : null;
const autostart = scene?.previewVideoAutostart ?? false;
const lastTargetRef = useRef<{ sceneKey: string; assetId: string; autostart: boolean } | null>(null);
const [tick, setTick] = useState(0);
const dur = useMemo(
@@ -61,14 +62,18 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
useEffect(() => {
if (!isVideo) return;
if (!assetId) return;
// `target.set` bumps revision and resets anchors; avoid firing on every render.
if (vp?.targetAssetId === assetId) return;
const sceneKey = session?.project?.currentGraphNodeId ?? session?.currentSceneId ?? '';
const prev = lastTargetRef.current;
if (prev && prev.sceneKey === sceneKey && prev.assetId === assetId && prev.autostart === autostart) {
return;
}
lastTargetRef.current = { sceneKey, assetId, autostart };
void video.dispatch({
kind: 'target.set',
assetId,
autostart,
});
}, [assetId, isVideo, autostart, vp?.targetAssetId, video]);
}, [assetId, isVideo, autostart, session?.currentSceneId, session?.project?.currentGraphNodeId, video]);
useEffect(() => {
const v = videoRef.current;
@@ -108,6 +113,7 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
className={styles.video}
src={url}
playsInline
loop={Boolean(scene?.settings?.loopVideo)}
preload="auto"
onTimeUpdate={() => setTick((x) => x + 1)}
onLoadedMetadata={() => setTick((x) => x + 1)}