diff --git a/web/src/components/preview/Preview.test.tsx b/web/src/components/preview/Preview.test.tsx index 245dd7e..18a05a5 100644 --- a/web/src/components/preview/Preview.test.tsx +++ b/web/src/components/preview/Preview.test.tsx @@ -11,6 +11,7 @@ const store = vi.hoisted(() => ({ isPlaying: false, isScrubbing: false, previewMediaId: null as string | null, + selectedClipIds: new Set(), setCurrentFrame: vi.fn(), setScrubbing: vi.fn(), togglePlay: vi.fn(), @@ -109,6 +110,7 @@ describe("Preview timeline rendering", () => { isPlaying: false, isScrubbing: false, previewMediaId: null, + selectedClipIds: new Set(), }; store.media.items = [ { id: "base", name: "base", type: "video", duration: 10, hasAudio: true, path: "/base.mov" }, @@ -187,4 +189,67 @@ describe("Preview timeline rendering", () => { expect(html).toContain(" { + 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(); + + 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(); + + 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(); + + 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(); + + expect(html).not.toContain('data-testid="transform-overlay"'); + }); + }); }); diff --git a/web/src/components/preview/Preview.tsx b/web/src/components/preview/Preview.tsx index b53d84c..286ca68 100644 --- a/web/src/components/preview/Preview.tsx +++ b/web/src/components/preview/Preview.tsx @@ -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 { @@ -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() { @@ -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 //