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>
This commit is contained in:
@@ -1,12 +1,19 @@
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { containMediaRect } from '../../shared/types/containMediaRect';
|
||||
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;
|
||||
@@ -14,11 +21,13 @@ export type ContainedVideoProps = {
|
||||
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;
|
||||
};
|
||||
@@ -54,22 +63,26 @@ function assignRef<T>(ref: React.Ref<T> | undefined, value: T): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Video laid out like RotatedImage(mode=contain): reports the visible content rect
|
||||
* so grid / traps / tokens / effects align with the letterboxed frame.
|
||||
* 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) {
|
||||
@@ -78,9 +91,9 @@ export function ContainedVideo({
|
||||
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 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;
|
||||
@@ -95,9 +108,9 @@ export function ContainedVideo({
|
||||
if (el.readyState >= 1) syncMediaSize(el);
|
||||
}, [url]);
|
||||
|
||||
const contentRect = useMemo(() => {
|
||||
const layout = useMemo(() => {
|
||||
if (!mediaSize) return null;
|
||||
return containMediaRect({
|
||||
return containMediaLayout({
|
||||
hostW: size.w,
|
||||
hostH: size.h,
|
||||
mediaW: mediaSize.w,
|
||||
@@ -105,16 +118,18 @@ export function ContainedVideo({
|
||||
scale: viewScale,
|
||||
ox: viewOx,
|
||||
oy: viewOy,
|
||||
rotationDeg,
|
||||
mode,
|
||||
});
|
||||
}, [mediaSize, size.h, size.w, viewOx, viewOy, viewScale]);
|
||||
}, [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 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;
|
||||
|
||||
@@ -134,6 +149,7 @@ export function ContainedVideo({
|
||||
loop={loop}
|
||||
muted={muted}
|
||||
playsInline={playsInline}
|
||||
autoPlay={autoPlay}
|
||||
preload={preload}
|
||||
draggable={false}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
@@ -141,14 +157,15 @@ export function ContainedVideo({
|
||||
syncMediaSize(e.currentTarget);
|
||||
onLoadedMetadata?.(e);
|
||||
}}
|
||||
onLoadedData={onLoadedData}
|
||||
onError={onError}
|
||||
style={{
|
||||
width: w ?? '100%',
|
||||
height: h ?? '100%',
|
||||
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 : 'contain',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
objectFit: mediaSize ? undefined : mode,
|
||||
transform: `translate(-50%, -50%) rotate(${String(rotationDeg)}deg)`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
Reference in New Issue
Block a user