perf: cut ControlApp re-renders and lazy-load VFX packs
Move audio scrub/volume and brush drafts off root React ticks, coalesce overlay layout IPC, cache machine fingerprint and asset URLs, share one scene overlay host, and stop idle Pixi ticker. Also harden console against EPIPE on window load failures. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,14 +4,20 @@
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
gap: 16px;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.remote {
|
||||
padding: 12px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-height: calc(100vh - 32px);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.remoteTitle {
|
||||
@@ -456,10 +462,51 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.audioControls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
align-items: stretch;
|
||||
min-width: 132px;
|
||||
}
|
||||
|
||||
.audioTransport {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.audioVolumeRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.audioVolumeIcon {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--text2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.audioVolumeIcon svg {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.audioVolume {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
accent-color: var(--accent-fill-solid);
|
||||
}
|
||||
|
||||
.scrubFill {
|
||||
|
||||
+398
-435
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,304 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Button } from '../shared/ui/controls';
|
||||
|
||||
import styles from './ControlApp.module.css';
|
||||
|
||||
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')}`;
|
||||
}
|
||||
|
||||
function clampAudioGain(v: number): number {
|
||||
if (!Number.isFinite(v)) return 1;
|
||||
return Math.max(0, Math.min(1, v));
|
||||
}
|
||||
|
||||
function VolumeSpeakerIcon({ gain }: { gain: number }) {
|
||||
if (gain <= 0.001) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden focusable="false">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3 3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4 9.91 6.09 12 8.18V4z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (gain < 0.5) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden focusable="false">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden focusable="false">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export type ControlAudioCardProps = {
|
||||
assetId: string;
|
||||
name: string;
|
||||
autoplay: boolean;
|
||||
loop: boolean;
|
||||
statusLabel: string;
|
||||
statusDetail?: string;
|
||||
extraBadge?: React.ReactNode;
|
||||
audioEl: HTMLAudioElement | null;
|
||||
initialGain: number;
|
||||
gainMap: Map<string, number>;
|
||||
playTitle: string;
|
||||
playLabel: string;
|
||||
pauseLabel: string;
|
||||
stopLabel: string;
|
||||
volumeLabel: string;
|
||||
modeAutoLabel: string;
|
||||
modeManualLabel: string;
|
||||
loopLabel: string;
|
||||
onceLabel: string;
|
||||
scrubSeekLabel: string;
|
||||
durationUnknownLabel: string;
|
||||
/** Редкий bump родителя (play/pause/error) — не для scrub. */
|
||||
onStatusChange: () => void;
|
||||
onPlay: () => void;
|
||||
onPause: () => void;
|
||||
onStop: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Карточка трека: scrub/time обновляются локально (RAF → DOM), без ре-рендера всего ControlApp.
|
||||
*/
|
||||
export function ControlAudioCard({
|
||||
assetId,
|
||||
name,
|
||||
autoplay,
|
||||
loop,
|
||||
statusLabel,
|
||||
statusDetail,
|
||||
extraBadge,
|
||||
audioEl,
|
||||
initialGain,
|
||||
gainMap,
|
||||
playTitle,
|
||||
playLabel,
|
||||
pauseLabel,
|
||||
stopLabel,
|
||||
volumeLabel,
|
||||
modeAutoLabel,
|
||||
modeManualLabel,
|
||||
loopLabel,
|
||||
onceLabel,
|
||||
scrubSeekLabel,
|
||||
durationUnknownLabel,
|
||||
onStatusChange,
|
||||
onPlay,
|
||||
onPause,
|
||||
onStop,
|
||||
}: ControlAudioCardProps) {
|
||||
const scrubRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrubFillRef = useRef<HTMLDivElement | null>(null);
|
||||
const curTimeRef = useRef<HTMLDivElement | null>(null);
|
||||
const durTimeRef = useRef<HTMLDivElement | null>(null);
|
||||
const [gainUi, setGainUi] = useState(() => clampAudioGain(initialGain));
|
||||
const onStatusChangeRef = useRef(onStatusChange);
|
||||
onStatusChangeRef.current = onStatusChange;
|
||||
|
||||
useEffect(() => {
|
||||
setGainUi(clampAudioGain(gainMap.get(assetId) ?? initialGain));
|
||||
}, [assetId, gainMap, initialGain]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioEl) return;
|
||||
let raf = 0;
|
||||
|
||||
const paint = (): void => {
|
||||
const dur = audioEl.duration && Number.isFinite(audioEl.duration) ? audioEl.duration : 0;
|
||||
const cur = audioEl.currentTime && Number.isFinite(audioEl.currentTime) ? audioEl.currentTime : 0;
|
||||
const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0;
|
||||
if (scrubFillRef.current) {
|
||||
scrubFillRef.current.style.width = `${String(Math.round(pct * 100))}%`;
|
||||
}
|
||||
if (curTimeRef.current) curTimeRef.current.textContent = formatTime(cur);
|
||||
if (durTimeRef.current) durTimeRef.current.textContent = dur ? formatTime(dur) : '—:—';
|
||||
if (scrubRef.current) {
|
||||
scrubRef.current.setAttribute('aria-valuemin', '0');
|
||||
scrubRef.current.setAttribute('aria-valuemax', String(dur > 0 ? Math.round(dur) : 0));
|
||||
scrubRef.current.setAttribute('aria-valuenow', String(Math.round(cur)));
|
||||
scrubRef.current.title = dur > 0 ? scrubSeekLabel : durationUnknownLabel;
|
||||
scrubRef.current.classList.toggle(styles.audioScrubPointer ?? 'audioScrubPointer', dur > 0);
|
||||
scrubRef.current.classList.toggle(styles.audioScrubDefault ?? 'audioScrubDefault', dur <= 0);
|
||||
}
|
||||
};
|
||||
|
||||
const stopLoop = (): void => {
|
||||
if (raf !== 0) {
|
||||
window.cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const loopPaint = (): void => {
|
||||
paint();
|
||||
if (!audioEl.paused) {
|
||||
raf = window.requestAnimationFrame(loopPaint);
|
||||
} else {
|
||||
raf = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const startLoop = (): void => {
|
||||
stopLoop();
|
||||
raf = window.requestAnimationFrame(loopPaint);
|
||||
};
|
||||
|
||||
const onPlayEv = (): void => {
|
||||
startLoop();
|
||||
onStatusChangeRef.current();
|
||||
};
|
||||
const onPauseEv = (): void => {
|
||||
stopLoop();
|
||||
paint();
|
||||
onStatusChangeRef.current();
|
||||
};
|
||||
const onEndedEv = (): void => {
|
||||
stopLoop();
|
||||
paint();
|
||||
onStatusChangeRef.current();
|
||||
};
|
||||
const onMetaEv = (): void => {
|
||||
paint();
|
||||
onStatusChangeRef.current();
|
||||
};
|
||||
|
||||
audioEl.addEventListener('play', onPlayEv);
|
||||
audioEl.addEventListener('pause', onPauseEv);
|
||||
audioEl.addEventListener('ended', onEndedEv);
|
||||
audioEl.addEventListener('canplay', onMetaEv);
|
||||
audioEl.addEventListener('error', onMetaEv);
|
||||
paint();
|
||||
if (!audioEl.paused) startLoop();
|
||||
|
||||
return () => {
|
||||
stopLoop();
|
||||
audioEl.removeEventListener('play', onPlayEv);
|
||||
audioEl.removeEventListener('pause', onPauseEv);
|
||||
audioEl.removeEventListener('ended', onEndedEv);
|
||||
audioEl.removeEventListener('canplay', onMetaEv);
|
||||
audioEl.removeEventListener('error', onMetaEv);
|
||||
};
|
||||
}, [audioEl, durationUnknownLabel, scrubSeekLabel]);
|
||||
|
||||
const seekByClientX = (clientX: number): void => {
|
||||
if (!audioEl || !scrubRef.current) return;
|
||||
const dur = audioEl.duration && Number.isFinite(audioEl.duration) ? audioEl.duration : 0;
|
||||
if (!dur) return;
|
||||
const rect = scrubRef.current.getBoundingClientRect();
|
||||
const next = (clientX - rect.left) / Math.max(1, rect.width);
|
||||
audioEl.currentTime = Math.max(0, Math.min(dur, next * dur));
|
||||
const cur = audioEl.currentTime;
|
||||
const pct = Math.max(0, Math.min(1, cur / dur));
|
||||
if (scrubFillRef.current) scrubFillRef.current.style.width = `${String(Math.round(pct * 100))}%`;
|
||||
if (curTimeRef.current) curTimeRef.current.textContent = formatTime(cur);
|
||||
};
|
||||
|
||||
const applyGain = (v: number): void => {
|
||||
const g = clampAudioGain(v);
|
||||
gainMap.set(assetId, g);
|
||||
if (audioEl) {
|
||||
try {
|
||||
audioEl.volume = g;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
setGainUi(g);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.audioCard}>
|
||||
<div className={styles.audioMeta}>
|
||||
<div className={styles.audioName}>{name}</div>
|
||||
<div className={styles.audioBadges}>
|
||||
<div>{autoplay ? modeAutoLabel : modeManualLabel}</div>
|
||||
<div>{loop ? loopLabel : onceLabel}</div>
|
||||
<div title={statusDetail}>{statusLabel}</div>
|
||||
{extraBadge}
|
||||
</div>
|
||||
<div className={styles.spacer10} />
|
||||
<div
|
||||
ref={scrubRef}
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
className={[styles.audioScrub, styles.audioScrubDefault].join(' ')}
|
||||
onKeyDown={(e) => {
|
||||
if (!audioEl) return;
|
||||
const dur = audioEl.duration && Number.isFinite(audioEl.duration) ? audioEl.duration : 0;
|
||||
if (!dur) return;
|
||||
if (e.key === 'ArrowLeft') audioEl.currentTime = Math.max(0, audioEl.currentTime - 5);
|
||||
if (e.key === 'ArrowRight') audioEl.currentTime = Math.min(dur, audioEl.currentTime + 5);
|
||||
if (curTimeRef.current) curTimeRef.current.textContent = formatTime(audioEl.currentTime);
|
||||
const pct = Math.max(0, Math.min(1, audioEl.currentTime / dur));
|
||||
if (scrubFillRef.current) scrubFillRef.current.style.width = `${String(Math.round(pct * 100))}%`;
|
||||
}}
|
||||
onClick={(e) => seekByClientX(e.clientX)}
|
||||
>
|
||||
<div ref={scrubFillRef} className={styles.scrubFill} style={{ width: '0%' }} />
|
||||
</div>
|
||||
<div className={styles.timeRow}>
|
||||
<div ref={curTimeRef}>0:00</div>
|
||||
<div ref={durTimeRef}>—:—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.audioControls}>
|
||||
<div className={styles.audioTransport}>
|
||||
<Button variant="primary" title={playTitle} ariaLabel={playLabel} onClick={onPlay}>
|
||||
▶
|
||||
</Button>
|
||||
<Button title={pauseLabel} ariaLabel={pauseLabel} onClick={onPause}>
|
||||
❚❚
|
||||
</Button>
|
||||
<Button
|
||||
title={stopLabel}
|
||||
ariaLabel={stopLabel}
|
||||
onClick={() => {
|
||||
onStop();
|
||||
if (scrubFillRef.current) scrubFillRef.current.style.width = '0%';
|
||||
if (curTimeRef.current) curTimeRef.current.textContent = '0:00';
|
||||
}}
|
||||
>
|
||||
■
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.audioVolumeRow}>
|
||||
<span className={styles.audioVolumeIcon} aria-hidden>
|
||||
<VolumeSpeakerIcon gain={gainUi} />
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={gainUi}
|
||||
disabled={!audioEl}
|
||||
className={styles.audioVolume}
|
||||
aria-label={volumeLabel}
|
||||
title={`${volumeLabel}: ${String(Math.round(gainUi * 100))}%`}
|
||||
onChange={(e) => applyGain(Number(e.currentTarget.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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));
|
||||
|
||||
/** Регресс: RAF в ControlApp бампил оба audio-tick на каждом кадре → полный ре-рендер пульта. */
|
||||
void test('ControlApp: нет per-frame RAF setState для аудио scrub', () => {
|
||||
const app = fs.readFileSync(path.join(here, 'ControlApp.tsx'), 'utf8');
|
||||
const card = fs.readFileSync(path.join(here, 'ControlAudioCard.tsx'), 'utf8');
|
||||
|
||||
assert.doesNotMatch(app, /\banyPlaying\b/);
|
||||
// Старый паттерн: RAF tick → оба set*AudioStateTick.
|
||||
assert.doesNotMatch(
|
||||
app,
|
||||
/const tick = \(\) => \{\s*setSceneAudioStateTick/,
|
||||
'корневой RAF-тик аудио удалён',
|
||||
);
|
||||
assert.doesNotMatch(app, /requestAnimationFrame\s*\(\s*tick\s*\)/);
|
||||
|
||||
assert.ok(app.includes('ControlAudioCard'));
|
||||
assert.ok(card.includes('requestAnimationFrame'), 'scrub крутится локально в карточке');
|
||||
assert.ok(card.includes('scrubFillRef'), 'прогресс пишется в DOM, не через setState корня');
|
||||
assert.ok(card.includes('setGainUi'), 'громкость обновляет только карточку');
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
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));
|
||||
|
||||
/** Регресс: scheduleDraftRepaint бампил draftFxTick → полный ре-рендер ControlApp на кадр штриха. */
|
||||
void test('ControlApp: draft кисти без корневого draftFxTick', () => {
|
||||
const app = fs.readFileSync(path.join(here, 'ControlApp.tsx'), 'utf8');
|
||||
const pixi = fs.readFileSync(
|
||||
path.join(here, '../shared/effects/PxiEffectsOverlay.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
assert.doesNotMatch(app, /\bdraftFxTick\b/);
|
||||
assert.doesNotMatch(app, /\bsetDraftFxTick\b/);
|
||||
assert.doesNotMatch(app, /\bfxMergedState\b/);
|
||||
|
||||
assert.ok(app.includes('scheduleDraftRepaint'));
|
||||
assert.ok(app.includes('pushDraftToPixi'));
|
||||
assert.ok(app.includes('effectsOverlayRef'));
|
||||
assert.match(app, /scheduleDraftRepaint[\s\S]*?pushDraftToPixi\(\)/);
|
||||
assert.doesNotMatch(
|
||||
app,
|
||||
/scheduleDraftRepaint[\s\S]*?setDraftFxTick/,
|
||||
'RAF draft не трогает React state корня',
|
||||
);
|
||||
|
||||
assert.ok(pixi.includes('PixiEffectsOverlayHandle'));
|
||||
assert.ok(pixi.includes('setDraft'));
|
||||
assert.ok(pixi.includes('mergeDraftState'));
|
||||
assert.ok(pixi.includes('forwardRef'));
|
||||
});
|
||||
@@ -152,6 +152,31 @@ void test('ControlApp: музыка разделена на сцену и кам
|
||||
assert.match(src, /pause campaign\./i);
|
||||
});
|
||||
|
||||
void test('ControlApp: у каждой аудиозаписи есть регулятор громкости под транспортом', () => {
|
||||
const src = readControlApp();
|
||||
const card = fs.readFileSync(path.join(here, 'ControlAudioCard.tsx'), 'utf8');
|
||||
const css = readControlAppCss();
|
||||
assert.ok(src.includes('ControlAudioCard'));
|
||||
assert.ok(src.includes("t('control.volume')"));
|
||||
assert.ok(src.includes('sceneAudioGainRef'));
|
||||
assert.ok(src.includes('campaignAudioGainRef'));
|
||||
assert.ok(src.includes('applyAudioGain'));
|
||||
assert.ok(card.includes('VolumeSpeakerIcon'));
|
||||
assert.ok(card.includes('styles.audioVolume'));
|
||||
assert.ok(card.includes('styles.audioVolumeRow'));
|
||||
assert.match(css, /\.audioControls[\s\S]*?flex-direction:\s*column/);
|
||||
assert.match(css, /\.audioVolumeRow\b/);
|
||||
assert.match(css, /\.audioVolumeIcon\b/);
|
||||
assert.match(css, /\.audioVolume\b/);
|
||||
});
|
||||
|
||||
void test('ControlApp: весь контент скроллится в окне, отступы сверху и снизу равны', () => {
|
||||
const css = readControlAppCss();
|
||||
assert.match(css, /\.page\s*\{[^}]*padding:\s*16px/s);
|
||||
assert.match(css, /\.page\s*\{[^}]*overflow:\s*auto/s);
|
||||
assert.doesNotMatch(css, /\.rightStack\s*\{[^}]*overflow-y:\s*auto/s);
|
||||
});
|
||||
|
||||
void test('ControlApp: загрузка камп. аудио — useEffect зависит только от api и campaignAudioSpecKey', () => {
|
||||
const src = readControlApp();
|
||||
const re = /\/\/ Campaign elements:[\s\S]*?useEffect\(\(\) => \{[\s\S]*?\}\s*,\s*\[([^\]]*)\]\s*\)\s*;/;
|
||||
|
||||
Reference in New Issue
Block a user