feat(scene): video map editor parity and help updates

Enable scene editor, overlays, effects, and darkness on video scenes; brighten GM trap markers; document snap, NPC types, materials, and control controls in RU/EN help.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-08-07 08:23:08 +08:00
parent 8d5a68c71e
commit 1fbaaa6e77
17 changed files with 443 additions and 98 deletions
@@ -0,0 +1,16 @@
.root {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
.video {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
transform-origin: center;
display: block;
background: #000;
}
+158
View File
@@ -0,0 +1,158 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { containMediaRect } from '../../shared/types/containMediaRect';
import { DEFAULT_SCENE_VIEW_CAMERA, type SceneViewCamera } from '../../shared/types/sceneView';
import styles from './ContainedVideo.module.css';
export type ContainedVideoProps = {
url: string;
/** Зум/пан как у RotatedImage в mode=contain. */
viewCamera?: SceneViewCamera | null;
onContentRectChange?: ((rect: { x: number; y: number; w: number; h: number }) => void) | undefined;
videoRef?: React.Ref<HTMLVideoElement | null>;
loop?: boolean;
muted?: boolean;
playsInline?: boolean;
preload?: React.VideoHTMLAttributes<HTMLVideoElement>['preload'];
className?: string;
style?: React.CSSProperties;
onTimeUpdate?: React.VideoHTMLAttributes<HTMLVideoElement>['onTimeUpdate'];
onLoadedMetadata?: React.VideoHTMLAttributes<HTMLVideoElement>['onLoadedMetadata'];
onError?: React.VideoHTMLAttributes<HTMLVideoElement>['onError'];
children?: React.ReactNode;
};
function useElementSize<T extends HTMLElement>() {
const ref = useRef<T | null>(null);
const [size, setSize] = useState<{ w: number; h: number }>({ w: 0, h: 0 });
useEffect(() => {
const el = ref.current;
if (!el) return;
const readLayoutSize = () => {
setSize({ w: el.clientWidth, h: el.clientHeight });
};
const ro = new ResizeObserver(() => {
readLayoutSize();
});
ro.observe(el);
readLayoutSize();
return () => ro.disconnect();
}, []);
return [ref, size] as const;
}
function assignRef<T>(ref: React.Ref<T> | undefined, value: T): void {
if (!ref) return;
if (typeof ref === 'function') {
ref(value);
return;
}
(ref as React.MutableRefObject<T>).current = value;
}
/**
* Video laid out like RotatedImage(mode=contain): reports the visible content rect
* so grid / traps / tokens / effects align with the letterboxed frame.
*/
export function ContainedVideo({
url,
viewCamera = null,
onContentRectChange,
videoRef,
loop = false,
muted = false,
playsInline = true,
preload = 'auto',
className,
style,
onTimeUpdate,
onLoadedMetadata,
onError,
children,
}: ContainedVideoProps) {
const [hostRef, size] = useElementSize<HTMLDivElement>();
const [mediaSize, setMediaSize] = useState<{ w: number; h: number } | null>(null);
const elRef = useRef<HTMLVideoElement | null>(null);
const cam = viewCamera ?? DEFAULT_SCENE_VIEW_CAMERA;
const viewScale = Math.max(1, cam.scale);
const viewOx = cam.ox;
const viewOy = cam.oy;
const syncMediaSize = (el: HTMLVideoElement) => {
const w0 = el.videoWidth || 0;
const h0 = el.videoHeight || 0;
if (w0 <= 0 || h0 <= 0) return;
setMediaSize((prev) => (prev && prev.w === w0 && prev.h === h0 ? prev : { w: w0, h: h0 }));
};
useLayoutEffect(() => {
const el = elRef.current;
if (!el) return;
if (el.readyState >= 1) syncMediaSize(el);
}, [url]);
const contentRect = useMemo(() => {
if (!mediaSize) return null;
return containMediaRect({
hostW: size.w,
hostH: size.h,
mediaW: mediaSize.w,
mediaH: mediaSize.h,
scale: viewScale,
ox: viewOx,
oy: viewOy,
});
}, [mediaSize, size.h, size.w, viewOx, viewOy, viewScale]);
useEffect(() => {
if (!onContentRectChange || !contentRect) return;
onContentRectChange(contentRect);
}, [contentRect, onContentRectChange]);
const w = contentRect?.w;
const h = contentRect?.h;
const leftPx = contentRect ? contentRect.x + contentRect.w / 2 : undefined;
const topPx = contentRect ? contentRect.y + contentRect.h / 2 : undefined;
return (
<div
ref={hostRef}
className={[styles.root, className].filter(Boolean).join(' ')}
style={style}
>
<video
ref={(el) => {
elRef.current = el;
assignRef(videoRef, el);
}}
className={styles.video}
src={url}
loop={loop}
muted={muted}
playsInline={playsInline}
preload={preload}
draggable={false}
onTimeUpdate={onTimeUpdate}
onLoadedMetadata={(e) => {
syncMediaSize(e.currentTarget);
onLoadedMetadata?.(e);
}}
onError={onError}
style={{
width: w ?? '100%',
height: h ?? '100%',
left: leftPx !== undefined ? `${String(leftPx)}px` : '50%',
top: topPx !== undefined ? `${String(topPx)}px` : '50%',
objectFit: mediaSize ? undefined : 'contain',
transform: 'translate(-50%, -50%)',
}}
>
{children}
</video>
</div>
);
}
+31 -20
View File
@@ -29,6 +29,7 @@ import { useSceneTokensSession } from './tokens/useSceneTokensSession';
import { SceneTrapsOverlay } from './traps/SceneTrapsOverlay';
import { useSceneTrapsState } from './traps/useSceneTrapsState';
import styles from './PresentationView.module.css';
import { ContainedVideo } from './ContainedVideo';
import { RotatedImage } from './RotatedImage';
import { useAssetUrl } from './useAssetImageUrl';
import { useVideoPlaybackState } from './video/useVideoPlaybackState';
@@ -171,26 +172,29 @@ export function PresentationView({
/>
</div>
) : originalUrl && scene?.previewAssetType === 'video' ? (
<video
ref={videoElRef}
className={styles.video}
src={originalUrl}
muted
playsInline
loop={Boolean(scene?.settings?.loopVideo)}
preload="auto"
onError={() => {
// noop: status surfaced in control app; keep presentation clean
}}
/>
<div className={styles.fill}>
<ContainedVideo
url={originalUrl}
videoRef={videoElRef}
muted
playsInline
loop={Boolean(scene?.settings?.loopVideo)}
preload="auto"
viewCamera={sceneView}
onContentRectChange={setContentRect}
onError={() => {
// noop: status surfaced in control app; keep presentation clean
}}
/>
</div>
) : (
<div className={styles.placeholderBg} />
)}
{scene?.previewAssetType === 'image' ? (
{scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video' ? (
<SceneGridOverlay grid={scene.grid} viewport={contentRect} />
) : null}
<div className={styles.vignette} />
{scene?.previewAssetType === 'image' && contentRect ? (
{(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') && contentRect ? (
<SceneTokensOverlay
placements={scene.tokens ?? []}
library={appTokens}
@@ -198,7 +202,9 @@ export function PresentationView({
viewport={contentRect}
/>
) : null}
{USERS_BRANCH_FEATURES_ENABLED && scene?.previewAssetType === 'image' && contentRect ? (
{USERS_BRANCH_FEATURES_ENABLED &&
(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') &&
contentRect ? (
<SceneNpcTokensOverlay
placements={scene.npcTokens ?? []}
library={project?.npcs ?? []}
@@ -207,7 +213,9 @@ export function PresentationView({
grid={scene.grid}
/>
) : null}
{USERS_BRANCH_FEATURES_ENABLED && scene?.previewAssetType === 'image' && contentRect ? (
{USERS_BRANCH_FEATURES_ENABLED &&
(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') &&
contentRect ? (
<ScenePlayerTokensOverlay
library={appPlayers}
session={scenePlayerTokensSession}
@@ -216,7 +224,7 @@ export function PresentationView({
grid={scene.grid}
/>
) : null}
{scene?.previewAssetType === 'image' && contentRect ? (
{(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') && contentRect ? (
<SceneTrapsOverlay
traps={scene.traps ?? []}
session={sceneTraps}
@@ -224,7 +232,7 @@ export function PresentationView({
mode="presentation"
/>
) : null}
{showEffects && scene?.previewAssetType !== 'video' ? (
{showEffects && (scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') ? (
<PixiEffectsOverlay
state={fxState}
style={{ zIndex: 6 }}
@@ -235,10 +243,13 @@ export function PresentationView({
}
/>
) : null}
{showEffects && scene?.previewAssetType !== 'video' ? (
{showEffects && (scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') ? (
<ExplosionVideoOverlay state={fxState} viewport={contentRect} />
) : null}
{showEffects && scene?.previewAssetType === 'image' && scene.darkenScene && contentRect ? (
{showEffects &&
(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') &&
scene.darkenScene &&
contentRect ? (
<SceneDarknessOverlay state={sdState} overlayAlpha={1} viewport={contentRect} style={{ zIndex: 30 }} />
) : null}
<SceneOverlayHost active={activeMaterialItems.length > 0 || activeNpcItems.length > 0}>
@@ -11,13 +11,15 @@
position: absolute;
transform: translate(-50%, -50%);
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.5);
background: rgba(0, 0, 0, 0.5);
border: 2px solid rgba(255, 255, 255, 0.72);
background: rgba(12, 14, 20, 0.62);
display: grid;
place-items: center;
pointer-events: auto;
cursor: context-menu;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.45),
0 0 10px rgba(0, 0, 0, 0.35);
}
.trapActive {
@@ -30,12 +32,18 @@
.trapDisarmed {
border-color: #9ca3af;
filter: grayscale(0.7);
opacity: 0.75;
opacity: 0.8;
}
.trapGmHidden {
opacity: 0.55;
/* Hidden from players, but still readable for the GM on control preview. */
opacity: 0.88;
border-style: dashed;
border-color: rgba(255, 230, 160, 0.85);
background: rgba(28, 24, 12, 0.72);
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.4),
0 0 12px rgba(255, 200, 80, 0.22);
}
.trapNonInteractive {
@@ -0,0 +1,48 @@
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));
const rendererRoot = path.resolve(here, '..');
void test('video scenes share map overlays / effects with image scenes', () => {
const control = fs.readFileSync(path.join(rendererRoot, 'control/ControlApp.tsx'), 'utf8');
const presentation = fs.readFileSync(path.join(rendererRoot, 'shared/PresentationView.tsx'), 'utf8');
const sceneEditor = fs.readFileSync(path.join(rendererRoot, 'sceneEditor/SceneEditorApp.tsx'), 'utf8');
const editor = fs.readFileSync(path.join(rendererRoot, 'editor/EditorApp.tsx'), 'utf8');
const main = fs.readFileSync(path.join(rendererRoot, '../main/index.ts'), 'utf8');
assert.equal(control.includes('isVideoPreviewScene'), false);
assert.ok(fs.readFileSync(path.join(rendererRoot, 'control/ControlScenePreview.tsx'), 'utf8').includes('ContainedVideo'));
assert.ok(presentation.includes('ContainedVideo'));
assert.ok(presentation.includes("previewAssetType === 'video'"));
assert.ok(presentation.includes('SceneTrapsOverlay'));
assert.ok(presentation.includes('PixiEffectsOverlay'));
assert.ok(presentation.includes('SceneDarknessOverlay'));
assert.ok(sceneEditor.includes('ContainedVideo'));
assert.ok(sceneEditor.includes('hasMapMedia'));
assert.ok(sceneEditor.includes('Нужно изображение или видео сцены'));
assert.ok(editor.includes("previewAssetType === 'image' || previewAssetType === 'video'"));
assert.ok(editor.includes('windows.openSceneEditor'));
assert.ok(main.includes("scene?.previewAssetType === 'video'"));
assert.ok(main.includes('syncSceneDarknessForProject'));
assert.match(
main,
/darkenScene[\s\S]{0,120}previewAssetType === 'image'[\s\S]{0,80}previewAssetType === 'video'/,
);
});
void test('control traps: GM-hidden markers stay relatively bright', () => {
const css = fs.readFileSync(
path.join(rendererRoot, 'shared/traps/SceneTrapsOverlay.module.css'),
'utf8',
);
assert.ok(css.includes('.trapGmHidden'));
assert.doesNotMatch(css, /\.trapGmHidden\s*\{[^}]*opacity:\s*0\.[0-6]/);
});