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
@@ -109,6 +109,7 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
) : url && isVideo ? (
<ContainedVideo
url={url}
rotationDeg={rot}
videoRef={videoRef}
playsInline
loop={Boolean(scene?.settings?.loopVideo)}
+28 -4
View File
@@ -29,6 +29,7 @@ import type {
SceneId,
} from '../../shared/types';
import { AppLogo } from '../shared/branding/AppLogo';
import { ContainedVideo } from '../shared/ContainedVideo';
import { getDndApi } from '../shared/dndApi';
import { RotatedImage } from '../shared/RotatedImage';
import { Button, Input } from '../shared/ui/controls';
@@ -2696,14 +2697,16 @@ function SceneInspector({
</div>
) : previewUrl && previewAssetType === 'video' ? (
<div className={styles.previewFill}>
<video
src={previewUrl}
<ContainedVideo
url={previewUrl}
rotationDeg={previewRotationDeg}
mode="cover"
muted
playsInline
autoPlay={previewVideoAutostart}
loop={previewVideoLoop}
preload="metadata"
className={styles.videoCover}
style={{ width: '100%', height: '100%' }}
/>
</div>
) : (
@@ -2744,7 +2747,7 @@ function SceneInspector({
</label>
</div>
) : null}
{previewAssetId && previewAssetType === 'image' ? (
{previewAssetId && (previewAssetType === 'image' || previewAssetType === 'video') ? (
<>
<div className={styles.spacer6} />
<Button
@@ -2989,6 +2992,7 @@ function SceneListCard({
</div>
) : previewUrl && scene.previewAssetType === 'video' ? (
<div className={styles.sceneThumbInner}>
{scene.previewRotationDeg === 0 ? (
<video
src={previewUrl}
muted
@@ -3006,6 +3010,26 @@ function SceneListCard({
}
}}
/>
) : (
<ContainedVideo
url={previewUrl}
rotationDeg={scene.previewRotationDeg}
mode="cover"
muted
playsInline
preload="metadata"
style={{ width: '100%', height: '100%' }}
onLoadedData={(e) => {
const v = e.currentTarget;
try {
v.currentTime = 0;
v.pause();
} catch {
// ignore
}
}}
/>
)}
</div>
) : (
<div className={styles.sceneThumbEmptyInner} aria-hidden />
+24
View File
@@ -26,6 +26,7 @@ import {
isSideStoryEdge,
} from '../../../shared/graph/sceneGraphLineage';
import type { AssetId, GraphNodeId, SceneGraphEdge, SceneGraphNode, SceneId } from '../../../shared/types';
import { ContainedVideo } from '../../shared/ContainedVideo';
import { RotatedImage } from '../../shared/RotatedImage';
import { EllipsisText } from '../../shared/ui/EllipsisText';
import ellipsisStyles from '../../shared/ui/ellipsisText.module.css';
@@ -260,6 +261,8 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
)}
</div>
) : previewUrl && data.previewAssetType === 'video' ? (
<div className={styles.previewFill}>
{data.previewRotationDeg === 0 ? (
<video
src={previewUrl}
muted
@@ -276,6 +279,27 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
}
}}
/>
) : (
<ContainedVideo
url={previewUrl}
rotationDeg={data.previewRotationDeg}
mode="cover"
muted
playsInline
preload="metadata"
style={{ width: '100%', height: '100%' }}
onLoadedData={(e) => {
const v = e.currentTarget;
try {
v.currentTime = 0;
v.pause();
} catch {
// ignore
}
}}
/>
)}
</div>
) : (
<div className={styles.previewPlaceholder} aria-hidden />
)}
+4 -4
View File
@@ -180,7 +180,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.sceneProps.title': 'Свойства сцены',
'help.section.sceneProps.body':
'Выберите сцену в списке слева — справа откроются её свойства.\n\n«Название сцены» — для мастера. «Описание» — заметки мастера с форматированием: рядом с подписью нажмите карандаш, откроется редактор (жирный, курсив, заголовки, списки, ссылки). Под подписью видно фрагмент текста или «описание отсутствует», если поле пустое. Во время сессии описание открывается с пульта в отдельном окне (см. «Пульт управления»), а не в блоке «Сюжетная линия».\n\nЕсли у сцены на карте есть карточка с синей меткой «ПОБОЧНАЯ», появится поле «Название побочной линии».\n\nКартинка или видео для игроков:\n\n1) В «Превью сцены» нажмите «Загрузить» или «Изменить».\n\n2) Выберите файл (PNG, JPG, WebP, GIF, видео и др.).\n\n3) Для картинки можно «Повернуть» (шаг 90°). «Очистить» — убрать превью.\n\n4) Для картинки и видео можно включить «Затемнить сцену»: при показе игроки сначала увидят кадр в полной темноте, а вы снимете её с пульта «Кистью Открытия» (см. «Эффекты»).\n\n5) Кнопка «Редактор сцены» доступна и для картинки, и для видео — сетка, ловушки и неигровые токены поверх превью (см. «Редактор сцены», «Генератор сетки», «Ловушки», «Неигровые токены»).\n\nДля видео включите «Автостарт», если ролик должен сам начаться на экране игроков, и при необходимости «Цикл».\n\n«Аудио сцены» — музыка и звуки этого эпизода. «Загрузить» добавляет файлы. У каждого трека: «Авто» (играть при входе в сцену) и «Цикл». Корзина удаляет трек.\n\nСвязи между сценами задаются на карте, а не здесь — см. «Граф сцен».',
'Выберите сцену в списке слева — справа откроются её свойства.\n\n«Название сцены» — для мастера. «Описание» — заметки мастера с форматированием: рядом с подписью нажмите карандаш, откроется редактор (жирный, курсив, заголовки, списки, ссылки). Под подписью видно фрагмент текста или «описание отсутствует», если поле пустое. Во время сессии описание открывается с пульта в отдельном окне (см. «Пульт управления»), а не в блоке «Сюжетная линия».\n\nЕсли у сцены на карте есть карточка с синей меткой «ПОБОЧНАЯ», появится поле «Название побочной линии».\n\nКартинка или видео для игроков:\n\n1) В «Превью сцены» нажмите «Загрузить» или «Изменить».\n\n2) Выберите файл (PNG, JPG, WebP, GIF, видео и др.).\n\n3) Для картинки и видео можно «Повернуть» (шаг 90°). «Очистить» — убрать превью.\n\n4) Для картинки и видео можно включить «Затемнить сцену»: при показе игроки сначала увидят кадр в полной темноте, а вы снимете её с пульта «Кистью Открытия» (см. «Эффекты»).\n\n5) Кнопка «Редактор сцены» доступна и для картинки, и для видео — сетка, ловушки и неигровые токены поверх превью (см. «Редактор сцены», «Генератор сетки», «Ловушки», «Неигровые токены»).\n\nДля видео включите «Автостарт», если ролик должен сам начаться на экране игроков, и при необходимости «Цикл».\n\n«Аудио сцены» — музыка и звуки этого эпизода. «Загрузить» добавляет файлы. У каждого трека: «Авто» (играть при входе в сцену) и «Цикл». Корзина удаляет трек.\n\nСвязи между сценами задаются на карте, а не здесь — см. «Граф сцен».',
'help.section.sceneEditor.title': 'Редактор сцены',
'help.section.sceneEditor.body':
@@ -235,7 +235,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.presentation.title': 'Экран презентации',
'help.section.presentation.body':
'«Презентация» — то, что видят игроки: картинка сцены (с учётом поворота из редактора) или видео по вашим настройкам.\n\nЭффекты с пульта накладываются поверх. Меню и кнопки мастера здесь не показываются — клики по карте, ловушкам и токенам для игроков недоступны.\n\nЕсли у сцены включено «Затемнить сцену», игроки сначала видят полностью чёрный экран. Мастер открывает карту «Кистью Открытия» и при необходимости снова закрывает участки «Кистью Закрытия» на пульте.\n\nПри смене сцены с пульта картинка обновляется сама. Перенесите окно на экран для игроков и при необходимости спрячьте панель задач.\n\nПустой или тёмный экран — скорее всего, у сцены нет превью. Добавьте картинку в свойствах сцены в редакторе.',
'«Презентация» — то, что видят игроки: картинка или видео сцены (с учётом поворота из редактора) по вашим настройкам.\n\nЭффекты с пульта накладываются поверх. Меню и кнопки мастера здесь не показываются — клики по карте, ловушкам и токенам для игроков недоступны.\n\nЕсли у сцены включено «Затемнить сцену», игроки сначала видят полностью чёрный экран. Мастер открывает карту «Кистью Открытия» и при необходимости снова закрывает участки «Кистью Закрытия» на пульте.\n\nПри смене сцены с пульта картинка обновляется сама. Перенесите окно на экран для игроков и при необходимости спрячьте панель задач.\n\nПустой или тёмный экран — скорее всего, у сцены нет превью. Добавьте картинку в свойствах сцены в редакторе.',
'help.section.importExport.title': 'Импорт и экспорт',
'help.section.importExport.body':
@@ -800,7 +800,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.sceneProps.title': 'Scene properties',
'help.section.sceneProps.body':
'Select a scene in the left list — its properties open on the right.\n\nScene title is for the GM. Description is GM notes with formatting: click the pencil next to the label to open the editor (bold, italic, headings, lists, links). Below the label you see a text preview, or “no description” when empty. During a session, open the description from the control panel in a separate window (see Control panel) — it is not shown inside the Storyline list.\n\nIf the scene has a graph card with a blue SIDE badge, a Side storyline title field appears.\n\nImage or video for players:\n\n1) Under Scene preview, click Upload or Change.\n\n2) Pick a file (PNG, JPG, WebP, GIF, video, etc.).\n\n3) For images, use Rotate (90° steps). Clear removes the preview.\n\n4) For images and video, enable Darken scene so players start in full darkness and you reveal the frame with the Opening brush on the control panel (see Effects).\n\n5) Scene editor works for both images and video — battle grid, traps, and non-player tokens over the preview (see Scene editor, Grid generator, Traps, and Non-player tokens).\n\nFor video, enable Autostart if the clip should start on its own on the player screen, and Loop if needed.\n\nScene audio — music and sounds for this episode. Upload adds files. Per track: Auto (play when entering the scene) and Loop. The trash icon removes a track.\n\nLinks between scenes are drawn on the map, not here — see Scene graph.',
'Select a scene in the left list — its properties open on the right.\n\nScene title is for the GM. Description is GM notes with formatting: click the pencil next to the label to open the editor (bold, italic, headings, lists, links). Below the label you see a text preview, or “no description” when empty. During a session, open the description from the control panel in a separate window (see Control panel) — it is not shown inside the Storyline list.\n\nIf the scene has a graph card with a blue SIDE badge, a Side storyline title field appears.\n\nImage or video for players:\n\n1) Under Scene preview, click Upload or Change.\n\n2) Pick a file (PNG, JPG, WebP, GIF, video, etc.).\n\n3) For images and video, use Rotate (90° steps). Clear removes the preview.\n\n4) For images and video, enable Darken scene so players start in full darkness and you reveal the frame with the Opening brush on the control panel (see Effects).\n\n5) Scene editor works for both images and video — battle grid, traps, and non-player tokens over the preview (see Scene editor, Grid generator, Traps, and Non-player tokens).\n\nFor video, enable Autostart if the clip should start on its own on the player screen, and Loop if needed.\n\nScene audio — music and sounds for this episode. Upload adds files. Per track: Auto (play when entering the scene) and Loop. The trash icon removes a track.\n\nLinks between scenes are drawn on the map, not here — see Scene graph.',
'help.section.sceneEditor.title': 'Scene editor',
'help.section.sceneEditor.body':
@@ -855,7 +855,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.presentation.title': 'Presentation screen',
'help.section.presentation.body':
'Presentation is what players see: the scene image (with rotation from the editor) or video according to your settings.\n\nEffects from the control panel draw on top. There are no GM menus or buttons here — players cannot click the map, traps, or tokens.\n\nIf Darken scene is enabled, players first see a fully black screen. The GM reveals the map with the Opening brush and can cover areas again with the Closing brush on the control panel.\n\nWhen you switch scenes from the control panel, the image updates automatically. Move the window to the display players watch and hide the taskbar if needed.\n\nA blank or dark screen usually means the scene has no preview — add one in scene properties in the editor.',
'Presentation is what players see: the scene image or video (with rotation from the editor) according to your settings.\n\nEffects from the control panel draw on top. There are no GM menus or buttons here — players cannot click the map, traps, or tokens.\n\nIf Darken scene is enabled, players first see a fully black screen. The GM reveals the map with the Opening brush and can cover areas again with the Closing brush on the control panel.\n\nWhen you switch scenes from the control panel, the image updates automatically. Move the window to the display players watch and hide the taskbar if needed.\n\nA blank or dark screen usually means the scene has no preview — add one in scene properties in the editor.',
'help.section.importExport.title': 'Import and export',
'help.section.importExport.body':
@@ -822,6 +822,7 @@ export function SceneEditorApp() {
) : (
<ContainedVideo
url={url!}
rotationDeg={rot}
muted
playsInline
loop
+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'));
+20 -1
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { containMediaRect } from './containMediaRect';
import { containMediaLayout, containMediaRect } from './containMediaRect';
void test('containMediaRect: letterboxes 16:9 into square host', () => {
const r = containMediaRect({
@@ -43,3 +43,22 @@ void test('containMediaRect: zoom grows rect around ox/oy', () => {
assert.ok(zoomed!.w > base!.w);
assert.ok(zoomed!.x < base!.x);
});
void test('containMediaLayout: 90° swaps fit axes and element stays unrotated size', () => {
const layout = containMediaLayout({
hostW: 400,
hostH: 400,
mediaW: 1920,
mediaH: 1080,
scale: 1,
ox: 0.5,
oy: 0.5,
rotationDeg: 90,
});
assert.ok(layout);
// After 90°, layout AABB is portrait 1080x1920 fitted into square → height fills.
assert.ok(Math.abs(layout!.contentRect.h - 400) < 0.01);
assert.ok(Math.abs(layout!.contentRect.w - (400 * 1080) / 1920) < 0.01);
assert.ok(Math.abs(layout!.elementW - layout!.contentRect.h) < 0.01);
assert.ok(Math.abs(layout!.elementH - layout!.contentRect.w) < 0.01);
});
+61 -13
View File
@@ -2,6 +2,63 @@
* Pure layout math shared by ContainedVideo / RotatedImage contain-mode.
* Kept free of DOM so unit tests can lock overlay alignment for video scenes.
*/
export type MediaRotationDeg = 0 | 90 | 180 | 270;
export type ContainMediaRect = { x: number; y: number; w: number; h: number };
export type ContainMediaLayout = {
/** Bounding box of the visible media after rotation (overlay coordinate space). */
contentRect: ContainMediaRect;
/** Unrotated element size; apply CSS rotate(rotationDeg) on the media node. */
elementW: number;
elementH: number;
};
export function containMediaLayout(args: {
hostW: number;
hostH: number;
mediaW: number;
mediaH: number;
scale: number;
ox: number;
oy: number;
rotationDeg?: MediaRotationDeg;
mode?: 'contain' | 'cover';
}): ContainMediaLayout | null {
const {
hostW,
hostH,
mediaW,
mediaH,
scale,
ox,
oy,
rotationDeg = 0,
mode = 'contain',
} = args;
if (hostW <= 1 || hostH <= 1 || mediaW <= 0 || mediaH <= 0) return null;
const rotated = rotationDeg === 90 || rotationDeg === 270;
const layoutW = rotated ? mediaH : mediaW;
const layoutH = rotated ? mediaW : mediaH;
const sx = hostW / layoutW;
const sy = hostH / layoutH;
const fit = mode === 'cover' ? Math.max(sx, sy) : Math.min(sx, sy);
const s = fit * Math.max(1, scale);
const w = layoutW * s;
const h = layoutH * s;
return {
contentRect: {
x: hostW / 2 - ox * w,
y: hostH / 2 - oy * h,
w,
h,
},
elementW: mediaW * s,
elementH: mediaH * s,
};
}
export function containMediaRect(args: {
hostW: number;
hostH: number;
@@ -10,17 +67,8 @@ export function containMediaRect(args: {
scale: number;
ox: number;
oy: number;
}): { x: number; y: number; w: number; h: number } | null {
const { hostW, hostH, mediaW, mediaH, scale, ox, oy } = args;
if (hostW <= 1 || hostH <= 1 || mediaW <= 0 || mediaH <= 0) return null;
const fit = Math.min(hostW / mediaW, hostH / mediaH);
const s = fit * Math.max(1, scale);
const w = mediaW * s;
const h = mediaH * s;
return {
x: hostW / 2 - ox * w,
y: hostH / 2 - oy * h,
w,
h,
};
rotationDeg?: MediaRotationDeg;
mode?: 'contain' | 'cover';
}): ContainMediaRect | null {
return containMediaLayout(args)?.contentRect ?? null;
}