Skip to content
Draft
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
109 changes: 109 additions & 0 deletions packages/studio/src/components/EditorShell.selectionSync.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// @vitest-environment happy-dom

import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { EditorShell } from "./EditorShell";

const hookMocks = vi.hoisted(() => ({
useTimelineSelectionPreviewSync: vi.fn(),
}));

vi.mock("../hooks/useTimelineSelectionPreviewSync", () => hookMocks);
vi.mock("../contexts/StudioContext", () => ({
useStudioPlaybackContext: () => ({
captionEditMode: false,
refreshKey: 0,
refreshPreviewDocumentVersion: vi.fn(),
timelineElements: [],
}),
useStudioShellContext: () => ({
projectId: "project-1",
activeCompPath: "index.html",
setActiveCompPath: vi.fn(),
handlePreviewIframeRef: vi.fn(),
showToast: vi.fn(),
}),
}));
vi.mock("../contexts/DomEditContext", () => ({
useDomEditActionsContext: () => ({
handleTimelineElementSelect: vi.fn(),
buildDomSelectionForTimelineElement: vi.fn(),
applyDomSelection: vi.fn(),
applyMarqueeSelection: vi.fn(),
}),
useDomEditSelectionContext: () => ({
domEditSelection: null,
domEditGroupSelections: [],
}),
}));
vi.mock("./nle/NLEContext", () => ({
NLEProvider: ({ children }: { children: React.ReactNode }) => children,
useNLEContext: () => ({
compositionStack: [],
updateCompositionStack: vi.fn(),
containerRef: { current: null },
}),
}));
vi.mock("./nle/useTimelineEditCallbacks", () => ({
useTimelineEditCallbacks: () => ({}),
}));
vi.mock("./nle/PreviewPane", () => ({ PreviewPane: () => null }));
vi.mock("./nle/PreviewOverlays", () => ({ PreviewOverlays: () => null }));
vi.mock("./nle/TimelinePane", () => ({ TimelinePane: () => null }));
vi.mock("../captions/components/CaptionTimeline", () => ({ CaptionTimeline: () => null }));
vi.mock("./StudioFeedbackBar", () => ({ StudioFeedbackBar: () => null }));

Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });

afterEach(() => {
document.body.innerHTML = "";
hookMocks.useTimelineSelectionPreviewSync.mockClear();
});

describe("EditorShell timeline selection sync", () => {
it("keeps the timeline store mirrored into the preview selection", () => {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);

act(() => {
root.render(
<EditorShell
left={null}
right={null}
timelineToolbar={null}
renderClipContent={() => null}
handleTimelineElementDelete={vi.fn()}
handleTimelineAssetDrop={vi.fn()}
handleTimelineFileDrop={vi.fn()}
handleTimelineElementMove={vi.fn()}
handleTimelineElementsMove={vi.fn()}
handleTimelineElementResize={vi.fn()}
handleTimelineGroupResize={vi.fn()}
handleToggleTrackHidden={vi.fn()}
handleBlockedTimelineEdit={vi.fn()}
handleTimelineElementSplit={vi.fn()}
handleRazorSplit={vi.fn()}
handleRazorSplitAll={vi.fn()}
setCompIdToSrc={vi.fn()}
setCompositionLoading={vi.fn()}
shouldShowMotionPath={false}
shouldShowSelectedDomBounds={false}
/>,
);
});

expect(hookMocks.useTimelineSelectionPreviewSync).toHaveBeenCalledOnce();
expect(hookMocks.useTimelineSelectionPreviewSync).toHaveBeenCalledWith(
expect.objectContaining({
activeCompPath: "index.html",
timelineElements: [],
domEditSelection: null,
domEditGroupSelections: [],
}),
);

act(() => root.unmount());
});
});
36 changes: 31 additions & 5 deletions packages/studio/src/components/EditorShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ import { NLEProvider, useNLEContext } from "./nle/NLEContext";
import { CaptionTimeline } from "../captions/components/CaptionTimeline";
import { StudioFeedbackBar } from "./StudioFeedbackBar";
import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
import { useDomEditActionsContext } from "../contexts/DomEditContext";
import { useDomEditActionsContext, useDomEditSelectionContext } from "../contexts/DomEditContext";
import { TimelineEditProvider } from "../contexts/TimelineEditContext";
import type { TimelineElement } from "../player";
import { usePlayerStore, type TimelineElement } from "../player";
import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
import type { GestureRecordingState } from "./editor/GestureRecordControl";
import { useTimelineSelectionPreviewSync } from "../hooks/useTimelineSelectionPreviewSync";

