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:
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]/);
|
||||
});
|
||||
Reference in New Issue
Block a user