Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions web/src/components/preview/Preview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const store = vi.hoisted(() => ({
isPlaying: false,
isScrubbing: false,
previewMediaId: null as string | null,
selectedClipIds: new Set<string>(),
setCurrentFrame: vi.fn(),
setScrubbing: vi.fn(),
togglePlay: vi.fn(),
Expand Down Expand Up @@ -109,6 +110,7 @@ describe("Preview timeline rendering", () => {
isPlaying: false,
isScrubbing: false,
previewMediaId: null,
selectedClipIds: new Set<string>(),
};
store.media.items = [
{ id: "base", name: "base", type: "video", duration: 10, hasAudio: true, path: "/base.mov" },
Expand Down Expand Up @@ -187,4 +189,67 @@ describe("Preview timeline rendering", () => {
expect(html).toContain("<img");
expect(html).toContain("pointer-events:none");
});

// Note: only the NEGATIVE guard cases are provable here. `renderToStaticMarkup`
// never runs `useEffect`, so the ResizeObserver that measures `stageSize` (and
// therefore `fittedCanvas`, Preview.tsx:156) never fires — `fittedCanvas` is
// always null in this test file, and TransformOverlay correctly (and
// deliberately) renders nothing for a degenerate canvas. The POSITIVE
// rendering path — given a real canvasPx — is covered by TransformOverlay.test.tsx,
// which renders the component directly instead of through Preview's sizing.
describe("Transform overlay mount guard (T3-10)", () => {
beforeEach(() => {
store.timeline = timeline([
track({
id: "v1",
type: "video",
clips: [
clip({ id: "base-clip", mediaRef: "base", mediaType: "video" }),
clip({ id: "text-clip", mediaRef: "base", mediaType: "text", startFrame: 10 }),
],
}),
track({
id: "a1",
type: "audio",
clips: [clip({ id: "audio-clip", mediaRef: "base", mediaType: "audio", startFrame: 20 })],
}),
]);
});

it("stays hidden when no clip is selected", () => {
store.ui.selectedClipIds = new Set();

const html = renderToStaticMarkup(<Preview />);

expect(html).not.toContain('data-testid="transform-overlay"');
});

it("stays hidden when more than one clip is selected (marquee)", () => {
store.ui.selectedClipIds = new Set(["base-clip", "text-clip"]);

const html = renderToStaticMarkup(<Preview />);

expect(html).not.toContain('data-testid="transform-overlay"');
});

it("stays hidden when the single selected clip is on an audio track", () => {
store.ui.selectedClipIds = new Set(["audio-clip"]);

const html = renderToStaticMarkup(<Preview />);

expect(html).not.toContain('data-testid="transform-overlay"');
});

it("stays hidden while viewing a media-library preview, even with a visual clip selected", () => {
store.ui.selectedClipIds = new Set(["base-clip"]);
store.ui.previewMediaId = "still";
store.media.items = [
{ id: "still", name: "still", type: "image", duration: 1, hasAudio: false, path: "/still.png" },
];

const html = renderToStaticMarkup(<Preview />);

expect(html).not.toContain('data-testid="transform-overlay"');
});
});
});
26 changes: 26 additions & 0 deletions web/src/components/preview/Preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { useMediaStore } from "../../store/mediaStore";
import { formatTimecode, totalFrames } from "../../lib/geometry";
import { assetUrl } from "../../lib/asset";
import { TimelinePlayback } from "./TimelinePlaybackLayer";
import { TransformOverlay } from "./TransformOverlay";
import { aspectFitBox, timelinePreviewCanvasStyle } from "./previewLayerStyles";
import { useT } from "../../i18n";
import {
Expand All @@ -32,6 +33,7 @@ import {
} from "../../lib/api";
import { rustEngineEnabled } from "./rustEngine";
import { shouldUseRustEngine } from "./timelinePlayback";
import { findSelectedVisualClip, mediaCanvasAspect } from "../../lib/clip";
import type { MediaItem } from "../../lib/types";

export function Preview() {
Expand All @@ -43,10 +45,27 @@ export function Preview() {
const setScrubbing = useEditorUiStore((s) => s.setScrubbing);
const togglePlayTimeline = useEditorUiStore((s) => s.togglePlay);
const previewMediaId = useEditorUiStore((s) => s.previewMediaId);
const selectedClipIds = useEditorUiStore((s) => s.selectedClipIds);
const pushToast = useEditorUiStore((s) => s.pushToast);
const previewItem = useMediaStore((s) =>
previewMediaId ? s.items.find((m) => m.id === previewMediaId) ?? null : null,
);
// The Transform overlay's target clip + media aspect (Inspector.tsx:295-301's
// same mediaCanvasAspect lookup pattern, reused so both surfaces agree on
// aspect-preserving resize). `transformClip` is null whenever upstream's
// TransformOverlayView.selectedClip would also be nil (see clip.ts's
// findSelectedVisualClip doc comment) — resolved unconditionally here (cheap)
// and gated at render time alongside the timeline-tab / has-content checks.
const transformClip = findSelectedVisualClip(timeline, selectedClipIds);
const transformMediaItem = useMediaStore((s) =>
transformClip ? s.items.find((m) => m.id === transformClip.mediaRef) ?? null : null,
);
const transformMediaAspect = mediaCanvasAspect(
transformMediaItem?.width,
transformMediaItem?.height,
timeline.width,
timeline.height,
);

// Media-preview playback is driven by the app transport (more capable than the
// <video>'s native controls), so the <video>/<audio> renders WITHOUT controls
Expand Down Expand Up @@ -179,6 +198,13 @@ export function Preview() {
<>
<TimelinePlayback timeline={timeline} fps={fps} />
<TimelineRustOverlay />
{transformClip && fittedCanvas && (
<TransformOverlay
clip={transformClip}
canvasPx={fittedCanvas}
mediaAspect={transformMediaAspect}
/>
)}
</>
) : (
// Empty timeline: a framed 16:9 canvas surface placeholder.
Expand Down
135 changes: 135 additions & 0 deletions web/src/components/preview/TransformOverlay.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* TransformOverlay (T3-10) render tests. Renders the component directly with
* an explicit `canvasPx`, instead of through <Preview/>'s ResizeObserver-driven
* `fittedCanvas` — `renderToStaticMarkup` never runs `useEffect`, so that value
* is always null in Preview.test.tsx and can't exercise the positive render
* path there. See Preview.test.tsx's "Transform overlay mount guard" describe
* block for the (negative-only) guard coverage at the Preview.tsx level.
*/
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import type { Clip } from "../../lib/types";

const uiStore = vi.hoisted(() => ({ activeFrame: 0 }));

vi.mock("../../store/uiStore", () => ({
useEditorUiStore: Object.assign((selector: (state: typeof uiStore) => unknown) => selector(uiStore), {
getState: () => uiStore,
}),
}));

vi.mock("../../store/editActions", () => ({
setClipProperties: vi.fn(),
}));

import { TransformOverlay } from "./TransformOverlay";

function clip(over: Partial<Clip> = {}): Clip {
return {
id: "clip",
mediaRef: "asset",
mediaType: "video",
sourceClipType: "video",
startFrame: 0,
durationFrames: 100,
trimStartFrame: 0,
trimEndFrame: 0,
speed: 1,
volume: 1,
fadeInFrames: 0,
fadeOutFrames: 0,
fadeInInterpolation: "smooth",
fadeOutInterpolation: "smooth",
opacity: 1,
transform: {
centerX: 0.5,
centerY: 0.5,
width: 0.4,
height: 0.3,
rotation: 0,
flipHorizontal: false,
flipVertical: false,
},
crop: { left: 0, top: 0, right: 0, bottom: 0 },
...over,
};
}

describe("TransformOverlay", () => {
it("positions and sizes the box from transform * canvasPx", () => {
const html = renderToStaticMarkup(
<TransformOverlay clip={clip()} canvasPx={{ width: 1000, height: 500 }} mediaAspect={null} />,
);

expect(html).toContain('data-testid="transform-overlay"');
expect(html).toContain("left:500"); // centerX(0.5) * 1000
expect(html).toContain("top:250"); // centerY(0.5) * 500
expect(html).toContain("width:400"); // width(0.4) * 1000
expect(html).toContain("height:150"); // height(0.3) * 500
expect(html).toContain("translate(-50%, -50%) rotate(0deg)");
});

it("rotates the box via the transform's rotation degrees", () => {
const html = renderToStaticMarkup(
<TransformOverlay
clip={clip({ transform: { ...clip().transform, rotation: 45 } })}
canvasPx={{ width: 1000, height: 500 }}
mediaAspect={null}
/>,
);

expect(html).toContain("rotate(45deg)");
});

it("renders 4 corner handles at the OpenTake spacing/opacity tokens matching upstream AppTheme.Spacing.smMd / Opacity.strong", () => {
const html = renderToStaticMarkup(
<TransformOverlay clip={clip()} canvasPx={{ width: 1000, height: 500 }} mediaAspect={null} />,
);

expect(html.match(/cursor:nwse-resize/g)?.length).toBe(2); // topLeft + bottomRight
expect(html.match(/cursor:nesw-resize/g)?.length).toBe(2); // topRight + bottomLeft
expect(html).toContain("width:8px;height:8px"); // AppTheme.Spacing.smMd
expect(html).toContain("background:rgba(255,255,255,0.55)"); // white @ Opacity.strong
expect(html).toContain("border:1px solid rgba(255,255,255,0.55)"); // BorderWidth.thin
});

it("keeps the outer container pointer-events:none and only the move-surface/handles pointer-events:auto", () => {
const html = renderToStaticMarkup(
<TransformOverlay clip={clip()} canvasPx={{ width: 1000, height: 500 }} mediaAspect={null} />,
);
const overlayStart = html.indexOf('data-testid="transform-overlay"');

// The outer <div data-testid=...> itself carries pointer-events:none.
expect(html.slice(overlayStart, overlayStart + 300)).toContain("pointer-events:none");
// 1 move-surface + 4 corner handles = 5 pointer-events:auto elements.
expect(html.match(/pointer-events:auto/g)?.length).toBe(5);
});

it("follows a live keyframe track (sampledTransform), not the static transform, when one is active", () => {
uiStore.activeFrame = 60;
const animated = clip({
startFrame: 0,
positionTrack: {
keyframes: [{ frame: 60, value: { a: 0.1, b: 0.2 }, interpolationOut: "hold" }],
},
});

const html = renderToStaticMarkup(
<TransformOverlay clip={animated} canvasPx={{ width: 1000, height: 1000 }} mediaAspect={null} />,
);

// topLeft (0.1, 0.2) + half of the static size (0.4/2, 0.3/2) = center (0.3, 0.35).
expect(html).toContain("left:300");
expect(html).toContain("top:350");
uiStore.activeFrame = 0;
});

it("renders nothing for a degenerate (zero-size) canvas", () => {
const html = renderToStaticMarkup(
<TransformOverlay clip={clip()} canvasPx={{ width: 0, height: 0 }} mediaAspect={null} />,
);

expect(html).toBe("");
});
});
Loading
Loading