DNDGamePlayer: Electron редактор сцен, презентация, упаковка electron-builder

Made-with: Cursor
This commit is contained in:
Ivan Fontosh
2026-04-19 14:16:54 +08:00
commit a6cbcc273e
82 changed files with 22195 additions and 0 deletions
+97
View File
@@ -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;
}
}
}
+344
View File
@@ -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();
}
});
+19
View File
@@ -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));
}
}
+119
View File
@@ -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)));
});
+51
View File
@@ -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;
}
+27
View File
@@ -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
+73
View File
@@ -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 });
}
});
}
+14
View File
@@ -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"]
}
+24
View File
@@ -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;
}
+108
View File
@@ -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'));
});
+159
View File
@@ -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();
}
+24
View File
@@ -0,0 +1,24 @@
import { contextBridge } from 'electron';
import type { IpcEventMap, IpcInvokeMap } from '../shared/ipc/contracts';
import { invoke, on } from './ipcClient';
export type DndApi = {
invoke: <K extends keyof IpcInvokeMap>(
channel: K,
payload: IpcInvokeMap[K]['req'],
) => Promise<IpcInvokeMap[K]['res']>;
on: <K extends keyof IpcEventMap>(channel: K, listener: (payload: IpcEventMap[K]) => void) => () => void;
};
const api: DndApi = { invoke, on };
contextBridge.exposeInMainWorld('dnd', api);
declare global {
var dnd: DndApi | undefined;
interface Window {
dnd: DndApi;
}
}
+23
View File
@@ -0,0 +1,23 @@
import { ipcRenderer } from 'electron';
import type { IpcEventMap, IpcInvokeMap } from '../shared/ipc/contracts';
export async function invoke<K extends keyof IpcInvokeMap>(
channel: K,
payload: IpcInvokeMap[K]['req'],
): Promise<IpcInvokeMap[K]['res']> {
return (await ipcRenderer.invoke(channel as string, payload)) as IpcInvokeMap[K]['res'];
}
export function on<K extends keyof IpcEventMap>(
channel: K,
listener: (payload: IpcEventMap[K]) => void,
): () => void {
const wrapped = (_: Electron.IpcRendererEvent, payload: unknown) => {
listener(payload as IpcEventMap[K]);
};
ipcRenderer.on(channel as string, wrapped);
return () => {
ipcRenderer.off(channel as string, wrapped);
};
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"noEmit": false,
"outDir": "../../dist/preload",
"moduleResolution": "NodeNext",
"module": "NodeNext",
"types": ["node", "electron"],
"lib": ["ES2022", "DOM"]
},
"include": ["./**/*.ts"],
"exclude": ["../../dist", "../../node_modules"]
}
+1
View File
@@ -0,0 +1 @@
declare module 'reactflow/dist/style.css';
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/app-window-icon.png" type="image/png" />
<title>DnD Player — Control</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/control/main.tsx"></script>
</body>
</html>
+359
View File
@@ -0,0 +1,359 @@
.page {
height: 100vh;
padding: 16px;
display: grid;
grid-template-columns: 280px 1fr;
gap: 16px;
}
.remote {
padding: 12px;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
}
.remoteTitle {
font-size: var(--text-xs);
font-weight: 900;
letter-spacing: 0.8px;
color: var(--text1);
}
.spacer12 {
height: 12px;
}
.spacer8 {
height: 8px;
}
.spacer10 {
height: 10px;
}
.sectionLabel {
font-size: var(--text-xs);
font-weight: 800;
color: var(--text2);
}
.effectsStack {
display: flex;
flex-direction: column;
gap: 10px;
}
.iconRow {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.iconGlyph {
font-size: 18px;
line-height: 1;
display: block;
}
.clearIcon {
display: flex;
width: 20px;
height: 20px;
align-items: center;
justify-content: center;
}
.radiusRow {
display: grid;
grid-template-columns: 100px 1fr 44px;
align-items: center;
gap: 8px;
}
.radiusLabel {
color: var(--text2);
font-size: var(--text-xs);
font-weight: 800;
}
.range {
width: 100%;
}
.radiusValue {
text-align: right;
font-variant-numeric: tabular-nums;
color: var(--text2);
font-size: var(--text-xs);
}
.storyWrap {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.storyScroll {
flex: 0 0 70%;
min-height: 0;
border-radius: var(--radius-lg);
border: 1px solid var(--stroke);
background: var(--color-overlay-dark-2);
overflow: auto;
padding: 10px;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
gap: 10px;
}
.historyBtn {
text-align: left;
padding: 10px;
border-radius: var(--scene-tile-radius);
border: none;
background: transparent;
color: inherit;
cursor: pointer;
opacity: 0.9;
}
.historyBtn:not(:disabled):not(.historyBtnCurrent):hover {
background: var(--scene-list-hover-bg);
}
.historyBtn:disabled {
cursor: default;
opacity: 0.9;
}
.historyBtnCurrent {
border: 1px solid var(--scene-list-selected-border);
background: var(--scene-list-selected-bg);
cursor: default;
}
.historyBadge {
color: var(--accent2);
font-size: var(--text-xs);
font-weight: 900;
}
.historyMuted {
color: var(--text2);
font-size: var(--text-xs);
}
.historyTitle {
font-weight: 800;
}
.emptyStory {
color: var(--text2);
font-size: var(--text-xs);
}
.rightStack {
min-width: 0;
display: grid;
grid-template-rows: auto auto auto;
gap: 16px;
}
.surfacePad {
padding: 12px;
}
.previewHeader {
display: flex;
align-items: center;
justify-content: space-between;
}
.previewTitle {
font-size: var(--text-md);
font-weight: 900;
}
.previewActions {
display: flex;
gap: 10px;
}
.videoHint {
color: var(--text2);
font-size: var(--text-xs);
line-height: 1.45;
margin-bottom: 8px;
}
.previewFrame {
border-radius: 18px;
border: 1px solid var(--stroke);
height: 360px;
overflow: hidden;
background: var(--color-overlay-dark-2);
position: relative;
}
.previewHost {
position: absolute;
inset: 0;
}
.brushCursor {
position: absolute;
z-index: 2;
transform: translate(-50%, -50%);
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.55);
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.3),
0 0 18px rgba(255, 140, 40, 0.12),
inset 0 0 18px rgba(255, 140, 40, 0.1);
background: rgba(255, 160, 60, 0.03);
pointer-events: none;
}
.brushLayer {
position: absolute;
inset: 0;
z-index: 3;
cursor: crosshair;
}
.branchTitle {
font-size: var(--text-md);
font-weight: 900;
margin-bottom: 10px;
}
.branchGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
.branchCard {
border-radius: var(--scene-tile-radius);
border: 1px solid var(--stroke);
background: var(--color-overlay-dark-2);
padding: 12px;
display: grid;
gap: 10px;
}
.branchCardHeader {
display: flex;
justify-content: space-between;
gap: 12px;
color: var(--text2);
}
.branchOption {
font-size: 11px;
font-weight: 900;
}
.branchName {
font-weight: 900;
}
.musicHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.musicEmpty {
color: var(--text2);
font-size: var(--text-xs);
}
.audioMeta {
min-width: 0;
}
.audioBadges {
display: flex;
gap: 10px;
color: var(--text2);
font-size: var(--text-xs);
}
.audioName {
font-weight: 800;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.audioTransport {
display: flex;
gap: 10px;
flex-shrink: 0;
}
.scrubFill {
height: 100%;
background: var(--accent-fill-solid);
}
.timeRow {
margin-top: 6px;
display: flex;
justify-content: space-between;
color: var(--text2);
font-size: 11px;
}
.branchEmpty {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--text2);
font-size: var(--text-xs);
padding: 6px 2px;
}
.audioList {
display: grid;
gap: 8px;
max-height: 210px;
overflow: auto;
}
.audioCard {
padding: 10px;
border-radius: 14px;
border: 1px solid var(--stroke);
background: var(--color-overlay-dark-2);
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
}
.audioScrub {
margin-top: 10px;
height: 10px;
border-radius: var(--radius-pill);
border: 1px solid var(--stroke);
background: rgba(0, 0, 0, 0.22);
overflow: hidden;
}
.audioScrubPointer {
cursor: pointer;
}
.audioScrubDefault {
cursor: default;
}
+887
View File
@@ -0,0 +1,887 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ipcChannels } from '../../shared/ipc/contracts';
import type { SessionState } from '../../shared/ipc/contracts';
import type { GraphNodeId, Scene, SceneId } from '../../shared/types';
import { getDndApi } from '../shared/dndApi';
import { PixiEffectsOverlay } from '../shared/effects/PxiEffectsOverlay';
import { useEffectsState } from '../shared/effects/useEffectsState';
import { Button } from '../shared/ui/controls';
import { Surface } from '../shared/ui/Surface';
import styles from './ControlApp.module.css';
import { ControlScenePreview } from './ControlScenePreview';
function formatTime(sec: number): string {
if (!Number.isFinite(sec) || sec < 0) return '0:00';
const s = Math.floor(sec);
const m = Math.floor(s / 60);
const r = s % 60;
return `${String(m)}:${String(r).padStart(2, '0')}`;
}
export function ControlApp() {
const api = getDndApi();
const [fxState, fx] = useEffectsState();
const [session, setSession] = useState<SessionState | null>(null);
const historyRef = useRef<GraphNodeId[]>([]);
const suppressNextHistoryPushRef = useRef(false);
const [history, setHistory] = useState<GraphNodeId[]>([]);
const audioElsRef = useRef<Map<string, HTMLAudioElement>>(new Map());
const audioMetaRef = useRef<Map<string, { lastPlayError: string | null }>>(new Map());
const [audioStateTick, setAudioStateTick] = useState(0);
const audioLoadRunRef = useRef(0);
const previewHostRef = useRef<HTMLDivElement | null>(null);
const previewVideoRef = useRef<HTMLVideoElement | null>(null);
const brushRef = useRef<{
tool: 'fog' | 'fire' | 'rain' | 'lightning' | 'freeze' | 'eraser';
startN?: { x: number; y: number };
points?: { x: number; y: number; tMs: number }[];
} | null>(null);
const [draftFxTick, setDraftFxTick] = useState(0);
const [cursorN, setCursorN] = useState<{ x: number; y: number } | null>(null);
const [previewSize, setPreviewSize] = useState<{ w: number; h: number }>({ w: 1, h: 1 });
const [previewContentRect, setPreviewContentRect] = useState<{
x: number;
y: number;
w: number;
h: number;
} | null>(null);
useEffect(() => {
void api.invoke(ipcChannels.project.get, {}).then((res) => {
const next: SessionState = {
project: res.project,
currentSceneId: res.project?.currentSceneId ?? null,
};
setSession(next);
historyRef.current = next.project?.currentGraphNodeId ? [next.project.currentGraphNodeId] : [];
setHistory(historyRef.current);
});
return api.on(ipcChannels.session.stateChanged, ({ state }) => {
setSession(state);
const cur = state.project?.currentGraphNodeId ?? null;
if (!cur) return;
const arr = historyRef.current;
if (suppressNextHistoryPushRef.current) {
suppressNextHistoryPushRef.current = false;
setHistory(arr);
return;
}
// Если мы перемотались на уже существующий шаг, не дублируем его в истории.
if (arr.includes(cur)) {
setHistory(arr);
return;
}
if (arr[arr.length - 1] !== cur) {
historyRef.current = [...arr, cur];
setHistory(historyRef.current);
}
});
}, [api]);
const project = session?.project ?? null;
const currentGraphNodeId = project?.currentGraphNodeId ?? null;
const currentScene =
project && session?.currentSceneId ? project.scenes[session.currentSceneId] : undefined;
const isVideoPreviewScene = currentScene?.previewAssetType === 'video';
const sceneAudioRefs = useMemo(() => currentScene?.media.audios ?? [], [currentScene]);
const sceneAudios = useMemo(() => {
if (!project) return [];
return sceneAudioRefs
.map((r) => {
const a = project.assets[r.assetId];
return a?.type === 'audio' ? { ref: r, asset: a } : null;
})
.filter((x): x is { ref: (typeof sceneAudioRefs)[number]; asset: NonNullable<typeof x>['asset'] } =>
Boolean(x),
);
}, [project, sceneAudioRefs]);
useEffect(() => {
audioLoadRunRef.current += 1;
const runId = audioLoadRunRef.current;
// Cleanup old audios on scene change.
const els = audioElsRef.current;
for (const el of els.values()) {
try {
el.pause();
el.currentTime = 0;
} catch {
// ignore
}
}
els.clear();
audioMetaRef.current.clear();
setAudioStateTick((x) => x + 1);
if (!project || !currentScene) return;
void (async () => {
const loaded: { ref: (typeof sceneAudioRefs)[number]; el: HTMLAudioElement }[] = [];
for (const item of sceneAudioRefs) {
const r = await api.invoke(ipcChannels.project.assetFileUrl, { assetId: item.assetId });
if (audioLoadRunRef.current !== runId) return;
if (!r.url) continue;
const el = new Audio(r.url);
el.loop = item.loop;
el.preload = 'auto';
audioMetaRef.current.set(item.assetId, { lastPlayError: null });
el.addEventListener('play', () => setAudioStateTick((x) => x + 1));
el.addEventListener('pause', () => setAudioStateTick((x) => x + 1));
el.addEventListener('ended', () => setAudioStateTick((x) => x + 1));
el.addEventListener('canplay', () => setAudioStateTick((x) => x + 1));
el.addEventListener('error', () => setAudioStateTick((x) => x + 1));
loaded.push({ ref: item, el });
audioElsRef.current.set(item.assetId, el);
}
setAudioStateTick((x) => x + 1);
for (const { ref, el } of loaded) {
if (audioLoadRunRef.current !== runId) {
try {
el.pause();
el.currentTime = 0;
} catch {
// ignore
}
continue;
}
if (!ref.autoplay) continue;
try {
await el.play();
} catch {
const m = audioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
audioMetaRef.current.set(ref.assetId, {
...m,
lastPlayError:
'Автозапуск заблокирован (нужно действие пользователя) или ошибка воспроизведения.',
});
setAudioStateTick((x) => x + 1);
}
}
})();
}, [api, currentScene, project, sceneAudioRefs]);
const anyPlaying = useMemo(() => {
for (const el of audioElsRef.current.values()) {
if (!el.paused) return true;
}
return false;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [audioStateTick]);
useEffect(() => {
if (!anyPlaying) return;
let raf = 0;
const tick = () => {
setAudioStateTick((x) => x + 1);
raf = window.requestAnimationFrame(tick);
};
raf = window.requestAnimationFrame(tick);
return () => window.cancelAnimationFrame(raf);
}, [anyPlaying]);
useEffect(() => {
const host = previewHostRef.current;
if (!host) return;
const update = () => {
const r = host.getBoundingClientRect();
setPreviewSize({ w: Math.max(1, r.width), h: Math.max(1, r.height) });
};
update();
const ro = new ResizeObserver(update);
ro.observe(host);
return () => ro.disconnect();
}, []);
function audioStatus(assetId: string): { label: string; detail?: string } {
const el = audioElsRef.current.get(assetId) ?? null;
if (!el) return { label: 'URL не получен', detail: 'Не удалось получить dnd://asset URL для аудио.' };
const meta = audioMetaRef.current.get(assetId) ?? { lastPlayError: null };
if (meta.lastPlayError) return { label: 'Ошибка/блок', detail: meta.lastPlayError };
if (el.error)
return {
label: 'Ошибка',
detail: `MediaError code=${String(el.error.code)} (1=ABORTED, 2=NETWORK, 3=DECODE, 4=SRC_NOT_SUPPORTED)`,
};
if (el.readyState < 2) return { label: 'Загрузка…' };
if (!el.paused) return { label: 'Играет' };
if (el.currentTime > 0) return { label: 'Пауза' };
return { label: 'Остановлено' };
}
const nextScenes = useMemo(() => {
if (!project) return [];
if (!currentGraphNodeId) return [];
const outgoing = project.sceneGraphEdges
.filter((e) => e.sourceGraphNodeId === currentGraphNodeId)
.map((e) => {
const n = project.sceneGraphNodes.find((x) => x.id === e.targetGraphNodeId);
return n ? { graphNodeId: e.targetGraphNodeId, sceneId: n.sceneId } : null;
})
.filter((x): x is { graphNodeId: GraphNodeId; sceneId: SceneId } => Boolean(x));
return outgoing
.map((o) => ({ graphNodeId: o.graphNodeId, scene: project.scenes[o.sceneId] }))
.filter((x): x is { graphNodeId: GraphNodeId; scene: Scene } => x.scene !== undefined);
}, [currentGraphNodeId, project]);
const tool = fxState?.tool ?? { tool: 'fog', radiusN: 0.08, intensity: 0.6 };
function toNPoint(e: React.PointerEvent): { x: number; y: number } | null {
const host = previewHostRef.current;
if (!host) return null;
const r = host.getBoundingClientRect();
const cr = previewContentRect;
const ox = cr ? cr.x : 0;
const oy = cr ? cr.y : 0;
const cw = cr ? cr.w : r.width;
const ch = cr ? cr.h : r.height;
const x = (e.clientX - (r.left + ox)) / Math.max(1, cw);
const y = (e.clientY - (r.top + oy)) / Math.max(1, ch);
return { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) };
}
async function commitStroke(): Promise<void> {
if (isVideoPreviewScene) {
brushRef.current = null;
setDraftFxTick((x) => x + 1);
return;
}
if (!fxState) return;
const b = brushRef.current;
if (!b) return;
const createdAtMs = Date.now();
const seed = Math.floor(Math.random() * 1_000_000_000);
if (b.tool === 'fog' && b.points && b.points.length > 0) {
await fx.dispatch({
kind: 'instance.add',
instance: {
id: `fog_${String(createdAtMs)}_${String(seed)}`,
type: 'fog',
seed,
createdAtMs,
points: b.points,
radiusN: tool.radiusN,
opacity: Math.max(0.05, Math.min(0.9, tool.intensity)),
lifetimeMs: null,
},
});
}
if (b.tool === 'fire' && b.points && b.points.length > 0) {
await fx.dispatch({
kind: 'instance.add',
instance: {
id: `fire_${String(createdAtMs)}_${String(seed)}`,
type: 'fire',
seed,
createdAtMs,
points: b.points,
radiusN: tool.radiusN,
// Огонь визуально ярче, но всё равно ограничиваемся безопасными пределами.
opacity: Math.max(0.12, Math.min(0.95, tool.intensity)),
lifetimeMs: null,
},
});
}
if (b.tool === 'rain' && b.points && b.points.length > 0) {
await fx.dispatch({
kind: 'instance.add',
instance: {
id: `rain_${String(createdAtMs)}_${String(seed)}`,
type: 'rain',
seed,
createdAtMs,
points: b.points,
radiusN: tool.radiusN,
opacity: Math.max(0.08, Math.min(0.9, tool.intensity)),
lifetimeMs: null,
},
});
}
if (b.tool === 'lightning' && b.startN && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return;
const end = { x: last.x, y: last.y };
const start = { x: end.x, y: 0 };
await fx.dispatch({
kind: 'instance.add',
instance: {
id: `lt_${String(createdAtMs)}_${String(seed)}`,
type: 'lightning',
seed,
createdAtMs,
start,
end,
widthN: Math.max(0.01, tool.radiusN * 0.9),
intensity: Math.max(0.9, Math.min(1.2, tool.intensity * 1.35)),
lifetimeMs: 180,
},
});
await fx.dispatch({
kind: 'instance.add',
instance: {
id: `sc_${String(createdAtMs)}_${String(seed)}`,
type: 'scorch',
seed: seed ^ 0x5a5a5a,
createdAtMs,
at: end,
radiusN: Math.max(0.03, tool.radiusN * 0.625),
opacity: 0.92,
lifetimeMs: 60_000,
},
});
}
if (b.tool === 'freeze' && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return;
const at = { x: last.x, y: last.y };
await fx.dispatch({
kind: 'instance.add',
instance: {
id: `fr_${String(createdAtMs)}_${String(seed)}`,
type: 'freeze',
seed,
createdAtMs,
at,
intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)),
// Быстро появиться → чуть задержаться → плавно исчезнуть.
lifetimeMs: 820,
},
});
await fx.dispatch({
kind: 'instance.add',
instance: {
id: `ice_${String(createdAtMs)}_${String(seed)}`,
type: 'ice',
seed: seed ^ 0x33cc99,
createdAtMs,
at,
radiusN: Math.max(0.03, tool.radiusN * 0.9),
opacity: 0.85,
lifetimeMs: 60_000,
},
});
}
brushRef.current = null;
setDraftFxTick((x) => x + 1);
}
const draftInstance = useMemo(() => {
const b = brushRef.current;
if (!b) return null;
const seed = 12345;
const createdAtMs = Date.now();
if (b.tool === 'fog' && b.points && b.points.length > 0) {
return {
id: '__draft__',
type: 'fog' as const,
seed,
createdAtMs,
points: b.points,
radiusN: tool.radiusN,
opacity: Math.max(0.05, Math.min(0.6, tool.intensity * 0.7)),
lifetimeMs: null,
};
}
if (b.tool === 'fire' && b.points && b.points.length > 0) {
return {
id: '__draft__',
type: 'fire' as const,
seed,
createdAtMs,
points: b.points,
radiusN: tool.radiusN,
opacity: Math.max(0.12, Math.min(0.75, tool.intensity * 0.85)),
lifetimeMs: null,
};
}
if (b.tool === 'rain' && b.points && b.points.length > 0) {
return {
id: '__draft__',
type: 'rain' as const,
seed,
createdAtMs,
points: b.points,
radiusN: tool.radiusN,
opacity: Math.max(0.08, Math.min(0.65, tool.intensity * 0.85)),
lifetimeMs: null,
};
}
if (b.tool === 'lightning' && b.startN && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return null;
return {
id: '__draft__',
type: 'lightning' as const,
seed,
createdAtMs,
start: { x: last.x, y: 0 },
end: { x: last.x, y: last.y },
widthN: Math.max(0.01, tool.radiusN * 0.9),
intensity: Math.max(0.9, Math.min(1.2, tool.intensity * 1.35)),
lifetimeMs: 180,
};
}
if (b.tool === 'freeze' && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return null;
return {
id: '__draft__',
type: 'freeze' as const,
seed,
createdAtMs,
at: { x: last.x, y: last.y },
intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)),
lifetimeMs: 240,
};
}
return null;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [draftFxTick, tool.intensity, tool.radiusN, tool.tool]);
const fxMergedState = useMemo(() => {
if (!fxState) return null;
if (!draftInstance) return fxState;
return { ...fxState, instances: [...fxState.instances, draftInstance] };
}, [draftInstance, fxState]);
return (
<div className={styles.page}>
<Surface className={styles.remote}>
<div className={styles.remoteTitle}>ПУЛЬТ УПРАВЛЕНИЯ</div>
<div className={styles.spacer12} />
{!isVideoPreviewScene ? (
<>
<div className={styles.sectionLabel}>ЭФФЕКТЫ</div>
<div className={styles.spacer8} />
<div className={styles.effectsStack}>
<div className={styles.iconRow}>
<Button
variant={tool.tool === 'fog' ? 'primary' : 'ghost'}
iconOnly
title="Туман"
ariaLabel="Туман"
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'fog' } })}
>
<span className={styles.iconGlyph}>🌫</span>
</Button>
<Button
variant={tool.tool === 'fire' ? 'primary' : 'ghost'}
iconOnly
title="Огонь"
ariaLabel="Огонь"
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'fire' } })}
>
<span className={styles.iconGlyph}>🔥</span>
</Button>
<Button
variant={tool.tool === 'rain' ? 'primary' : 'ghost'}
iconOnly
title="Дождь"
ariaLabel="Дождь"
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'rain' } })}
>
<span className={styles.iconGlyph}>🌧</span>
</Button>
<Button
variant={tool.tool === 'lightning' ? 'primary' : 'ghost'}
iconOnly
title="Молния"
ariaLabel="Молния"
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'lightning' } })}
>
<span className={styles.iconGlyph}></span>
</Button>
<Button
variant={tool.tool === 'freeze' ? 'primary' : 'ghost'}
iconOnly
title="Заморозка"
ariaLabel="Заморозка"
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'freeze' } })}
>
<span className={styles.iconGlyph}></span>
</Button>
<Button
variant={tool.tool === 'eraser' ? 'primary' : 'ghost'}
iconOnly
title="Ластик"
ariaLabel="Ластик"
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'eraser' } })}
>
<span className={styles.iconGlyph}>🧹</span>
</Button>
<Button
variant="ghost"
iconOnly
title="Очистить эффекты"
ariaLabel="Очистить эффекты"
onClick={() => void fx.dispatch({ kind: 'instances.clear' })}
>
<span className={styles.clearIcon}>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
<circle cx="12" cy="12" r="8" fill="none" stroke="#e5484d" strokeWidth="2" />
<line
x1="7"
y1="17"
x2="17"
y2="7"
stroke="#e5484d"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</span>
</Button>
</div>
<div className={styles.radiusRow}>
<div className={styles.radiusLabel}>Радиус кисти</div>
<input
type="range"
min={0.015}
max={0.18}
step={0.001}
value={tool.radiusN}
onChange={(e) => {
const v = Number((e.currentTarget as HTMLInputElement).value);
const next = Math.max(0.01, Math.min(0.25, Number.isFinite(v) ? v : tool.radiusN));
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, radiusN: next } });
}}
className={styles.range}
aria-label="Радиус кисти"
/>
<div className={styles.radiusValue}>{Math.round(tool.radiusN * 100)}</div>
</div>
</div>
<div className={styles.spacer12} />
</>
) : null}
<div className={styles.storyWrap}>
<div className={styles.sectionLabel}>СЮЖЕТНАЯ ЛИНИЯ</div>
<div className={styles.spacer10} />
<div className={styles.storyScroll}>
{history.map((gnId, idx) => {
const gn = project?.sceneGraphNodes.find((n) => n.id === gnId);
const s = gn ? project?.scenes[gn.sceneId] : undefined;
const isCurrent = gnId === project?.currentGraphNodeId;
return (
<button
type="button"
key={`${gnId}_${String(idx)}`}
disabled={!project || isCurrent}
className={[styles.historyBtn, isCurrent ? styles.historyBtnCurrent : '']
.filter(Boolean)
.join(' ')}
title={project && !isCurrent ? 'Перейти к этой сцене' : undefined}
onClick={() => {
if (!project) return;
if (isCurrent) return;
// Перемотка: переходим на выбранный шаг без добавления нового пункта в историю.
suppressNextHistoryPushRef.current = true;
void api.invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId: gnId });
}}
>
{isCurrent ? (
<div className={styles.historyBadge}>ТЕКУЩАЯ СЦЕНА</div>
) : (
<div className={styles.historyMuted}>Пройдено</div>
)}
<div className={styles.historyTitle}>{s?.title ?? (gn ? String(gn.sceneId) : gnId)}</div>
</button>
);
})}
{history.length === 0 ? <div className={styles.emptyStory}>Нет активной сцены.</div> : null}
</div>
</div>
</Surface>
<div className={styles.rightStack}>
<Surface className={styles.surfacePad}>
<div className={styles.previewHeader}>
<div className={styles.previewTitle}>Предпросмотр экрана</div>
<div className={styles.previewActions}>
<Button onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})}>
Выключить демонстрацию
</Button>
</div>
</div>
<div className={styles.spacer10} />
{isVideoPreviewScene ? (
<div className={styles.videoHint}>
Видео-превью: кисть эффектов отключена (как на экране демонстрации оверлей только для
изображения).
</div>
) : null}
<div className={styles.spacer10} />
<div className={styles.previewFrame}>
<div ref={previewHostRef} className={styles.previewHost}>
<ControlScenePreview
session={session}
videoRef={previewVideoRef}
onContentRectChange={setPreviewContentRect}
/>
</div>
{!isVideoPreviewScene ? (
<>
<PixiEffectsOverlay
state={fxMergedState}
style={{ zIndex: 1 }}
viewport={
previewContentRect
? {
x: previewContentRect.x,
y: previewContentRect.y,
w: previewContentRect.w,
h: previewContentRect.h,
}
: undefined
}
/>
{cursorN ? (
<div
className={styles.brushCursor}
style={{
left:
(previewContentRect ? previewContentRect.x : 0) +
cursorN.x * (previewContentRect ? previewContentRect.w : previewSize.w),
top:
(previewContentRect ? previewContentRect.y : 0) +
cursorN.y * (previewContentRect ? previewContentRect.h : previewSize.h),
width:
tool.radiusN *
Math.min(
previewContentRect ? previewContentRect.w : previewSize.w,
previewContentRect ? previewContentRect.h : previewSize.h,
) *
2,
height:
tool.radiusN *
Math.min(
previewContentRect ? previewContentRect.w : previewSize.w,
previewContentRect ? previewContentRect.h : previewSize.h,
) *
2,
}}
/>
) : null}
<div
className={styles.brushLayer}
onPointerEnter={(e) => {
const p = toNPoint(e);
if (!p) return;
setCursorN(p);
}}
onPointerLeave={() => setCursorN(null)}
onPointerDown={(e) => {
const p = toNPoint(e);
if (!p) return;
setCursorN(p);
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
if (tool.tool === 'eraser') {
const rN = tool.radiusN;
const nearest = (fxState?.instances ?? [])
.map((inst) => {
if (inst.type === 'fog') {
const d = inst.points.reduce((best, q) => {
const dx = q.x - p.x;
const dy = q.y - p.y;
const dd = dx * dx + dy * dy;
return Math.min(best, dd);
}, Number.POSITIVE_INFINITY);
return { id: inst.id, dd: d };
}
if (inst.type === 'lightning') {
const dx = inst.end.x - p.x;
const dy = inst.end.y - p.y;
return { id: inst.id, dd: dx * dx + dy * dy };
}
if (inst.type === 'freeze') {
const dx = inst.at.x - p.x;
const dy = inst.at.y - p.y;
return { id: inst.id, dd: dx * dx + dy * dy };
}
return { id: inst.id, dd: Number.POSITIVE_INFINITY };
})
.sort((a, b) => a.dd - b.dd)[0];
if (nearest && nearest.dd <= rN * rN) {
void fx.dispatch({ kind: 'instance.remove', id: nearest.id });
}
return;
}
brushRef.current = {
tool: tool.tool,
startN: p,
points: [{ x: p.x, y: p.y, tMs: Date.now() }],
};
setDraftFxTick((x) => x + 1);
}}
onPointerMove={(e) => {
const b = brushRef.current;
const p = toNPoint(e);
if (!p) return;
setCursorN(p);
if (!b?.points) return;
const last = b.points[b.points.length - 1];
if (!last) return;
const dx = p.x - last.x;
const dy = p.y - last.y;
const minStep = Math.max(0.004, tool.radiusN * 0.25);
if (dx * dx + dy * dy < minStep * minStep) return;
b.points.push({ x: p.x, y: p.y, tMs: Date.now() });
setDraftFxTick((x) => x + 1);
}}
onPointerUp={() => {
void commitStroke();
}}
onPointerCancel={() => {
brushRef.current = null;
setDraftFxTick((x) => x + 1);
}}
/>
</>
) : null}
</div>
</Surface>
<Surface className={styles.surfacePad}>
<div className={styles.branchTitle}>Варианты ветвления</div>
<div className={styles.branchGrid}>
{nextScenes.map((o, i) => (
<div key={o.graphNodeId} className={styles.branchCard}>
<div className={styles.branchCardHeader}>
<div className={styles.branchOption}>ОПЦИЯ {String(i + 1)}</div>
</div>
<div className={styles.branchName}>{o.scene.title || 'Без названия'}</div>
<Button
variant="primary"
onClick={() =>
void api.invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId: o.graphNodeId })
}
>
Переключить
</Button>
</div>
))}
{nextScenes.length === 0 ? (
<div className={styles.branchEmpty}>
<div>Нет вариантов перехода.</div>
<Button
variant="primary"
disabled={!session?.project?.currentGraphNodeId}
onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})}
>
Завершить показ
</Button>
</div>
) : null}
</div>
</Surface>
<Surface className={styles.surfacePad}>
<div className={styles.musicHeader}>
<div className={styles.previewTitle}>Музыка</div>
</div>
<div className={styles.spacer10} />
{sceneAudios.length === 0 ? (
<div className={styles.musicEmpty}>В текущей сцене нет аудио.</div>
) : (
<div className={styles.audioList}>
{sceneAudios.map(({ ref, asset }) => {
const el = audioElsRef.current.get(ref.assetId) ?? null;
const st = audioStatus(ref.assetId);
const dur = el?.duration && Number.isFinite(el.duration) ? el.duration : 0;
const cur = el?.currentTime && Number.isFinite(el.currentTime) ? el.currentTime : 0;
const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0;
return (
<div key={ref.assetId} className={styles.audioCard}>
<div className={styles.audioMeta}>
<div className={styles.audioName}>{asset.originalName}</div>
<div className={styles.audioBadges}>
<div>{ref.autoplay ? 'Авто' : 'Ручн.'}</div>
<div>{ref.loop ? 'Цикл' : 'Один раз'}</div>
<div title={st.detail}>{st.label}</div>
</div>
<div className={styles.spacer10} />
<div
role="slider"
aria-valuemin={0}
aria-valuemax={dur > 0 ? Math.round(dur) : 0}
aria-valuenow={Math.round(cur)}
tabIndex={0}
onKeyDown={(e) => {
if (!el) return;
if (!dur) return;
if (e.key === 'ArrowLeft') el.currentTime = Math.max(0, el.currentTime - 5);
if (e.key === 'ArrowRight') el.currentTime = Math.min(dur, el.currentTime + 5);
setAudioStateTick((x) => x + 1);
}}
onClick={(e) => {
if (!el) return;
if (!dur) return;
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
const next = (e.clientX - rect.left) / rect.width;
el.currentTime = Math.max(0, Math.min(dur, next * dur));
setAudioStateTick((x) => x + 1);
}}
className={[
styles.audioScrub,
dur > 0 ? styles.audioScrubPointer : styles.audioScrubDefault,
].join(' ')}
title={dur > 0 ? 'Клик — перемотка' : 'Длительность неизвестна'}
>
<div
className={styles.scrubFill}
style={{ width: `${String(Math.round(pct * 100))}%` }}
/>
</div>
<div className={styles.timeRow}>
<div>{formatTime(cur)}</div>
<div>{dur ? formatTime(dur) : '—:—'}</div>
</div>
</div>
<div className={styles.audioTransport}>
<Button
variant="primary"
onClick={() => {
if (!el) return;
const m = audioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
audioMetaRef.current.set(ref.assetId, { ...m, lastPlayError: null });
void el.play().catch(() => {
const mm = audioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
audioMetaRef.current.set(ref.assetId, {
...mm,
lastPlayError: 'Не удалось запустить.',
});
setAudioStateTick((x) => x + 1);
});
}}
>
</Button>
<Button
onClick={() => {
if (!el) return;
el.pause();
}}
>
</Button>
<Button
onClick={() => {
if (!el) return;
el.pause();
el.currentTime = 0;
setAudioStateTick((x) => x + 1);
}}
>
</Button>
</div>
</div>
);
})}
</div>
)}
</Surface>
</div>
</div>
);
}
@@ -0,0 +1,72 @@
.root {
position: absolute;
inset: 0;
}
.video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
}
.placeholder {
position: absolute;
inset: 0;
background: var(--color-overlay-dark-6);
}
.controls {
position: absolute;
left: 12px;
right: 12px;
bottom: 12px;
display: grid;
gap: 8px;
pointer-events: auto;
}
.scrub {
height: 10px;
border-radius: var(--radius-pill);
border: 1px solid var(--stroke);
background: var(--color-overlay-dark-3);
overflow: hidden;
}
.scrubPointer {
cursor: pointer;
}
.scrubDefault {
cursor: default;
}
.scrubFill {
height: 100%;
background: var(--accent-fill-solid);
}
.row {
display: flex;
justify-content: space-between;
gap: 12px;
color: var(--text2);
font-size: var(--text-xs);
}
.transport {
display: flex;
gap: 8px;
}
.transportBtn {
width: 34px;
height: 30px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke-light);
background: var(--color-overlay-dark-3);
color: var(--text-muted-on-dark-2);
cursor: pointer;
}
@@ -0,0 +1,181 @@
import React, { useEffect, useMemo, useState } from 'react';
import { computeTimeSec } from '../../main/video/videoPlaybackStore';
import type { SessionState } from '../../shared/ipc/contracts';
import { RotatedImage } from '../shared/RotatedImage';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import { useVideoPlaybackState } from '../shared/video/useVideoPlaybackState';
import styles from './ControlScenePreview.module.css';
type Props = {
session: SessionState | null;
videoRef: React.RefObject<HTMLVideoElement | null>;
onContentRectChange?: (rect: { x: number; y: number; w: number; h: number }) => void;
};
function fmt(sec: number): string {
if (!Number.isFinite(sec) || sec < 0) return '0:00';
const s = Math.floor(sec);
const m = Math.floor(s / 60);
const r = s % 60;
return `${String(m)}:${String(r).padStart(2, '0')}`;
}
export function ControlScenePreview({ session, videoRef, onContentRectChange }: Props) {
const [vp, video] = useVideoPlaybackState();
const scene =
session?.project && session.currentSceneId ? session.project.scenes[session.currentSceneId] : undefined;
const url = useAssetUrl(scene?.previewAssetId ?? null);
const rot = scene?.previewRotationDeg ?? 0;
const isVideo = scene?.previewAssetType === 'video';
const assetId = scene?.previewAssetType === 'video' ? scene.previewAssetId : null;
const [tick, setTick] = useState(0);
const dur = useMemo(
() => {
const v = videoRef.current;
if (!v) return 0;
return Number.isFinite(v.duration) ? v.duration : 0;
},
// tick: перечитываем duration из video ref на каждом кадре RAF
// eslint-disable-next-line react-hooks/exhaustive-deps -- намеренно
[tick, videoRef],
);
const cur = useMemo(
() => {
const v = videoRef.current;
if (!v) return 0;
return Number.isFinite(v.currentTime) ? v.currentTime : 0;
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- намеренно
[tick, videoRef],
);
const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0;
useEffect(() => {
if (!isVideo) return;
let raf = 0;
const loop = () => {
setTick((x) => x + 1);
raf = window.requestAnimationFrame(loop);
};
raf = window.requestAnimationFrame(loop);
return () => window.cancelAnimationFrame(raf);
}, [isVideo]);
useEffect(() => {
if (!isVideo) return;
void video.dispatch({
kind: 'target.set',
assetId,
autostart: scene.previewVideoAutostart,
});
}, [assetId, isVideo, scene, video]);
useEffect(() => {
const v = videoRef.current;
if (!v) return;
if (!vp) return;
if (vp.targetAssetId !== assetId) return;
v.playbackRate = vp.playbackRate;
const desired = computeTimeSec(vp, vp.serverNowMs);
if (Number.isFinite(desired) && Math.abs(v.currentTime - desired) > 0.25) {
v.currentTime = Math.max(0, desired);
}
if (vp.playing) {
void v.play().catch(() => undefined);
} else {
v.pause();
}
}, [assetId, vp, videoRef]);
const scrubClass = [styles.scrub, dur ? styles.scrubPointer : styles.scrubDefault].join(' ');
return (
<div className={styles.root}>
{url && scene?.previewAssetType === 'image' ? (
<RotatedImage url={url} rotationDeg={rot} mode="contain" onContentRectChange={onContentRectChange} />
) : url && isVideo ? (
<video
ref={(el) => {
(videoRef as unknown as { current: HTMLVideoElement | null }).current = el;
}}
className={styles.video}
src={url}
playsInline
preload="auto"
>
<track kind="captions" srcLang="ru" label="Превью без субтитров" />
</video>
) : (
<div className={styles.placeholder} />
)}
{isVideo ? (
<div className={styles.controls}>
<div
role="slider"
tabIndex={0}
aria-valuemin={0}
aria-valuemax={dur > 0 ? Math.round(dur) : 0}
aria-valuenow={Math.round(cur)}
className={scrubClass}
onClick={(e) => {
const v = videoRef.current;
if (!v || !dur) return;
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
const next = (e.clientX - rect.left) / rect.width;
void video.dispatch({ kind: 'seek', timeSec: Math.max(0, Math.min(dur, next * dur)) });
setTick((x) => x + 1);
}}
onKeyDown={(e) => {
if (!dur) return;
if (e.key === 'ArrowLeft') void video.dispatch({ kind: 'seek', timeSec: Math.max(0, cur - 5) });
if (e.key === 'ArrowRight')
void video.dispatch({ kind: 'seek', timeSec: Math.min(dur, cur + 5) });
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') setTick((x) => x + 1);
}}
title="Клик — перемотка"
>
<div className={styles.scrubFill} style={{ width: `${String(Math.round(pct * 100))}%` }} />
</div>
<div className={styles.row}>
<div className={styles.transport}>
<button
type="button"
className={styles.transportBtn}
onClick={() => void video.dispatch({ kind: 'play' })}
title="Play"
>
</button>
<button
type="button"
className={styles.transportBtn}
onClick={() => void video.dispatch({ kind: 'pause' })}
title="Pause"
>
</button>
<button
type="button"
className={styles.transportBtn}
onClick={() => {
void video.dispatch({ kind: 'stop' });
setTick((x) => x + 1);
}}
title="Stop"
>
</button>
</div>
<div>
{fmt(cur)} / {dur ? fmt(dur) : '—:—'}
</div>
</div>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,59 @@
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 readControlApp(): string {
return fs.readFileSync(path.join(here, 'ControlApp.tsx'), 'utf8');
}
function readControlAppCss(): string {
return fs.readFileSync(path.join(here, 'ControlApp.module.css'), 'utf8');
}
void test('ControlApp: эффекты в пульте, иконки с тултипами и подписью для a11y', () => {
const src = readControlApp();
assert.ok(src.includes('ЭФФЕКТЫ'));
assert.ok(src.includes('title="Туман"'));
assert.ok(src.includes('ariaLabel="Туман"'));
assert.ok(src.includes('iconOnly'));
assert.ok(src.includes('title="Очистить эффекты"'));
assert.ok(src.includes('ariaLabel="Очистить эффекты"'));
assert.ok(src.includes('#e5484d'));
const fx = src.indexOf('ЭФФЕКТЫ');
const story = src.indexOf('СЮЖЕТНАЯ ЛИНИЯ');
assert.ok(fx !== -1 && story !== -1 && fx < story, 'Блок эффектов должен быть выше сюжетной линии');
});
void test('ControlApp: сюжетная линия — колонка сверху вниз и фон как у карточек ветвления', () => {
const src = readControlApp();
const css = readControlAppCss();
const story = src.indexOf('СЮЖЕТНАЯ ЛИНИЯ');
assert.ok(story !== -1);
assert.ok(src.includes('className={styles.storyScroll}'));
assert.match(css, /\.storyScroll[\s\S]*?justify-content:\s*flex-start/);
assert.match(css, /\.storyScroll[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/);
assert.match(css, /\.branchCard[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/);
});
void test('ControlApp: слой кисти не использует курсор not-allowed (ластик тоже crosshair)', () => {
const src = readControlApp();
const css = readControlAppCss();
assert.ok(!src.includes("tool.tool === 'eraser' ? 'not-allowed'"));
assert.ok(src.includes('className={styles.brushLayer}'));
assert.match(css, /\.brushLayer[\s\S]*?cursor:\s*crosshair/);
});
void test('ControlApp: радиус кисти не в блоке предпросмотра', () => {
const src = readControlApp();
const previewLabel = src.indexOf('Предпросмотр экрана');
const radius = src.indexOf('Радиус кисти');
assert.ok(previewLabel !== -1 && radius !== -1);
assert.ok(
radius < previewLabel,
'Слайдер радиуса должен быть в пульте (файл: выше заголовка предпросмотра)',
);
});
+16
View File
@@ -0,0 +1,16 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css';
import { ControlApp } from './ControlApp';
const rootEl = document.getElementById('root');
if (!rootEl) {
throw new Error('Missing #root element');
}
createRoot(rootEl).render(
<React.StrictMode>
<ControlApp />
</React.StrictMode>,
);
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/app-window-icon.png" type="image/png" />
<title>DnD Player — Editor</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/editor/main.tsx"></script>
</body>
</html>
+619
View File
@@ -0,0 +1,619 @@
.topBarRow {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
}
.brandButton {
display: flex;
align-items: center;
gap: 10px;
border: none;
background: transparent;
cursor: pointer;
padding: 0;
color: inherit;
font: inherit;
}
.brandLogo {
flex-shrink: 0;
display: block;
}
.brandTitle {
font-weight: 700;
}
.fileToolbar {
display: flex;
align-items: center;
gap: 14px;
color: var(--text2);
}
.fileMenuTrigger {
border: none;
background: transparent;
color: var(--text2);
cursor: pointer;
padding: 0;
font: inherit;
}
.flex1 {
flex: 1;
}
.appVersion {
flex-shrink: 0;
font-size: var(--text-xs);
color: var(--text2);
user-select: none;
}
.headerActions {
display: flex;
align-items: center;
gap: 10px;
}
.editorSidebar {
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: auto 1fr;
padding: 12px;
border-right: 1px solid var(--stroke);
background: var(--editor-column-bg);
overflow: hidden;
}
.editorGraphHost {
height: 100%;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
background: var(--bg0);
overflow: hidden;
}
.editorInspector {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 14px;
border-left: 1px solid var(--stroke);
background: var(--editor-column-bg);
}
.gridTools {
display: grid;
gap: 10px;
}
.spacer14 {
height: 14px;
}
.sidebarScroll {
overflow: auto;
padding-right: 2px;
min-height: 0;
align-self: stretch;
}
.sceneListGrid {
display: grid;
gap: 12px;
align-content: start;
justify-items: stretch;
}
.centerEmpty {
height: 100%;
flex: 1;
background: var(--bg0);
}
.inspectorTitle {
font-weight: 800;
margin-bottom: 12px;
flex-shrink: 0;
}
.inspectorScroll {
flex: 1;
min-height: 0;
overflow: auto;
padding-right: 4px;
}
.muted {
color: var(--text2);
}
.fileMenu {
position: fixed;
min-width: 220px;
border-radius: var(--radius-md);
border: 1px solid var(--stroke);
background: var(--color-surface-elevated-2);
box-shadow: var(--shadow-lg);
padding: 6px;
display: grid;
gap: 4px;
z-index: var(--z-file-menu);
}
.fileMenuItem {
text-align: left;
padding: 10px;
border-radius: var(--radius-sm);
border: none;
background: transparent;
color: var(--text0);
cursor: pointer;
font: inherit;
}
.modalBackdrop {
position: fixed;
inset: 0;
z-index: var(--z-modal-backdrop);
border: none;
padding: 0;
margin: 0;
background: var(--color-scrim);
cursor: default;
}
.modalDialog {
position: fixed;
z-index: var(--z-modal);
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 520px;
max-width: calc(100vw - 32px);
border-radius: var(--radius-lg);
border: 1px solid var(--stroke);
background: var(--color-surface-elevated);
box-shadow: var(--shadow-xl);
padding: 16px;
display: grid;
gap: 12px;
}
.modalHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.modalTitle {
font-weight: 900;
font-size: var(--text-lg);
}
.modalClose {
border: none;
background: var(--panel2);
color: var(--text2);
border-radius: var(--radius-sm);
width: 34px;
height: 34px;
cursor: pointer;
font-size: 18px;
line-height: 1;
}
.fieldGrid {
display: grid;
gap: 6px;
}
.fieldLabel {
color: var(--text2);
font-size: var(--text-xs);
font-weight: 800;
}
.fieldError {
color: var(--color-danger);
font-size: var(--text-xs);
}
.selectInput {
width: 100%;
box-sizing: border-box;
padding: 8px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: var(--bg0);
color: var(--text0);
font: inherit;
}
.rowFlex {
display: flex;
gap: 8px;
align-items: center;
}
.modalFooter {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 4px;
}
.fileSuffix {
color: var(--text2);
font-size: var(--text-xs);
flex-shrink: 0;
}
.projectPicker {
display: grid;
gap: 12px;
min-height: 0;
}
.projectPickerTitle {
font-weight: 900;
}
.projectPickerForm {
display: grid;
gap: 10px;
}
.spacer6 {
height: 6px;
}
.sectionLabel {
color: var(--text2);
font-size: var(--text-xs);
font-weight: 900;
letter-spacing: 0.6px;
}
.projectListScroll {
overflow: auto;
padding-right: 2px;
}
.projectList {
display: grid;
gap: 10px;
}
.projectCard {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 12px;
border-radius: 14px;
border: 1px solid var(--stroke);
background: var(--color-overlay-dark-2);
}
.projectCardBody {
flex: 1;
min-width: 0;
cursor: pointer;
border-radius: 10px;
}
.projectCardMenuBtn {
flex-shrink: 0;
margin: -4px -4px 0 0;
border: none;
background: transparent;
color: var(--text2);
cursor: pointer;
padding: 4px 8px;
border-radius: var(--radius-sm);
font-size: 18px;
line-height: 1;
}
.projectCardMenuBtn:hover {
background: var(--panel2);
color: var(--text0);
}
.projectCardName {
font-weight: 800;
}
.projectCardMeta {
color: var(--text2);
font-size: var(--text-xs);
}
.sceneInspector {
display: grid;
gap: 10px;
}
.labelSm {
color: var(--text2);
font-size: var(--text-xs);
font-weight: 700;
}
.spacer8 {
height: 8px;
}
.textarea {
min-height: 92px;
padding: 12px;
border-radius: var(--radius-md);
border: 1px solid var(--stroke);
background: var(--color-overlay-dark-3);
resize: none;
color: var(--text1);
outline: none;
}
.hint {
color: var(--text2);
font-size: var(--text-xs);
line-height: 1.4;
}
.previewBox {
border-radius: var(--radius-md);
border: 1px solid var(--stroke);
overflow: hidden;
background: var(--color-overlay-dark-3);
aspect-ratio: 16 / 9;
max-height: 140px;
display: flex;
align-items: center;
justify-content: center;
}
.videoCover {
width: 100%;
height: 100%;
object-fit: cover;
}
.previewEmpty {
color: var(--text2);
font-size: var(--text-xs);
padding: 12px;
}
.actionsRow {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.checkboxLabel {
display: flex;
gap: 8px;
align-items: center;
color: var(--text2);
}
.checkboxLabelSm {
display: flex;
gap: 6px;
align-items: center;
cursor: pointer;
}
.spanSm {
font-size: var(--text-xs);
font-weight: 700;
}
.spanXs {
font-size: 11px;
}
.audioDrop {
border-radius: var(--radius-md);
border: 1px dashed var(--stroke2);
padding: 10px;
display: grid;
gap: 8px;
}
.audioList {
display: grid;
gap: 6px;
max-height: 160px;
overflow: auto;
}
.audioRow {
font-size: var(--text-xs);
padding: 6px 8px;
border-radius: var(--radius-xs);
background: var(--color-overlay-dark-3);
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
min-width: 0;
}
.audioName {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.audioControls {
color: var(--text2);
flex-shrink: 0;
display: flex;
gap: 8px;
align-items: center;
min-width: 0;
}
.audioRemove {
flex-shrink: 0;
min-width: 28px;
min-height: 28px;
padding: 4px;
margin: -4px;
border: none;
background: transparent;
color: var(--text-muted-on-dark);
cursor: pointer;
line-height: 1;
display: grid;
place-items: center;
}
.audioRemove:hover {
color: rgba(255, 255, 255, 0.72);
}
.audioRemoveIcon {
display: block;
opacity: 0.92;
}
.hintBlock {
color: var(--text2);
}
.sceneCard {
border-radius: var(--scene-tile-radius);
overflow: hidden;
cursor: grab;
border: 1px solid transparent;
box-sizing: border-box;
background: transparent;
}
.sceneCard:not(.sceneCardActive):hover {
background: var(--scene-list-hover-bg);
}
.sceneCardActive {
border-color: var(--scene-list-selected-border);
background: var(--scene-list-selected-bg);
}
.sceneThumb {
height: 92px;
position: relative;
box-sizing: border-box;
padding: 8px;
}
.sceneThumbInner {
width: 100%;
height: 100%;
border-radius: 10px;
overflow: hidden;
}
.sceneThumbVideo {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.sceneThumbEmpty {
height: 92px;
position: relative;
box-sizing: border-box;
padding: 8px;
}
.sceneThumbEmptyInner {
width: 100%;
height: 100%;
border-radius: 10px;
background: var(--bg0);
}
.sceneCardBody {
padding: 10px;
display: grid;
gap: 6px;
}
.sceneCardHeader {
display: flex;
align-items: center;
gap: 8px;
}
.badgeCurrent {
font-size: var(--text-xs);
color: var(--accent2);
}
.sceneMenuBtn {
margin-left: auto;
border: none;
background: var(--panel2);
border-radius: var(--radius-xs);
padding: 4px 10px;
cursor: pointer;
color: var(--text2);
line-height: 1;
font-size: 16px;
flex-shrink: 0;
}
.sceneCardTitle {
font-weight: 750;
}
.menuBackdrop {
position: fixed;
inset: 0;
z-index: var(--z-menu-backdrop);
border: none;
padding: 0;
margin: 0;
background: transparent;
cursor: default;
}
.sceneCtxMenu {
position: fixed;
z-index: var(--z-file-menu);
min-width: 160px;
padding: 6px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: var(--color-surface-menu);
box-shadow: var(--shadow-menu);
display: grid;
gap: 2px;
}
.sceneCtxDanger {
text-align: left;
padding: 8px 10px;
border-radius: var(--radius-xs);
border: none;
background: transparent;
color: var(--color-danger);
font-size: 13px;
cursor: pointer;
width: 100%;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,276 @@
.nodeWrap {
width: 220px;
}
.handle {
background: var(--stroke-handle);
width: 8px;
height: 8px;
}
.card {
box-sizing: border-box;
width: 100%;
border-radius: var(--scene-tile-radius);
overflow: hidden;
color: rgba(255, 255, 255, 0.92);
padding: 8px;
background: #18181b;
border: 2px solid rgba(255, 255, 255, 0.12);
}
.cardActive {
border-color: var(--graph-node-active-border);
box-shadow: 0 25px 50px -12px rgba(139, 92, 246, 0.1);
}
.previewShell {
position: relative;
width: 100%;
height: 135px;
border-radius: 10px;
overflow: hidden;
background: #0c0c0e;
}
.previewFill {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
min-height: 0;
box-sizing: border-box;
}
.previewPlaceholder {
position: absolute;
inset: 0;
background: #0c0c0e;
}
.badgeStart {
position: absolute;
top: 8px;
left: 8px;
z-index: 2;
font-size: 8.5px;
font-weight: 800;
letter-spacing: 0.4px;
padding: 4px 8px;
border-radius: 8px;
background: var(--accent-fill-solid);
color: var(--text-on-accent);
box-shadow: var(--shadow-start-badge);
}
.cornerBadges {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
pointer-events: none;
}
.mediaBadge {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.72);
color: rgba(255, 255, 255, 0.92);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
}
.badgeGlyph {
display: block;
}
.imageCover,
.videoCover {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.imageCover {
user-select: none;
pointer-events: none;
}
.nodeBody {
padding-top: 8px;
display: grid;
gap: 8px;
min-width: 0;
}
.title {
font-weight: 900;
font-size: 20px;
line-height: 1.2;
letter-spacing: -0.02em;
}
.musicParams {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 12px;
min-width: 0;
}
.musicParam {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: calc(14px * 0.7);
font-weight: 800;
letter-spacing: 0.02em;
color: rgba(255, 255, 255, 0.95);
text-transform: none;
white-space: nowrap;
flex-shrink: 0;
}
.musicParamIcon {
flex-shrink: 0;
color: #a78bfa;
display: block;
}
.canvasWrap {
height: 100%;
width: 100%;
position: relative;
}
.canvasWrap :global(.react-flow) {
background-color: var(--bg0);
}
.canvasWrap :global(.react-flow__renderer) {
background-color: var(--bg0);
}
.canvasWrap :global(.react-flow__attribution) {
display: none;
}
.zoomPanel {
margin: 0 0 14px;
}
.zoomBar {
display: flex;
align-items: center;
gap: 2px;
padding: 4px 8px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(24, 24, 27, 0.96);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.35);
color: rgba(255, 255, 255, 0.55);
font-size: 13px;
font-weight: 600;
letter-spacing: -0.02em;
}
.zoomBtn {
display: grid;
place-items: center;
min-width: 30px;
height: 30px;
padding: 0 6px;
border: none;
border-radius: 8px;
background: transparent;
color: inherit;
font: inherit;
font-size: 17px;
font-weight: 500;
line-height: 1;
cursor: pointer;
}
.zoomBtn:hover {
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.82);
}
.zoomPct {
min-width: 44px;
text-align: center;
font-variant-numeric: tabular-nums;
color: rgba(255, 255, 255, 0.5);
font-size: 12px;
font-weight: 600;
}
.zoomDivider {
width: 1px;
height: 18px;
margin: 0 4px;
background: rgba(255, 255, 255, 0.12);
flex-shrink: 0;
}
.zoomFitIcon {
display: block;
opacity: 0.9;
}
.menuBackdrop {
position: fixed;
inset: 0;
z-index: var(--z-menu-backdrop);
border: none;
padding: 0;
margin: 0;
background: transparent;
cursor: default;
}
.ctxMenu {
position: fixed;
z-index: var(--z-file-menu);
min-width: 200px;
padding: 6px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: var(--color-surface-menu);
box-shadow: var(--shadow-menu);
display: grid;
gap: 2px;
}
.ctxItem {
text-align: left;
padding: 8px 10px;
border-radius: var(--radius-xs);
border: none;
background: transparent;
color: var(--text1);
font-size: 13px;
cursor: pointer;
width: 100%;
}
.ctxItemDanger {
text-align: left;
padding: 8px 10px;
border-radius: var(--radius-xs);
border: none;
background: transparent;
color: var(--color-danger);
font-size: 13px;
cursor: pointer;
width: 100%;
}
+496
View File
@@ -0,0 +1,496 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import ReactFlow, {
Background,
Handle,
MarkerType,
Panel,
Position,
ReactFlowProvider,
useEdgesState,
useNodesState,
useReactFlow,
useStore,
type Connection,
type Edge,
type Node,
type NodeProps,
} from 'reactflow';
import 'reactflow/dist/style.css';
import { isSceneGraphEdgeRejected } from '../../../shared/graph/sceneGraphEdgeRules';
import type {
AssetId,
GraphNodeId,
Scene,
SceneGraphEdge,
SceneGraphNode,
SceneId,
} from '../../../shared/types';
import { RotatedImage } from '../../shared/RotatedImage';
import { useAssetUrl } from '../../shared/useAssetImageUrl';
import styles from './SceneGraph.module.css';
/** MIME для перетаскивания сцены из списка на граф (см. EditorApp). */
export const DND_SCENE_ID_MIME = 'application/x-dnd-scene-id';
/** Примерные размеры карточки узла — чтобы точка сброса совпадала с центром карточки. */
const SCENE_CARD_W = 220;
const SCENE_CARD_H = 248;
export type SceneGraphProps = {
sceneGraphNodes: SceneGraphNode[];
sceneGraphEdges: SceneGraphEdge[];
sceneById: Record<SceneId, Scene>;
currentSceneId: SceneId | null;
onCurrentSceneChange: (id: SceneId) => void;
onConnect: (sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId) => void;
onDisconnect: (edgeId: string) => void;
onNodePositionCommit: (nodeId: GraphNodeId, x: number, y: number) => void;
onRemoveGraphNodes: (nodeIds: GraphNodeId[]) => void;
onRemoveGraphNode: (graphNodeId: GraphNodeId) => void;
onSetGraphNodeStart: (graphNodeId: GraphNodeId | null) => void;
onDropSceneFromList: (sceneId: SceneId, x: number, y: number) => void;
};
type SceneCardData = {
sceneId: SceneId;
title: string;
active: boolean;
previewAssetId: AssetId | null;
previewAssetType: 'image' | 'video' | null;
previewVideoAutostart: boolean;
previewRotationDeg: 0 | 90 | 180 | 270;
isStartScene: boolean;
hasSceneAudio: boolean;
previewIsVideo: boolean;
hasAnyAudioLoop: boolean;
hasAnyAudioAutoplay: boolean;
showPreviewVideoAutostart: boolean;
showPreviewVideoLoop: boolean;
};
function IconAudioBadge() {
return (
<svg className={styles.badgeGlyph} viewBox="0 0 24 24" width={14} height={14} aria-hidden>
<path fill="currentColor" d="M12 3v10.55A4 4 0 1 0 14 17V7h4V3h-6zM6 15a2 2 0 1 0 4 0 2 2 0 0 0-4 0z" />
</svg>
);
}
function IconVideoBadge() {
return (
<svg className={styles.badgeGlyph} viewBox="0 0 24 24" width={14} height={14} aria-hidden>
<path
fill="currentColor"
d="M4 6.5A2.5 2.5 0 0 1 6.5 4h7A2.5 2.5 0 0 1 16 6.5v11a2.5 2.5 0 0 1-2.5 2.5h-7A2.5 2.5 0 0 1 4 17.5v-11zM19 8.2l-3 2.2v3.2l3 2.2V8.2z"
/>
</svg>
);
}
function IconLoopParam() {
return (
<svg className={styles.musicParamIcon} viewBox="0 0 24 24" width={11} height={11} aria-hidden>
<path
fill="currentColor"
d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46A7.93 7.93 0 0 0 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74A7.93 7.93 0 0 0 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"
/>
</svg>
);
}
function IconAutoplayParam() {
return (
<svg className={styles.musicParamIcon} viewBox="0 0 24 24" width={11} height={11} aria-hidden>
<path fill="currentColor" d="M13 2 3 14h7v8l11-14h-8l2-8z" />
</svg>
);
}
/** Иконка для «Авто превью» (видео-превью). */
function IconVideoPreviewAutostart() {
return (
<svg className={styles.musicParamIcon} viewBox="0 0 24 24" width={11} height={11} aria-hidden>
<path fill="currentColor" d="M8 5v14l11-7-11-7z" />
</svg>
);
}
function SceneCardNode({ data }: NodeProps<SceneCardData>) {
const url = useAssetUrl(data.previewAssetId);
const cardClass = [styles.card, data.active ? styles.cardActive : ''].filter(Boolean).join(' ');
const showCornerVideo = data.previewIsVideo;
const showCornerAudio = data.hasSceneAudio;
return (
<div className={styles.nodeWrap}>
<Handle type="target" position={Position.Top} className={styles.handle} />
<div className={cardClass}>
<div className={styles.previewShell}>
{data.isStartScene ? <div className={styles.badgeStart}>НАЧАЛО</div> : null}
{url && data.previewAssetType === 'image' ? (
<div className={styles.previewFill}>
{data.previewRotationDeg === 0 ? (
<img src={url} alt="" className={styles.imageCover} draggable={false} />
) : (
<RotatedImage
url={url}
rotationDeg={data.previewRotationDeg}
mode="cover"
style={{ width: '100%', height: '100%' }}
/>
)}
</div>
) : url && data.previewAssetType === 'video' ? (
<video
src={url}
muted
playsInline
preload="metadata"
className={styles.videoCover}
onLoadedData={(e) => {
const v = e.currentTarget;
try {
v.currentTime = 0;
v.pause();
} catch {
// ignore
}
}}
/>
) : (
<div className={styles.previewPlaceholder} aria-hidden />
)}
{showCornerVideo || showCornerAudio ? (
<div className={styles.cornerBadges}>
{showCornerVideo ? (
<span className={styles.mediaBadge} title="Видео">
<IconVideoBadge />
</span>
) : null}
{showCornerAudio ? (
<span className={styles.mediaBadge} title="Аудио">
<IconAudioBadge />
</span>
) : null}
</div>
) : null}
</div>
<div className={styles.nodeBody}>
<div className={styles.title}>{data.title || 'Без названия'}</div>
{data.hasAnyAudioLoop || data.hasAnyAudioAutoplay ? (
<div className={styles.musicParams}>
{data.hasAnyAudioLoop ? (
<div className={styles.musicParam}>
<IconLoopParam />
<span>Цикл</span>
</div>
) : null}
{data.hasAnyAudioAutoplay ? (
<div className={styles.musicParam}>
<IconAutoplayParam />
<span>Автостарт</span>
</div>
) : null}
</div>
) : null}
{data.showPreviewVideoAutostart || data.showPreviewVideoLoop ? (
<div className={styles.musicParams}>
{data.showPreviewVideoAutostart ? (
<div className={styles.musicParam}>
<IconVideoPreviewAutostart />
<span>Авто превью</span>
</div>
) : null}
{data.showPreviewVideoLoop ? (
<div className={styles.musicParam}>
<IconLoopParam />
<span>Цикл видео</span>
</div>
) : null}
</div>
) : null}
</div>
</div>
<Handle type="source" position={Position.Bottom} className={styles.handle} />
</div>
);
}
const nodeTypes = { sceneCard: SceneCardNode };
function GraphZoomToolbar() {
const { zoomIn, zoomOut, fitView } = useReactFlow();
const zoom = useStore((s) => s.transform[2]);
const pct = Math.max(1, Math.round(zoom * 100));
return (
<Panel position="bottom-center" className={styles.zoomPanel}>
<div className={styles.zoomBar} role="toolbar" aria-label="Масштаб графа">
<button type="button" className={styles.zoomBtn} onClick={() => zoomIn()} aria-label="Увеличить">
+
</button>
<span className={styles.zoomPct}>{pct}%</span>
<button type="button" className={styles.zoomBtn} onClick={() => zoomOut()} aria-label="Уменьшить">
</button>
<span className={styles.zoomDivider} aria-hidden />
<button
type="button"
className={styles.zoomBtn}
onClick={() => fitView({ padding: 0.25 })}
aria-label="Показать всё"
title="Показать всё"
>
<svg className={styles.zoomFitIcon} viewBox="0 0 24 24" width={18} height={18} aria-hidden>
<path
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
d="M9 4H4v5M15 4h5v5M9 20H4v-5M15 20h5v-5"
/>
</svg>
</button>
</div>
</Panel>
);
}
function SceneGraphCanvas({
sceneGraphNodes,
sceneGraphEdges,
sceneById,
currentSceneId,
onCurrentSceneChange,
onConnect,
onDisconnect,
onNodePositionCommit,
onRemoveGraphNodes,
onRemoveGraphNode,
onSetGraphNodeStart,
onDropSceneFromList,
}: SceneGraphProps) {
const { screenToFlowPosition } = useReactFlow();
const [menu, setMenu] = useState<{ x: number; y: number; graphNodeId: GraphNodeId } | null>(null);
useEffect(() => {
if (!menu) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setMenu(null);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [menu]);
const menuNodeIsStart = useMemo(() => {
if (!menu) return false;
return sceneGraphNodes.some((n) => n.id === menu.graphNodeId && n.isStartScene);
}, [menu, sceneGraphNodes]);
const desiredNodes = useMemo<Node<SceneCardData>[]>(() => {
return sceneGraphNodes.map((gn) => {
const s = sceneById[gn.sceneId];
const active = gn.sceneId === currentSceneId;
const audios = s?.media.audios ?? [];
return {
id: gn.id,
type: 'sceneCard',
position: { x: gn.x, y: gn.y },
data: {
sceneId: gn.sceneId,
title: s?.title ?? '',
active,
previewAssetId: s?.previewAssetId ?? null,
previewAssetType: s?.previewAssetType ?? null,
previewVideoAutostart: s?.previewVideoAutostart ?? false,
previewRotationDeg: s?.previewRotationDeg ?? 0,
isStartScene: gn.isStartScene,
hasSceneAudio: audios.length >= 1,
previewIsVideo: s?.previewAssetType === 'video',
hasAnyAudioLoop: audios.some((a) => a.loop),
hasAnyAudioAutoplay: audios.some((a) => a.autoplay),
showPreviewVideoAutostart: s?.previewAssetType === 'video' ? s.previewVideoAutostart : false,
showPreviewVideoLoop: s?.previewAssetType === 'video' ? s.settings.loopVideo : false,
},
style: { padding: 0, background: 'transparent', border: 'none' },
};
});
}, [currentSceneId, sceneById, sceneGraphNodes]);
const desiredEdges = useMemo<Edge[]>(() => {
return sceneGraphEdges.map((e) => ({
id: e.id,
source: e.sourceGraphNodeId,
target: e.targetGraphNodeId,
type: 'smoothstep',
animated: false,
style: { stroke: 'rgba(167,139,250,0.55)', strokeWidth: 2 },
markerEnd: { type: MarkerType.ArrowClosed, color: 'rgba(167,139,250,0.85)', strokeWidth: 2 },
}));
}, [sceneGraphEdges]);
const [nodes, setNodes, onNodesChange] = useNodesState<Node<SceneCardData>>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
useEffect(() => {
setNodes(desiredNodes as unknown as Parameters<typeof setNodes>[0]);
setEdges(desiredEdges);
}, [desiredEdges, desiredNodes, setEdges, setNodes]);
const isValidConnection = useCallback(
(conn: Connection) => {
const source = conn.source as GraphNodeId | null;
const target = conn.target as GraphNodeId | null;
if (!source || !target) return false;
return !isSceneGraphEdgeRejected(sceneGraphNodes, sceneGraphEdges, source, target);
},
[sceneGraphEdges, sceneGraphNodes],
);
const onConnectInternal = (conn: Connection) => {
const source = conn.source as GraphNodeId | null;
const target = conn.target as GraphNodeId | null;
if (!source || !target) return;
if (!isValidConnection(conn)) return;
onConnect(source, target);
};
const onDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
};
const onDrop = (e: React.DragEvent) => {
e.preventDefault();
const id = e.dataTransfer.getData(DND_SCENE_ID_MIME);
if (!id) return;
const p = screenToFlowPosition({ x: e.clientX, y: e.clientY });
onDropSceneFromList(id as SceneId, p.x - SCENE_CARD_W / 2, p.y - SCENE_CARD_H / 2);
};
const menuPosition = useMemo(() => {
if (!menu) return null;
const pad = 8;
const mw = 220;
const mh = 120;
const x = Math.max(pad, Math.min(menu.x, window.innerWidth - mw - pad));
const y = Math.max(pad, Math.min(menu.y, window.innerHeight - mh - pad));
return { x, y };
}, [menu]);
return (
<div className={styles.canvasWrap}>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onNodeDragStop={(_, node) => {
onNodePositionCommit(node.id as GraphNodeId, node.position.x, node.position.y);
}}
onEdgesChange={onEdgesChange}
isValidConnection={isValidConnection}
onConnect={onConnectInternal}
onEdgesDelete={(eds) => {
for (const ed of eds) {
onDisconnect(ed.id);
}
}}
onEdgeClick={(_, edge) => {
onDisconnect(edge.id);
}}
onNodesDelete={(nds) => {
onRemoveGraphNodes(nds.map((n) => n.id as GraphNodeId));
}}
onNodeClick={(_, node) => {
setMenu(null);
const d = node.data as SceneCardData;
onCurrentSceneChange(d.sceneId);
}}
onNodeContextMenu={(e, node) => {
e.preventDefault();
setMenu({ x: e.clientX, y: e.clientY, graphNodeId: node.id as GraphNodeId });
}}
onPaneClick={() => {
setMenu(null);
}}
onPaneContextMenu={(e) => {
e.preventDefault();
setMenu(null);
}}
onInit={(instance) => {
instance.fitView({ padding: 0.25 });
}}
onDragOver={onDragOver}
onDrop={onDrop}
panOnScroll
selectionOnDrag={false}
deleteKeyCode={['Backspace', 'Delete']}
proOptions={{ hideAttribution: true }}
>
<Background gap={18} size={1} color="rgba(255,255,255,0.06)" />
<GraphZoomToolbar />
</ReactFlow>
{menu && menuPosition
? createPortal(
<>
<button
type="button"
aria-label="Закрыть меню"
className={styles.menuBackdrop}
onClick={() => setMenu(null)}
/>
<div
role="menu"
tabIndex={-1}
className={styles.ctxMenu}
style={{ left: menuPosition.x, top: menuPosition.y }}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Escape') setMenu(null);
}}
>
<button
type="button"
role="menuitem"
className={styles.ctxItem}
onClick={() => {
if (menuNodeIsStart) {
onSetGraphNodeStart(null);
} else {
onSetGraphNodeStart(menu.graphNodeId);
}
setMenu(null);
}}
>
{menuNodeIsStart ? 'Снять метку «Начальная сцена»' : 'Начальная сцена'}
</button>
<button
type="button"
role="menuitem"
className={styles.ctxItemDanger}
onClick={() => {
onRemoveGraphNode(menu.graphNodeId);
setMenu(null);
}}
>
Удалить
</button>
</div>
</>,
document.body,
)
: null}
</div>
);
}
export function SceneGraph(props: SceneGraphProps) {
return (
<ReactFlowProvider>
<SceneGraphCanvas {...props} />
</ReactFlowProvider>
);
}
+16
View File
@@ -0,0 +1,16 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css';
import { EditorApp } from './EditorApp';
const rootEl = document.getElementById('root');
if (!rootEl) {
throw new Error('Missing #root element');
}
createRoot(rootEl).render(
<React.StrictMode>
<EditorApp />
</React.StrictMode>,
);
+322
View File
@@ -0,0 +1,322 @@
import { useEffect, useMemo, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type { AssetId, GraphNodeId, Project, ProjectId, Scene, SceneId } from '../../../shared/types';
import { getDndApi } from '../../shared/dndApi';
type ProjectSummary = { id: ProjectId; name: string; updatedAt: string; fileName: string };
type State = {
projects: ProjectSummary[];
project: Project | null;
selectedSceneId: SceneId | null;
};
type Actions = {
refreshProjects: () => Promise<void>;
createProject: (name: string) => Promise<void>;
openProject: (id: ProjectId) => Promise<void>;
closeProject: () => Promise<void>;
createScene: () => Promise<void>;
selectScene: (id: SceneId) => Promise<void>;
updateScene: (
sceneId: SceneId,
patch: {
title?: string;
description?: string;
previewAssetId?: AssetId | null;
previewAssetType?: 'image' | 'video' | null;
previewVideoAutostart?: boolean;
previewRotationDeg?: 0 | 90 | 180 | 270;
settings?: Partial<Scene['settings']>;
media?: Partial<Scene['media']>;
layout?: { x: number; y: number };
},
) => Promise<void>;
updateConnections: (sceneId: SceneId, connections: SceneId[]) => Promise<void>;
importMediaToScene: (sceneId: SceneId) => Promise<void>;
importScenePreview: (sceneId: SceneId) => Promise<void>;
clearScenePreview: (sceneId: SceneId) => Promise<void>;
updateSceneGraphNodePosition: (nodeId: GraphNodeId, x: number, y: number) => Promise<void>;
addSceneGraphNode: (sceneId: SceneId, x: number, y: number) => Promise<void>;
removeSceneGraphNode: (nodeId: GraphNodeId) => Promise<void>;
addSceneGraphEdge: (sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId) => Promise<void>;
removeSceneGraphEdge: (edgeId: string) => Promise<void>;
setSceneGraphNodeStart: (graphNodeId: GraphNodeId | null) => Promise<void>;
deleteScene: (sceneId: SceneId) => Promise<void>;
renameProject: (name: string, fileBaseName: string) => Promise<void>;
importProject: () => Promise<void>;
exportProject: (projectId: ProjectId) => Promise<void>;
deleteProject: (projectId: ProjectId) => Promise<void>;
};
function randomId(prefix: string): string {
return `${prefix}_${Math.random().toString(16).slice(2)}_${Date.now().toString(16)}`;
}
export function useProjectState(): readonly [State, Actions] {
const api = getDndApi();
const [state, setState] = useState<State>({ projects: [], project: null, selectedSceneId: null });
const actions = useMemo<Actions>(() => {
const refreshProjects = async () => {
const res = await api.invoke(ipcChannels.project.list, {});
setState((s) => ({ ...s, projects: res.projects }));
};
const createProject = async (name: string) => {
const res = await api.invoke(ipcChannels.project.create, { name });
setState((s) => ({ ...s, project: res.project, selectedSceneId: res.project.currentSceneId }));
await refreshProjects();
};
const openProject = async (id: ProjectId) => {
const res = await api.invoke(ipcChannels.project.open, { projectId: id });
setState((s) => ({ ...s, project: res.project, selectedSceneId: res.project.currentSceneId }));
};
const closeProject = async () => {
setState((s) => ({ ...s, project: null, selectedSceneId: null }));
await refreshProjects();
};
const createScene = async () => {
const p = state.project;
if (!p) return;
const sceneId = randomId('scene') as SceneId;
const scene: Scene = {
id: sceneId,
title: `Новая сцена`,
description: '',
previewAssetId: null,
previewAssetType: null,
previewVideoAutostart: false,
previewRotationDeg: 0,
media: { videos: [], audios: [] },
settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
connections: [],
layout: { x: 0, y: 0 },
};
await api.invoke(ipcChannels.project.updateScene, {
sceneId,
patch: {
title: scene.title,
description: scene.description,
media: scene.media,
settings: scene.settings,
layout: scene.layout,
previewAssetId: scene.previewAssetId,
previewAssetType: scene.previewAssetType,
previewVideoAutostart: scene.previewVideoAutostart,
},
});
await api.invoke(ipcChannels.project.setCurrentScene, { sceneId });
const res = await api.invoke(ipcChannels.project.get, {});
setState((s) => ({ ...s, project: res.project, selectedSceneId: sceneId }));
};
const selectScene = async (id: SceneId) => {
setState((s) => ({ ...s, selectedSceneId: id }));
await api.invoke(ipcChannels.project.setCurrentScene, { sceneId: id });
};
const updateScene = async (
sceneId: SceneId,
patch: {
title?: string;
description?: string;
previewAssetId?: AssetId | null;
previewAssetType?: 'image' | 'video' | null;
previewVideoAutostart?: boolean;
previewRotationDeg?: 0 | 90 | 180 | 270;
settings?: Partial<Scene['settings']>;
media?: Partial<Scene['media']>;
layout?: { x: number; y: number };
},
) => {
setState((s) => {
const p = s.project;
if (!p) return s;
const scene = p.scenes[sceneId];
if (!scene) return s;
const next: Scene = {
...scene,
...(patch.title !== undefined ? { title: patch.title } : null),
...(patch.description !== undefined ? { description: patch.description } : null),
...(patch.previewAssetId !== undefined ? { previewAssetId: patch.previewAssetId } : null),
...(patch.previewAssetType !== undefined ? { previewAssetType: patch.previewAssetType } : null),
...(patch.previewVideoAutostart !== undefined
? { previewVideoAutostart: patch.previewVideoAutostart }
: null),
...(patch.previewRotationDeg !== undefined
? { previewRotationDeg: patch.previewRotationDeg }
: null),
...(patch.settings ? { settings: { ...scene.settings, ...patch.settings } } : null),
...(patch.media ? { media: { ...scene.media, ...patch.media } } : null),
layout: patch.layout ? { ...scene.layout, ...patch.layout } : scene.layout,
};
const scenes = { ...p.scenes, [sceneId]: next };
const project: Project = { ...p, scenes };
return { ...s, project };
});
await api.invoke(ipcChannels.project.updateScene, { sceneId, patch });
};
const updateConnections = async (sceneId: SceneId, connections: SceneId[]) => {
setState((s) => {
const p = s.project;
if (!p) return s;
const scene = p.scenes[sceneId];
if (!scene) return s;
const next: Scene = { ...scene, connections };
const scenes = { ...p.scenes, [sceneId]: next };
const project: Project = { ...p, scenes };
return { ...s, project };
});
await api.invoke(ipcChannels.project.updateConnections, { sceneId, connections });
};
const importMediaToScene = async (sceneId: SceneId) => {
const res = await api.invoke(ipcChannels.project.importMedia, { sceneId });
setState((s) => ({ ...s, project: res.project }));
await refreshProjects();
};
const importScenePreview = async (sceneId: SceneId) => {
const res = await api.invoke(ipcChannels.project.importScenePreview, { sceneId });
setState((s) => ({ ...s, project: res.project }));
await refreshProjects();
};
const clearScenePreview = async (sceneId: SceneId) => {
const res = await api.invoke(ipcChannels.project.clearScenePreview, { sceneId });
setState((s) => ({ ...s, project: res.project }));
await refreshProjects();
};
const updateSceneGraphNodePosition = async (nodeId: GraphNodeId, x: number, y: number) => {
setState((s) => {
const p = s.project;
if (!p) return s;
return {
...s,
project: {
...p,
sceneGraphNodes: p.sceneGraphNodes.map((n) => (n.id === nodeId ? { ...n, x, y } : n)),
},
};
});
const res = await api.invoke(ipcChannels.project.updateSceneGraphNodePosition, { nodeId, x, y });
setState((s) => ({ ...s, project: res.project }));
};
const addSceneGraphNode = async (sceneId: SceneId, x: number, y: number) => {
const res = await api.invoke(ipcChannels.project.addSceneGraphNode, { sceneId, x, y });
setState((s) => ({ ...s, project: res.project }));
};
const removeSceneGraphNode = async (nodeId: GraphNodeId) => {
const res = await api.invoke(ipcChannels.project.removeSceneGraphNode, { nodeId });
setState((s) => ({ ...s, project: res.project }));
};
const addSceneGraphEdge = async (sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId) => {
const res = await api.invoke(ipcChannels.project.addSceneGraphEdge, {
sourceGraphNodeId,
targetGraphNodeId,
});
setState((s) => ({ ...s, project: res.project }));
};
const removeSceneGraphEdge = async (edgeId: string) => {
const res = await api.invoke(ipcChannels.project.removeSceneGraphEdge, { edgeId });
setState((s) => ({ ...s, project: res.project }));
};
const setSceneGraphNodeStart = async (graphNodeId: GraphNodeId | null) => {
const res = await api.invoke(ipcChannels.project.setSceneGraphNodeStart, { graphNodeId });
setState((s) => ({ ...s, project: res.project }));
};
const deleteScene = async (sceneId: SceneId) => {
const res = await api.invoke(ipcChannels.project.deleteScene, { sceneId });
setState((s) => ({
...s,
project: res.project,
selectedSceneId: res.project.currentSceneId ?? null,
}));
await refreshProjects();
};
const renameProject = async (name: string, fileBaseName: string) => {
const res = await api.invoke(ipcChannels.project.rename, { name, fileBaseName });
setState((s) => ({ ...s, project: res.project }));
await refreshProjects();
};
const importProject = async () => {
const res = await api.invoke(ipcChannels.project.importZip, {});
if (res.canceled) return;
setState((s) => ({
...s,
project: res.project,
selectedSceneId: res.project.currentSceneId,
}));
await refreshProjects();
};
const exportProject = async (projectId: ProjectId) => {
const res = await api.invoke(ipcChannels.project.exportZip, { projectId });
if (res.canceled) return;
};
const deleteProject = async (projectId: ProjectId) => {
await api.invoke(ipcChannels.project.deleteProject, { projectId });
const listRes = await api.invoke(ipcChannels.project.list, {});
const res = await api.invoke(ipcChannels.project.get, {});
setState((s) => ({
...s,
projects: listRes.projects,
project: res.project,
selectedSceneId: res.project?.currentSceneId ?? null,
}));
};
return {
refreshProjects,
createProject,
openProject,
closeProject,
createScene,
selectScene,
updateScene,
updateConnections,
importMediaToScene,
importScenePreview,
clearScenePreview,
updateSceneGraphNodePosition,
addSceneGraphNode,
removeSceneGraphNode,
addSceneGraphEdge,
removeSceneGraphEdge,
setSceneGraphNodeStart,
deleteScene,
renameProject,
importProject,
exportProject,
deleteProject,
};
}, [api, state.project]);
useEffect(() => {
void (async () => {
await actions.refreshProjects();
const res = await api.invoke(ipcChannels.project.get, {});
setState((s) => ({ ...s, project: res.project, selectedSceneId: res.project?.currentSceneId ?? null }));
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return [state, actions] as const;
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/app-window-icon.png" type="image/png" />
<title>DnD Player — Presentation</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/presentation/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,4 @@
.root {
height: 100vh;
width: 100vw;
}
@@ -0,0 +1,41 @@
import React, { useEffect, useState } from 'react';
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
import { getDndApi } from '../shared/dndApi';
import { PresentationView } from '../shared/PresentationView';
import styles from './PresentationApp.module.css';
export function PresentationApp() {
const [session, setSession] = useState<SessionState | null>(null);
const api = getDndApi();
useEffect(() => {
void api.invoke(ipcChannels.project.get, {}).then((res) => {
setSession({
project: res.project,
currentSceneId: res.project?.currentSceneId ?? null,
});
});
return api.on(ipcChannels.session.stateChanged, ({ state }) => setSession(state));
}, [api]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
void api.invoke(ipcChannels.windows.closeMultiWindow, {});
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [api]);
return (
<div
className={styles.root}
onDoubleClick={() => void api.invoke(ipcChannels.windows.togglePresentationFullscreen, {})}
>
<PresentationView session={session} showTitle={false} />
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css';
import { PresentationApp } from './PresentationApp';
const rootEl = document.getElementById('root');
if (!rootEl) {
throw new Error('Missing #root element');
}
createRoot(rootEl).render(
<React.StrictMode>
<PresentationApp />
</React.StrictMode>,
);
+10
View File
@@ -0,0 +1,10 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="12" fill="#8B5CF6" />
<path
d="M9.33333 17.6667C9.01146 17.6678 8.71773 17.4834 8.57879 17.193C8.43985 16.9027 8.48055 16.5583 8.68333 16.3083L16.9333 7.80833C17.0608 7.66121 17.2731 7.62194 17.4448 7.71375C17.6164 7.80556 17.7016 8.00398 17.65 8.19167L16.05 13.2083C15.9542 13.4646 15.9904 13.7516 16.1467 13.9762C16.3031 14.2007 16.5597 14.3342 16.8333 14.3333H22.6667C22.9885 14.3322 23.2823 14.5166 23.4212 14.807C23.5601 15.0973 23.5195 15.4417 23.3167 15.6917L15.0667 24.1917C14.9392 24.3388 14.7269 24.3781 14.5552 24.2862C14.3836 24.1944 14.2984 23.996 14.35 23.8083L15.95 18.7917C16.0458 18.5354 16.0096 18.2484 15.8533 18.0238C15.6969 17.7993 15.4403 17.6658 15.1667 17.6667H9.33333"
stroke="white"
stroke-width="1.66667"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>

After

Width:  |  Height:  |  Size: 944 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

@@ -0,0 +1,63 @@
.root {
height: 100%;
width: 100%;
position: relative;
overflow: hidden;
background: var(--bg0);
}
.fill {
position: absolute;
inset: 0;
}
.placeholderBg {
position: absolute;
inset: 0;
background: var(--color-overlay-dark-6);
}
.video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
}
.vignette {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.35);
pointer-events: none;
}
.titleWrap {
position: absolute;
left: 18px;
bottom: 16px;
right: 18px;
color: var(--text0);
}
.titleCompact {
font-size: var(--text-xl);
font-weight: 900;
letter-spacing: -0.5px;
text-shadow: var(--shadow-title);
line-height: 1.1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.titleFull {
font-size: var(--text-title-lg);
font-weight: 900;
letter-spacing: -0.5px;
text-shadow: var(--shadow-title);
line-height: 1.1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
+105
View File
@@ -0,0 +1,105 @@
import React, { useEffect, useRef } from 'react';
import { computeTimeSec } from '../../main/video/videoPlaybackStore';
import type { SessionState } from '../../shared/ipc/contracts';
import { PixiEffectsOverlay } from './effects/PxiEffectsOverlay';
import { useEffectsState } from './effects/useEffectsState';
import styles from './PresentationView.module.css';
import { RotatedImage } from './RotatedImage';
import { useAssetUrl } from './useAssetImageUrl';
import { useVideoPlaybackState } from './video/useVideoPlaybackState';
export type PresentationViewProps = {
session: SessionState | null;
/** Если true — показываем укороченный заголовок/оверлей для предпросмотра. */
compact?: boolean;
showTitle?: boolean;
showEffects?: boolean;
};
export function PresentationView({
session,
compact = false,
showTitle = true,
showEffects = true,
}: PresentationViewProps) {
const [fxState] = useEffectsState();
const [vp] = useVideoPlaybackState();
const videoElRef = useRef<HTMLVideoElement | null>(null);
const [contentRect, setContentRect] = React.useState<{ x: number; y: number; w: number; h: number } | null>(
null,
);
const scene =
session?.project && session.currentSceneId ? session.project.scenes[session.currentSceneId] : undefined;
const previewUrl = useAssetUrl(scene?.previewAssetId ?? null);
const rot = scene?.previewRotationDeg ?? 0;
useEffect(() => {
const el = videoElRef.current;
if (!el) return;
if (!vp) return;
if (!scene?.previewAssetId) return;
if (scene.previewAssetType !== 'video') return;
if (vp.targetAssetId !== scene.previewAssetId) return;
el.playbackRate = vp.playbackRate;
const desired = computeTimeSec(vp, vp.serverNowMs);
if (Number.isFinite(desired) && Math.abs(el.currentTime - desired) > 0.35) {
el.currentTime = Math.max(0, desired);
}
if (vp.playing) {
void el.play().catch(() => undefined);
} else {
el.pause();
}
}, [scene?.previewAssetId, scene?.previewAssetType, vp]);
return (
<div className={styles.root}>
{previewUrl && scene?.previewAssetType === 'image' ? (
<div className={styles.fill}>
<RotatedImage
url={previewUrl}
rotationDeg={rot}
mode="contain"
onContentRectChange={setContentRect}
/>
</div>
) : previewUrl && scene?.previewAssetType === 'video' ? (
<video
ref={videoElRef}
className={styles.video}
src={previewUrl}
muted
playsInline
loop={false}
preload="auto"
onError={() => {
// noop: status surfaced in control app; keep presentation clean
}}
/>
) : (
<div className={styles.placeholderBg} />
)}
<div className={styles.vignette} />
{showEffects && scene?.previewAssetType !== 'video' ? (
<PixiEffectsOverlay
state={fxState}
viewport={
contentRect
? { x: contentRect.x, y: contentRect.y, w: contentRect.w, h: contentRect.h }
: undefined
}
/>
) : null}
{showTitle ? (
<div className={styles.titleWrap}>
<div className={compact ? styles.titleCompact : styles.titleFull}>
{scene?.title ?? 'Выберите сцену в редакторе'}
</div>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,15 @@
.root {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
.img {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
transform-origin: center;
display: block;
}
+104
View File
@@ -0,0 +1,104 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import styles from './RotatedImage.module.css';
type Mode = 'cover' | 'contain';
type RotatedImageProps = {
url: string;
rotationDeg: 0 | 90 | 180 | 270;
mode: Mode;
alt?: string;
/** Высота/ширина полностью контролируются родителем. */
style?: React.CSSProperties;
/** Прямоугольник видимого контента (contain/cover) внутри контейнера. */
onContentRectChange?: ((rect: { x: number; y: number; w: number; h: number }) => void) | undefined;
};
function useElementSize<T extends HTMLElement>() {
const ref = useRef<T | null>(null);
const [size, setSize] = useState<{ w: number; h: number }>({ w: 0, h: 0 });
useEffect(() => {
const el = ref.current;
if (!el) return;
const ro = new ResizeObserver(() => {
const r = el.getBoundingClientRect();
setSize({ w: r.width, h: r.height });
});
ro.observe(el);
const r = el.getBoundingClientRect();
setSize({ w: r.width, h: r.height });
return () => ro.disconnect();
}, []);
return [ref, size] as const;
}
export function RotatedImage({
url,
rotationDeg,
mode,
alt = '',
style,
onContentRectChange,
}: RotatedImageProps) {
const [ref, size] = useElementSize<HTMLDivElement>();
const [imgSize, setImgSize] = useState<{ w: number; h: number } | null>(null);
useEffect(() => {
let cancelled = false;
const img = new Image();
img.onload = () => {
if (cancelled) return;
setImgSize({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 });
};
img.src = url;
return () => {
cancelled = true;
};
}, [url]);
const scale = useMemo(() => {
if (!imgSize) return 1;
if (size.w <= 1 || size.h <= 1) return 1;
const rotated = rotationDeg === 90 || rotationDeg === 270;
const iw = rotated ? imgSize.h : imgSize.w;
const ih = rotated ? imgSize.w : imgSize.h;
const sx = size.w / iw;
const sy = size.h / ih;
return mode === 'cover' ? Math.max(sx, sy) : Math.min(sx, sy);
}, [imgSize, mode, rotationDeg, size.h, size.w]);
useEffect(() => {
if (!onContentRectChange) return;
if (!imgSize) return;
if (size.w <= 1 || size.h <= 1) return;
const rotated = rotationDeg === 90 || rotationDeg === 270;
// Bounding-box размеров после rotate(): при 90/270 меняются местами.
const bw = (rotated ? imgSize.h : imgSize.w) * scale;
const bh = (rotated ? imgSize.w : imgSize.h) * scale;
const x = (size.w - bw) / 2;
const y = (size.h - bh) / 2;
onContentRectChange({ x, y, w: bw, h: bh });
}, [imgSize, mode, onContentRectChange, rotationDeg, scale, size.h, size.w]);
const w = imgSize ? imgSize.w * scale : undefined;
const h = imgSize ? imgSize.h * scale : undefined;
return (
<div ref={ref} className={styles.root} style={style}>
<img
alt={alt}
src={url}
className={styles.img}
style={{
width: w ?? '100%',
height: h ?? '100%',
objectFit: imgSize ? undefined : mode,
transform: `translate(-50%, -50%) rotate(${String(rotationDeg)}deg)`,
}}
/>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import React from 'react';
type Props = {
className?: string | undefined;
size?: number;
title?: string;
};
/** Логотип приложения (SVG из брендинга). */
export function AppLogo({ className, size = 26, title }: Props) {
return (
<svg
className={className}
width={size}
height={size}
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden={title ? undefined : true}
role={title ? 'img' : undefined}
aria-label={title}
>
<rect width="32" height="32" rx="12" fill="#8B5CF6" />
<path
d="M9.33333 17.6667C9.01146 17.6678 8.71773 17.4834 8.57879 17.193C8.43985 16.9027 8.48055 16.5583 8.68333 16.3083L16.9333 7.80833C17.0608 7.66121 17.2731 7.62194 17.4448 7.71375C17.6164 7.80556 17.7016 8.00398 17.65 8.19167L16.05 13.2083C15.9542 13.4646 15.9904 13.7516 16.1467 13.9762C16.3031 14.2007 16.5597 14.3342 16.8333 14.3333H22.6667C22.9885 14.3322 23.2823 14.5166 23.4212 14.807C23.5601 15.0973 23.5195 15.4417 23.3167 15.6917L15.0667 24.1917C14.9392 24.3388 14.7269 24.3781 14.5552 24.2862C14.3836 24.1944 14.2984 23.996 14.35 23.8083L15.95 18.7917C16.0458 18.5354 16.0096 18.2484 15.8533 18.0238C15.6969 17.7993 15.4403 17.6658 15.1667 17.6667H9.33333"
stroke="white"
strokeWidth="1.66667"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
+5
View File
@@ -0,0 +1,5 @@
import type { DndApi } from '../../preload/index';
export function getDndApi(): DndApi {
return window.dnd;
}
@@ -0,0 +1,12 @@
.host {
position: absolute;
inset: 0;
}
.hostInteractive {
pointer-events: auto;
}
.hostPassthrough {
pointer-events: none;
}
@@ -0,0 +1,12 @@
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));
void test('PxiEffectsOverlay: canvas не перехватывает указатель в режиме без interactive', () => {
const src = fs.readFileSync(path.join(here, 'PxiEffectsOverlay.tsx'), 'utf8');
assert.ok(src.includes("app.canvas.style.pointerEvents = interactive ? 'auto' : 'none'"));
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,31 @@
import { useEffect, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type { EffectsEvent, EffectsState } from '../../../shared/types';
import { getDndApi } from '../dndApi';
export function useEffectsState(): readonly [
EffectsState | null,
{ dispatch: (event: EffectsEvent) => Promise<void> },
] {
const api = getDndApi();
const [state, setState] = useState<EffectsState | null>(null);
useEffect(() => {
void api.invoke(ipcChannels.effects.getState, {}).then((r) => {
setState(r.state);
});
return api.on(ipcChannels.effects.stateChanged, ({ state: next }) => {
setState(next);
});
}, [api]);
return [
state,
{
dispatch: async (event) => {
await api.invoke(ipcChannels.effects.dispatch, { event });
},
},
] as const;
}
+114
View File
@@ -0,0 +1,114 @@
/* ==========================================================================
Дизайн-токены (общие для всех окон). Импортируйте через globals.css.
========================================================================== */
:root {
/* --- Цвета: фон (legacy алиасы для body/globals) --- */
--bg0: #09090b;
--bg1: #09090b;
--color-bg-0: #09090b;
--color-bg-1: #09090b;
--color-bg-black: #000;
--color-overlay-dark: rgba(0, 0, 0, 0.15);
--color-overlay-dark-2: rgba(0, 0, 0, 0.18);
--color-overlay-dark-3: rgba(0, 0, 0, 0.25);
--color-overlay-dark-4: rgba(0, 0, 0, 0.28);
--color-overlay-dark-5: rgba(0, 0, 0, 0.45);
--color-overlay-dark-6: rgba(0, 0, 0, 0.55);
--color-overlay-dark-7: rgba(0, 0, 0, 0.65);
--color-scrim: rgba(0, 0, 0, 0.45);
--color-panel: rgba(255, 255, 255, 0.04);
--color-panel-2: rgba(255, 255, 255, 0.06);
--color-panel-3: rgba(255, 255, 255, 0.05);
--color-surface-elevated: rgba(10, 10, 14, 0.96);
--color-surface-elevated-2: rgba(10, 10, 14, 0.98);
--color-surface-menu: rgba(15, 16, 22, 0.98);
--color-tooltip-bg: rgba(20, 22, 28, 0.96);
/* --- Сцены: плитки (список, граф, пульт) --- */
--scene-tile-radius: 12px;
--scene-list-selected-bg: rgba(139, 92, 146, 0.1);
--scene-list-selected-border: rgba(139, 92, 146, 0.3);
--scene-list-hover-bg: rgba(139, 92, 146, 0.08);
--graph-node-active-border: rgba(139, 92, 146, 1);
/* --- Редактор: колонки --- */
--editor-column-bg: #18181b;
/* --- Цвета: обводка --- */
--stroke: rgba(255, 255, 255, 0.08);
--stroke2: rgba(255, 255, 255, 0.12);
--stroke-2: rgba(255, 255, 255, 0.12);
--stroke-light: rgba(255, 255, 255, 0.12);
--stroke-handle: rgba(167, 139, 250, 0.9);
/* --- Цвета: текст --- */
--text0: rgba(255, 255, 255, 0.92);
--text1: rgba(255, 255, 255, 0.72);
--text2: rgba(255, 255, 255, 0.52);
--panel: rgba(255, 255, 255, 0.04);
--panel2: rgba(255, 255, 255, 0.06);
--text-on-accent: rgba(255, 255, 255, 0.98);
--text-muted-on-dark: rgba(255, 255, 255, 0.85);
--text-muted-on-dark-2: rgba(255, 255, 255, 0.9);
/* --- Цвета: акцент / бренд --- */
--accent: #7c3aed;
--accent2: #a78bfa;
--accent-border: rgba(124, 58, 237, 0.55);
--accent-border-strong: rgba(124, 58, 237, 0.85);
--accent-fill-soft: rgba(124, 58, 237, 0.1);
--accent-fill-soft-2: rgba(124, 58, 237, 0.12);
--accent-fill-solid: rgba(124, 58, 237, 0.92);
--accent-glow: rgba(124, 58, 237, 0.35);
--selection-bg: rgba(124, 58, 237, 0.35);
/* --- Цвета: опасность / ошибка --- */
--color-danger: rgba(248, 113, 113, 0.95);
--color-danger-icon: #e5484d;
/* --- Тени --- */
--shadow: 0 24px 60px rgba(0, 0, 0, 0.55);
--shadow-lg: 0 18px 60px rgba(0, 0, 0, 0.55);
--shadow-xl: 0 24px 80px rgba(0, 0, 0, 0.6);
--shadow-menu: 0 12px 40px rgba(0, 0, 0, 0.55);
--shadow-tooltip: 0 8px 24px rgba(0, 0, 0, 0.45);
--shadow-title: 0 4px 24px rgba(0, 0, 0, 0.65);
--shadow-start-badge: 0 4px 12px rgba(0, 0, 0, 0.35);
/* --- Скругления --- */
--radius-lg: 16px;
--radius-md: 12px;
--radius-sm: 10px;
--radius-xs: 8px;
--radius-pill: 999px;
/* --- Типографика --- */
--font:
'Nimbus Sans', 'Nimbus Sans L', 'Nimbus Sans OT', 'Nimbus Sans PS', ui-sans-serif, system-ui,
-apple-system, 'Segoe UI', Roboto, Helvetica, Arial, 'Apple Color Emoji', 'Segoe UI Emoji';
--text-xs: 12px;
--text-sm: 13px;
--text-md: 14px;
--text-lg: 16px;
--text-xl: 18px;
--text-title-lg: 42px;
/* --- Вёрстка: сетка редактора --- */
--topbar-h: 56px;
--sidebar-w: 280px;
--inspector-w: 380px;
--gap: 16px;
--pad: 16px;
/* --- Z-index --- */
--z-menu-backdrop: 9999;
--z-modal-backdrop: 20000;
--z-modal: 20001;
--z-file-menu: 40000;
--z-tooltip: 200000;
/* --- Прочее --- */
--backdrop-blur-shell: blur(14px);
--backdrop-blur-surface: blur(18px);
}
@@ -0,0 +1,50 @@
.button {
height: 34px;
padding: 0 14px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: var(--panel);
cursor: pointer;
position: relative;
}
.button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.buttonPrimary {
border: 1px solid var(--accent-border);
background: var(--accent-fill-solid);
}
.iconOnly {
min-width: 38px;
padding: 0 8px;
}
.tooltip {
position: fixed;
transform: translate(-50%, calc(-100% - 8px));
padding: 6px 10px;
border-radius: var(--radius-xs);
font-size: var(--text-xs);
font-weight: 600;
color: rgba(255, 255, 255, 0.95);
background: var(--color-tooltip-bg);
border: 1px solid var(--stroke-2);
box-shadow: var(--shadow-tooltip);
pointer-events: none;
z-index: var(--z-tooltip);
white-space: nowrap;
}
.input {
height: 34px;
width: 100%;
padding: 0 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: var(--color-overlay-dark-3);
outline: none;
}
@@ -0,0 +1,27 @@
.root {
height: 100vh;
display: grid;
grid-template-rows: var(--topbar-h) 1fr;
}
.topBar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
border-bottom: 1px solid var(--stroke);
background: #18181b;
backdrop-filter: var(--backdrop-blur-shell);
}
.body {
display: grid;
grid-template-columns: var(--sidebar-w) 1fr var(--inspector-w);
gap: 0;
padding: 0;
min-height: 0;
}
.col {
min-height: 0;
}
+23
View File
@@ -0,0 +1,23 @@
import React from 'react';
import styles from './LayoutShell.module.css';
type Props = {
topBar: React.ReactNode;
left: React.ReactNode;
center: React.ReactNode;
right: React.ReactNode;
};
export function LayoutShell({ topBar, left, center, right }: Props) {
return (
<div className={styles.root}>
<div className={styles.topBar}>{topBar}</div>
<div className={styles.body}>
<div className={styles.col}>{left}</div>
<div className={styles.col}>{center}</div>
<div className={styles.col}>{right}</div>
</div>
</div>
);
}
@@ -0,0 +1,8 @@
.root {
border-radius: var(--radius-lg);
background: var(--color-panel-2);
border: 1px solid var(--stroke);
box-shadow: var(--shadow);
backdrop-filter: var(--backdrop-blur-surface);
overflow: hidden;
}
+18
View File
@@ -0,0 +1,18 @@
import React from 'react';
import styles from './Surface.module.css';
type Props = {
children: React.ReactNode;
/** Разрешено `undefined` из CSS-модулей при `exactOptionalPropertyTypes`. */
className?: string | undefined;
style?: React.CSSProperties | undefined;
};
export function Surface({ children, className, style }: Props) {
return (
<div className={[styles.root, className].filter(Boolean).join(' ')} style={style}>
{children}
</div>
);
}
@@ -0,0 +1,15 @@
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));
void test('Button: тултип через портал (title), не только нативный атрибут', () => {
const src = fs.readFileSync(path.join(here, 'controls.tsx'), 'utf8');
assert.ok(src.includes('createPortal'));
assert.ok(src.includes('role="tooltip"'));
assert.ok(src.includes('onMouseEnter={showTip}'));
assert.ok(src.includes('document.body'));
});
+96
View File
@@ -0,0 +1,96 @@
import React, { useCallback, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import styles from './Controls.module.css';
type ButtonProps = {
children: React.ReactNode;
onClick?: () => void;
variant?: 'primary' | 'ghost';
disabled?: boolean;
title?: string | undefined;
/** Подпись для скринридеров (иконки без текста). */
ariaLabel?: string | undefined;
/** Компактная кнопка под одну иконку. */
iconOnly?: boolean;
};
export function Button({
children,
onClick,
variant = 'ghost',
disabled = false,
title,
ariaLabel,
iconOnly = false,
}: ButtonProps) {
const btnRef = useRef<HTMLButtonElement | null>(null);
const [tipPos, setTipPos] = useState<{ x: number; y: number } | null>(null);
const showTip = useCallback(() => {
if (disabled || !title) return;
const el = btnRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
setTipPos({ x: r.left + r.width / 2, y: r.top });
}, [disabled, title]);
const hideTip = useCallback(() => {
setTipPos(null);
}, []);
const btnClass = [
styles.button,
variant === 'primary' ? styles.buttonPrimary : '',
iconOnly ? styles.iconOnly : '',
]
.filter(Boolean)
.join(' ');
const tip =
title && tipPos && typeof document !== 'undefined'
? createPortal(
<div role="tooltip" className={styles.tooltip} style={{ left: tipPos.x, top: tipPos.y }}>
{title}
</div>,
document.body,
)
: null;
return (
<>
<button
ref={btnRef}
type="button"
className={btnClass}
disabled={disabled}
aria-label={ariaLabel}
onClick={disabled ? undefined : onClick}
onMouseEnter={showTip}
onMouseLeave={hideTip}
onFocus={showTip}
onBlur={hideTip}
>
{children}
</button>
{tip}
</>
);
}
type InputProps = {
value: string;
placeholder?: string;
onChange: (v: string) => void;
};
export function Input({ value, placeholder, onChange }: InputProps) {
return (
<input
className={styles.input}
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
/>
);
}
+54
View File
@@ -0,0 +1,54 @@
@import '../styles/variables.css';
@import url('https://fonts.cdnfonts.com/css/nimbus-sans');
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
html {
background: var(--bg0);
}
body {
margin: 0;
font-family: var(--font);
color: var(--text0);
background: var(--bg0);
}
a {
color: inherit;
text-decoration: none;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.08);
border: 2px solid rgba(0, 0, 0, 0);
background-clip: padding-box;
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.12);
border: 2px solid rgba(0, 0, 0, 0);
background-clip: padding-box;
}
button,
input,
textarea {
font: inherit;
color: inherit;
}
::selection {
background: var(--selection-bg);
}
+42
View File
@@ -0,0 +1,42 @@
import { useEffect, useState } from 'react';
import { ipcChannels } from '../../shared/ipc/contracts';
import type { AssetId } from '../../shared/types';
import { getDndApi } from './dndApi';
/**
* Возвращает `file://` URL для превью изображения. Пока загрузка или сменился id — `null`.
*/
export function useAssetUrl(assetId: AssetId | null | undefined): string | null {
const id = assetId ?? null;
const [entry, setEntry] = useState<{ assetId: AssetId; url: string | null } | null>(null);
useEffect(() => {
if (id === null) {
return undefined;
}
let cancelled = false;
void getDndApi()
.invoke(ipcChannels.project.assetFileUrl, { assetId: id })
.then((r) => {
if (!cancelled) setEntry({ assetId: id, url: r.url });
});
return () => {
cancelled = true;
};
}, [id]);
if (id === null) {
return null;
}
if (entry?.assetId !== id) {
return null;
}
return entry.url;
}
/** @deprecated use `useAssetUrl` */
export function useAssetImageUrl(assetId: AssetId | null | undefined): string | null {
return useAssetUrl(assetId);
}
@@ -0,0 +1,43 @@
import { useEffect, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type { VideoPlaybackEvent, VideoPlaybackState } from '../../../shared/types';
import { getDndApi } from '../dndApi';
export function useVideoPlaybackState(): readonly [
VideoPlaybackState | null,
{ dispatch: (event: VideoPlaybackEvent) => Promise<void> },
] {
const api = getDndApi();
const [state, setState] = useState<VideoPlaybackState | null>(null);
const [timeOffsetMs, setTimeOffsetMs] = useState(0);
const [clientNowMs, setClientNowMs] = useState(() => Date.now());
useEffect(() => {
if (!state) return;
const id = window.setInterval(() => {
setClientNowMs(Date.now());
}, 250);
return () => window.clearInterval(id);
}, [state]);
useEffect(() => {
void api.invoke(ipcChannels.video.getState, {}).then((r) => {
setState(r.state);
setTimeOffsetMs(r.state.serverNowMs - Date.now());
});
return api.on(ipcChannels.video.stateChanged, ({ state: next }) => {
setState(next);
setTimeOffsetMs(next.serverNowMs - Date.now());
});
}, [api]);
return [
state ? { ...state, serverNowMs: clientNowMs + timeOffsetMs } : null,
{
dispatch: async (event) => {
await api.invoke(ipcChannels.video.dispatch, { event });
},
},
] as const;
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"types": ["vite/client"],
"noEmit": true
},
"include": ["./**/*.ts", "./**/*.tsx", "./**/*.css"],
"exclude": ["../../dist", "../../node_modules"]
}
+26
View File
@@ -0,0 +1,26 @@
import type { GraphNodeId, SceneGraphEdge, SceneGraphNode, SceneId } from '../types';
/**
* true — связь добавлять нельзя: нет узлов, петля по одной сцене, то же ребро уже есть,
* или с этого узла уже ведёт связь к другой карточке той же целевой сцены.
* Разрешены несколько исходящих «вариантов» с одной ноды только на разные сцены.
*/
export function isSceneGraphEdgeRejected(
sceneGraphNodes: SceneGraphNode[],
sceneGraphEdges: SceneGraphEdge[],
sourceGraphNodeId: GraphNodeId,
targetGraphNodeId: GraphNodeId,
): boolean {
const gnScene = new Map<GraphNodeId, SceneId>(sceneGraphNodes.map((n) => [n.id, n.sceneId]));
const srcScene = gnScene.get(sourceGraphNodeId);
const tgtScene = gnScene.get(targetGraphNodeId);
if (srcScene === undefined || tgtScene === undefined) return true;
if (srcScene === tgtScene) return true;
for (const e of sceneGraphEdges) {
if (e.sourceGraphNodeId !== sourceGraphNodeId) continue;
if (e.targetGraphNodeId === targetGraphNodeId) return true;
if (gnScene.get(e.targetGraphNodeId) === tgtScene) return true;
}
return false;
}
@@ -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';
import { ipcChannels } from './contracts';
const here = path.dirname(fileURLToPath(import.meta.url));
function readRel(rel: string): string {
return fs.readFileSync(path.join(here, rel), 'utf8');
}
void test('ipcChannels: удалён media API (быстрый микшер)', () => {
assert.ok(!('media' in ipcChannels));
});
void test('ControlApp: UI быстрого микшера удалён', () => {
const src = readRel('../../renderer/control/ControlApp.tsx');
assert.ok(!src.includes('Быстрый микшер'));
assert.ok(!src.includes('MixerRow'));
});
void test('main: обработчики media IPC удалены', () => {
const src = readRel('../../main/index.ts');
assert.ok(!src.includes('ipcChannels.media'));
});
+222
View File
@@ -0,0 +1,222 @@
import type {
AssetId,
EffectsEvent,
EffectsState,
GraphNodeId,
MediaAsset,
Project,
ProjectId,
Scene,
SceneId,
VideoPlaybackEvent,
VideoPlaybackState,
} from '../types';
export const ipcChannels = {
app: {
quit: 'app.quit',
getVersion: 'app.getVersion',
},
project: {
list: 'project.list',
create: 'project.create',
open: 'project.open',
saveNow: 'project.saveNow',
get: 'project.get',
updateScene: 'project.updateScene',
updateConnections: 'project.updateConnections',
setCurrentScene: 'project.setCurrentScene',
setCurrentGraphNode: 'project.setCurrentGraphNode',
importMedia: 'project.importMedia',
importScenePreview: 'project.importScenePreview',
clearScenePreview: 'project.clearScenePreview',
assetFileUrl: 'project.assetFileUrl',
updateSceneGraphNodePosition: 'project.updateSceneGraphNodePosition',
addSceneGraphNode: 'project.addSceneGraphNode',
removeSceneGraphNode: 'project.removeSceneGraphNode',
addSceneGraphEdge: 'project.addSceneGraphEdge',
removeSceneGraphEdge: 'project.removeSceneGraphEdge',
setSceneGraphNodeStart: 'project.setSceneGraphNodeStart',
deleteScene: 'project.deleteScene',
rename: 'project.rename',
importZip: 'project.importZip',
exportZip: 'project.exportZip',
deleteProject: 'project.deleteProject',
},
windows: {
openMultiWindow: 'windows.openMultiWindow',
closeMultiWindow: 'windows.closeMultiWindow',
togglePresentationFullscreen: 'windows.togglePresentationFullscreen',
},
session: {
stateChanged: 'session.stateChanged',
},
effects: {
getState: 'effects.getState',
dispatch: 'effects.dispatch',
stateChanged: 'effects.stateChanged',
},
video: {
getState: 'video.getState',
dispatch: 'video.dispatch',
stateChanged: 'video.stateChanged',
},
} as const;
export type IpcInvokeMap = {
[ipcChannels.app.quit]: {
req: Record<string, never>;
res: { ok: true };
};
[ipcChannels.app.getVersion]: {
req: Record<string, never>;
res: { version: string; buildNumber: string | null };
};
[ipcChannels.project.list]: {
req: Record<string, never>;
res: { projects: { id: ProjectId; name: string; updatedAt: string; fileName: string }[] };
};
[ipcChannels.project.create]: {
req: { name: string };
res: { project: Project };
};
[ipcChannels.project.open]: {
req: { projectId: ProjectId };
res: { project: Project };
};
[ipcChannels.project.get]: {
req: Record<string, never>;
res: { project: Project | null };
};
[ipcChannels.project.saveNow]: {
req: Record<string, never>;
res: { ok: true };
};
[ipcChannels.project.updateScene]: {
req: { sceneId: SceneId; patch: ScenePatch };
res: { scene: Scene };
};
[ipcChannels.project.updateConnections]: {
req: { sceneId: SceneId; connections: SceneId[] };
res: { scene: Scene };
};
[ipcChannels.project.setCurrentScene]: {
req: { sceneId: SceneId | null };
res: { currentSceneId: SceneId | null };
};
[ipcChannels.project.setCurrentGraphNode]: {
req: { graphNodeId: GraphNodeId | null };
res: { currentGraphNodeId: GraphNodeId | null; currentSceneId: SceneId | null };
};
[ipcChannels.project.importMedia]: {
req: { sceneId: SceneId };
res: { project: Project; imported: MediaAsset[] };
};
[ipcChannels.project.importScenePreview]: {
req: { sceneId: SceneId };
res: { project: Project };
};
[ipcChannels.project.clearScenePreview]: {
req: { sceneId: SceneId };
res: { project: Project };
};
[ipcChannels.project.assetFileUrl]: {
req: { assetId: AssetId };
res: { url: string | null };
};
[ipcChannels.project.updateSceneGraphNodePosition]: {
req: { nodeId: GraphNodeId; x: number; y: number };
res: { project: Project };
};
[ipcChannels.project.addSceneGraphNode]: {
req: { sceneId: SceneId; x: number; y: number };
res: { project: Project };
};
[ipcChannels.project.removeSceneGraphNode]: {
req: { nodeId: GraphNodeId };
res: { project: Project };
};
[ipcChannels.project.addSceneGraphEdge]: {
req: { sourceGraphNodeId: GraphNodeId; targetGraphNodeId: GraphNodeId };
res: { project: Project };
};
[ipcChannels.project.removeSceneGraphEdge]: {
req: { edgeId: string };
res: { project: Project };
};
[ipcChannels.project.setSceneGraphNodeStart]: {
req: { graphNodeId: GraphNodeId | null };
res: { project: Project };
};
[ipcChannels.project.deleteScene]: {
req: { sceneId: SceneId };
res: { project: Project };
};
[ipcChannels.project.rename]: {
req: { name: string; fileBaseName: string };
res: { project: Project };
};
[ipcChannels.project.importZip]: {
req: Record<string, never>;
res: { canceled: true } | { canceled: false; project: Project };
};
[ipcChannels.project.exportZip]: {
req: { projectId: ProjectId };
res: { canceled: true } | { canceled: false };
};
[ipcChannels.project.deleteProject]: {
req: { projectId: ProjectId };
res: { ok: true };
};
[ipcChannels.windows.openMultiWindow]: {
req: Record<string, never>;
res: { ok: true };
};
[ipcChannels.windows.closeMultiWindow]: {
req: Record<string, never>;
res: { ok: true };
};
[ipcChannels.windows.togglePresentationFullscreen]: {
req: Record<string, never>;
res: { ok: true; isFullScreen: boolean };
};
[ipcChannels.effects.getState]: {
req: Record<string, never>;
res: { state: EffectsState };
};
[ipcChannels.effects.dispatch]: {
req: { event: EffectsEvent };
res: { ok: true };
};
[ipcChannels.video.getState]: {
req: Record<string, never>;
res: { state: VideoPlaybackState };
};
[ipcChannels.video.dispatch]: {
req: { event: VideoPlaybackEvent };
res: { ok: true };
};
};
export type SessionState = {
project: Project | null;
currentSceneId: SceneId | null;
};
export type IpcEventMap = {
[ipcChannels.session.stateChanged]: { state: SessionState };
[ipcChannels.effects.stateChanged]: { state: EffectsState };
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
};
export type ScenePatch = {
title?: string;
description?: string;
previewAssetId?: AssetId | null;
previewAssetType?: 'image' | 'video' | null;
previewVideoAutostart?: boolean;
previewRotationDeg?: 0 | 90 | 180 | 270;
settings?: Partial<Scene['settings']>;
media?: Partial<Scene['media']>;
layout?: Partial<Scene['layout']>;
};
+17
View File
@@ -0,0 +1,17 @@
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 root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
void test('package.json: конфиг electron-builder (mac/win)', () => {
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) as {
build: { appId: string; mac: { target: unknown }; files: string[] };
};
assert.ok(pkg.build);
assert.equal(pkg.build.appId, 'com.dndplayer.app');
assert.ok(Array.isArray(pkg.build.mac.target));
assert.ok(pkg.build.files.includes('dist/**/*'));
});
+121
View File
@@ -0,0 +1,121 @@
import type { AssetId, GraphNodeId, ProjectId, SceneId } from './ids';
export const PROJECT_SCHEMA_VERSION = 4 as const;
export type IsoDateTimeString = string;
export type MediaAssetType = 'image' | 'video' | 'audio';
export type MediaAssetBase = {
id: AssetId;
type: MediaAssetType;
mime: string;
originalName: string;
relPath: string;
sha256: string;
sizeBytes: number;
createdAt: IsoDateTimeString;
};
export type ImageAsset = MediaAssetBase & {
type: 'image';
widthPx?: number;
heightPx?: number;
};
export type VideoAsset = MediaAssetBase & {
type: 'video';
durationMs?: number;
widthPx?: number;
heightPx?: number;
};
export type AudioAsset = MediaAssetBase & {
type: 'audio';
durationMs?: number;
};
export type MediaAsset = ImageAsset | VideoAsset | AudioAsset;
/** Только видео и аудио сцены (изображения — только превью, отдельное поле). */
export type SceneAudioRef = {
assetId: AssetId;
autoplay: boolean;
loop: boolean;
};
export type SceneMediaRefs = {
videos: AssetId[];
audios: SceneAudioRef[];
};
export type SceneSettings = {
autoplayVideo: boolean;
autoplayAudio: boolean;
loopVideo: boolean;
loopAudio: boolean;
};
export type SceneLayout = {
x: number;
y: number;
};
/** Узел на визуальном графе (одна сцена может иметь несколько узлов). */
export type SceneGraphNode = {
id: GraphNodeId;
sceneId: SceneId;
x: number;
y: number;
/** Ровно один узел в проекте может быть начальной сценой для входа в граф. */
isStartScene: boolean;
};
export type SceneGraphEdge = {
id: string;
sourceGraphNodeId: GraphNodeId;
targetGraphNodeId: GraphNodeId;
};
export type Scene = {
id: SceneId;
title: string;
description: string;
/** Превью ассет (изображение или видео). */
previewAssetId: AssetId | null;
previewAssetType: 'image' | 'video' | null;
/** Для видео-превью: автозапуск (в редакторе/списках/на графе). */
previewVideoAutostart: boolean;
/** Поворот превью в градусах (0/90/180/270). */
previewRotationDeg: 0 | 90 | 180 | 270;
media: SceneMediaRefs;
settings: SceneSettings;
connections: SceneId[];
layout: SceneLayout;
};
export type ProjectMeta = {
name: string;
/** Имя файла проекта без суффикса `.dnd.zip` (то, что пользователь редактирует). */
fileBaseName: string;
createdAt: IsoDateTimeString;
updatedAt: IsoDateTimeString;
/** Версия приложения, с которой проект был создан (не меняется при сохранениях). */
createdWithAppVersion: string;
/** Версия приложения при последнем сохранении. */
appVersion: string;
schemaVersion: typeof PROJECT_SCHEMA_VERSION;
};
export type Project = {
id: ProjectId;
meta: ProjectMeta;
scenes: Record<SceneId, Scene>;
assets: Record<AssetId, MediaAsset>;
currentSceneId: SceneId | null;
/** Текущая нода графа (важно, когда одна сцена имеет несколько нод). */
currentGraphNodeId: GraphNodeId | null;
/** Позиции карточек на графе; логические связи сцен по-прежнему в `Scene.connections`. */
sceneGraphNodes: SceneGraphNode[];
sceneGraphEdges: SceneGraphEdge[];
};
+102
View File
@@ -0,0 +1,102 @@
export type EffectToolType = 'fog' | 'fire' | 'rain' | 'lightning' | 'freeze' | 'eraser';
export type EffectInstanceType = 'fog' | 'fire' | 'rain' | 'lightning' | 'freeze' | 'scorch' | 'ice';
/** Нормализованные координаты (0..1) относительно области предпросмотра/презентации. */
export type NPoint = { x: number; y: number; tMs: number; pressure?: number };
export type EffectInstanceBase = {
id: string;
type: EffectInstanceType;
/** Для детерминизма процедурных эффектов. */
seed: number;
/** Время создания по часам main-процесса. */
createdAtMs: number;
};
export type FogInstance = EffectInstanceBase & {
type: 'fog';
points: NPoint[];
radiusN: number;
opacity: number;
lifetimeMs: number | null;
};
export type FireInstance = EffectInstanceBase & {
type: 'fire';
points: NPoint[];
radiusN: number;
opacity: number;
lifetimeMs: number | null;
};
export type RainInstance = EffectInstanceBase & {
type: 'rain';
points: NPoint[];
radiusN: number;
opacity: number;
lifetimeMs: number | null;
};
export type LightningInstance = EffectInstanceBase & {
type: 'lightning';
start: { x: number; y: number };
end: { x: number; y: number };
widthN: number;
intensity: number;
lifetimeMs: number;
};
export type FreezeInstance = EffectInstanceBase & {
type: 'freeze';
at: { x: number; y: number };
intensity: number;
lifetimeMs: number;
};
export type ScorchInstance = EffectInstanceBase & {
/** Внутренний инстанс: след после молнии. */
type: 'scorch';
at: { x: number; y: number };
radiusN: number;
opacity: number;
lifetimeMs: number;
};
export type IceInstance = EffectInstanceBase & {
/** Внутренний инстанс: ледяной след после заморозки. */
type: 'ice';
at: { x: number; y: number };
radiusN: number;
opacity: number;
lifetimeMs: number;
};
export type EffectInstance =
| FogInstance
| FireInstance
| RainInstance
| LightningInstance
| FreezeInstance
| ScorchInstance
| IceInstance;
export type EffectToolState = {
tool: EffectToolType;
radiusN: number;
intensity: number;
};
export type EffectsState = {
revision: number;
/** Текущее время по часам main-процесса (для синхронной анимации между окнами). */
serverNowMs: number;
tool: EffectToolState;
instances: EffectInstance[];
};
export type EffectsEvent =
| { kind: 'tool.set'; tool: EffectToolState }
| { kind: 'instances.clear' }
| { kind: 'instance.add'; instance: EffectInstance }
| { kind: 'instance.remove'; id: string };
+22
View File
@@ -0,0 +1,22 @@
export type Brand<T, B extends string> = T & { readonly __brand: B };
export type ProjectId = Brand<string, 'ProjectId'>;
export type SceneId = Brand<string, 'SceneId'>;
export type AssetId = Brand<string, 'AssetId'>;
export type GraphNodeId = Brand<string, 'GraphNodeId'>;
export function asProjectId(value: string): ProjectId {
return value as ProjectId;
}
export function asSceneId(value: string): SceneId {
return value as SceneId;
}
export function asAssetId(value: string): AssetId {
return value as AssetId;
}
export function asGraphNodeId(value: string): GraphNodeId {
return value as GraphNodeId;
}
+4
View File
@@ -0,0 +1,4 @@
export * from './domain';
export * from './effects';
export * from './ids';
export * from './videoPlayback';
+21
View File
@@ -0,0 +1,21 @@
import type { AssetId } from './ids';
export type VideoPlaybackState = {
revision: number;
serverNowMs: number;
/** Какая именно видео-дорожка сейчас синхронизируется (previewAssetId). */
targetAssetId: AssetId | null;
playing: boolean;
playbackRate: number;
/** Опорная точка: при `anchorServerMs` видео было на `anchorVideoTimeSec`. */
anchorServerMs: number;
anchorVideoTimeSec: number;
};
export type VideoPlaybackEvent =
| { kind: 'target.set'; assetId: AssetId | null; autostart?: boolean }
| { kind: 'play' }
| { kind: 'pause' }
| { kind: 'stop' }
| { kind: 'seek'; timeSec: number }
| { kind: 'rate.set'; rate: number };