feat(npcs): inspect cards, Show overlay, and optional external link

Clicking an NPC tile opens description cards (multi-select accordion); Show puts avatars on screen. Optional URL opens a webview tab. Avatar pan in the editor commits on pointer up.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-08-19 10:42:57 +08:00
parent 1a9f0f2557
commit 0f4bb76b41
35 changed files with 1189 additions and 259 deletions
+54 -38
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useEffect, useRef } from 'react';
import { computeTimeSec } from '../../main/video/videoPlaybackStore';
import type { SessionState } from '../../shared/ipc/contracts';
@@ -26,6 +26,13 @@ function fmt(sec: number): string {
return `${String(m)}:${String(r).padStart(2, '0')}`;
}
function readVideoTimes(v: HTMLVideoElement | null): { cur: number; dur: number } {
if (!v) return { cur: 0, dur: 0 };
const dur = Number.isFinite(v.duration) ? v.duration : 0;
const cur = Number.isFinite(v.currentTime) ? v.currentTime : 0;
return { cur, dur };
}
export function ControlScenePreview({ session, videoRef, viewCamera = null, onContentRectChange }: Props) {
const { t } = useEditorI18n();
const [vp, video] = useVideoPlaybackState();
@@ -37,28 +44,27 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
const assetId = scene?.previewAssetType === 'video' ? scene.previewAssetId : null;
const autostart = scene?.previewVideoAutostart ?? false;
const lastTargetRef = useRef<{ sceneKey: string; assetId: string; autostart: boolean } | null>(null);
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 [tick, setTick] = useState(0);
const dur = useMemo(
() => {
const v = videoRef.current;
if (!v) return 0;
return Number.isFinite(v.duration) ? v.duration : 0;
},
// tick: timeupdate / loadedmetadata перечитывают duration и currentTime
// eslint-disable-next-line react-hooks/exhaustive-deps -- намеренно
[tick, videoRef],
);
const cur = useMemo(
() => {
const v = videoRef.current;
if (!v) return 0;
return Number.isFinite(v.currentTime) ? v.currentTime : 0;
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- намеренно
[tick, videoRef],
);
const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0;
const paintScrub = (): void => {
const { cur, dur } = readVideoTimes(videoRef.current);
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 = fmt(cur);
if (durTimeRef.current) durTimeRef.current.textContent = dur ? fmt(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.classList.toggle(styles.scrubPointer, dur > 0);
scrubRef.current.classList.toggle(styles.scrubDefault, dur <= 0);
}
};
useEffect(() => {
if (!isVideo) return;
@@ -91,10 +97,21 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
} else {
v.pause();
}
paintScrub();
// 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(' ');
const seekByClientX = (clientX: number): void => {
const v = videoRef.current;
const { dur } = readVideoTimes(v);
if (!v || !dur || !scrubRef.current) return;
const rect = scrubRef.current.getBoundingClientRect();
const next = (clientX - rect.left) / Math.max(1, rect.width);
const timeSec = Math.max(0, Math.min(dur, next * dur));
void video.dispatch({ kind: 'seek', timeSec });
v.currentTime = timeSec;
paintScrub();
};
return (
<div className={styles.root}>
@@ -116,8 +133,8 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
preload="auto"
viewCamera={viewCamera}
onContentRectChange={onContentRectChange}
onTimeUpdate={() => setTick((x) => x + 1)}
onLoadedMetadata={() => setTick((x) => x + 1)}
onTimeUpdate={paintScrub}
onLoadedMetadata={paintScrub}
>
<track kind="captions" srcLang="ru" label={t('control.previewTrackLabel')} />
</ContainedVideo>
@@ -128,30 +145,27 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
{isVideo ? (
<div className={styles.controls}>
<div
ref={scrubRef}
role="slider"
tabIndex={0}
aria-valuemin={0}
aria-valuemax={dur > 0 ? Math.round(dur) : 0}
aria-valuenow={Math.round(cur)}
className={scrubClass}
aria-valuemax={0}
aria-valuenow={0}
className={[styles.scrub, styles.scrubDefault].join(' ')}
onClick={(e) => {
const v = videoRef.current;
if (!v || !dur) return;
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
const next = (e.clientX - rect.left) / rect.width;
void video.dispatch({ kind: 'seek', timeSec: Math.max(0, Math.min(dur, next * dur)) });
setTick((x) => x + 1);
seekByClientX(e.clientX);
}}
onKeyDown={(e) => {
const { cur, dur } = readVideoTimes(videoRef.current);
if (!dur) return;
if (e.key === 'ArrowLeft') void video.dispatch({ kind: 'seek', timeSec: Math.max(0, cur - 5) });
if (e.key === 'ArrowRight')
void video.dispatch({ kind: 'seek', timeSec: Math.min(dur, cur + 5) });
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') setTick((x) => x + 1);
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') paintScrub();
}}
title={t('control.scrubSeek')}
>
<div className={styles.scrubFill} style={{ width: `${String(Math.round(pct * 100))}%` }} />
<div ref={scrubFillRef} className={styles.scrubFill} style={{ width: '0%' }} />
</div>
<div className={styles.row}>
<div className={styles.transport}>
@@ -176,7 +190,7 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
className={styles.transportBtn}
onClick={() => {
void video.dispatch({ kind: 'stop' });
setTick((x) => x + 1);
paintScrub();
}}
title={t('control.transportStop')}
>
@@ -184,7 +198,9 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
</button>
</div>
<div>
{fmt(cur)} / {dur ? fmt(dur) : '—:—'}
<span ref={curTimeRef}>0:00</span>
{' / '}
<span ref={durTimeRef}>—:—</span>
</div>
</div>
</div>