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:
Ivan Fontosh
2026-08-11 12:17:57 +08:00
parent 1d94c95cc8
commit 7362a36fe5
10 changed files with 220 additions and 70 deletions
+32 -15
View File
@@ -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}
+1
View File
@@ -175,6 +175,7 @@ export function PresentationView({
<div className={styles.fill}>
<ContainedVideo
url={originalUrl}
rotationDeg={rot}
videoRef={videoElRef}
muted
playsInline
@@ -29,6 +29,21 @@ void test('video scenes share map overlays / effects with image scenes', () => {
assert.ok(editor.includes("previewAssetType === 'image' || previewAssetType === 'video'"));
assert.ok(editor.includes('windows.openSceneEditor'));
assert.ok(editor.includes('ContainedVideo'));
assert.ok(editor.includes('onRotatePreview'));
assert.match(
editor,
/previewAssetId && \(previewAssetType === 'image' \|\| previewAssetType === 'video'\) \? \([\s\S]*?onRotatePreview/,
);
assert.ok(fs.readFileSync(path.join(rendererRoot, 'shared/ContainedVideo.tsx'), 'utf8').includes('rotationDeg'));
assert.ok(
fs
.readFileSync(path.join(rendererRoot, 'control/ControlScenePreview.tsx'), 'utf8')
.includes('rotationDeg={rot}'),
);
assert.ok(presentation.includes('rotationDeg={rot}'));
assert.ok(sceneEditor.includes('rotationDeg={rot}'));
assert.ok(main.includes("scene?.previewAssetType === 'video'"));
assert.ok(main.includes('syncSceneDarknessForProject'));