7362a36fe5
Reuse previewRotationDeg for video scenes across editor, control, presentation and overlays via ContainedVideo layout parity. Co-authored-by: Cursor <cursoragent@cursor.com>
176 lines
5.2 KiB
TypeScript
176 lines
5.2 KiB
TypeScript
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<HTMLVideoElement | null>;
|
||
loop?: boolean;
|
||
muted?: boolean;
|
||
playsInline?: boolean;
|
||
autoPlay?: boolean;
|
||
preload?: React.VideoHTMLAttributes<HTMLVideoElement>['preload'];
|
||
className?: string;
|
||
style?: React.CSSProperties;
|
||
onTimeUpdate?: React.VideoHTMLAttributes<HTMLVideoElement>['onTimeUpdate'];
|
||
onLoadedMetadata?: React.VideoHTMLAttributes<HTMLVideoElement>['onLoadedMetadata'];
|
||
onLoadedData?: React.VideoHTMLAttributes<HTMLVideoElement>['onLoadedData'];
|
||
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: 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<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 = 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 (
|
||
<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}
|
||
autoPlay={autoPlay}
|
||
preload={preload}
|
||
draggable={false}
|
||
onTimeUpdate={onTimeUpdate}
|
||
onLoadedMetadata={(e) => {
|
||
syncMediaSize(e.currentTarget);
|
||
onLoadedMetadata?.(e);
|
||
}}
|
||
onLoadedData={onLoadedData}
|
||
onError={onError}
|
||
style={{
|
||
width: layout ? layout.elementW : '100%',
|
||
height: layout ? layout.elementH : '100%',
|
||
left: leftPx !== undefined ? `${String(leftPx)}px` : '50%',
|
||
top: topPx !== undefined ? `${String(topPx)}px` : '50%',
|
||
objectFit: mediaSize ? undefined : mode,
|
||
transform: `translate(-50%, -50%) rotate(${String(rotationDeg)}deg)`,
|
||
}}
|
||
>
|
||
{children}
|
||
</video>
|
||
</div>
|
||
);
|
||
}
|