feat(materials): add campaign materials overlay for sessions

Let GMs manage and show images over the scene from the editor and control panel, with zoom tools, help docs, and full ru/en i18n.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-17 11:05:38 +08:00
parent 195d4be086
commit 61875be857
39 changed files with 2521 additions and 22 deletions
+63
View File
@@ -17,6 +17,9 @@ import { PixiEffectsOverlay } from '../shared/effects/PxiEffectsOverlay';
import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay';
import { useEffectsState } from '../shared/effects/useEffectsState';
import { useSceneDarknessState } from '../shared/effects/useSceneDarknessState';
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT } from '../../shared/types';
import { MaterialOverlay } from '../shared/materials/MaterialOverlay';
import { useMaterialsOverlayState } from '../shared/materials/useMaterialsOverlayState';
import { Button } from '../shared/ui/controls';
import { Surface } from '../shared/ui/Surface';
import { useAssetUrl } from '../shared/useAssetImageUrl';
@@ -87,6 +90,7 @@ export function ControlApp() {
tRef.current = t;
const [fxState, fx] = useEffectsState();
const [sdState, sd] = useSceneDarknessState();
const [materialsOverlay, materialsApi] = useMaterialsOverlayState();
const [session, setSession] = useState<SessionState | null>(null);
const historyRef = useRef<GraphNodeId[]>([]);
const [history, setHistory] = useState<GraphNodeId[]>([]);
@@ -1167,6 +1171,38 @@ export function ControlApp() {
📖
</span>
</Button>
<Button
variant="ghost"
iconOnly
title={t('control.materialsTool')}
ariaLabel={t('control.materialsTool')}
onClick={() => {
void api.invoke(ipcChannels.windows.openMaterials, {}).catch((err) => {
console.error('[control] openMaterials failed', err);
});
}}
>
<span className={styles.iconGlyph} aria-hidden>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
<path
fill="#c9a227"
d="M4.2 5.4c1.6-.9 3.5-.7 5 .5l1.3 1.1c.4.3 1 .3 1.4 0L13.2 5.9c1.5-1.2 3.4-1.4 5-.5l1.4.8c.8.5 1.3 1.4 1.3 2.3v8.6c0 1.4-1.4 2.3-2.7 1.8l-2.3-.9c-.7-.3-1.5-.2-2.1.2l-1.3.9c-.6.4-1.4.4-2 0l-1.3-.9c-.6-.4-1.4-.5-2.1-.2l-2.3.9c-1.3.5-2.7-.4-2.7-1.8V8.5c0-.9.5-1.8 1.3-2.3l1.5-.8z"
/>
<path
fill="#7a4e1d"
d="M8.2 10.2c1.3-.4 2.5.2 3.3 1.1.3.3.8.3 1.1 0 .8-.9 2-1.5 3.3-1.1.5.2.8.7.6 1.2-.5 1.4-1.7 2.5-3.1 3.1-.5.2-1 .2-1.4 0-1.4-.6-2.6-1.7-3.1-3.1-.2-.5.1-1 .6-1.2z"
/>
<circle cx="12" cy="12.2" r="1.15" fill="#e8c547" />
<path
fill="none"
stroke="#5c3a16"
strokeWidth="1.1"
strokeLinecap="round"
d="M7.5 15.8c1.2.7 2.7 1.1 4.5 1.1s3.3-.4 4.5-1.1"
/>
</svg>
</span>
</Button>
</div>
<div className={styles.spacer12} />
{!isVideoPreviewScene ? (
@@ -1523,6 +1559,33 @@ export function ControlApp() {
/>
</>
) : null}
{(() => {
const activeMaterial =
session?.project && materialsOverlay?.activeMaterialId
? (session.project.materials ?? []).find((m) => m.id === materialsOverlay.activeMaterialId)
: undefined;
if (!activeMaterial) return null;
return (
<MaterialOverlay
assetId={activeMaterial.assetId}
rotationDeg={activeMaterial.rotationDeg ?? 0}
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
editable
zoomTool={materialsOverlay?.zoomTool ?? null}
showClose
closeLabel={t('materials.closeOverlay')}
onClose={() => {
void materialsApi.dispatch({ kind: 'hide' });
}}
onLayoutChange={(layout) => {
void materialsApi.dispatch({ kind: 'layout.set', layout });
}}
onZoomAt={(nx, ny) => {
void materialsApi.dispatch({ kind: 'zoomAt', nx, ny });
}}
/>
);
})()}
</div>
</Surface>