import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { containMediaLayout, type MediaRotationDeg, } 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; /** Same 90° steps as scene image previewRotationDeg. */ rotationDeg?: MediaRotationDeg; /** Default contain (map overlays). Cover for editor/graph thumbnails. */ mode?: 'contain' | 'cover'; /** Зум/пан как у RotatedImage в mode=contain. */ viewCamera?: SceneViewCamera | null; onContentRectChange?: ((rect: { x: number; y: number; w: number; h: number }) => void) | undefined; videoRef?: React.Ref; loop?: boolean; muted?: boolean; playsInline?: boolean; autoPlay?: boolean; preload?: React.VideoHTMLAttributes['preload']; className?: string; style?: React.CSSProperties; onTimeUpdate?: React.VideoHTMLAttributes['onTimeUpdate']; onLoadedMetadata?: React.VideoHTMLAttributes['onLoadedMetadata']; onLoadedData?: React.VideoHTMLAttributes['onLoadedData']; onError?: React.VideoHTMLAttributes['onError']; children?: React.ReactNode; }; function useElementSize() { const ref = useRef(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(ref: React.Ref | undefined, value: T): void { if (!ref) return; if (typeof ref === 'function') { ref(value); return; } (ref as React.MutableRefObject).current = value; } /** * Video laid out like RotatedImage: reports the visible content rect * so grid / traps / tokens / effects align with the letterboxed (or cover) frame. */ export function ContainedVideo({ url, rotationDeg = 0, mode = 'contain', viewCamera = null, onContentRectChange, videoRef, loop = false, muted = false, playsInline = true, autoPlay = false, preload = 'auto', className, style, onTimeUpdate, onLoadedMetadata, onLoadedData, onError, children, }: ContainedVideoProps) { const [hostRef, size] = useElementSize(); const [mediaSize, setMediaSize] = useState<{ w: number; h: number } | null>(null); const elRef = useRef(null); const cam = viewCamera ?? DEFAULT_SCENE_VIEW_CAMERA; const viewScale = mode === 'contain' ? Math.max(1, cam.scale) : 1; const viewOx = mode === 'contain' ? cam.ox : 0.5; const viewOy = mode === 'contain' ? cam.oy : 0.5; 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 layout = useMemo(() => { if (!mediaSize) return null; return containMediaLayout({ hostW: size.w, hostH: size.h, mediaW: mediaSize.w, mediaH: mediaSize.h, scale: viewScale, ox: viewOx, oy: viewOy, rotationDeg, mode, }); }, [mediaSize, mode, rotationDeg, size.h, size.w, viewOx, viewOy, viewScale]); const contentRect = layout?.contentRect ?? null; useEffect(() => { if (!onContentRectChange || !contentRect) return; onContentRectChange(contentRect); }, [contentRect, onContentRectChange]); const leftPx = contentRect ? contentRect.x + contentRect.w / 2 : undefined; const topPx = contentRect ? contentRect.y + contentRect.h / 2 : undefined; return (
); }