Compare commits
3 Commits
20c838da7d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| add699a320 | |||
| 2ce1e02753 | |||
| e39a72206d |
+82
-4
@@ -9,16 +9,32 @@ import { ZipProjectStore } from './project/zipStore';
|
|||||||
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
|
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
|
||||||
import { getAppSemanticVersion, getOptionalBuildNumber } from './versionInfo';
|
import { getAppSemanticVersion, getOptionalBuildNumber } from './versionInfo';
|
||||||
import { VideoPlaybackStore } from './video/videoPlaybackStore';
|
import { VideoPlaybackStore } from './video/videoPlaybackStore';
|
||||||
|
import {
|
||||||
|
createBootWindow,
|
||||||
|
destroyBootWindow,
|
||||||
|
setBootWindowStatus,
|
||||||
|
waitForBootWindowReady,
|
||||||
|
} from './windows/bootWindow';
|
||||||
import {
|
import {
|
||||||
applyDockIconIfNeeded,
|
applyDockIconIfNeeded,
|
||||||
closeMultiWindow,
|
closeMultiWindow,
|
||||||
|
createEditorWindowDeferred,
|
||||||
createWindows,
|
createWindows,
|
||||||
focusEditorWindow,
|
focusEditorWindow,
|
||||||
markAppQuitting,
|
markAppQuitting,
|
||||||
openMultiWindow,
|
openMultiWindow,
|
||||||
togglePresentationFullscreen,
|
togglePresentationFullscreen,
|
||||||
|
waitForEditorWindowReady,
|
||||||
} from './windows/createWindows';
|
} from './windows/createWindows';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отключение GPU ломает скорость вторичных окон (презентация/пульт — WebGL). По умолчанию не трогаем.
|
||||||
|
* При чёрном экране в упакованной сборке: `DND_DISABLE_GPU=1`.
|
||||||
|
*/
|
||||||
|
if (process.platform === 'win32' && app.isPackaged && process.env.DND_DISABLE_GPU === '1') {
|
||||||
|
app.disableHardwareAcceleration();
|
||||||
|
}
|
||||||
|
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
app.setAppUserModelId('com.dndplayer.app');
|
app.setAppUserModelId('com.dndplayer.app');
|
||||||
}
|
}
|
||||||
@@ -89,6 +105,71 @@ function emitSessionState(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Упакованное приложение: экран загрузки → проверки → редактор.
|
||||||
|
* В dev по умолчанию без экрана; тест: `DND_SHOW_BOOT=1`. Отключить везде: `DND_SKIP_BOOT=1`.
|
||||||
|
*/
|
||||||
|
async function runStartupAfterHandlers(licenseService: LicenseService): Promise<void> {
|
||||||
|
const useBootSequence =
|
||||||
|
process.env.DND_SKIP_BOOT !== '1' && (app.isPackaged || process.env.DND_SHOW_BOOT === '1');
|
||||||
|
|
||||||
|
if (!useBootSequence) {
|
||||||
|
createWindows();
|
||||||
|
emitSessionState();
|
||||||
|
emitEffectsState();
|
||||||
|
emitVideoState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const splash = createBootWindow();
|
||||||
|
try {
|
||||||
|
await waitForBootWindowReady(splash);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[boot] splash load failed', err);
|
||||||
|
destroyBootWindow(splash);
|
||||||
|
createWindows();
|
||||||
|
emitSessionState();
|
||||||
|
emitEffectsState();
|
||||||
|
emitVideoState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
splash.show();
|
||||||
|
setBootWindowStatus(splash, 'Инициализация…');
|
||||||
|
|
||||||
|
try {
|
||||||
|
setBootWindowStatus(splash, 'Подготовка данных…');
|
||||||
|
await projectStore.ensureRoots();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[boot] ensureRoots', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
setBootWindowStatus(splash, 'Проверка лицензии…');
|
||||||
|
// Сеть в `getStatus()` не блокируем старт: синхронный снимок, отзыв — в фоне.
|
||||||
|
licenseService.getStatusSync();
|
||||||
|
queueMicrotask(() => {
|
||||||
|
licenseService.getStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
setBootWindowStatus(splash, 'Загрузка редактора…');
|
||||||
|
const editor = createEditorWindowDeferred();
|
||||||
|
const bootEditorMs = 2000;
|
||||||
|
await Promise.race([
|
||||||
|
waitForEditorWindowReady(editor),
|
||||||
|
new Promise<void>((resolve) => setTimeout(resolve, bootEditorMs)),
|
||||||
|
]);
|
||||||
|
setBootWindowStatus(splash, 'Готово');
|
||||||
|
destroyBootWindow(splash);
|
||||||
|
if (!editor.isDestroyed()) {
|
||||||
|
editor.show();
|
||||||
|
editor.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
emitSessionState();
|
||||||
|
emitEffectsState();
|
||||||
|
emitVideoState();
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
await app.whenReady();
|
await app.whenReady();
|
||||||
const licenseService = new LicenseService(app.getPath('userData'));
|
const licenseService = new LicenseService(app.getPath('userData'));
|
||||||
@@ -334,10 +415,7 @@ async function main() {
|
|||||||
|
|
||||||
installIpcRouter();
|
installIpcRouter();
|
||||||
applyDockIconIfNeeded();
|
applyDockIconIfNeeded();
|
||||||
createWindows();
|
await runStartupAfterHandlers(licenseService);
|
||||||
emitSessionState();
|
|
||||||
emitEffectsState();
|
|
||||||
emitVideoState();
|
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
focusEditorWindow();
|
focusEditorWindow();
|
||||||
|
|||||||
@@ -113,12 +113,17 @@ export class LicenseService {
|
|||||||
return normalizeLicenseTokenInput(token);
|
return normalizeLicenseTokenInput(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Онлайн-проверка отзыва. Не вызывать через `await` из UI-пути: без VPN/DNS до сервера
|
||||||
|
* лицензий TCP может висеть до таймаута (см. fetch), из‑за чего главное окно долго «чёрное».
|
||||||
|
*/
|
||||||
private async maybeRefreshRemoteRevocation(payload: LicensePayloadV1): Promise<void> {
|
private async maybeRefreshRemoteRevocation(payload: LicensePayloadV1): Promise<void> {
|
||||||
const base = process.env.DND_LICENSE_STATUS_URL?.trim();
|
const base = process.env.DND_LICENSE_STATUS_URL?.trim();
|
||||||
if (!base) return;
|
if (!base) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - this.lastRemoteRevokeCheckMs < 60_000) return;
|
if (now - this.lastRemoteRevokeCheckMs < 60_000) return;
|
||||||
this.lastRemoteRevokeCheckMs = now;
|
this.lastRemoteRevokeCheckMs = now;
|
||||||
|
const wasRevoked = this.lastRemoteRevoked;
|
||||||
try {
|
try {
|
||||||
const u = new URL('v1/status', base.endsWith('/') ? base : `${base}/`);
|
const u = new URL('v1/status', base.endsWith('/') ? base : `${base}/`);
|
||||||
u.searchParams.set('sub', payload.sub);
|
u.searchParams.set('sub', payload.sub);
|
||||||
@@ -129,6 +134,9 @@ export class LicenseService {
|
|||||||
} catch {
|
} catch {
|
||||||
/* offline: не блокируем */
|
/* offline: не блокируем */
|
||||||
}
|
}
|
||||||
|
if (wasRevoked !== this.lastRemoteRevoked) {
|
||||||
|
emitLicenseStatusChanged();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getStatusSync(): LicenseSnapshot {
|
getStatusSync(): LicenseSnapshot {
|
||||||
@@ -212,7 +220,8 @@ export class LicenseService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getStatus(): Promise<LicenseSnapshot> {
|
/** Снимок для UI/IPC: без ожидания сети (проверка отзыва уходит в фон). */
|
||||||
|
getStatus(): LicenseSnapshot {
|
||||||
if (this.isSkipLicense()) return this.getStatusSync();
|
if (this.isSkipLicense()) return this.getStatusSync();
|
||||||
const base = this.getStatusSync();
|
const base = this.getStatusSync();
|
||||||
if (!base.active || !base.summary) return base;
|
if (!base.active || !base.summary) return base;
|
||||||
@@ -223,7 +232,7 @@ export class LicenseService {
|
|||||||
deviceId: this.deviceId,
|
deviceId: this.deviceId,
|
||||||
});
|
});
|
||||||
if (!v.ok) return this.getStatusSync();
|
if (!v.ok) return this.getStatusSync();
|
||||||
await this.maybeRefreshRemoteRevocation(v.payload);
|
void this.maybeRefreshRemoteRevocation(v.payload);
|
||||||
return this.getStatusSync();
|
return this.getStatusSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 readBootWindow(): string {
|
||||||
|
return fs.readFileSync(path.join(here, 'bootWindow.ts'), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('bootWindow: экран загрузки без preload, статус из main', () => {
|
||||||
|
const src = readBootWindow();
|
||||||
|
assert.ok(src.includes('createBootWindow'));
|
||||||
|
assert.ok(src.includes('destroyBootWindow'));
|
||||||
|
assert.ok(src.includes('setBootWindowStatus'));
|
||||||
|
assert.ok(src.includes('waitForBootWindowReady'));
|
||||||
|
assert.ok(src.includes('executeJavaScript'));
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('createWindows: отложенный показ редактора для boot', () => {
|
||||||
|
const src = fs.readFileSync(path.join(here, 'createWindows.ts'), 'utf8');
|
||||||
|
assert.ok(src.includes('deferVisibility'));
|
||||||
|
assert.ok(src.includes('createEditorWindowDeferred'));
|
||||||
|
assert.ok(src.includes('waitForEditorWindowReady'));
|
||||||
|
assert.ok(src.includes('getBootSplashWindow'));
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { app, BrowserWindow } from 'electron';
|
||||||
|
|
||||||
|
import { getAppSemanticVersion } from '../versionInfo';
|
||||||
|
|
||||||
|
let bootSplashRef: BrowserWindow | null = null;
|
||||||
|
|
||||||
|
export function getBootSplashWindow(): BrowserWindow | null {
|
||||||
|
return bootSplashRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadBootPage(win: BrowserWindow): void {
|
||||||
|
const dev = process.env.VITE_DEV_SERVER_URL;
|
||||||
|
if (dev) {
|
||||||
|
void win.loadURL(new URL('boot.html', dev).toString());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const htmlPath = path.join(app.getAppPath(), 'dist', 'renderer', 'boot.html');
|
||||||
|
void win.loadFile(htmlPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Без preload: только статический экран; статус задаётся из main через executeJavaScript. */
|
||||||
|
function bootWebPreferences(): Electron.WebPreferences {
|
||||||
|
const dev = Boolean(process.env.VITE_DEV_SERVER_URL);
|
||||||
|
return {
|
||||||
|
contextIsolation: true,
|
||||||
|
sandbox: dev ? true : process.platform !== 'win32',
|
||||||
|
nodeIntegration: false,
|
||||||
|
devTools: dev || process.env.DND_OPEN_DEVTOOLS === '1',
|
||||||
|
webSecurity: dev,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Окно без системного заголовка: логотип, название, строка статуса.
|
||||||
|
* Показывать после `waitForBootWindowReady`.
|
||||||
|
*/
|
||||||
|
export function createBootWindow(): BrowserWindow {
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: 440,
|
||||||
|
height: 420,
|
||||||
|
show: false,
|
||||||
|
frame: false,
|
||||||
|
resizable: false,
|
||||||
|
maximizable: false,
|
||||||
|
minimizable: false,
|
||||||
|
closable: false,
|
||||||
|
center: true,
|
||||||
|
transparent: false,
|
||||||
|
backgroundColor: '#09090B',
|
||||||
|
roundedCorners: true,
|
||||||
|
webPreferences: bootWebPreferences(),
|
||||||
|
});
|
||||||
|
|
||||||
|
bootSplashRef = win;
|
||||||
|
win.once('closed', () => {
|
||||||
|
if (bootSplashRef === win) {
|
||||||
|
bootSplashRef = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
loadBootPage(win);
|
||||||
|
return win;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Закрыть splash: при `closable: false` на Windows `close()` из main часто не срабатывает — используем `destroy()`.
|
||||||
|
*/
|
||||||
|
export function destroyBootWindow(win: BrowserWindow): void {
|
||||||
|
if (win.isDestroyed()) return;
|
||||||
|
win.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setBootWindowStatus(win: BrowserWindow, text: string): void {
|
||||||
|
if (win.isDestroyed()) return;
|
||||||
|
const escaped = JSON.stringify(text);
|
||||||
|
void win.webContents.executeJavaScript(
|
||||||
|
`(() => { const el = document.getElementById('boot-status'); if (el) el.textContent = ${escaped}; })()`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyBootWindowBranding(win: BrowserWindow): void {
|
||||||
|
if (win.isDestroyed()) return;
|
||||||
|
const name = app.getName();
|
||||||
|
const version = getAppSemanticVersion();
|
||||||
|
const versionLabel = version.trim().length > 0 ? `v${version.trim()}` : '';
|
||||||
|
void win.webContents.executeJavaScript(
|
||||||
|
`(() => {
|
||||||
|
const t = document.querySelector('[data-boot-title]');
|
||||||
|
if (t) t.textContent = ${JSON.stringify(name)};
|
||||||
|
const v = document.querySelector('[data-boot-version]');
|
||||||
|
if (v) v.textContent = ${JSON.stringify(versionLabel)};
|
||||||
|
})()`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Дождаться загрузки разметки экрана загрузки. */
|
||||||
|
export function waitForBootWindowReady(win: BrowserWindow): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (win.isDestroyed()) {
|
||||||
|
reject(new Error('boot window destroyed'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const onFail = (): void => {
|
||||||
|
reject(new Error('boot window failed to load'));
|
||||||
|
};
|
||||||
|
win.webContents.once('did-fail-load', onFail);
|
||||||
|
win.webContents.once('did-finish-load', () => {
|
||||||
|
win.webContents.removeListener('did-fail-load', onFail);
|
||||||
|
applyBootWindowBranding(win);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -33,3 +33,15 @@ void test('createWindows: пульт поверх экрана просмотр
|
|||||||
assert.ok(src.includes('parent: presentation'));
|
assert.ok(src.includes('parent: presentation'));
|
||||||
assert.ok(src.includes("createWindow('control'"));
|
assert.ok(src.includes("createWindow('control'"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('createWindows: production — loadFile для HTML (не только file://)', () => {
|
||||||
|
const src = readCreateWindows();
|
||||||
|
assert.ok(src.includes('loadFile'));
|
||||||
|
assert.ok(src.includes('loadWindowPage'));
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('createWindows: показ окна — не только ready-to-show (холодный старт Windows)', () => {
|
||||||
|
const src = readCreateWindows();
|
||||||
|
assert.ok(src.includes('ensureWindowBecomesVisible'));
|
||||||
|
assert.ok(src.includes('did-finish-load'));
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { pathToFileURL } from 'node:url';
|
|
||||||
|
|
||||||
import { app, BrowserWindow, nativeImage, screen } from 'electron';
|
import { app, BrowserWindow, nativeImage, screen } from 'electron';
|
||||||
|
|
||||||
|
import { getBootSplashWindow } from './bootWindow';
|
||||||
|
|
||||||
type WindowKind = 'editor' | 'presentation' | 'control';
|
type WindowKind = 'editor' | 'presentation' | 'control';
|
||||||
|
|
||||||
const windows = new Map<WindowKind, BrowserWindow>();
|
const windows = new Map<WindowKind, BrowserWindow>();
|
||||||
@@ -24,15 +25,29 @@ function isDev() {
|
|||||||
return process.env.NODE_ENV === 'development' || process.env.VITE_DEV_SERVER_URL !== undefined;
|
return process.env.NODE_ENV === 'development' || process.env.VITE_DEV_SERVER_URL !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRendererUrl(kind: WindowKind): string {
|
/** Вне dev-сервера на Windows с `loadFile` + preload иногда ломается sandbox; оставляем изоляцию через preload/contextBridge. */
|
||||||
|
function shouldUseRendererSandbox(): boolean {
|
||||||
|
if (process.env.VITE_DEV_SERVER_URL) return true;
|
||||||
|
return process.platform !== 'win32';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRendererHtmlPath(kind: WindowKind): string {
|
||||||
|
return path.join(app.getAppPath(), 'dist', 'renderer', `${kind}.html`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* В production `loadURL(file://…)` на Windows с asar иногда даёт чёрный экран;
|
||||||
|
* `loadFile` корректно открывает HTML из asar и на Windows, и на macOS.
|
||||||
|
*/
|
||||||
|
function loadWindowPage(win: BrowserWindow, kind: WindowKind): void {
|
||||||
const dev = process.env.VITE_DEV_SERVER_URL;
|
const dev = process.env.VITE_DEV_SERVER_URL;
|
||||||
if (dev) {
|
if (dev) {
|
||||||
const page =
|
const page =
|
||||||
kind === 'editor' ? 'editor.html' : kind === 'presentation' ? 'presentation.html' : 'control.html';
|
kind === 'editor' ? 'editor.html' : kind === 'presentation' ? 'presentation.html' : 'control.html';
|
||||||
return new URL(page, dev).toString();
|
void win.loadURL(new URL(page, dev).toString());
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
const filePath = path.join(app.getAppPath(), 'dist', 'renderer', `${kind}.html`);
|
void win.loadFile(getRendererHtmlPath(kind));
|
||||||
return pathToFileURL(filePath).toString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPreloadPath(): string {
|
function getPreloadPath(): string {
|
||||||
@@ -121,9 +136,35 @@ export function applyDockIconIfNeeded(): void {
|
|||||||
type CreateWindowOpts = {
|
type CreateWindowOpts = {
|
||||||
/** Дочернее окно (например пульт) держится над родителем (экран просмотра). */
|
/** Дочернее окно (например пульт) держится над родителем (экран просмотра). */
|
||||||
parent?: BrowserWindow;
|
parent?: BrowserWindow;
|
||||||
|
/** Только редактор: не показывать окно до `show()` (экран загрузки). */
|
||||||
|
deferVisibility?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Только `ready-to-show` на части систем (первый холодный старт Windows) не приходит вовремя —
|
||||||
|
* окно остаётся с `show: false` и кажется «зависшим». Дублируем показ по `did-finish-load` и таймауту.
|
||||||
|
*/
|
||||||
|
function ensureWindowBecomesVisible(win: BrowserWindow): void {
|
||||||
|
let shown = false;
|
||||||
|
const showOnce = (): void => {
|
||||||
|
if (shown) return;
|
||||||
|
if (win.isDestroyed()) return;
|
||||||
|
shown = true;
|
||||||
|
win.show();
|
||||||
|
};
|
||||||
|
|
||||||
|
win.once('ready-to-show', showOnce);
|
||||||
|
win.webContents.once('did-finish-load', () => {
|
||||||
|
showOnce();
|
||||||
|
});
|
||||||
|
const safetyTimer = setTimeout(showOnce, 8000);
|
||||||
|
win.once('closed', () => {
|
||||||
|
clearTimeout(safetyTimer);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow {
|
function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow {
|
||||||
|
const deferEditor = kind === 'editor' && opts?.deferVisibility === true;
|
||||||
const icon = resolveWindowIcon();
|
const icon = resolveWindowIcon();
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
width: kind === 'editor' ? 1280 : kind === 'control' ? 1200 : 1280,
|
width: kind === 'editor' ? 1280 : kind === 'control' ? 1200 : 1280,
|
||||||
@@ -134,11 +175,13 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
|||||||
...(opts?.parent ? { parent: opts.parent } : {}),
|
...(opts?.parent ? { parent: opts.parent } : {}),
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
sandbox: true,
|
sandbox: shouldUseRendererSandbox(),
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
devTools: isDev(),
|
devTools: isDev() || process.env.DND_OPEN_DEVTOOLS === '1',
|
||||||
preload: getPreloadPath(),
|
preload: getPreloadPath(),
|
||||||
autoplayPolicy: 'no-user-gesture-required',
|
autoplayPolicy: 'no-user-gesture-required',
|
||||||
|
// file:// + бандл Vite: без этого на Windows часто не грузятся чанки; http:// (dev server) оставляем строгим.
|
||||||
|
webSecurity: Boolean(process.env.VITE_DEV_SERVER_URL),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -148,9 +191,14 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
|||||||
win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
|
win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
|
||||||
console.error(`[did-fail-load] ${String(errorCode)} ${errorDescription} ${validatedURL}`);
|
console.error(`[did-fail-load] ${String(errorCode)} ${errorDescription} ${validatedURL}`);
|
||||||
});
|
});
|
||||||
|
win.webContents.on('render-process-gone', (_event, details) => {
|
||||||
|
console.error('[render-process-gone]', details.reason, details.exitCode);
|
||||||
|
});
|
||||||
|
|
||||||
win.once('ready-to-show', () => win.show());
|
if (!deferEditor) {
|
||||||
void win.loadURL(getRendererUrl(kind));
|
ensureWindowBecomesVisible(win);
|
||||||
|
}
|
||||||
|
loadWindowPage(win, kind);
|
||||||
if (kind === 'editor') {
|
if (kind === 'editor') {
|
||||||
win.on('close', (e) => {
|
win.on('close', (e) => {
|
||||||
if (appQuitting) return;
|
if (appQuitting) return;
|
||||||
@@ -169,7 +217,47 @@ export function createWindows() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Редактор создаётся скрытым до окончания экрана загрузки. */
|
||||||
|
export function createEditorWindowDeferred(): BrowserWindow {
|
||||||
|
const existing = windows.get('editor');
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
return createWindow('editor', { deferVisibility: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Дождаться первой отрисовки редактора (готовность к показу без чёрного экрана). */
|
||||||
|
export function waitForEditorWindowReady(win: BrowserWindow): Promise<void> {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
resolve(undefined);
|
||||||
|
}
|
||||||
|
}, 35000);
|
||||||
|
const finish = (): void => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(undefined);
|
||||||
|
};
|
||||||
|
win.once('ready-to-show', finish);
|
||||||
|
win.webContents.once('did-finish-load', finish);
|
||||||
|
}).then(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((r) => {
|
||||||
|
setTimeout(r, 120);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function focusEditorWindow(): void {
|
export function focusEditorWindow(): void {
|
||||||
|
const splash = getBootSplashWindow();
|
||||||
|
if (splash && !splash.isDestroyed()) {
|
||||||
|
splash.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const win = windows.get('editor');
|
const win = windows.get('editor');
|
||||||
if (win) {
|
if (win) {
|
||||||
if (win.isMinimized()) win.restore();
|
if (win.isMinimized()) win.restore();
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline';" />
|
||||||
|
<title>Загрузка</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family:
|
||||||
|
ui-sans-serif,
|
||||||
|
system-ui,
|
||||||
|
'Segoe UI',
|
||||||
|
Roboto,
|
||||||
|
'Helvetica Neue',
|
||||||
|
Arial,
|
||||||
|
sans-serif;
|
||||||
|
background: #09090b;
|
||||||
|
color: #fafafa;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
.wrap {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 28px 24px;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 120% 80% at 50% -20%, rgba(99, 102, 241, 0.18), transparent 55%),
|
||||||
|
radial-gradient(ellipse 90% 60% at 100% 100%, rgba(168, 85, 247, 0.1), transparent 45%),
|
||||||
|
#09090b;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 32px 28px 28px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(24, 24, 27, 0.85);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(0, 0, 0, 0.35) inset,
|
||||||
|
0 24px 48px rgba(0, 0, 0, 0.45);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
display: block;
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
margin: 0 0 22px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #a1a1aa;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.version {
|
||||||
|
margin: 0 0 20px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #71717a;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #e4e4e7;
|
||||||
|
min-height: 1.4em;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.bar {
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.bar::after {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
width: 32%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, #6366f1, #a855f7);
|
||||||
|
animation: boot-indeterminate 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes boot-indeterminate {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-120%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(380%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="card">
|
||||||
|
<img class="logo" src="./app-window-icon.png" width="72" height="72" alt="" />
|
||||||
|
<h1 class="title" data-boot-title>DNDGamePlayer</h1>
|
||||||
|
<p class="subtitle">редактор и проигрыватель</p>
|
||||||
|
<p class="version" data-boot-version></p>
|
||||||
|
<p class="status" id="boot-status">Запуск…</p>
|
||||||
|
<div class="bar" aria-hidden="true"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -222,6 +222,7 @@
|
|||||||
.brushCursor {
|
.brushCursor {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
will-change: left, top;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.55);
|
border: 1px solid rgba(255, 255, 255, 0.55);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { pickEraseTargetId } from '../../shared/effectEraserHitTest';
|
import { pickEraseTargetId } from '../../shared/effectEraserHitTest';
|
||||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||||
@@ -62,7 +62,6 @@ export function ControlApp() {
|
|||||||
points?: { x: number; y: number; tMs: number }[];
|
points?: { x: number; y: number; tMs: number }[];
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [draftFxTick, setDraftFxTick] = useState(0);
|
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 [previewSize, setPreviewSize] = useState<{ w: number; h: number }>({ w: 1, h: 1 });
|
||||||
const [previewContentRect, setPreviewContentRect] = useState<{
|
const [previewContentRect, setPreviewContentRect] = useState<{
|
||||||
x: number;
|
x: number;
|
||||||
@@ -70,6 +69,13 @@ export function ControlApp() {
|
|||||||
w: number;
|
w: number;
|
||||||
h: number;
|
h: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const previewContentRectRef = useRef(previewContentRect);
|
||||||
|
previewContentRectRef.current = previewContentRect;
|
||||||
|
const previewSizeRef = useRef(previewSize);
|
||||||
|
previewSizeRef.current = previewSize;
|
||||||
|
const brushCursorElRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const cursorPosRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
const draftPaintRafRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void api.invoke(ipcChannels.project.get, {}).then((res) => {
|
void api.invoke(ipcChannels.project.get, {}).then((res) => {
|
||||||
@@ -351,12 +357,58 @@ export function ControlApp() {
|
|||||||
}, [currentGraphNodeId, project]);
|
}, [currentGraphNodeId, project]);
|
||||||
|
|
||||||
const tool = fxState?.tool ?? { tool: 'fog', radiusN: 0.08, intensity: 0.6 };
|
const tool = fxState?.tool ?? { tool: 'fog', radiusN: 0.08, intensity: 0.6 };
|
||||||
|
const toolRef = useRef(tool);
|
||||||
|
toolRef.current = tool;
|
||||||
|
|
||||||
|
function layoutBrushCursor(): void {
|
||||||
|
const el = brushCursorElRef.current;
|
||||||
|
const p = cursorPosRef.current;
|
||||||
|
const cr = previewContentRectRef.current;
|
||||||
|
const ps = previewSizeRef.current;
|
||||||
|
const t = toolRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
if (!p) {
|
||||||
|
el.style.visibility = 'hidden';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.style.visibility = 'visible';
|
||||||
|
const ox = cr ? cr.x : 0;
|
||||||
|
const oy = cr ? cr.y : 0;
|
||||||
|
const cw = cr ? cr.w : ps.w;
|
||||||
|
const ch = cr ? cr.h : ps.h;
|
||||||
|
const minDim = Math.min(cw, ch);
|
||||||
|
const size = Math.max(2, t.radiusN * minDim * 2);
|
||||||
|
el.style.left = `${String(ox + p.x * cw)}px`;
|
||||||
|
el.style.top = `${String(oy + p.y * ch)}px`;
|
||||||
|
el.style.width = `${String(size)}px`;
|
||||||
|
el.style.height = `${String(size)}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleDraftRepaint(): void {
|
||||||
|
if (draftPaintRafRef.current !== 0) return;
|
||||||
|
draftPaintRafRef.current = requestAnimationFrame(() => {
|
||||||
|
draftPaintRafRef.current = 0;
|
||||||
|
setDraftFxTick((x) => x + 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
layoutBrushCursor();
|
||||||
|
}, [tool.radiusN, previewContentRect, previewSize.w, previewSize.h]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (draftPaintRafRef.current !== 0) {
|
||||||
|
cancelAnimationFrame(draftPaintRafRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
function toNPoint(e: React.PointerEvent): { x: number; y: number } | null {
|
function toNPoint(e: React.PointerEvent): { x: number; y: number } | null {
|
||||||
const host = previewHostRef.current;
|
const host = previewHostRef.current;
|
||||||
if (!host) return null;
|
if (!host) return null;
|
||||||
const r = host.getBoundingClientRect();
|
const r = host.getBoundingClientRect();
|
||||||
const cr = previewContentRect;
|
const cr = previewContentRectRef.current;
|
||||||
const ox = cr ? cr.x : 0;
|
const ox = cr ? cr.x : 0;
|
||||||
const oy = cr ? cr.y : 0;
|
const oy = cr ? cr.y : 0;
|
||||||
const cw = cr ? cr.w : r.width;
|
const cw = cr ? cr.w : r.width;
|
||||||
@@ -914,45 +966,29 @@ export function ControlApp() {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{cursorN ? (
|
<div
|
||||||
<div
|
ref={brushCursorElRef}
|
||||||
className={styles.brushCursor}
|
className={styles.brushCursor}
|
||||||
style={{
|
style={{ visibility: 'hidden' }}
|
||||||
left:
|
aria-hidden
|
||||||
(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
|
<div
|
||||||
className={styles.brushLayer}
|
className={styles.brushLayer}
|
||||||
onPointerEnter={(e) => {
|
onPointerEnter={(e) => {
|
||||||
const p = toNPoint(e);
|
const p = toNPoint(e);
|
||||||
if (!p) return;
|
if (!p) return;
|
||||||
setCursorN(p);
|
cursorPosRef.current = p;
|
||||||
|
layoutBrushCursor();
|
||||||
|
}}
|
||||||
|
onPointerLeave={() => {
|
||||||
|
cursorPosRef.current = null;
|
||||||
|
layoutBrushCursor();
|
||||||
}}
|
}}
|
||||||
onPointerLeave={() => setCursorN(null)}
|
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
const p = toNPoint(e);
|
const p = toNPoint(e);
|
||||||
if (!p) return;
|
if (!p) return;
|
||||||
setCursorN(p);
|
cursorPosRef.current = p;
|
||||||
|
layoutBrushCursor();
|
||||||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||||
if (tool.tool === 'eraser') {
|
if (tool.tool === 'eraser') {
|
||||||
const id = pickEraseTargetId(fxState?.instances ?? [], p, tool.radiusN);
|
const id = pickEraseTargetId(fxState?.instances ?? [], p, tool.radiusN);
|
||||||
@@ -969,7 +1005,8 @@ export function ControlApp() {
|
|||||||
onPointerMove={(e) => {
|
onPointerMove={(e) => {
|
||||||
const p = toNPoint(e);
|
const p = toNPoint(e);
|
||||||
if (!p) return;
|
if (!p) return;
|
||||||
setCursorN(p);
|
cursorPosRef.current = p;
|
||||||
|
layoutBrushCursor();
|
||||||
if (tool.tool === 'eraser' && (e.buttons & 1) !== 0) {
|
if (tool.tool === 'eraser' && (e.buttons & 1) !== 0) {
|
||||||
const id = pickEraseTargetId(fxState?.instances ?? [], p, tool.radiusN);
|
const id = pickEraseTargetId(fxState?.instances ?? [], p, tool.radiusN);
|
||||||
if (id) void fx.dispatch({ kind: 'instance.remove', id });
|
if (id) void fx.dispatch({ kind: 'instance.remove', id });
|
||||||
@@ -984,7 +1021,7 @@ export function ControlApp() {
|
|||||||
const minStep = Math.max(0.004, tool.radiusN * 0.25);
|
const minStep = Math.max(0.004, tool.radiusN * 0.25);
|
||||||
if (dx * dx + dy * dy < minStep * minStep) return;
|
if (dx * dx + dy * dy < minStep * minStep) return;
|
||||||
b.points.push({ x: p.x, y: p.y, tMs: Date.now() });
|
b.points.push({ x: p.x, y: p.y, tMs: Date.now() });
|
||||||
setDraftFxTick((x) => x + 1);
|
scheduleDraftRepaint();
|
||||||
}}
|
}}
|
||||||
onPointerUp={() => {
|
onPointerUp={() => {
|
||||||
void commitStroke();
|
void commitStroke();
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
|
|||||||
const rot = scene?.previewRotationDeg ?? 0;
|
const rot = scene?.previewRotationDeg ?? 0;
|
||||||
const isVideo = scene?.previewAssetType === 'video';
|
const isVideo = scene?.previewAssetType === 'video';
|
||||||
const assetId = scene?.previewAssetType === 'video' ? scene.previewAssetId : null;
|
const assetId = scene?.previewAssetType === 'video' ? scene.previewAssetId : null;
|
||||||
|
const autostart = scene?.previewVideoAutostart ?? false;
|
||||||
|
|
||||||
const [tick, setTick] = useState(0);
|
const [tick, setTick] = useState(0);
|
||||||
const dur = useMemo(
|
const dur = useMemo(
|
||||||
@@ -38,7 +39,7 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
|
|||||||
if (!v) return 0;
|
if (!v) return 0;
|
||||||
return Number.isFinite(v.duration) ? v.duration : 0;
|
return Number.isFinite(v.duration) ? v.duration : 0;
|
||||||
},
|
},
|
||||||
// tick: перечитываем duration из video ref на каждом кадре RAF
|
// tick: timeupdate / loadedmetadata перечитывают duration и currentTime
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- намеренно
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- намеренно
|
||||||
[tick, videoRef],
|
[tick, videoRef],
|
||||||
);
|
);
|
||||||
@@ -55,23 +56,15 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVideo) return;
|
if (!isVideo) return;
|
||||||
let raf = 0;
|
if (!assetId) return;
|
||||||
const loop = () => {
|
// `target.set` bumps revision and resets anchors; avoid firing on every render.
|
||||||
setTick((x) => x + 1);
|
if (vp?.targetAssetId === assetId) return;
|
||||||
raf = window.requestAnimationFrame(loop);
|
|
||||||
};
|
|
||||||
raf = window.requestAnimationFrame(loop);
|
|
||||||
return () => window.cancelAnimationFrame(raf);
|
|
||||||
}, [isVideo]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isVideo) return;
|
|
||||||
void video.dispatch({
|
void video.dispatch({
|
||||||
kind: 'target.set',
|
kind: 'target.set',
|
||||||
assetId,
|
assetId,
|
||||||
autostart: scene.previewVideoAutostart,
|
autostart,
|
||||||
});
|
});
|
||||||
}, [assetId, isVideo, scene, video]);
|
}, [assetId, isVideo, autostart, vp?.targetAssetId, video]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
@@ -88,7 +81,8 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
|
|||||||
} else {
|
} else {
|
||||||
v.pause();
|
v.pause();
|
||||||
}
|
}
|
||||||
}, [assetId, vp, videoRef]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- avoid reruns on 500ms heartbeats (serverNowMs-only updates)
|
||||||
|
}, [assetId, url, vp?.revision, vp?.targetAssetId, vp?.playing, vp?.playbackRate, videoRef]);
|
||||||
|
|
||||||
const scrubClass = [styles.scrub, dur ? styles.scrubPointer : styles.scrubDefault].join(' ');
|
const scrubClass = [styles.scrub, dur ? styles.scrubPointer : styles.scrubDefault].join(' ');
|
||||||
|
|
||||||
@@ -105,6 +99,8 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
|
|||||||
src={url}
|
src={url}
|
||||||
playsInline
|
playsInline
|
||||||
preload="auto"
|
preload="auto"
|
||||||
|
onTimeUpdate={() => setTick((x) => x + 1)}
|
||||||
|
onLoadedMetadata={() => setTick((x) => x + 1)}
|
||||||
>
|
>
|
||||||
<track kind="captions" srcLang="ru" label="Превью без субтитров" />
|
<track kind="captions" srcLang="ru" label="Превью без субтитров" />
|
||||||
</video>
|
</video>
|
||||||
|
|||||||
@@ -53,7 +53,16 @@ export function PresentationView({
|
|||||||
} else {
|
} else {
|
||||||
el.pause();
|
el.pause();
|
||||||
}
|
}
|
||||||
}, [scene?.previewAssetId, scene?.previewAssetType, vp]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- avoid reruns on 500ms heartbeats (serverNowMs-only updates)
|
||||||
|
}, [
|
||||||
|
scene?.previewAssetId,
|
||||||
|
scene?.previewAssetType,
|
||||||
|
previewUrl,
|
||||||
|
vp?.revision,
|
||||||
|
vp?.targetAssetId,
|
||||||
|
vp?.playing,
|
||||||
|
vp?.playbackRate,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
.host {
|
.host {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
contain: layout paint;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hostInteractive {
|
.hostInteractive {
|
||||||
|
|||||||
@@ -10,3 +10,8 @@ void test('PxiEffectsOverlay: canvas не перехватывает указа
|
|||||||
const src = fs.readFileSync(path.join(here, 'PxiEffectsOverlay.tsx'), 'utf8');
|
const src = fs.readFileSync(path.join(here, 'PxiEffectsOverlay.tsx'), 'utf8');
|
||||||
assert.ok(src.includes("app.canvas.style.pointerEvents = interactive ? 'auto' : 'none'"));
|
assert.ok(src.includes("app.canvas.style.pointerEvents = interactive ? 'auto' : 'none'"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('PxiEffectsOverlay: ограничение FPS тикера для нагрузки', () => {
|
||||||
|
const src = fs.readFileSync(path.join(here, 'PxiEffectsOverlay.tsx'), 'utf8');
|
||||||
|
assert.ok(src.includes('app.ticker.maxFPS'));
|
||||||
|
});
|
||||||
|
|||||||
@@ -41,13 +41,15 @@ export function PixiEffectsOverlay({ state, interactive = false, style, viewport
|
|||||||
const viewportRef = useRef<{ x: number; y: number; w: number; h: number }>({ x: 0, y: 0, w: 1, h: 1 });
|
const viewportRef = useRef<{ x: number; y: number; w: number; h: number }>({ x: 0, y: 0, w: 1, h: 1 });
|
||||||
const viewportProvidedRef = useRef(false);
|
const viewportProvidedRef = useRef(false);
|
||||||
|
|
||||||
const dpr = useMemo(() => Math.min(2, window.devicePixelRatio || 1), []);
|
/** Снижаем resolution на HiDPI — меньше пикселей в WebGL, визуально ок для оверлея эффектов. */
|
||||||
|
const dpr = useMemo(() => Math.min(1.5, window.devicePixelRatio || 1), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const host = hostRef.current;
|
const host = hostRef.current;
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
|
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
|
let resizeRaf = 0;
|
||||||
let app: any = null;
|
let app: any = null;
|
||||||
let cleanup: (() => void) | null = null;
|
let cleanup: (() => void) | null = null;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -58,10 +60,14 @@ export function PixiEffectsOverlay({ state, interactive = false, style, viewport
|
|||||||
app = new pixi.Application();
|
app = new pixi.Application();
|
||||||
await app.init({
|
await app.init({
|
||||||
backgroundAlpha: 0,
|
backgroundAlpha: 0,
|
||||||
antialias: true,
|
antialias: false,
|
||||||
|
powerPreference: 'high-performance',
|
||||||
resolution: dpr,
|
resolution: dpr,
|
||||||
autoDensity: true,
|
autoDensity: true,
|
||||||
|
preference: 'webgl',
|
||||||
});
|
});
|
||||||
|
// Меньше кадров — меньше CPU/GPU; анимации эффектов остаются плавными.
|
||||||
|
app.ticker.maxFPS = 32;
|
||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
host.appendChild(app.canvas);
|
host.appendChild(app.canvas);
|
||||||
// Canvas по умолчанию перехватывает hit-test; оставляем клики «сквозь» оверлей для слоя кисти сверху.
|
// Canvas по умолчанию перехватывает hit-test; оставляем клики «сквозь» оверлей для слоя кисти сверху.
|
||||||
@@ -72,13 +78,16 @@ export function PixiEffectsOverlay({ state, interactive = false, style, viewport
|
|||||||
app.stage.addChild(root);
|
app.stage.addChild(root);
|
||||||
|
|
||||||
const ro = new ResizeObserver(() => {
|
const ro = new ResizeObserver(() => {
|
||||||
const r = host.getBoundingClientRect();
|
cancelAnimationFrame(resizeRaf);
|
||||||
app.renderer.resize(Math.max(1, Math.floor(r.width)), Math.max(1, Math.floor(r.height)));
|
resizeRaf = requestAnimationFrame(() => {
|
||||||
sizeRef.current = { w: app.renderer.width, h: app.renderer.height };
|
const r = host.getBoundingClientRect();
|
||||||
if (!viewportProvidedRef.current) {
|
app.renderer.resize(Math.max(1, Math.floor(r.width)), Math.max(1, Math.floor(r.height)));
|
||||||
viewportRef.current = { x: 0, y: 0, w: sizeRef.current.w, h: sizeRef.current.h };
|
sizeRef.current = { w: app.renderer.width, h: app.renderer.height };
|
||||||
}
|
if (!viewportProvidedRef.current) {
|
||||||
syncNodes(pixi, root, nodesRef.current, stateRef.current, sizeRef.current, viewportRef.current);
|
viewportRef.current = { x: 0, y: 0, w: sizeRef.current.w, h: sizeRef.current.h };
|
||||||
|
}
|
||||||
|
syncNodes(pixi, root, nodesRef.current, stateRef.current, sizeRef.current, viewportRef.current);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
ro.observe(host);
|
ro.observe(host);
|
||||||
|
|
||||||
@@ -117,6 +126,7 @@ export function PixiEffectsOverlay({ state, interactive = false, style, viewport
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
destroyed = true;
|
destroyed = true;
|
||||||
|
cancelAnimationFrame(resizeRaf);
|
||||||
cleanup?.();
|
cleanup?.();
|
||||||
const a = appRef.current;
|
const a = appRef.current;
|
||||||
appRef.current = null;
|
appRef.current = null;
|
||||||
@@ -378,7 +388,8 @@ function createInstanceNode(
|
|||||||
if (inst.type === 'poisonCloud') {
|
if (inst.type === 'poisonCloud') {
|
||||||
const cont = new pixi.Container();
|
const cont = new pixi.Container();
|
||||||
const tex = getPoisonParticleTexture(pixi);
|
const tex = getPoisonParticleTexture(pixi);
|
||||||
const particleCount = 520;
|
/** Меньше спрайтов — быстрее тик; картина остаётся плотной. */
|
||||||
|
const particleCount = 400;
|
||||||
const particles: PoisonParticleFx[] = [];
|
const particles: PoisonParticleFx[] = [];
|
||||||
for (let i = 0; i < particleCount; i += 1) {
|
for (let i = 0; i < particleCount; i += 1) {
|
||||||
const s = new pixi.Sprite(tex);
|
const s = new pixi.Sprite(tex);
|
||||||
|
|||||||
@@ -85,7 +85,7 @@
|
|||||||
|
|
||||||
/* --- Типографика --- */
|
/* --- Типографика --- */
|
||||||
--font:
|
--font:
|
||||||
'Nimbus Sans', 'Nimbus Sans L', 'Nimbus Sans OT', 'Nimbus Sans PS', ui-sans-serif, system-ui,
|
Inter, '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';
|
-apple-system, 'Segoe UI', Roboto, Helvetica, Arial, 'Apple Color Emoji', 'Segoe UI Emoji';
|
||||||
--text-xs: 12px;
|
--text-xs: 12px;
|
||||||
--text-sm: 13px;
|
--text-sm: 13px;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
@import '../styles/variables.css';
|
@import '../styles/variables.css';
|
||||||
@import url('https://fonts.cdnfonts.com/css/nimbus-sans');
|
@import '@fontsource/inter/latin.css';
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||||
import type { VideoPlaybackEvent, VideoPlaybackState } from '../../../shared/types';
|
import type { VideoPlaybackEvent, VideoPlaybackState } from '../../../shared/types';
|
||||||
@@ -9,31 +9,36 @@ export function useVideoPlaybackState(): readonly [
|
|||||||
{ dispatch: (event: VideoPlaybackEvent) => Promise<void> },
|
{ dispatch: (event: VideoPlaybackEvent) => Promise<void> },
|
||||||
] {
|
] {
|
||||||
const api = getDndApi();
|
const api = getDndApi();
|
||||||
const [state, setState] = useState<VideoPlaybackState | null>(null);
|
const [playback, setPlayback] = useState<VideoPlaybackState | null>(null);
|
||||||
const [timeOffsetMs, setTimeOffsetMs] = useState(0);
|
/** serverNowMs − Date.now() at last IPC sync; lets us compute a live clock without React timers. */
|
||||||
const [clientNowMs, setClientNowMs] = useState(() => Date.now());
|
const timeOffsetMsRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!state) return;
|
|
||||||
const id = window.setInterval(() => {
|
|
||||||
setClientNowMs(Date.now());
|
|
||||||
}, 250);
|
|
||||||
return () => window.clearInterval(id);
|
|
||||||
}, [state]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void api.invoke(ipcChannels.video.getState, {}).then((r) => {
|
void api.invoke(ipcChannels.video.getState, {}).then((r) => {
|
||||||
setState(r.state);
|
timeOffsetMsRef.current = r.state.serverNowMs - Date.now();
|
||||||
setTimeOffsetMs(r.state.serverNowMs - Date.now());
|
setPlayback(r.state);
|
||||||
});
|
});
|
||||||
return api.on(ipcChannels.video.stateChanged, ({ state: next }) => {
|
return api.on(ipcChannels.video.stateChanged, ({ state: next }) => {
|
||||||
setState(next);
|
timeOffsetMsRef.current = next.serverNowMs - Date.now();
|
||||||
setTimeOffsetMs(next.serverNowMs - Date.now());
|
setPlayback((prev) => {
|
||||||
|
if (prev?.revision === next.revision) return prev;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}, [api]);
|
}, [api]);
|
||||||
|
|
||||||
|
const state = useMemo((): VideoPlaybackState | null => {
|
||||||
|
if (!playback) return null;
|
||||||
|
return {
|
||||||
|
...playback,
|
||||||
|
get serverNowMs(): number {
|
||||||
|
return Date.now() + timeOffsetMsRef.current;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [playback]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
state ? { ...state, serverNowMs: clientNowMs + timeOffsetMs } : null,
|
state,
|
||||||
{
|
{
|
||||||
dispatch: async (event) => {
|
dispatch: async (event) => {
|
||||||
await api.invoke(ipcChannels.video.dispatch, { event });
|
await api.invoke(ipcChannels.video.dispatch, { event });
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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)), '..', '..', '..');
|
||||||
|
|
||||||
|
/** Регресс: `await` сетевой проверки отзыва блокировал IPC `license.getStatus` до 8 с без доступа к серверу. */
|
||||||
|
void test('licenseService: getStatus не ждёт await проверки отзыва', () => {
|
||||||
|
const src = fs.readFileSync(path.join(root, 'app', 'main', 'license', 'licenseService.ts'), 'utf8');
|
||||||
|
assert.match(src, /void this\.maybeRefreshRemoteRevocation\(/);
|
||||||
|
assert.doesNotMatch(src, /await this\.maybeRefreshRemoteRevocation\(/);
|
||||||
|
});
|
||||||
@@ -8,11 +8,19 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '.
|
|||||||
|
|
||||||
void test('package.json: конфиг electron-builder (mac/win)', () => {
|
void test('package.json: конфиг electron-builder (mac/win)', () => {
|
||||||
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) as {
|
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) as {
|
||||||
build: { appId: string; asar: boolean; mac: { target: unknown }; files: string[] };
|
build: {
|
||||||
|
appId: string;
|
||||||
|
asar: boolean;
|
||||||
|
asarUnpack: string[];
|
||||||
|
mac: { target: unknown };
|
||||||
|
files: string[];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
assert.ok(pkg.build);
|
assert.ok(pkg.build);
|
||||||
assert.equal(pkg.build.appId, 'com.dndplayer.app');
|
assert.equal(pkg.build.appId, 'com.dndplayer.app');
|
||||||
assert.equal(pkg.build.asar, true, 'релизный артефакт: app.asar без «голого» дерева dist в .app/.exe');
|
assert.equal(pkg.build.asar, true, 'релизный артефакт: app.asar без «голого» дерева dist в .app/.exe');
|
||||||
|
assert.ok(Array.isArray(pkg.build.asarUnpack));
|
||||||
|
assert.ok(pkg.build.asarUnpack.some((p) => p.includes('preload')));
|
||||||
assert.ok(Array.isArray(pkg.build.mac.target));
|
assert.ok(Array.isArray(pkg.build.mac.target));
|
||||||
assert.ok(pkg.build.files.includes('dist/**/*'));
|
assert.ok(pkg.build.files.includes('dist/**/*'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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('video playback: control preview does not dispatch target.set on every render', () => {
|
||||||
|
const src = fs.readFileSync(
|
||||||
|
path.join(root, 'app', 'renderer', 'control', 'ControlScenePreview.tsx'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
// Регресс: зависимость `[... , scene, ...]` заставляла эффект с `target.set` срабатывать постоянно,
|
||||||
|
// сбрасывая видео (доля секунды проигрывается и начинается сначала).
|
||||||
|
assert.doesNotMatch(src, /\]\s*,\s*\[\s*[^\]]*\bscene\b/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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('video playback: renderer hooks/components do not tick React each frame', () => {
|
||||||
|
const hookSrc = fs.readFileSync(
|
||||||
|
path.join(root, 'app', 'renderer', 'shared', 'video', 'useVideoPlaybackState.ts'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
// Регресс: раньше был setInterval(250ms) для clientNowMs, который заставлял перерисовываться окна.
|
||||||
|
assert.doesNotMatch(hookSrc, /\bsetInterval\s*\(/);
|
||||||
|
|
||||||
|
const previewSrc = fs.readFileSync(
|
||||||
|
path.join(root, 'app', 'renderer', 'control', 'ControlScenePreview.tsx'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
// Регресс: раньше был RAF loop с setState на каждом кадре.
|
||||||
|
assert.doesNotMatch(previewSrc, /\brequestAnimationFrame\s*\(\s*loop\s*\)/);
|
||||||
|
assert.doesNotMatch(previewSrc, /\brequestAnimationFrame\s*\(\s*loop\b/);
|
||||||
|
});
|
||||||
+1
-1
@@ -12,7 +12,7 @@ const tsProject = ['./tsconfig.eslint.json'];
|
|||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{
|
{
|
||||||
ignores: ['dist/**', 'node_modules/**', '.cursor/**', 'scripts/**', 'eslint.config.js'],
|
ignores: ['dist/**', 'release/**', 'node_modules/**', '.cursor/**', 'scripts/**', 'eslint.config.js'],
|
||||||
},
|
},
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
...tseslint.configs.strictTypeChecked,
|
...tseslint.configs.strictTypeChecked,
|
||||||
|
|||||||
Generated
+12
-3
@@ -1,14 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "dnd_player",
|
"name": "DndGamePlayer",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "dnd_player",
|
"name": "DndGamePlayer",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/inter": "^5.2.8",
|
||||||
"pixi.js": "^8.18.1",
|
"pixi.js": "^8.18.1",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
@@ -1410,6 +1411,15 @@
|
|||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fontsource/inter": {
|
||||||
|
"version": "5.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
|
||||||
|
"integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
@@ -10144,7 +10154,6 @@
|
|||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|||||||
+5
-1
@@ -10,7 +10,7 @@
|
|||||||
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
||||||
"lint": "eslint . --max-warnings 0",
|
"lint": "eslint . --max-warnings 0",
|
||||||
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
||||||
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/effects/effectsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/zipRead.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/main/license/verifyLicenseToken.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs",
|
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/zipRead.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs",
|
||||||
"format": "prettier . --check",
|
"format": "prettier . --check",
|
||||||
"format:write": "prettier . --write",
|
"format:write": "prettier . --write",
|
||||||
"release:info": "node scripts/print-release-info.mjs",
|
"release:info": "node scripts/print-release-info.mjs",
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/inter": "^5.2.8",
|
||||||
"pixi.js": "^8.18.1",
|
"pixi.js": "^8.18.1",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
@@ -74,6 +75,9 @@
|
|||||||
"package.json"
|
"package.json"
|
||||||
],
|
],
|
||||||
"asar": true,
|
"asar": true,
|
||||||
|
"asarUnpack": [
|
||||||
|
"dist/preload/**"
|
||||||
|
],
|
||||||
"mac": {
|
"mac": {
|
||||||
"category": "public.app-category.games",
|
"category": "public.app-category.games",
|
||||||
"target": [
|
"target": [
|
||||||
|
|||||||
+18
-1
@@ -2,7 +2,22 @@ import path from 'node:path';
|
|||||||
|
|
||||||
import strip from '@rollup/plugin-strip';
|
import strip from '@rollup/plugin-strip';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig, type Plugin } from 'vite';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vite в проде вешает `crossorigin` на script/link; при открытии HTML через `file://` в Electron
|
||||||
|
* на Windows это часто приводит к тихому отказу загрузки ES-модулей (чёрный экран). macOS может «проглатывать».
|
||||||
|
*/
|
||||||
|
function stripCrossoriginForElectronFile(): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'strip-crossorigin-electron-file',
|
||||||
|
enforce: 'post',
|
||||||
|
apply: 'build',
|
||||||
|
transformIndexHtml(html) {
|
||||||
|
return html.replace(/\s+crossorigin(?:=["']?[^"'>\s]+["']?)?/gi, '');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
const isProd = mode === 'production';
|
const isProd = mode === 'production';
|
||||||
@@ -17,6 +32,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
plugins: [['babel-plugin-react-compiler', { target: '19' }]],
|
plugins: [['babel-plugin-react-compiler', { target: '19' }]],
|
||||||
},
|
},
|
||||||
} as Parameters<typeof react>[0]),
|
} as Parameters<typeof react>[0]),
|
||||||
|
...(isProd ? [stripCrossoriginForElectronFile()] : []),
|
||||||
],
|
],
|
||||||
build: {
|
build: {
|
||||||
outDir: path.resolve(__dirname, 'dist/renderer'),
|
outDir: path.resolve(__dirname, 'dist/renderer'),
|
||||||
@@ -33,6 +49,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
]
|
]
|
||||||
: [],
|
: [],
|
||||||
input: {
|
input: {
|
||||||
|
boot: path.resolve(__dirname, 'app/renderer/boot.html'),
|
||||||
editor: path.resolve(__dirname, 'app/renderer/editor.html'),
|
editor: path.resolve(__dirname, 'app/renderer/editor.html'),
|
||||||
presentation: path.resolve(__dirname, 'app/renderer/presentation.html'),
|
presentation: path.resolve(__dirname, 'app/renderer/presentation.html'),
|
||||||
control: path.resolve(__dirname, 'app/renderer/control.html'),
|
control: path.resolve(__dirname, 'app/renderer/control.html'),
|
||||||
|
|||||||
Reference in New Issue
Block a user