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
+116 -3
View File
@@ -1,5 +1,9 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { app, BrowserWindow, dialog, Menu, protocol } from 'electron';
import { openDialogFilterLabel } from '../shared/appBranding';
import { ipcChannels, type ScenePreviewImportEvent, type SessionState } from '../shared/ipc/contracts';
import {
PROJECT_ZIP_OPEN_DIALOG_FILTER,
@@ -15,6 +19,7 @@ import { EffectsStore } from './effects/effectsStore';
import { SceneDarknessStore } from './effects/sceneDarknessStore';
import { installIpcRouter, registerHandler, setLicenseAssert } from './ipc/router';
import { LicenseService } from './license/licenseService';
import { MaterialsOverlayStore } from './materials/materialsOverlayStore';
import { ZipProjectStore } from './project/zipStore';
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
import { installAutoUpdater } from './update/installAutoUpdater';
@@ -36,8 +41,10 @@ import {
getSceneDescriptionContent,
isMultiWindowOpen,
markAppQuitting,
openMaterialsWindow,
openMultiWindow,
openSceneDescriptionWindow,
closeMaterialsWindow,
togglePresentationFullscreen,
waitForEditorWindowReady,
} from './windows/createWindows';
@@ -130,6 +137,7 @@ function installAppMenuForSession(): void {
const effectsStore = new EffectsStore();
const sceneDarknessStore = new SceneDarknessStore();
const videoStore = new VideoPlaybackStore();
const materialsOverlayStore = new MaterialsOverlayStore();
function emitEffectsState(): void {
const state = effectsStore.getState();
@@ -138,6 +146,22 @@ function emitEffectsState(): void {
}
}
function emitMaterialsOverlayState(): void {
const state = materialsOverlayStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.materialsOverlay.stateChanged, { state });
}
}
function syncMaterialsOverlayWithProject(project: Project | null): void {
if (!project) {
materialsOverlayStore.clear();
return;
}
const ids = new Set((project.materials ?? []).map((m) => m.id));
materialsOverlayStore.ensureMaterialStillExists(ids);
}
function emitSceneDarknessState(): void {
const state = sceneDarknessStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
@@ -196,6 +220,7 @@ async function runStartupAfterHandlers(licenseService: LicenseService): Promise<
createWindows();
emitSessionState();
emitEffectsState();
emitMaterialsOverlayState();
emitVideoState();
return;
}
@@ -209,6 +234,7 @@ async function runStartupAfterHandlers(licenseService: LicenseService): Promise<
createWindows();
emitSessionState();
emitEffectsState();
emitMaterialsOverlayState();
emitVideoState();
return;
}
@@ -246,6 +272,7 @@ async function runStartupAfterHandlers(licenseService: LicenseService): Promise<
emitSessionState();
emitEffectsState();
emitMaterialsOverlayState();
emitVideoState();
}
@@ -322,6 +349,23 @@ async function main() {
registerHandler(ipcChannels.windows.getSceneDescriptionContent, () => {
return { html: getSceneDescriptionContent() };
});
registerHandler(ipcChannels.windows.openMaterials, () => {
openMaterialsWindow();
return { ok: true };
});
registerHandler(ipcChannels.windows.closeMaterials, () => {
closeMaterialsWindow();
return { ok: true };
});
registerHandler(ipcChannels.materialsOverlay.getState, () => {
return { state: materialsOverlayStore.getState() };
});
registerHandler(ipcChannels.materialsOverlay.dispatch, ({ event }) => {
materialsOverlayStore.dispatch(event);
emitMaterialsOverlayState();
return { ok: true };
});
registerHandler(ipcChannels.project.list, async () => {
const projects = await projectStore.listProjects();
@@ -347,8 +391,10 @@ async function main() {
registerHandler(ipcChannels.project.close, async () => {
await projectStore.closeOpenProject();
effectsStore.clear();
materialsOverlayStore.clear();
sceneDarknessStore.resetSession();
emitEffectsState();
emitMaterialsOverlayState();
emitSceneDarknessState();
emitSessionState();
return { ok: true };
@@ -363,9 +409,11 @@ async function main() {
registerHandler(ipcChannels.project.setCurrentScene, async ({ sceneId }) => {
await projectStore.updateProject((p) => ({ ...p, currentSceneId: sceneId, currentGraphNodeId: null }));
effectsStore.clear();
materialsOverlayStore.clear();
const project = projectStore.getOpenProject();
if (project) syncSceneDarknessForProject(project);
emitEffectsState();
emitMaterialsOverlayState();
emitSceneDarknessState();
emitSessionState();
return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null };
@@ -380,9 +428,11 @@ async function main() {
currentSceneId: gn ? gn.sceneId : null,
}));
effectsStore.clear();
materialsOverlayStore.clear();
const project = projectStore.getOpenProject();
if (project) syncSceneDarknessForProject(project);
emitEffectsState();
emitMaterialsOverlayState();
emitSceneDarknessState();
emitSessionState();
const p = projectStore.getOpenProject();
@@ -413,7 +463,7 @@ async function main() {
properties: ['openFile', 'multiSelections'],
filters: [
{
name: 'Видео и аудио',
name: openDialogFilterLabel('videoAndAudio', app.getLocale()),
extensions: ['mp4', 'webm', 'mov', 'mp3', 'wav', 'ogg', 'm4a', 'aac'],
},
],
@@ -436,7 +486,7 @@ async function main() {
properties: ['openFile', 'multiSelections'],
filters: [
{
name: 'Аудио',
name: openDialogFilterLabel('audio', app.getLocale()),
extensions: ['mp3', 'wav', 'ogg', 'm4a', 'aac'],
},
],
@@ -457,6 +507,69 @@ async function main() {
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.upsertMaterial, async ({ materialId, name, filePath: pathFromDrop }) => {
let filePath = pathFromDrop;
if (!filePath && !materialId) {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{
name: openDialogFilterLabel('images', app.getLocale()),
extensions: ['png', 'jpg', 'jpeg', 'webp'],
},
],
});
if (canceled || filePaths.length === 0) {
throw new Error('Material image is required');
}
filePath = filePaths[0];
}
const project = await projectStore.upsertMaterial({
...(materialId ? { materialId } : {}),
name,
...(filePath ? { filePath } : {}),
});
syncMaterialsOverlayWithProject(project);
emitMaterialsOverlayState();
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.deleteMaterial, async ({ materialId }) => {
const project = await projectStore.deleteMaterial(materialId);
syncMaterialsOverlayWithProject(project);
emitMaterialsOverlayState();
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.setMaterialsOrder, async ({ materialIds }) => {
const project = await projectStore.setMaterialsOrder(materialIds);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.setMaterialRotation, async ({ materialId, rotationDeg }) => {
const project = await projectStore.setMaterialRotation(materialId, rotationDeg);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.pickMaterialImage, async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{
name: openDialogFilterLabel('images', app.getLocale()),
extensions: ['png', 'jpg', 'jpeg', 'webp'],
},
],
});
if (canceled || filePaths.length === 0) return { canceled: true as const };
const filePath = filePaths[0]!;
const buf = await fs.readFile(filePath);
const ext = path.extname(filePath).toLowerCase();
const mime =
ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
const previewDataUrl = `data:${mime};base64,${buf.toString('base64')}`;
return { canceled: false as const, filePath, previewDataUrl };
});
registerHandler(ipcChannels.project.importScenePreview, async ({ sceneId, filePath: pathFromDrop }) => {
let filePath = pathFromDrop;
if (!filePath) {
@@ -464,7 +577,7 @@ async function main() {
properties: ['openFile'],
filters: [
{
name: 'Изображения и видео',
name: openDialogFilterLabel('imagesAndVideo', app.getLocale()),
extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'mp4', 'webm', 'mov'],
},
],