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
+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;
}