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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user