Compare commits
1 Commits
2ce1e02753
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| add699a320 |
@@ -30,6 +30,7 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
|
||||
const rot = scene?.previewRotationDeg ?? 0;
|
||||
const isVideo = scene?.previewAssetType === 'video';
|
||||
const assetId = scene?.previewAssetType === 'video' ? scene.previewAssetId : null;
|
||||
const autostart = scene?.previewVideoAutostart ?? false;
|
||||
|
||||
const [tick, setTick] = useState(0);
|
||||
const dur = useMemo(
|
||||
@@ -38,7 +39,7 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
|
||||
if (!v) return 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 -- намеренно
|
||||
[tick, videoRef],
|
||||
);
|
||||
@@ -55,23 +56,15 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVideo) return;
|
||||
let raf = 0;
|
||||
const loop = () => {
|
||||
setTick((x) => x + 1);
|
||||
raf = window.requestAnimationFrame(loop);
|
||||
};
|
||||
raf = window.requestAnimationFrame(loop);
|
||||
return () => window.cancelAnimationFrame(raf);
|
||||
}, [isVideo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVideo) return;
|
||||
if (!assetId) return;
|
||||
// `target.set` bumps revision and resets anchors; avoid firing on every render.
|
||||
if (vp?.targetAssetId === assetId) return;
|
||||
void video.dispatch({
|
||||
kind: 'target.set',
|
||||
assetId,
|
||||
autostart: scene.previewVideoAutostart,
|
||||
autostart,
|
||||
});
|
||||
}, [assetId, isVideo, scene, video]);
|
||||
}, [assetId, isVideo, autostart, vp?.targetAssetId, video]);
|
||||
|
||||
useEffect(() => {
|
||||
const v = videoRef.current;
|
||||
@@ -88,7 +81,8 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
|
||||
} else {
|
||||
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(' ');
|
||||
|
||||
@@ -105,6 +99,8 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
|
||||
src={url}
|
||||
playsInline
|
||||
preload="auto"
|
||||
onTimeUpdate={() => setTick((x) => x + 1)}
|
||||
onLoadedMetadata={() => setTick((x) => x + 1)}
|
||||
>
|
||||
<track kind="captions" srcLang="ru" label="Превью без субтитров" />
|
||||
</video>
|
||||
|
||||
@@ -53,7 +53,16 @@ export function PresentationView({
|
||||
} else {
|
||||
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 (
|
||||
<div className={styles.root}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
import type { VideoPlaybackEvent, VideoPlaybackState } from '../../../shared/types';
|
||||
@@ -9,31 +9,36 @@ export function useVideoPlaybackState(): readonly [
|
||||
{ dispatch: (event: VideoPlaybackEvent) => Promise<void> },
|
||||
] {
|
||||
const api = getDndApi();
|
||||
const [state, setState] = useState<VideoPlaybackState | null>(null);
|
||||
const [timeOffsetMs, setTimeOffsetMs] = useState(0);
|
||||
const [clientNowMs, setClientNowMs] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (!state) return;
|
||||
const id = window.setInterval(() => {
|
||||
setClientNowMs(Date.now());
|
||||
}, 250);
|
||||
return () => window.clearInterval(id);
|
||||
}, [state]);
|
||||
const [playback, setPlayback] = useState<VideoPlaybackState | null>(null);
|
||||
/** serverNowMs − Date.now() at last IPC sync; lets us compute a live clock without React timers. */
|
||||
const timeOffsetMsRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.video.getState, {}).then((r) => {
|
||||
setState(r.state);
|
||||
setTimeOffsetMs(r.state.serverNowMs - Date.now());
|
||||
timeOffsetMsRef.current = r.state.serverNowMs - Date.now();
|
||||
setPlayback(r.state);
|
||||
});
|
||||
return api.on(ipcChannels.video.stateChanged, ({ state: next }) => {
|
||||
setState(next);
|
||||
setTimeOffsetMs(next.serverNowMs - Date.now());
|
||||
timeOffsetMsRef.current = next.serverNowMs - Date.now();
|
||||
setPlayback((prev) => {
|
||||
if (prev?.revision === next.revision) return prev;
|
||||
return next;
|
||||
});
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
const state = useMemo((): VideoPlaybackState | null => {
|
||||
if (!playback) return null;
|
||||
return {
|
||||
...playback,
|
||||
get serverNowMs(): number {
|
||||
return Date.now() + timeOffsetMsRef.current;
|
||||
},
|
||||
};
|
||||
}, [playback]);
|
||||
|
||||
return [
|
||||
state ? { ...state, serverNowMs: clientNowMs + timeOffsetMs } : null,
|
||||
state,
|
||||
{
|
||||
dispatch: async (event) => {
|
||||
await api.invoke(ipcChannels.video.dispatch, { event });
|
||||
|
||||
@@ -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
@@ -10,7 +10,7 @@
|
||||
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
||||
"lint": "eslint . --max-warnings 0",
|
||||
"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/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/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:write": "prettier . --write",
|
||||
"release:info": "node scripts/print-release-info.mjs",
|
||||
|
||||
Reference in New Issue
Block a user