DNDGamePlayer: Electron редактор сцен, презентация, упаковка electron-builder
Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import type { EffectsEvent, EffectsState, EffectToolState } from '../../shared/types';
|
||||
|
||||
function nowMs(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function defaultTool(): EffectToolState {
|
||||
return { tool: 'fog', radiusN: 0.08, intensity: 0.6 };
|
||||
}
|
||||
|
||||
export class EffectsStore {
|
||||
private state: EffectsState = {
|
||||
revision: 1,
|
||||
serverNowMs: nowMs(),
|
||||
tool: defaultTool(),
|
||||
instances: [],
|
||||
};
|
||||
|
||||
getState(): EffectsState {
|
||||
// Всегда обновляем serverNowMs при чтении — это наш "таймкод" для рендереров.
|
||||
return { ...this.state, serverNowMs: nowMs() };
|
||||
}
|
||||
|
||||
clear(): EffectsState {
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
serverNowMs: nowMs(),
|
||||
instances: [],
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
|
||||
dispatch(event: EffectsEvent): EffectsState {
|
||||
const s = this.state;
|
||||
const next: EffectsState = applyEvent(s, event);
|
||||
this.state = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Удаляет истёкшие (по lifetime) эффекты, чтобы state не разрастался бесконечно. */
|
||||
pruneExpired(): boolean {
|
||||
const now = nowMs();
|
||||
const before = this.state.instances.length;
|
||||
const kept = this.state.instances.filter((i) => {
|
||||
if (i.type === 'lightning') {
|
||||
return now - i.createdAtMs < i.lifetimeMs;
|
||||
}
|
||||
if (i.type === 'scorch') {
|
||||
return now - i.createdAtMs < i.lifetimeMs;
|
||||
}
|
||||
if (i.type === 'fog') {
|
||||
if (i.lifetimeMs === null) return true;
|
||||
return now - i.createdAtMs < i.lifetimeMs;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (kept.length === before) return false;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
serverNowMs: now,
|
||||
instances: kept,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
makeId(prefix: string): string {
|
||||
return `${prefix}_${crypto.randomBytes(6).toString('hex')}_${String(nowMs())}`;
|
||||
}
|
||||
}
|
||||
|
||||
function applyEvent(state: EffectsState, event: EffectsEvent): EffectsState {
|
||||
const bump = (patch: Omit<EffectsState, 'revision' | 'serverNowMs'>): EffectsState => ({
|
||||
...patch,
|
||||
revision: state.revision + 1,
|
||||
serverNowMs: nowMs(),
|
||||
});
|
||||
switch (event.kind) {
|
||||
case 'tool.set':
|
||||
return bump({ ...state, tool: event.tool });
|
||||
case 'instances.clear':
|
||||
return bump({ ...state, instances: [] });
|
||||
case 'instance.add':
|
||||
return bump({ ...state, instances: [...state.instances, event.instance] });
|
||||
case 'instance.remove':
|
||||
return bump({ ...state, instances: state.instances.filter((i) => i.id !== event.id) });
|
||||
default: {
|
||||
// Exhaustiveness
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const _x: never = event;
|
||||
return state;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import { app, BrowserWindow, dialog, Menu, protocol } from 'electron';
|
||||
|
||||
import { ipcChannels, type SessionState } from '../shared/ipc/contracts';
|
||||
|
||||
import { EffectsStore } from './effects/effectsStore';
|
||||
import { installIpcRouter, registerHandler } from './ipc/router';
|
||||
import { ZipProjectStore } from './project/zipStore';
|
||||
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
|
||||
import { getAppSemanticVersion, getOptionalBuildNumber } from './versionInfo';
|
||||
import { VideoPlaybackStore } from './video/videoPlaybackStore';
|
||||
import {
|
||||
closeMultiWindow,
|
||||
createWindows,
|
||||
focusEditorWindow,
|
||||
markAppQuitting,
|
||||
openMultiWindow,
|
||||
togglePresentationFullscreen,
|
||||
} from './windows/createWindows';
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
app.setAppUserModelId('com.dndplayer.app');
|
||||
}
|
||||
// Не вызывать app.setName() с другим именем: на Windows/macOS меняется каталог userData,
|
||||
// и проекты в …/userData/projects «пропадают» из списка (остаются в старой папке).
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: 'dnd',
|
||||
privileges: {
|
||||
standard: true,
|
||||
secure: true,
|
||||
supportFetchAPI: true,
|
||||
corsEnabled: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
if (!gotTheLock) {
|
||||
app.quit();
|
||||
} else {
|
||||
app.on('second-instance', () => {
|
||||
focusEditorWindow();
|
||||
});
|
||||
}
|
||||
|
||||
const projectStore = new ZipProjectStore();
|
||||
const effectsStore = new EffectsStore();
|
||||
const videoStore = new VideoPlaybackStore();
|
||||
|
||||
function emitEffectsState(): void {
|
||||
const state = effectsStore.getState();
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send(ipcChannels.effects.stateChanged, { state });
|
||||
}
|
||||
}
|
||||
|
||||
function emitVideoState(): void {
|
||||
const state = videoStore.getState();
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send(ipcChannels.video.stateChanged, { state });
|
||||
}
|
||||
}
|
||||
|
||||
// Периодически чистим истёкшие эффекты (в основном молнии).
|
||||
setInterval(() => {
|
||||
if (effectsStore.pruneExpired()) {
|
||||
emitEffectsState();
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// Пока видео "играет" — периодически рассылаем state (нужен для новых окон и коррекции).
|
||||
setInterval(() => {
|
||||
if (videoStore.getState().playing) {
|
||||
emitVideoState();
|
||||
}
|
||||
}, 500);
|
||||
|
||||
function emitSessionState(): void {
|
||||
const project = projectStore.getOpenProject();
|
||||
const state: SessionState = {
|
||||
project,
|
||||
currentSceneId: project?.currentSceneId ?? null,
|
||||
};
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send(ipcChannels.session.stateChanged, { state });
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await app.whenReady();
|
||||
Menu.setApplicationMenu(null);
|
||||
registerDndAssetProtocol(projectStore);
|
||||
registerHandler(ipcChannels.app.quit, () => {
|
||||
markAppQuitting();
|
||||
app.quit();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.app.getVersion, () => ({
|
||||
version: getAppSemanticVersion(),
|
||||
buildNumber: getOptionalBuildNumber(),
|
||||
}));
|
||||
registerHandler(ipcChannels.windows.openMultiWindow, () => {
|
||||
openMultiWindow();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.closeMultiWindow, () => {
|
||||
closeMultiWindow();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.togglePresentationFullscreen, () => {
|
||||
const isFullScreen = togglePresentationFullscreen();
|
||||
return { ok: true, isFullScreen };
|
||||
});
|
||||
|
||||
registerHandler(ipcChannels.project.list, async () => {
|
||||
const projects = await projectStore.listProjects();
|
||||
return {
|
||||
projects: projects.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
updatedAt: p.updatedAt,
|
||||
fileName: p.fileName,
|
||||
})),
|
||||
};
|
||||
});
|
||||
registerHandler(ipcChannels.project.create, async ({ name }) => {
|
||||
const project = await projectStore.createProject(name);
|
||||
emitSessionState();
|
||||
return { project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.open, async ({ projectId }) => {
|
||||
const project = await projectStore.openProjectById(projectId);
|
||||
emitSessionState();
|
||||
return { project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.get, () => {
|
||||
return { project: projectStore.getOpenProject() };
|
||||
});
|
||||
registerHandler(ipcChannels.project.saveNow, async () => {
|
||||
await projectStore.saveNow();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.project.setCurrentScene, async ({ sceneId }) => {
|
||||
await projectStore.updateProject((p) => ({ ...p, currentSceneId: sceneId, currentGraphNodeId: null }));
|
||||
effectsStore.clear();
|
||||
emitEffectsState();
|
||||
emitSessionState();
|
||||
return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null };
|
||||
});
|
||||
registerHandler(ipcChannels.project.setCurrentGraphNode, async ({ graphNodeId }) => {
|
||||
const open = projectStore.getOpenProject();
|
||||
if (!open) throw new Error('No open project');
|
||||
const gn = graphNodeId ? open.sceneGraphNodes.find((n) => n.id === graphNodeId) : null;
|
||||
await projectStore.updateProject((p) => ({
|
||||
...p,
|
||||
currentGraphNodeId: graphNodeId,
|
||||
currentSceneId: gn ? gn.sceneId : null,
|
||||
}));
|
||||
effectsStore.clear();
|
||||
emitEffectsState();
|
||||
emitSessionState();
|
||||
const p = projectStore.getOpenProject();
|
||||
return {
|
||||
currentGraphNodeId: p?.currentGraphNodeId ?? null,
|
||||
currentSceneId: p?.currentSceneId ?? null,
|
||||
};
|
||||
});
|
||||
registerHandler(ipcChannels.project.updateScene, async ({ sceneId, patch }) => {
|
||||
const next = await projectStore.updateScene(sceneId, patch);
|
||||
emitSessionState();
|
||||
return { scene: next };
|
||||
});
|
||||
registerHandler(ipcChannels.project.updateConnections, async ({ sceneId, connections }) => {
|
||||
const next = await projectStore.updateConnections(sceneId, connections);
|
||||
emitSessionState();
|
||||
return { scene: next };
|
||||
});
|
||||
registerHandler(ipcChannels.project.importMedia, async ({ sceneId }) => {
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||
properties: ['openFile', 'multiSelections'],
|
||||
filters: [
|
||||
{
|
||||
name: 'Видео и аудио',
|
||||
extensions: ['mp4', 'webm', 'mov', 'mp3', 'wav', 'ogg', 'm4a', 'aac'],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (canceled || filePaths.length === 0) {
|
||||
const project = projectStore.getOpenProject();
|
||||
if (!project) throw new Error('No open project');
|
||||
return { project, imported: [] };
|
||||
}
|
||||
const result = await projectStore.importMediaFiles(sceneId, filePaths);
|
||||
emitSessionState();
|
||||
return result;
|
||||
});
|
||||
registerHandler(ipcChannels.project.importScenePreview, async ({ sceneId }) => {
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||
properties: ['openFile'],
|
||||
filters: [
|
||||
{
|
||||
name: 'Изображения и видео',
|
||||
extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'mp4', 'webm', 'mov'],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (canceled || !filePaths[0]) {
|
||||
const project = projectStore.getOpenProject();
|
||||
if (!project) throw new Error('No open project');
|
||||
return { project };
|
||||
}
|
||||
const project = await projectStore.importScenePreviewMedia(sceneId, filePaths[0]);
|
||||
emitSessionState();
|
||||
return { project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.clearScenePreview, async ({ sceneId }) => {
|
||||
const project = await projectStore.clearScenePreview(sceneId);
|
||||
emitSessionState();
|
||||
return { project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.updateSceneGraphNodePosition, async ({ nodeId, x, y }) => {
|
||||
const project = await projectStore.updateSceneGraphNodePosition(nodeId, x, y);
|
||||
emitSessionState();
|
||||
return { project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.addSceneGraphNode, async ({ sceneId, x, y }) => {
|
||||
const project = await projectStore.addSceneGraphNode(sceneId, x, y);
|
||||
emitSessionState();
|
||||
return { project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.removeSceneGraphNode, async ({ nodeId }) => {
|
||||
const project = await projectStore.removeSceneGraphNode(nodeId);
|
||||
emitSessionState();
|
||||
return { project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.addSceneGraphEdge, async ({ sourceGraphNodeId, targetGraphNodeId }) => {
|
||||
const project = await projectStore.addSceneGraphEdge(sourceGraphNodeId, targetGraphNodeId);
|
||||
emitSessionState();
|
||||
return { project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.removeSceneGraphEdge, async ({ edgeId }) => {
|
||||
const project = await projectStore.removeSceneGraphEdge(edgeId);
|
||||
emitSessionState();
|
||||
return { project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.setSceneGraphNodeStart, async ({ graphNodeId }) => {
|
||||
const project = await projectStore.setSceneGraphNodeStart(graphNodeId);
|
||||
emitSessionState();
|
||||
return { project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.deleteScene, async ({ sceneId }) => {
|
||||
const project = await projectStore.deleteScene(sceneId);
|
||||
emitSessionState();
|
||||
return { project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.rename, async ({ name, fileBaseName }) => {
|
||||
const project = await projectStore.renameOpenProject(name, fileBaseName);
|
||||
emitSessionState();
|
||||
return { project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.importZip, async () => {
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||
properties: ['openFile'],
|
||||
filters: [{ name: 'Проект DND (*.dnd.zip)', extensions: ['dnd.zip'] }],
|
||||
});
|
||||
if (canceled || !filePaths[0]) {
|
||||
return { canceled: true as const };
|
||||
}
|
||||
const project = await projectStore.importProjectFromExternalZip(filePaths[0]);
|
||||
emitSessionState();
|
||||
return { canceled: false as const, project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.exportZip, async ({ projectId }) => {
|
||||
const list = await projectStore.listProjects();
|
||||
const entry = list.find((p) => p.id === projectId);
|
||||
if (!entry) {
|
||||
throw new Error('Проект не найден');
|
||||
}
|
||||
const defaultName = entry.fileName.toLowerCase().endsWith('.dnd.zip')
|
||||
? entry.fileName
|
||||
: `${entry.fileName}.dnd.zip`;
|
||||
const { canceled, filePath } = await dialog.showSaveDialog({
|
||||
defaultPath: defaultName,
|
||||
filters: [{ name: 'Проект DND (*.dnd.zip)', extensions: ['dnd.zip'] }],
|
||||
});
|
||||
if (canceled || !filePath) {
|
||||
return { canceled: true as const };
|
||||
}
|
||||
let dest = filePath;
|
||||
const lower = dest.toLowerCase();
|
||||
if (!lower.endsWith('.dnd.zip')) {
|
||||
dest = lower.endsWith('.zip') ? dest.replace(/\.zip$/iu, '.dnd.zip') : `${dest}.dnd.zip`;
|
||||
}
|
||||
await projectStore.exportProjectZipToPath(projectId, dest);
|
||||
return { canceled: false as const };
|
||||
});
|
||||
registerHandler(ipcChannels.project.deleteProject, async ({ projectId }) => {
|
||||
await projectStore.deleteProjectById(projectId);
|
||||
emitSessionState();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.project.assetFileUrl, ({ assetId }) => ({
|
||||
url: projectStore.getAssetFileUrl(assetId),
|
||||
}));
|
||||
|
||||
registerHandler(ipcChannels.effects.getState, () => {
|
||||
return { state: effectsStore.getState() };
|
||||
});
|
||||
registerHandler(ipcChannels.effects.dispatch, ({ event }) => {
|
||||
effectsStore.dispatch(event);
|
||||
emitEffectsState();
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
registerHandler(ipcChannels.video.getState, () => {
|
||||
return { state: videoStore.getState() };
|
||||
});
|
||||
registerHandler(ipcChannels.video.dispatch, ({ event }) => {
|
||||
videoStore.dispatch(event);
|
||||
emitVideoState();
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
installIpcRouter();
|
||||
createWindows();
|
||||
emitSessionState();
|
||||
emitEffectsState();
|
||||
emitVideoState();
|
||||
|
||||
app.on('activate', () => {
|
||||
focusEditorWindow();
|
||||
});
|
||||
}
|
||||
|
||||
if (gotTheLock) {
|
||||
void main();
|
||||
}
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ipcMain } from 'electron';
|
||||
|
||||
import type { IpcInvokeMap } from '../../shared/ipc/contracts';
|
||||
|
||||
type Handler<K extends keyof IpcInvokeMap> = (
|
||||
payload: IpcInvokeMap[K]['req'],
|
||||
) => Promise<IpcInvokeMap[K]['res']> | IpcInvokeMap[K]['res'];
|
||||
|
||||
const handlers = new Map<string, (payload: unknown) => Promise<unknown>>();
|
||||
|
||||
export function registerHandler<K extends keyof IpcInvokeMap>(channel: K, handler: Handler<K>) {
|
||||
handlers.set(channel as string, async (payload: unknown) => handler(payload as IpcInvokeMap[K]['req']));
|
||||
}
|
||||
|
||||
export function installIpcRouter() {
|
||||
for (const [channel, handler] of handlers.entries()) {
|
||||
ipcMain.handle(channel, async (_event, payload: unknown) => handler(payload));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { PROJECT_SCHEMA_VERSION, type Project } from '../../shared/types';
|
||||
import type { AssetId } from '../../shared/types/ids';
|
||||
|
||||
import { collectReferencedAssetIds, reconcileAssetFiles } from './assetPrune';
|
||||
|
||||
void test('collectReferencedAssetIds: превью, видео и аудио', () => {
|
||||
const p = {
|
||||
scenes: {
|
||||
s1: {
|
||||
previewAssetId: 'pr' as AssetId,
|
||||
media: {
|
||||
videos: ['v1' as AssetId],
|
||||
audios: [{ assetId: 'a1' as AssetId, autoplay: true, loop: true }],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Project;
|
||||
const s = collectReferencedAssetIds(p);
|
||||
assert.deepEqual([...s].sort(), ['a1', 'pr', 'v1'].sort());
|
||||
});
|
||||
|
||||
void test('reconcileAssetFiles: снимает осиротевшие assets и удаляет файлы', async () => {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-asset-prune-'));
|
||||
const relPath = 'assets/orphan.bin';
|
||||
await fs.mkdir(path.join(tmp, 'assets'), { recursive: true });
|
||||
await fs.writeFile(path.join(tmp, relPath), Buffer.from([1, 2, 3]));
|
||||
|
||||
const asset = {
|
||||
id: 'orph' as AssetId,
|
||||
type: 'audio' as const,
|
||||
mime: 'audio/wav',
|
||||
originalName: 'x.wav',
|
||||
relPath,
|
||||
sha256: 'a',
|
||||
sizeBytes: 3,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const base = {
|
||||
id: 'p1',
|
||||
meta: {
|
||||
name: 't',
|
||||
fileBaseName: 't',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
createdWithAppVersion: '1',
|
||||
appVersion: '1',
|
||||
schemaVersion: PROJECT_SCHEMA_VERSION,
|
||||
},
|
||||
currentSceneId: null,
|
||||
currentGraphNodeId: null,
|
||||
sceneGraphNodes: [],
|
||||
sceneGraphEdges: [],
|
||||
} as unknown as Project;
|
||||
|
||||
const prev: Project = {
|
||||
...base,
|
||||
scenes: {},
|
||||
assets: { orphan: asset } as Project['assets'],
|
||||
};
|
||||
const next: Project = {
|
||||
...base,
|
||||
scenes: {},
|
||||
assets: { orphan: asset } as Project['assets'],
|
||||
};
|
||||
|
||||
const out = await reconcileAssetFiles(prev, next, tmp);
|
||||
assert.ok(!('orphan' in out.assets));
|
||||
await assert.rejects(() => fs.stat(path.join(tmp, relPath)));
|
||||
});
|
||||
|
||||
void test('reconcileAssetFiles: удаляет файл при исключении id из assets', async () => {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-asset-prune-'));
|
||||
const relPath = 'assets/gone.bin';
|
||||
await fs.mkdir(path.join(tmp, 'assets'), { recursive: true });
|
||||
await fs.writeFile(path.join(tmp, relPath), Buffer.from([9]));
|
||||
|
||||
const asset = {
|
||||
id: 'gone' as AssetId,
|
||||
type: 'audio' as const,
|
||||
mime: 'audio/wav',
|
||||
originalName: 'x.wav',
|
||||
relPath,
|
||||
sha256: 'b',
|
||||
sizeBytes: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const base = {
|
||||
id: 'p1',
|
||||
meta: {
|
||||
name: 't',
|
||||
fileBaseName: 't',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
createdWithAppVersion: '1',
|
||||
appVersion: '1',
|
||||
schemaVersion: PROJECT_SCHEMA_VERSION,
|
||||
},
|
||||
scenes: {},
|
||||
currentSceneId: null,
|
||||
currentGraphNodeId: null,
|
||||
sceneGraphNodes: [],
|
||||
sceneGraphEdges: [],
|
||||
} as unknown as Project;
|
||||
|
||||
const prev: Project = { ...base, assets: { gone: asset } as Project['assets'] };
|
||||
const next: Project = { ...base, assets: {} as Project['assets'] };
|
||||
|
||||
const out = await reconcileAssetFiles(prev, next, tmp);
|
||||
assert.deepEqual(out.assets, {});
|
||||
await assert.rejects(() => fs.stat(path.join(tmp, relPath)));
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { MediaAsset, Project } from '../../shared/types';
|
||||
import type { AssetId } from '../../shared/types/ids';
|
||||
|
||||
/** Все asset id, на которые есть ссылки из сцен (превью, видео, аудио). */
|
||||
export function collectReferencedAssetIds(p: Project): Set<AssetId> {
|
||||
const refs = new Set<AssetId>();
|
||||
for (const sc of Object.values(p.scenes)) {
|
||||
if (sc.previewAssetId) refs.add(sc.previewAssetId);
|
||||
for (const vid of sc.media.videos) refs.add(vid);
|
||||
for (const au of sc.media.audios) refs.add(au.assetId);
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет с диска файлы снятых материалов и записи в `assets`, на которые больше нет ссылок.
|
||||
*/
|
||||
export async function reconcileAssetFiles(prev: Project, next: Project, cacheDir: string): Promise<Project> {
|
||||
const prevIds = new Set(Object.keys(prev.assets) as AssetId[]);
|
||||
const nextIds = new Set(Object.keys(next.assets) as AssetId[]);
|
||||
|
||||
for (const id of prevIds) {
|
||||
if (nextIds.has(id)) continue;
|
||||
const a = prev.assets[id];
|
||||
if (a) {
|
||||
const abs = path.join(cacheDir, a.relPath);
|
||||
await fs.unlink(abs).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const refs = collectReferencedAssetIds(next);
|
||||
const assets = next.assets;
|
||||
const kept: Record<AssetId, MediaAsset> = {} as Record<AssetId, MediaAsset>;
|
||||
let droppedOrphans = false;
|
||||
for (const id of Object.keys(assets) as AssetId[]) {
|
||||
const a = assets[id];
|
||||
if (!a) continue;
|
||||
if (refs.has(id)) {
|
||||
kept[id] = a;
|
||||
} else {
|
||||
droppedOrphans = true;
|
||||
const abs = path.join(cacheDir, a.relPath);
|
||||
await fs.unlink(abs).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
return droppedOrphans ? { ...next, assets: kept } : next;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { app } from 'electron';
|
||||
|
||||
export function getProjectsRootDir(): string {
|
||||
return path.join(app.getPath('userData'), 'projects');
|
||||
}
|
||||
|
||||
export function getProjectsCacheRootDir(): string {
|
||||
return path.join(app.getPath('userData'), 'projects-cache');
|
||||
}
|
||||
|
||||
/**
|
||||
* Каталоги `…/projects` из других имён приложения в `%AppData%` (родитель `userData`).
|
||||
* Если когда‑то меняли `app.setName`, проекты могли остаться в соседней папке — их подхватываем при старте.
|
||||
*/
|
||||
export function getLegacyProjectsRootDirs(): string[] {
|
||||
const cur = getProjectsRootDir();
|
||||
const parent = path.dirname(app.getPath('userData'));
|
||||
const siblingNames = ['DnD Player', 'dnd-player', 'DNDGamePlayer', 'dnd_player'];
|
||||
const out: string[] = [];
|
||||
for (const n of siblingNames) {
|
||||
const p = path.join(parent, n, 'projects');
|
||||
if (p !== cur) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
import { session } from 'electron';
|
||||
|
||||
import { asAssetId } from '../../shared/types/ids';
|
||||
import type { ZipProjectStore } from '../project/zipStore';
|
||||
|
||||
/**
|
||||
* Обслуживает `dnd://asset?...` — без этого `<img src="file://...">` в рендерере часто ломается.
|
||||
*/
|
||||
export function registerDndAssetProtocol(projectStore: ZipProjectStore): void {
|
||||
session.defaultSession.protocol.handle('dnd', async (request) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.hostname !== 'asset') {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
const id = url.searchParams.get('id');
|
||||
if (!id) {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
const info = projectStore.getAssetReadInfo(asAssetId(id));
|
||||
if (!info) {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
try {
|
||||
const stat = await fs.stat(info.absPath);
|
||||
const total = stat.size;
|
||||
const range = request.headers.get('range') ?? request.headers.get('Range');
|
||||
|
||||
if (range) {
|
||||
const m = /^bytes=(\d+)-(\d+)?$/iu.exec(range.trim());
|
||||
if (m) {
|
||||
const start = Number(m[1]);
|
||||
const endRaw = m[2] ? Number(m[2]) : total - 1;
|
||||
const end = Math.min(endRaw, total - 1);
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start) {
|
||||
return new Response(null, { status: 416 });
|
||||
}
|
||||
const len = end - start + 1;
|
||||
const fh = await fs.open(info.absPath, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(len);
|
||||
await fh.read(buf, 0, len, start);
|
||||
return new Response(buf, {
|
||||
status: 206,
|
||||
headers: {
|
||||
'Content-Type': info.mime,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Range': `bytes ${String(start)}-${String(end)}/${String(total)}`,
|
||||
'Content-Length': String(len),
|
||||
'Cache-Control': 'public, max-age=300',
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const buf = await fs.readFile(info.absPath);
|
||||
return new Response(buf, {
|
||||
headers: {
|
||||
'Content-Type': info.mime,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': String(buf.length),
|
||||
'Cache-Control': 'public, max-age=300',
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": false,
|
||||
"outDir": "../../dist/main",
|
||||
"moduleResolution": "NodeNext",
|
||||
"module": "NodeNext",
|
||||
"types": ["node", "electron"],
|
||||
"lib": ["ES2022"]
|
||||
},
|
||||
"include": ["./**/*.ts"],
|
||||
"exclude": ["../../dist", "../../node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { app } from 'electron';
|
||||
|
||||
/**
|
||||
* Семантическая версия приложения: из упакованного приложения — `app.getVersion()` (= `version` из package.json),
|
||||
* в dev при запуске через npm — обычно то же; иначе fallback на `npm_package_version` / `0.0.0`.
|
||||
*/
|
||||
export function getAppSemanticVersion(): string {
|
||||
try {
|
||||
const v = app.getVersion();
|
||||
if (typeof v === 'string' && v.trim().length > 0) {
|
||||
return v.trim();
|
||||
}
|
||||
} catch {
|
||||
/* вне процесса Electron */
|
||||
}
|
||||
const fromEnv = process.env.npm_package_version?.trim();
|
||||
return fromEnv !== undefined && fromEnv.length > 0 ? fromEnv : '0.0.0';
|
||||
}
|
||||
|
||||
/** Необязательный номер сборки из CI (`DND_BUILD_NUMBER`). */
|
||||
export function getOptionalBuildNumber(): string | null {
|
||||
const b = process.env.DND_BUILD_NUMBER?.trim();
|
||||
return b && b.length > 0 ? b : null;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { VideoPlaybackEvent, VideoPlaybackState } from '../../shared/types';
|
||||
|
||||
function nowMs(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function clamp(v: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, v));
|
||||
}
|
||||
|
||||
export class VideoPlaybackStore {
|
||||
private state: VideoPlaybackState = {
|
||||
revision: 1,
|
||||
serverNowMs: nowMs(),
|
||||
targetAssetId: null,
|
||||
playing: false,
|
||||
playbackRate: 1,
|
||||
anchorServerMs: nowMs(),
|
||||
anchorVideoTimeSec: 0,
|
||||
};
|
||||
|
||||
getState(): VideoPlaybackState {
|
||||
return { ...this.state, serverNowMs: nowMs() };
|
||||
}
|
||||
|
||||
dispatch(event: VideoPlaybackEvent): VideoPlaybackState {
|
||||
const s = this.getState();
|
||||
const curTime = computeTimeSec(s, s.serverNowMs);
|
||||
const bump = (patch: Omit<VideoPlaybackState, 'revision' | 'serverNowMs'>): VideoPlaybackState => ({
|
||||
...patch,
|
||||
revision: s.revision + 1,
|
||||
serverNowMs: nowMs(),
|
||||
});
|
||||
|
||||
switch (event.kind) {
|
||||
case 'target.set': {
|
||||
const nextTarget = event.assetId ?? null;
|
||||
const next: Omit<VideoPlaybackState, 'revision' | 'serverNowMs'> = {
|
||||
...s,
|
||||
targetAssetId: nextTarget,
|
||||
playing: Boolean(event.autostart) && nextTarget !== null,
|
||||
playbackRate: s.playbackRate,
|
||||
anchorServerMs: s.serverNowMs,
|
||||
anchorVideoTimeSec: 0,
|
||||
};
|
||||
this.state = bump(next);
|
||||
return this.state;
|
||||
}
|
||||
case 'play': {
|
||||
this.state = bump({
|
||||
...s,
|
||||
playing: true,
|
||||
anchorServerMs: s.serverNowMs,
|
||||
anchorVideoTimeSec: curTime,
|
||||
});
|
||||
return this.state;
|
||||
}
|
||||
case 'pause': {
|
||||
this.state = bump({
|
||||
...s,
|
||||
playing: false,
|
||||
anchorServerMs: s.serverNowMs,
|
||||
anchorVideoTimeSec: curTime,
|
||||
});
|
||||
return this.state;
|
||||
}
|
||||
case 'stop': {
|
||||
this.state = bump({
|
||||
...s,
|
||||
playing: false,
|
||||
anchorServerMs: s.serverNowMs,
|
||||
anchorVideoTimeSec: 0,
|
||||
});
|
||||
return this.state;
|
||||
}
|
||||
case 'seek': {
|
||||
const t = clamp(event.timeSec, 0, 1_000_000);
|
||||
this.state = bump({
|
||||
...s,
|
||||
anchorServerMs: s.serverNowMs,
|
||||
anchorVideoTimeSec: t,
|
||||
});
|
||||
return this.state;
|
||||
}
|
||||
case 'rate.set': {
|
||||
const rate = clamp(event.rate, 0.25, 3);
|
||||
this.state = bump({
|
||||
...s,
|
||||
playbackRate: rate,
|
||||
anchorServerMs: s.serverNowMs,
|
||||
anchorVideoTimeSec: curTime,
|
||||
});
|
||||
return this.state;
|
||||
}
|
||||
default: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const _x: never = event;
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function computeTimeSec(state: VideoPlaybackState, atServerNowMs: number): number {
|
||||
if (!state.playing) return state.anchorVideoTimeSec;
|
||||
const dt = Math.max(0, atServerNowMs - state.anchorServerMs);
|
||||
return state.anchorVideoTimeSec + (dt / 1000) * state.playbackRate;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function readCreateWindows(): string {
|
||||
return fs.readFileSync(path.join(here, 'createWindows.ts'), 'utf8');
|
||||
}
|
||||
|
||||
void test('createWindows: закрытие редактора завершает приложение', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.match(src, /kind === 'editor'/);
|
||||
assert.match(src, /win\.on\(\s*['"]close['"]/);
|
||||
assert.ok(src.includes('appQuitting'));
|
||||
assert.ok(src.includes('e.preventDefault()'));
|
||||
assert.ok(src.includes('quitAppFromEditorClose') || src.includes('app.quit()'));
|
||||
assert.ok(src.includes('markAppQuitting'));
|
||||
});
|
||||
|
||||
void test('createWindows: иконка окна (PNG приоритетно, затем SVG)', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.ok(src.includes('resolveWindowIconPath'));
|
||||
assert.ok(src.includes('app-window-icon.png'));
|
||||
assert.ok(src.includes('app-logo.svg'));
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import { app, BrowserWindow, nativeImage, screen } from 'electron';
|
||||
|
||||
type WindowKind = 'editor' | 'presentation' | 'control';
|
||||
|
||||
const windows = new Map<WindowKind, BrowserWindow>();
|
||||
|
||||
let appQuitting = false;
|
||||
|
||||
/** Разрешает реальное закрытие окна редактора (выход из приложения). */
|
||||
export function markAppQuitting(): void {
|
||||
appQuitting = true;
|
||||
}
|
||||
|
||||
function quitAppFromEditorClose(): void {
|
||||
markAppQuitting();
|
||||
app.quit();
|
||||
}
|
||||
|
||||
function isDev() {
|
||||
return process.env.NODE_ENV === 'development' || process.env.VITE_DEV_SERVER_URL !== undefined;
|
||||
}
|
||||
|
||||
function getRendererUrl(kind: WindowKind): string {
|
||||
const dev = process.env.VITE_DEV_SERVER_URL;
|
||||
if (dev) {
|
||||
const page =
|
||||
kind === 'editor' ? 'editor.html' : kind === 'presentation' ? 'presentation.html' : 'control.html';
|
||||
return new URL(page, dev).toString();
|
||||
}
|
||||
const filePath = path.join(app.getAppPath(), 'dist', 'renderer', `${kind}.html`);
|
||||
return pathToFileURL(filePath).toString();
|
||||
}
|
||||
|
||||
function getPreloadPath(): string {
|
||||
return path.join(app.getAppPath(), 'dist', 'preload', 'index.cjs');
|
||||
}
|
||||
|
||||
/**
|
||||
* Иконка окна. На Windows `nativeImage` из SVG часто пустой — сначала ищем PNG
|
||||
* (`app-window-icon.png`), затем SVG из public / dist.
|
||||
*/
|
||||
function resolveWindowIconPath(): string | undefined {
|
||||
const root = app.getAppPath();
|
||||
const candidates = [
|
||||
path.join(root, 'dist', 'renderer', 'app-window-icon.png'),
|
||||
path.join(root, 'app', 'renderer', 'public', 'app-window-icon.png'),
|
||||
path.join(root, 'dist', 'renderer', 'app-logo.svg'),
|
||||
path.join(root, 'app', 'renderer', 'public', 'app-logo.svg'),
|
||||
];
|
||||
for (const p of candidates) {
|
||||
try {
|
||||
if (fs.existsSync(p)) return p;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveWindowIcon(): Electron.NativeImage | undefined {
|
||||
const p = resolveWindowIconPath();
|
||||
if (!p) return undefined;
|
||||
try {
|
||||
const img = nativeImage.createFromPath(p);
|
||||
if (!img.isEmpty()) return img;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function createWindow(kind: WindowKind): BrowserWindow {
|
||||
const icon = resolveWindowIcon();
|
||||
const win = new BrowserWindow({
|
||||
width: kind === 'editor' ? 1280 : kind === 'control' ? 1200 : 1280,
|
||||
height: 800,
|
||||
show: false,
|
||||
backgroundColor: '#09090B',
|
||||
...(icon ? { icon } : {}),
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
nodeIntegration: false,
|
||||
devTools: isDev(),
|
||||
preload: getPreloadPath(),
|
||||
autoplayPolicy: 'no-user-gesture-required',
|
||||
},
|
||||
});
|
||||
|
||||
win.webContents.on('preload-error', (_event, preloadPath, error) => {
|
||||
console.error(`[preload-error] ${preloadPath}:`, error);
|
||||
});
|
||||
win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
|
||||
console.error(`[did-fail-load] ${String(errorCode)} ${errorDescription} ${validatedURL}`);
|
||||
});
|
||||
|
||||
win.once('ready-to-show', () => win.show());
|
||||
void win.loadURL(getRendererUrl(kind));
|
||||
if (kind === 'editor') {
|
||||
win.on('close', (e) => {
|
||||
if (appQuitting) return;
|
||||
e.preventDefault();
|
||||
quitAppFromEditorClose();
|
||||
});
|
||||
}
|
||||
win.on('closed', () => windows.delete(kind));
|
||||
windows.set(kind, win);
|
||||
return win;
|
||||
}
|
||||
|
||||
export function createWindows() {
|
||||
if (!windows.has('editor')) {
|
||||
createWindow('editor');
|
||||
}
|
||||
}
|
||||
|
||||
export function focusEditorWindow(): void {
|
||||
const win = windows.get('editor');
|
||||
if (win) {
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.show();
|
||||
win.focus();
|
||||
} else {
|
||||
createWindows();
|
||||
}
|
||||
}
|
||||
|
||||
export function openMultiWindow() {
|
||||
if (!windows.has('presentation')) {
|
||||
const display = screen.getPrimaryDisplay();
|
||||
const { x, y, width, height } = display.bounds;
|
||||
const win = createWindow('presentation');
|
||||
win.setBounds({ x, y, width, height });
|
||||
win.setMenuBarVisibility(false);
|
||||
win.maximize();
|
||||
}
|
||||
if (!windows.has('control')) {
|
||||
createWindow('control');
|
||||
}
|
||||
}
|
||||
|
||||
export function closeMultiWindow(): void {
|
||||
const pres = windows.get('presentation');
|
||||
const ctrl = windows.get('control');
|
||||
if (pres) pres.close();
|
||||
if (ctrl) ctrl.close();
|
||||
}
|
||||
|
||||
export function togglePresentationFullscreen(): boolean {
|
||||
const pres = windows.get('presentation');
|
||||
if (!pres) return false;
|
||||
const next = !pres.isFullScreen();
|
||||
pres.setFullScreen(next);
|
||||
return pres.isFullScreen();
|
||||
}
|
||||
Reference in New Issue
Block a user