Лицензия, редактор, пульт и сборка

- Main: license service, IPC, router; закрытие окон; yauzl закрытие zip (EMFILE), zipRead тест
- Editor: стабильный projectState без мигания, логотип и меню, строки UI, LayoutShell overlay
- Control: ластик для всех типов эффектов, затухание/нарастание музыки при смене сцены
- Сборка: vite, build/dev scripts, obfuscate-main и build-env скрипты с тестами; package.json

Made-with: Cursor
This commit is contained in:
Ivan Fontosh
2026-04-19 20:11:24 +08:00
parent 5e7dc5ea19
commit 2fa20da94d
40 changed files with 2629 additions and 211 deletions
+110 -41
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { pickEraseTargetId } from '../../shared/effectEraserHitTest';
import { ipcChannels } from '../../shared/ipc/contracts';
import type { SessionState } from '../../shared/ipc/contracts';
import type { GraphNodeId, Scene, SceneId } from '../../shared/types';
@@ -31,6 +32,7 @@ export function ControlApp() {
const audioMetaRef = useRef<Map<string, { lastPlayError: string | null }>>(new Map());
const [audioStateTick, setAudioStateTick] = useState(0);
const audioLoadRunRef = useRef(0);
const audioUnmountRef = useRef(false);
const previewHostRef = useRef<HTMLDivElement | null>(null);
const previewVideoRef = useRef<HTMLVideoElement | null>(null);
const brushRef = useRef<{
@@ -80,6 +82,13 @@ export function ControlApp() {
});
}, [api]);
useEffect(() => {
audioUnmountRef.current = false;
return () => {
audioUnmountRef.current = true;
};
}, []);
const project = session?.project ?? null;
const currentGraphNodeId = project?.currentGraphNodeId ?? null;
const currentScene =
@@ -102,21 +111,62 @@ export function ControlApp() {
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();
const oldEls = new Map(audioElsRef.current);
audioElsRef.current = new Map();
audioMetaRef.current.clear();
setAudioStateTick((x) => x + 1);
if (!project || !currentScene) return;
const FADE_OUT_MS = 450;
const fadeOutCtl = { raf: 0, cancelled: false };
const finishFadeOut = (): void => {
for (const el of oldEls.values()) {
try {
el.pause();
el.currentTime = 0;
el.volume = 1;
} catch {
// ignore
}
}
};
if (oldEls.size > 0) {
const startVol = new Map<string, number>();
for (const [id, el] of oldEls) {
startVol.set(id, el.volume);
}
const t0 = performance.now();
const tickOut = (now: number): void => {
if (fadeOutCtl.cancelled || audioUnmountRef.current) {
finishFadeOut();
return;
}
const u = Math.min(1, (now - t0) / FADE_OUT_MS);
for (const [id, el] of oldEls) {
try {
const v0 = startVol.get(id) ?? 1;
el.volume = v0 * (1 - u);
} catch {
// ignore
}
}
if (u < 1) {
fadeOutCtl.raf = window.requestAnimationFrame(tickOut);
} else {
finishFadeOut();
}
};
fadeOutCtl.raf = window.requestAnimationFrame(tickOut);
}
if (!project || !currentScene) {
return () => {
fadeOutCtl.cancelled = true;
window.cancelAnimationFrame(fadeOutCtl.raf);
};
}
const FADE_IN_MS = 550;
void (async () => {
const loaded: { ref: (typeof sceneAudioRefs)[number]; el: HTMLAudioElement }[] = [];
for (const item of sceneAudioRefs) {
@@ -126,6 +176,7 @@ export function ControlApp() {
const el = new Audio(r.url);
el.loop = item.loop;
el.preload = 'auto';
el.volume = item.autoplay ? 0 : 1;
audioMetaRef.current.set(item.assetId, { lastPlayError: null });
el.addEventListener('play', () => setAudioStateTick((x) => x + 1));
el.addEventListener('pause', () => setAudioStateTick((x) => x + 1));
@@ -141,6 +192,7 @@ export function ControlApp() {
try {
el.pause();
el.currentTime = 0;
el.volume = 1;
} catch {
// ignore
}
@@ -157,9 +209,47 @@ export function ControlApp() {
'Автозапуск заблокирован (нужно действие пользователя) или ошибка воспроизведения.',
});
setAudioStateTick((x) => x + 1);
try {
el.volume = 1;
} catch {
// ignore
}
continue;
}
if (audioLoadRunRef.current !== runId || audioUnmountRef.current) {
try {
el.volume = 1;
} catch {
// ignore
}
continue;
}
const tIn0 = performance.now();
const tickIn = (now: number): void => {
if (audioLoadRunRef.current !== runId || audioUnmountRef.current) {
try {
el.volume = 1;
} catch {
// ignore
}
return;
}
const u = Math.min(1, (now - tIn0) / FADE_IN_MS);
try {
el.volume = u;
} catch {
// ignore
}
if (u < 1) window.requestAnimationFrame(tickIn);
};
window.requestAnimationFrame(tickIn);
}
})();
return () => {
fadeOutCtl.cancelled = true;
window.cancelAnimationFrame(fadeOutCtl.raf);
};
}, [api, currentScene, project, sceneAudioRefs]);
const anyPlaying = useMemo(() => {
@@ -676,34 +766,8 @@ export function ControlApp() {
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 });
}
const id = pickEraseTargetId(fxState?.instances ?? [], p, tool.radiusN);
if (id) void fx.dispatch({ kind: 'instance.remove', id });
return;
}
brushRef.current = {
@@ -714,10 +778,15 @@ export function ControlApp() {
setDraftFxTick((x) => x + 1);
}}
onPointerMove={(e) => {
const b = brushRef.current;
const p = toNPoint(e);
if (!p) return;
setCursorN(p);
if (tool.tool === 'eraser' && (e.buttons & 1) !== 0) {
const id = pickEraseTargetId(fxState?.instances ?? [], p, tool.radiusN);
if (id) void fx.dispatch({ kind: 'instance.remove', id });
return;
}
const b = brushRef.current;
if (!b?.points) return;
const last = b.points[b.points.length - 1];
if (!last) return;