type RenderClipContent = (
element: TimelineElement,
Expand Down Expand Up @@ -99,10 +100,35 @@ export function EditorShell({
blockPreview,
gestureOverlay,
}: EditorShellProps) {
const { projectId, activeCompPath, setActiveCompPath, handlePreviewIframeRef } =
const { projectId, activeCompPath, setActiveCompPath, handlePreviewIframeRef, showToast } =
useStudioShellContext();
const { refreshKey, captionEditMode, refreshPreviewDocumentVersion } = useStudioPlaybackContext();
const { handleTimelineElementSelect } = useDomEditActionsContext();
const { refreshKey, captionEditMode, refreshPreviewDocumentVersion, timelineElements } =
useStudioPlaybackContext();
const {
handleTimelineElementSelect,
buildDomSelectionForTimelineElement,
applyDomSelection,
applyMarqueeSelection,
} = useDomEditActionsContext();
const { domEditSelection, domEditGroupSelections } = useDomEditSelectionContext();
const selectedElementId = usePlayerStore((state) => state.selectedElementId);
const selectedElementIds = usePlayerStore((state) => state.selectedElementIds);
const reportTimelineSelectionNotFound = useCallback(() => {
showToast("The selected clip is not available in the preview yet.", "info");
}, [showToast]);

useTimelineSelectionPreviewSync({
selectedElementId,
selectedElementIds,
timelineElements,
domEditSelection,
domEditGroupSelections,
activeCompPath,
buildDomSelectionForTimelineElement,
applyDomSelection,
applyMarqueeSelection,
onSelectionNotFound: reportTimelineSelectionNotFound,
});

const timelineEditCallbacks = useTimelineEditCallbacks({
handleTimelineElementMove,
Expand Down
2 changes: 1 addition & 1 deletion packages/studio/src/hooks/useDomSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,8 +370,8 @@ export function useDomSelection({

const handleTimelineElementSelect = useCallback(
async (element: TimelineElement | null) => {
if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
const seq = ++timelineSelectSeqRef.current;
if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
if (!element) {
applyDomSelection(null, { revealPanel: false });
return;
Expand Down
38 changes: 37 additions & 1 deletion packages/studio/src/hooks/useRenderClipContent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { CompositionThumbnail, VideoThumbnail } from "../player";
import { AudioWaveform } from "../player/components/AudioWaveform";
import type { TimelineClipRenderContext } from "../player/components/TimelineTypes";
import { usePlayerStore, type TimelineElement } from "../player/store/playerStore";
import { normalizeCompositionSrc } from "./useRenderClipContent";
import { useRenderClipContent } from "./useRenderClipContent";
Expand Down Expand Up @@ -68,6 +69,7 @@ describe("useRenderClipContent", () => {
function renderClipContent(
el: TimelineElement,
activePreviewUrl: string | null = "/api/projects/my-project/preview",
context?: TimelineClipRenderContext,
): ReactNode {
const host = document.createElement("div");
document.body.append(host);
Expand All @@ -81,7 +83,7 @@ describe("useRenderClipContent", () => {
activePreviewUrl,
effectiveTimelineDuration: 12,
});
content = render(el, { clip: "#222", label: "#fff" });
content = render(el, { clip: "#222", label: "#fff" }, context);
return null;
}

Expand Down Expand Up @@ -169,4 +171,38 @@ describe("useRenderClipContent", () => {
}
}
});

it("forwards the viewport priority and interaction detail to media work", () => {
usePlayerStore.setState({ thumbnailMode: "adaptive", timelineSessionEpoch: 7 });

const content = renderClipContent(
{
id: "clip-video",
tag: "video",
start: 0,
duration: 4,
track: 0,
src: "assets/clip.mp4",
},
null,
{ priority: "interaction", rich: true },
);

expect(
isValidElement<{
projectId: string;
sessionEpoch: number;
priority: string;
rich: boolean;
}>(content),
).toBe(true);
if (isValidElement(content)) {
expect(content.props).toMatchObject({
projectId: "my-project",
sessionEpoch: 7,
priority: "interaction",
rich: true,
});
}
});
});
56 changes: 51 additions & 5 deletions packages/studio/src/hooks/useRenderClipContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useCallback, type ReactNode } from "react";
import { createElement } from "react";
import { CompositionThumbnail, VideoThumbnail } from "../player";
import type { TimelineElement } from "../player";
import type { TimelineClipRenderContext } from "../player/components/TimelineTypes";
import { AudioWaveform } from "../player/components/AudioWaveform";
import { ImageThumbnail } from "../player/components/ImageThumbnail";
import { encodePreviewPath, resolveMediaPreviewUrl } from "../player/components/thumbnailUtils";
Expand Down Expand Up @@ -53,7 +54,13 @@ function trimFractions(el: TimelineElement): { start?: number; end?: number } {
* Build the waveform element for an audio clip, windowing the rendered peaks to
* the trimmed source slice so the bars track the clip edges.
*/
function renderAudioClip(el: TimelineElement, pid: string, labelColor: string): ReactNode {
function renderAudioClip(
el: TimelineElement,
pid: string,
sessionEpoch: number,
labelColor: string,
context: TimelineClipRenderContext,
): ReactNode {
const srcRelative = resolvePreviewRelative(el.src, pid);
// Encode each path segment (spaces, parens, U+202F, unicode) so the URL matches
// what the assets panel loads — a raw segment 404s. resolvePreviewRelative
Expand All @@ -73,6 +80,9 @@ function renderAudioClip(el: TimelineElement, pid: string, labelColor: string):
labelColor,
trimStartFraction: start,
trimEndFraction: end,
projectId: pid,
sessionEpoch,
priority: context.priority,
});
}

Expand All @@ -92,17 +102,24 @@ export function useRenderClipContent({
// Self-sourced so the adaptive policy gates thumbnail generation without App plumbing.
const thumbnailMode = usePlayerStore((s) => s.thumbnailMode);
const effectiveMode = effectiveThumbnailMode(thumbnailMode);
const sessionEpoch = usePlayerStore((s) => s.timelineSessionEpoch);
return useCallback(
// Pre-existing clip-content dispatcher; reduced by extracting renderAudioClip.
// fallow-ignore-next-line complexity
(el: TimelineElement, style: { clip: string; label: string }): ReactNode => {
(
el: TimelineElement,
style: { clip: string; label: string },
context: TimelineClipRenderContext = { priority: "visible", rich: false },
): ReactNode => {
const pid = projectIdRef.current;
if (!pid) return null;

// Thumbnail generation disabled (perf) -> plain clip bars. Audio still shows
// its waveform (cheap, not a frame thumbnail). Toggle: timeline toolbar.
if (effectiveMode === "hidden") {
return el.tag === "audio" ? renderAudioClip(el, pid, style.label) : null;
return el.tag === "audio"
? renderAudioClip(el, pid, sessionEpoch, style.label, context)
: null;
}

let compSrc = el.compositionSrc;
Expand All @@ -127,14 +144,18 @@ export function useRenderClipContent({

seekTime: 0,
duration: el.duration,
projectId: pid,
sessionEpoch,
priority: context.priority,
rich: context.rich,
});
}

// Audio clips — waveform visualization. Resolve these before the generic
// activePreviewUrl thumbnail branch; audio rows need waveform data, not a
// captured frame from the currently drilled composition preview.
if (el.tag === "audio") {
return renderAudioClip(el, pid, style.label);
return renderAudioClip(el, pid, sessionEpoch, style.label, context);
}

// When drilled into a composition, render all inner elements via
Expand All @@ -149,6 +170,10 @@ export function useRenderClipContent({
selectorIndex: el.selectorIndex,
seekTime: el.start,
duration: el.duration,
projectId: pid,
sessionEpoch,
priority: context.priority,
rich: context.rich,
});
}

Expand All @@ -168,13 +193,23 @@ export function useRenderClipContent({
imageSrc: mediaSrc,
label: "",
labelColor: style.label,
projectId: pid,
sessionEpoch,
priority: context.priority,
rich: context.rich,
});
}
return createElement(VideoThumbnail, {
videoSrc: mediaSrc,
label: "",
labelColor: style.label,
duration: el.duration,
sourceStart: el.playbackStart,
sourceRangeDuration: el.duration * (el.playbackRate ?? 1),
projectId: pid,
sessionEpoch,
priority: context.priority,
rich: context.rich,
});
}

Expand All @@ -188,11 +223,22 @@ export function useRenderClipContent({
selectorIndex: el.selectorIndex,
seekTime: el.start,
duration: el.duration,
projectId: pid,
sessionEpoch,
priority: context.priority,
rich: context.rich,
});
}

return null;
},
[projectIdRef, compIdToSrc, activePreviewUrl, effectiveTimelineDuration, effectiveMode],
[
projectIdRef,
compIdToSrc,
activePreviewUrl,
effectiveTimelineDuration,
effectiveMode,
sessionEpoch,
],
);
}
Loading
Loading