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
+45 -21
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,23 +2992,44 @@ function SceneListCard({
</div>
) : previewUrl && scene.previewAssetType === 'video' ? (
<div className={styles.sceneThumbInner}>
<video
src={previewUrl}
muted
playsInline
preload="metadata"
draggable={false}
className={styles.sceneThumbVideo}
onLoadedData={(e) => {
const v = e.currentTarget;
try {
v.currentTime = 0;
v.pause();
} catch {
// ignore
}
}}
/>
{scene.previewRotationDeg === 0 ? (
<video
src={previewUrl}
muted
playsInline
preload="metadata"
draggable={false}
className={styles.sceneThumbVideo}
onLoadedData={(e) => {
const v = e.currentTarget;
try {
v.currentTime = 0;
v.pause();
} catch {
// ignore
}
}}
/>
) : (
<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 />
+40 -16
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,22 +261,45 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
)}
</div>
) : previewUrl && data.previewAssetType === 'video' ? (
<video
src={previewUrl}
muted
playsInline
preload="metadata"
className={styles.videoCover}
onLoadedData={(e) => {
const v = e.currentTarget;
try {
v.currentTime = 0;
v.pause();
} catch {
// ignore
}
}}
/>
<div className={styles.previewFill}>
{data.previewRotationDeg === 0 ? (
<video
src={previewUrl}
muted
playsInline
preload="metadata"
className={styles.videoCover}
onLoadedData={(e) => {
const v = e.currentTarget;
try {
v.currentTime = 0;
v.pause();
} catch {
// ignore
}
}}
/>
) : (
<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':