Files
openmaic/OpenMAIC/tests/video-export/geometry.test.ts
2026-08-16 14:58:47 +08:00

212 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, it } from 'vitest';
import {
findElementGeometry,
getElementPercentageGeometry,
applyGeometry,
resolveEffectGeometry,
resolveVideoPlacement,
type EffectSegment,
type VideoSegment,
type VideoTimelineScene,
} from '@/lib/video-export';
import { el, slide, spotlight } from './helpers';
describe('geometry helper (pure)', () => {
it('computes percentage geometry against the fixed 1000 x 562.5 base', () => {
const g = getElementPercentageGeometry(
el('e1', { left: 100, top: 100, width: 200, height: 100 }),
)!;
expect(g.x).toBeCloseTo(10, 5);
expect(g.y).toBeCloseTo(17.7778, 3);
expect(g.w).toBeCloseTo(20, 5);
expect(g.h).toBeCloseTo(17.7778, 3);
expect(g.centerX).toBeCloseTo(20, 5);
expect(g.centerY).toBeCloseTo(26.6667, 3);
});
it('finds an element by id and returns null for a miss', () => {
const elements = [el('e1', { left: 0, top: 0, width: 100, height: 100 })];
expect(findElementGeometry(elements, 'e1')).not.toBeNull();
expect(findElementGeometry(elements, 'nope')).toBeNull();
});
});
const effect = (elementId: string): EffectSegment => ({
actionId: 'sp',
actionIndex: 0,
type: 'spotlight',
descriptorId: 'spotlight.v1',
startMs: 0,
durationMs: 100,
elementId,
geometry: null,
params: { dimness: 0.5 },
degraded: false,
});
describe('resolveEffectGeometry', () => {
it('attaches geometry when the element resolves', () => {
const elements = [el('e1', { left: 0, top: 0, width: 100, height: 100 })];
const { effect: out, unresolved } = resolveEffectGeometry(effect('e1'), elements);
expect(unresolved).toBe(false);
expect(out.geometry).not.toBeNull();
expect(out.degraded).toBe(false);
});
it('degrades (geometry null) when the element is missing or there are no elements', () => {
expect(resolveEffectGeometry(effect('e1'), []).unresolved).toBe(true);
expect(resolveEffectGeometry(effect('e1'), undefined).effect.degraded).toBe(true);
});
});
describe('applyGeometry — across scenes', () => {
it('resolves present elements and emits unresolved-element diagnostics for misses', () => {
const source = [
slide('s0', [spotlight('sp', 'e1')], {
elements: [el('e1', { left: 0, top: 0, width: 100, height: 100 })],
}),
slide('s1', [spotlight('sp2', 'ghost')], { elements: [] }),
];
const timelineScenes: VideoTimelineScene[] = [
{ ...baseScene('s0', 0), effects: [effect('e1')] },
{
...baseScene('s1', 1),
effects: [{ ...effect('ghost'), actionId: 'sp2', elementId: 'ghost' }],
},
];
const { scenes, diagnostics } = applyGeometry(timelineScenes, source);
expect(scenes[0].effects[0].geometry).not.toBeNull();
expect(scenes[0].effects[0].degraded).toBe(false);
expect(scenes[1].effects[0].geometry).toBeNull();
expect(scenes[1].effects[0].degraded).toBe(true);
expect(diagnostics).toEqual([
expect.objectContaining({ code: 'unresolved-element', sceneId: 's1', actionId: 'sp2' }),
]);
});
});
/**
* Issue #867 item 5 — the outer-box vs content-box delta. The pure calc places a
* spotlight on the element's authored box; the live overlay (and the frame PNG)
* measures the `.element-content` box, which for horizontal text is auto-height
* plus 10px content padding. This suite quantifies that delta and proves the
* compiler prefers a supplied GeometryProbe (measured content box) over the
* authored box, degrading to the pure calc only on a probe miss.
*/
describe('applyGeometry — GeometryProbe (content-box) vs authored box', () => {
// An authored 200×100 text box at (100,100). At 10px padding + a typical
// 2-line auto-height, the rendered content box measures taller and inset.
const authored = el('e1', { left: 100, top: 100, width: 200, height: 100 });
it('quantifies the delta the probe corrects for a padded auto-height text box', () => {
const outerBox = getElementPercentageGeometry(authored)!;
// Content box: 10px padding on all sides shrinks the box and shifts its
// origin; auto-height makes it 140px tall (2 wrapped lines) rather than 100.
const paddedLeft = 110; // 100 + 10
const paddedTop = 110;
const contentW = 180; // 200 - 2*10
const contentH = 140; // measured, taller than authored 100
const contentBox = getElementPercentageGeometry(
el('e1', { left: paddedLeft, top: paddedTop, width: contentW, height: contentH }),
)!;
// The delta is real and non-trivial in 0100 space — this is the misalignment
// users saw. Assert each axis moved so a regression that drops the probe fails.
expect(Math.abs(contentBox.x - outerBox.x)).toBeGreaterThan(0.5);
expect(Math.abs(contentBox.y - outerBox.y)).toBeGreaterThan(0.5);
expect(Math.abs(contentBox.h - outerBox.h)).toBeGreaterThan(5);
expect(contentBox.centerY).not.toBeCloseTo(outerBox.centerY, 1);
});
it('prefers the measured content-box geometry over the authored box', () => {
const measured = { x: 11, y: 19.5, w: 18, h: 24.9, centerX: 20, centerY: 31.95 };
const probe = {
contentGeometry: (elementId: string) => (elementId === 'e1' ? measured : null),
};
const source = [slide('s0', [spotlight('sp', 'e1')], { elements: [authored] })];
const timelineScenes: VideoTimelineScene[] = [
{ ...baseScene('s0', 0), effects: [effect('e1')] },
];
const { scenes } = applyGeometry(timelineScenes, source, probe);
expect(scenes[0].effects[0].geometry).toEqual(measured);
expect(scenes[0].effects[0].degraded).toBe(false);
});
it('falls back to the authored box when the probe returns null', () => {
const probe = { contentGeometry: () => null };
const source = [slide('s0', [spotlight('sp', 'e1')], { elements: [authored] })];
const timelineScenes: VideoTimelineScene[] = [
{ ...baseScene('s0', 0), effects: [effect('e1')] },
];
const { scenes } = applyGeometry(timelineScenes, source, probe);
expect(scenes[0].effects[0].geometry).toEqual(getElementPercentageGeometry(authored));
expect(scenes[0].effects[0].degraded).toBe(false);
});
it('uses measured geometry for an unrotated video clip', () => {
const unrotated = el('v1', { left: 100, top: 100, width: 200, height: 100 });
const measured = { x: 11, y: 19.5, w: 18, h: 15, centerX: 20, centerY: 27 };
const seg: VideoSegment = {
actionId: 'pv',
actionIndex: 0,
startMs: 0,
durationMs: 100,
elementId: 'v1',
geometry: null,
rotate: 0,
present: true,
degraded: false,
durationSource: 'stored',
};
const { video } = resolveVideoPlacement(seg, [unrotated], measured);
expect(video.geometry).toEqual(measured); // rendered box wins for a zero rotation
expect(video.rotate).toBe(0);
expect(video.degraded).toBe(false);
});
it('falls back to the authored box for a rotated video, so rotation is never doubled', () => {
// The measured box is the AABB of the already-rotated element; re-applying
// `rotate` to it would rotate the clip twice. A rotated element must use its
// authored box + rotate — the single, un-doubled source of truth.
const rotated = el('v1', { left: 100, top: 100, width: 200, height: 100, rotate: 30 });
const measured = { x: 11, y: 19.5, w: 18, h: 15, centerX: 20, centerY: 27 };
const seg: VideoSegment = {
actionId: 'pv',
actionIndex: 0,
startMs: 0,
durationMs: 100,
elementId: 'v1',
geometry: null,
rotate: 0,
present: true,
degraded: false,
durationSource: 'stored',
};
const { video } = resolveVideoPlacement(seg, [rotated], measured);
expect(video.geometry).toEqual(getElementPercentageGeometry(rotated)); // authored box, not measured
expect(video.rotate).toBe(30); // rotation from the authored element, applied once
expect(video.degraded).toBe(false);
});
});
function baseScene(id: string, index: number): VideoTimelineScene {
return {
id,
index,
title: id,
type: 'slide',
startMs: 0,
durationMs: 0,
supported: true,
base: { kind: 'slide-snapshot' },
visuals: [],
narration: [],
effects: [],
videos: [],
markers: [],
};
}