Лицензия, редактор, пульт и сборка
- 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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user