fix(npcs): faster editor open, save progress, black screen crash
Warm and show the NPC window sooner, lazy-load ReactFlow/TipTap, overlay save progress like materials, and fix the undefined controlStyles crash after creating an NPC. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+25
-8
@@ -63,6 +63,7 @@ import {
|
||||
sendToAppWindows,
|
||||
togglePresentationFullscreen,
|
||||
waitForEditorWindowReady,
|
||||
warmNpcsEditorWindow,
|
||||
} from './windows/createWindows';
|
||||
|
||||
function emitZipProgress(evt: {
|
||||
@@ -95,6 +96,16 @@ function emitMaterialUpsertProgress(evt: {
|
||||
}
|
||||
}
|
||||
|
||||
function emitNpcUpsertProgress(evt: {
|
||||
percent: number;
|
||||
stage: string;
|
||||
detail?: string;
|
||||
}): void {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send(ipcChannels.project.npcUpsertProgress, evt);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отключение GPU ломает скорость вторичных окон (презентация/пульт — WebGL). По умолчанию не трогаем.
|
||||
* При чёрном экране в упакованной сборке: `DND_DISABLE_GPU=1`.
|
||||
@@ -508,6 +519,7 @@ async function main() {
|
||||
registerHandler(ipcChannels.project.create, async ({ name }) => {
|
||||
const project = await projectStore.createProject(name);
|
||||
emitSessionState();
|
||||
warmNpcsEditorWindow();
|
||||
return { project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.open, async ({ projectId }) => {
|
||||
@@ -517,9 +529,11 @@ async function main() {
|
||||
emitSceneViewState();
|
||||
emitSceneTokensSessionState();
|
||||
emitSessionState();
|
||||
warmNpcsEditorWindow();
|
||||
return { project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.close, async () => {
|
||||
closeNpcsEditorWindow();
|
||||
await projectStore.closeOpenProject();
|
||||
effectsStore.clear();
|
||||
materialsOverlayStore.clear();
|
||||
@@ -760,14 +774,17 @@ async function main() {
|
||||
}
|
||||
filePath = filePaths[0];
|
||||
}
|
||||
const project = await projectStore.upsertNpc({
|
||||
...(npcId ? { npcId } : {}),
|
||||
name,
|
||||
...(typeof description === 'string' ? { description } : {}),
|
||||
...(filePath ? { filePath } : {}),
|
||||
...(groupId !== undefined ? { groupId } : {}),
|
||||
...(binding !== undefined ? { binding } : {}),
|
||||
});
|
||||
const project = await projectStore.upsertNpc(
|
||||
{
|
||||
...(npcId ? { npcId } : {}),
|
||||
name,
|
||||
...(typeof description === 'string' ? { description } : {}),
|
||||
...(filePath ? { filePath } : {}),
|
||||
...(groupId !== undefined ? { groupId } : {}),
|
||||
...(binding !== undefined ? { binding } : {}),
|
||||
},
|
||||
(p) => emitNpcUpsertProgress(p),
|
||||
);
|
||||
syncNpcsOverlayWithProject(project);
|
||||
emitNpcsOverlayState();
|
||||
emitSessionState();
|
||||
|
||||
@@ -1302,14 +1302,20 @@ export class ZipProjectStore {
|
||||
* Создаёт или обновляет НПС.
|
||||
* При создании `filePath` (аватар) обязателен; при обновлении можно сменить только имя/описание/аватар.
|
||||
*/
|
||||
async upsertNpc(input: {
|
||||
npcId?: NpcId;
|
||||
name: string;
|
||||
description?: string;
|
||||
filePath?: string;
|
||||
groupId?: NpcGroupId | null;
|
||||
binding?: NpcBinding;
|
||||
}): Promise<Project> {
|
||||
async upsertNpc(
|
||||
input: {
|
||||
npcId?: NpcId;
|
||||
name: string;
|
||||
description?: string;
|
||||
filePath?: string;
|
||||
groupId?: NpcGroupId | null;
|
||||
binding?: NpcBinding;
|
||||
},
|
||||
onProgress?: (p: { percent: number; stage: string; detail?: string }) => void,
|
||||
): Promise<Project> {
|
||||
const report = (percent: number, stage: string, detail?: string) => {
|
||||
onProgress?.({ percent, stage, ...(detail ? { detail } : {}) });
|
||||
};
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
const name = input.name.trim();
|
||||
@@ -1321,6 +1327,8 @@ export class ZipProjectStore {
|
||||
throw new Error('NPC name already exists');
|
||||
}
|
||||
|
||||
report(2, 'start', 'Подождите…');
|
||||
|
||||
let nextAssetId: AssetId | null = null;
|
||||
let stagedAsset: MediaAsset | null = null;
|
||||
if (input.filePath) {
|
||||
@@ -1330,13 +1338,16 @@ export class ZipProjectStore {
|
||||
if (!['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) {
|
||||
throw new Error('NPC avatar must be an image (png/jpg/webp)');
|
||||
}
|
||||
report(8, 'read', 'Чтение изображения…');
|
||||
let buf = await fs.readFile(input.filePath);
|
||||
report(18, 'optimize', 'Оптимизация изображения…');
|
||||
try {
|
||||
const opt = await optimizeImageBufferVisuallyLossless(buf);
|
||||
if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer);
|
||||
} catch {
|
||||
// keep original buffer
|
||||
}
|
||||
report(72, 'write', 'Сохранение файла…');
|
||||
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
|
||||
const id = asAssetId(this.randomId());
|
||||
const orig = path.basename(input.filePath);
|
||||
@@ -1349,6 +1360,7 @@ export class ZipProjectStore {
|
||||
nextAssetId = id;
|
||||
}
|
||||
|
||||
report(88, 'project', 'Обновление проекта…');
|
||||
await this.updateProject((p) => {
|
||||
const npcs = [...(p.npcs ?? [])];
|
||||
const assets = { ...p.assets };
|
||||
@@ -1393,6 +1405,7 @@ export class ZipProjectStore {
|
||||
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
report(100, 'done', 'Готово');
|
||||
return latest;
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ void test('createWindows: окно НПС закрывается с multi-window
|
||||
assert.ok(src.includes('openNpcsWindow'));
|
||||
assert.ok(src.includes('closeNpcsWindow'));
|
||||
assert.ok(src.includes('openNpcsEditorWindow'));
|
||||
assert.ok(src.includes('warmNpcsEditorWindow'));
|
||||
assert.ok(src.includes("createWindow('npcs'"));
|
||||
assert.ok(src.includes("createWindow('npcsEditor'"));
|
||||
assert.match(src, /export function closeMultiWindow[\s\S]*closeNpcsWindow/);
|
||||
|
||||
@@ -163,7 +163,7 @@ export function applyDockIconIfNeeded(): void {
|
||||
type CreateWindowOpts = {
|
||||
/** Дочернее окно (например пульт) держится над родителем (экран просмотра). */
|
||||
parent?: BrowserWindow;
|
||||
/** Только редактор: не показывать окно до `show()` (экран загрузки). */
|
||||
/** Не показывать окно до явного `show()` (экран загрузки / прогрев НПС). */
|
||||
deferVisibility?: boolean;
|
||||
};
|
||||
|
||||
@@ -202,7 +202,7 @@ function windowSizeForKind(kind: WindowKind): { width: number; height: number }
|
||||
}
|
||||
|
||||
function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow {
|
||||
const deferEditor = kind === 'editor' && opts?.deferVisibility === true;
|
||||
const deferShow = opts?.deferVisibility === true;
|
||||
const icon = loadBrandingWindowIcon();
|
||||
const size = windowSizeForKind(kind);
|
||||
const win = new BrowserWindow({
|
||||
@@ -300,7 +300,7 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
safeConsoleError('[render-process-gone]', details.reason, details.exitCode);
|
||||
});
|
||||
|
||||
if (!deferEditor) {
|
||||
if (!deferShow) {
|
||||
ensureWindowBecomesVisible(win);
|
||||
}
|
||||
loadWindowPage(win, kind);
|
||||
@@ -527,19 +527,8 @@ export function openMaterialsWindow(): void {
|
||||
});
|
||||
}
|
||||
|
||||
/** Редактор НПС: отдельное окно с графом и инспектором. */
|
||||
export function openNpcsEditorWindow(): void {
|
||||
const existing = windows.get('npcsEditor');
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
if (existing.isMinimized()) existing.restore();
|
||||
existing.show();
|
||||
existing.focus();
|
||||
existing.moveTop();
|
||||
return;
|
||||
}
|
||||
|
||||
function positionNpcsEditorWindow(win: BrowserWindow): void {
|
||||
const parent = windows.get('editor');
|
||||
const win = createWindow('npcsEditor', parent ? { parent } : undefined);
|
||||
const { width, height } = win.getBounds();
|
||||
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
|
||||
const { x, y, width: dw, height: dh } = display.workArea;
|
||||
@@ -549,9 +538,43 @@ export function openNpcsEditorWindow(): void {
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
|
||||
/** Прогрев окна НПС в фоне после открытия проекта — клик «НПС» не ждёт холодной загрузки. */
|
||||
export function warmNpcsEditorWindow(): void {
|
||||
const existing = windows.get('npcsEditor');
|
||||
if (existing && !existing.isDestroyed()) return;
|
||||
const parent = windows.get('editor');
|
||||
createWindow('npcsEditor', {
|
||||
...(parent ? { parent } : {}),
|
||||
deferVisibility: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Редактор НПС: отдельное окно с графом и инспектором. */
|
||||
export function openNpcsEditorWindow(): void {
|
||||
const existing = windows.get('npcsEditor');
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
if (existing.isMinimized()) existing.restore();
|
||||
positionNpcsEditorWindow(existing);
|
||||
existing.show();
|
||||
existing.focus();
|
||||
existing.moveTop();
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = windows.get('editor');
|
||||
const win = createWindow('npcsEditor', {
|
||||
...(parent ? { parent } : {}),
|
||||
deferVisibility: true,
|
||||
});
|
||||
positionNpcsEditorWindow(win);
|
||||
// Показываем сразу (тёмный фон), не дожидаясь полной загрузки React/ReactFlow.
|
||||
win.show();
|
||||
win.focus();
|
||||
win.moveTop();
|
||||
win.webContents.once('did-finish-load', () => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.show();
|
||||
win.focus();
|
||||
win.moveTop();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user