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:
@@ -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)}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user