Files
Ivan Fontosh 7362a36fe5 feat(scene): rotate video previews like images
Reuse previewRotationDeg for video scenes across editor, control,
presentation and overlays via ContainedVideo layout parity.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 12:17:57 +08:00

176 lines
5.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}