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:
+116
-3
@@ -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'],
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user