From dd4add54be3eac28e379bde5ceb431f9dfa2ff18 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 19 Sep 2026 22:16:44 -0400 Subject: [PATCH] fix(studio): publish desktop compatibility exports Expose range selection and the canonical loader from the built Studio package while retaining timeline editing seams. --- packages/studio/package-subpaths.json | 2 +- packages/studio/package.json | 4 +- .../src/components/nle/TimelinePane.tsx | 5 +- packages/studio/src/index.ts | 6 +- .../studio/src/player/components/Timeline.tsx | 12 ++-- .../src/player/components/TimelineTypes.ts | 3 + .../usePublishRangeSelection.test.tsx | 60 +++++++++++++++++++ .../components/usePublishRangeSelection.ts | 42 +++++++++++++ packages/studio/src/player/index.ts | 1 + .../studio/src/player/store/playerStore.ts | 4 ++ .../src/player/store/rangeSelectionSlice.ts | 13 ++++ packages/studio/vite.config.ts | 43 +++---------- 12 files changed, 147 insertions(+), 48 deletions(-) create mode 100644 packages/studio/src/player/components/usePublishRangeSelection.test.tsx create mode 100644 packages/studio/src/player/components/usePublishRangeSelection.ts create mode 100644 packages/studio/src/player/store/rangeSelectionSlice.ts diff --git a/packages/studio/package-subpaths.json b/packages/studio/package-subpaths.json index ac6ec74faf..2f7fd2004b 100644 --- a/packages/studio/package-subpaths.json +++ b/packages/studio/package-subpaths.json @@ -21,7 +21,7 @@ }, "./styles.css": { "source": "./src/styles/studio.css", - "runtime": "./dist/styles.css", + "runtime": "./dist/assets/styles.css", "types": null, "environments": ["browser"] }, diff --git a/packages/studio/package.json b/packages/studio/package.json index 79a4d578e2..9ef0a33a39 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/studio", - "version": "0.8.52", + "version": "0.8.53-desktop.0", "description": "", "repository": { "type": "git", @@ -42,7 +42,7 @@ "types": "./dist/styles/tailwind-preset.d.ts" }, "./theme.css": "./src/styles/theme.css", - "./styles.css": "./dist/styles.css", + "./styles.css": "./dist/assets/styles.css", "./package.json": "./package.json" }, "main": "./dist/index.js", diff --git a/packages/studio/src/components/nle/TimelinePane.tsx b/packages/studio/src/components/nle/TimelinePane.tsx index 85680c5a93..c1c82b0cca 100644 --- a/packages/studio/src/components/nle/TimelinePane.tsx +++ b/packages/studio/src/components/nle/TimelinePane.tsx @@ -1,6 +1,6 @@ import { useCallback, type ReactNode } from "react"; import { Timeline } from "../../player"; -import type { TimelineElement } from "../../player"; +import type { TimelineElement, TimelineTimeRange } from "../../player"; import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing"; import { useTimelineEditContext } from "../../contexts/TimelineEditContext"; import { trackStudioExpandedClipEdit } from "../../telemetry/events"; @@ -104,6 +104,7 @@ export interface TimelinePaneProps { ) => Promise | void; onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; onSelectTimelineElement?: (element: TimelineElement | null) => void; + onRangeSelect?: (range: TimelineTimeRange | null) => void; /** Copy/paste/duplicate act on the store's own selection, not a passed * element, so unlike onDeleteElement they need no composition-basis wrapper. */ onCopyClip?: () => boolean; @@ -125,6 +126,7 @@ export function TimelinePane({ onCompositionDrop, onBlockedEditAttempt, onSelectTimelineElement, + onRangeSelect, onCopyClip, onPasteClip, onDuplicateClip, @@ -282,6 +284,7 @@ export function TimelinePane({ onBlockedEditAttempt={onBlockedEditAttempt} onSplitElement={handleSplitElement} onSelectElement={onSelectTimelineElement} + onRangeSelect={onRangeSelect} onCopyClip={onCopyClip} onPasteClip={onPasteClip} onDuplicateClip={onDuplicateClip} diff --git a/packages/studio/src/index.ts b/packages/studio/src/index.ts index dd9fa4433e..dc692dae2d 100644 --- a/packages/studio/src/index.ts +++ b/packages/studio/src/index.ts @@ -1,10 +1,12 @@ // UI primitives export { Button, buttonBase, buttonSizes, buttonVariants } from "./components/ui/Button"; export type { ButtonSize, ButtonVariant, PreviewState } from "./components/ui/Button"; +export { HyperframesLogo } from "./components/StudioHeader"; export { IconButton } from "./components/ui/IconButton"; export { Tab, TabPanel, Tabs, TabsList } from "./components/ui/Tabs"; +export { HyperframesLoader } from "./components/ui/HyperframesLoader"; +export type { HyperframesLoaderProps } from "./components/ui/HyperframesLoader"; export { Tooltip } from "./components/ui/Tooltip"; -export { HyperframesLogo } from "./components/StudioHeader"; export { cn } from "./components/ui/cn"; export { ContextMenu, @@ -49,7 +51,7 @@ export { liveTime, formatTime, } from "./player"; -export type { TimelineElement } from "./player"; +export type { TimelineElement, TimelineTimeRange } from "./player"; // Host overlays: draw over the preview in composition coordinates (see EditorShellProps.gestureOverlay) export { usePreviewCompositionRect } from "./components/editor/usePreviewCompositionRect"; diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index c50e8d362a..94199fe513 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -4,6 +4,7 @@ import { usePlayerStore, type TimelineElement } from "../store/playerStore"; import { useTimelineRowElements } from "../hooks/useTimelineRowElements"; import { defaultTimelineTheme } from "./timelineTheme"; import { useTimelineRangeSelection } from "./useTimelineRangeSelection"; +import { usePublishRangeSelection } from "./usePublishRangeSelection"; import { useTimelinePlayhead } from "./useTimelinePlayhead"; import { useTimelineZoom } from "./useTimelineZoom"; import { useTimelineAssetDrop } from "./timelineDragDrop"; @@ -80,6 +81,7 @@ export const Timeline = memo(function Timeline({ onBlockedEditAttempt: onBlockedEditAttemptOverride, onSplitElement: onSplitElementOverride, onSelectElement, + onRangeSelect, onCopyClip, onPasteClip, onDuplicateClip, @@ -167,10 +169,10 @@ export const Timeline = memo(function Timeline({ selectedElementIds, ); const expandedElementsRef = useRef(expandedElements); - expandedElementsRef.current = expandedElements; + expandedElementsRef.current = expandedElements; // oxlint-disable-line react/refs -- event handlers read the latest elements const ppsRef = useRef(100); const durationRef = useRef(effectiveDuration); - durationRef.current = effectiveDuration; + durationRef.current = effectiveDuration; // oxlint-disable-line react/refs -- event handlers read the latest duration const fitPpsRef = useRef(100); const { pinZoomBeforeEdit, @@ -214,7 +216,6 @@ export const Timeline = memo(function Timeline({ onMoveElements: pinnedOnMoveElements, }); const onContextMenuClip = useClipContextMenu(onSelectElement, dismissGapMenu, setClipContextMenu); - const { draggedClip, setDraggedClip, @@ -241,7 +242,6 @@ export const Timeline = memo(function Timeline({ refreshAfterLaneMove, sessionEpoch, }); - const assetDrop = useTimelineAssetDrop({ scrollRef, ppsRef, @@ -320,7 +320,6 @@ export const Timeline = memo(function Timeline({ setKfContextMenu, toggleSelectedKeyframe, }); - const { clipIndex, renderTimeRange, visibleTimeRange, pinnedClipIdentities } = useTimelineClipRenderWindow({ tracks, @@ -415,7 +414,8 @@ export const Timeline = memo(function Timeline({ contentOrigin, sessionEpoch, }); - setRangeSelectionRef.current = setRangeSelection; // stable ref consumed by useTimelineClipDrag + usePublishRangeSelection(rangeSelection, onRangeSelect); + setRangeSelectionRef.current = setRangeSelection; // oxlint-disable-line react/refs -- stable ref consumed by useTimelineClipDrag useTimelineSelectionLifecycle(expandedElements, selectedElementId, setShowPopover, () => setRangeSelection(null), diff --git a/packages/studio/src/player/components/TimelineTypes.ts b/packages/studio/src/player/components/TimelineTypes.ts index da7a727046..4d6aa3f83c 100644 --- a/packages/studio/src/player/components/TimelineTypes.ts +++ b/packages/studio/src/player/components/TimelineTypes.ts @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import type { TimelineElement } from "../store/playerStore"; +import type { TimelineTimeRange } from "../store/rangeSelectionSlice"; import type { TimelineDropCallbacks } from "./timelineCallbacks"; import type { TimelineTheme } from "./timelineTheme"; import type { TimelineEditOverrides } from "./useResolvedTimelineEditCallbacks"; @@ -23,6 +24,8 @@ export interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverri renderClipOverlay?: (element: TimelineElement) => ReactNode; onDeleteElement?: (element: TimelineElement) => Promise | void; onSelectElement?: (element: TimelineElement | null) => void; + /** Notification only; null when cleared. The value lives in usePlayerStore.rangeSelection. */ + onRangeSelect?: (range: TimelineTimeRange | null) => void; onCopyClip?: () => boolean; onPasteClip?: () => Promise; onDuplicateClip?: () => Promise; diff --git a/packages/studio/src/player/components/usePublishRangeSelection.test.tsx b/packages/studio/src/player/components/usePublishRangeSelection.test.tsx new file mode 100644 index 0000000000..22e825a3e1 --- /dev/null +++ b/packages/studio/src/player/components/usePublishRangeSelection.test.tsx @@ -0,0 +1,60 @@ +// @vitest-environment happy-dom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { usePlayerStore } from "../store/playerStore"; +import type { TimelineTimeRange } from "../store/rangeSelectionSlice"; +import type { TimelineRangeSelection } from "./timelineEditing"; +import { usePublishRangeSelection } from "./usePublishRangeSelection"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function mount(onRangeSelect: (range: TimelineTimeRange | null) => void) { + const host = document.createElement("div"); + const root = createRoot(host); + const Harness = ({ selection }: { selection: TimelineRangeSelection | null }) => { + usePublishRangeSelection(selection, onRangeSelect); + return null; + }; + const render = (selection: TimelineRangeSelection | null) => + act(() => root.render()); + return { render, unmount: () => act(() => root.unmount()) }; +} + +const selection = (start: number, end: number): TimelineRangeSelection => ({ + start, + end, + anchorX: 0, + anchorY: 0, +}); + +describe("usePublishRangeSelection", () => { + afterEach(() => usePlayerStore.setState({ rangeSelection: null })); + + it("publishes an ordered range to the store and the host, and clears with null", () => { + const onRangeSelect = vi.fn(); + const view = mount(onRangeSelect); + + view.render(selection(3, 1)); + expect(usePlayerStore.getState().rangeSelection).toEqual({ t0: 1, t1: 3 }); + expect(onRangeSelect).toHaveBeenLastCalledWith({ t0: 1, t1: 3 }); + + view.render(null); + expect(usePlayerStore.getState().rangeSelection).toBeNull(); + expect(onRangeSelect).toHaveBeenLastCalledWith(null); + view.unmount(); + }); + + it("does not re-notify for an unchanged range and preserves a newer store value on unmount", () => { + const onRangeSelect = vi.fn(); + const view = mount(onRangeSelect); + + view.render(selection(1, 3)); + view.render(selection(3, 1)); + expect(onRangeSelect).toHaveBeenCalledTimes(1); + + usePlayerStore.setState({ rangeSelection: { t0: 9, t1: 10 } }); + view.unmount(); + expect(usePlayerStore.getState().rangeSelection).toEqual({ t0: 9, t1: 10 }); + }); +}); diff --git a/packages/studio/src/player/components/usePublishRangeSelection.ts b/packages/studio/src/player/components/usePublishRangeSelection.ts new file mode 100644 index 0000000000..5d13841379 --- /dev/null +++ b/packages/studio/src/player/components/usePublishRangeSelection.ts @@ -0,0 +1,42 @@ +import { useEffect, useRef } from "react"; +import { usePlayerStore } from "../store/playerStore"; +import type { TimelineTimeRange } from "../store/rangeSelectionSlice"; +import type { TimelineRangeSelection } from "./timelineEditing"; + +/** Mirrors the gesture's range into the store and notifies the host. */ +export function usePublishRangeSelection( + rangeSelection: TimelineRangeSelection | null, + onRangeSelect?: (range: TimelineTimeRange | null) => void, +) { + const onRangeSelectRef = useRef(onRangeSelect); + const publishedRangeRef = useRef(null); + + useEffect(() => { + onRangeSelectRef.current = onRangeSelect; + }, [onRangeSelect]); + + useEffect(() => { + const range = rangeSelection + ? { + t0: Math.min(rangeSelection.start, rangeSelection.end), + t1: Math.max(rangeSelection.start, rangeSelection.end), + } + : null; + const store = usePlayerStore.getState(); + const current = store.rangeSelection; + if (current?.t0 === range?.t0 && current?.t1 === range?.t1) return; + publishedRangeRef.current = range; + usePlayerStore.setState({ rangeSelection: range }); + onRangeSelectRef.current?.(range); + }, [rangeSelection]); + + useEffect( + () => () => { + const current = usePlayerStore.getState().rangeSelection; + const published = publishedRangeRef.current; + if (current?.t0 !== published?.t0 || current?.t1 !== published?.t1) return; + usePlayerStore.setState({ rangeSelection: null }); + }, + [], + ); +} diff --git a/packages/studio/src/player/index.ts b/packages/studio/src/player/index.ts index 89417ac576..b8852fe5ba 100644 --- a/packages/studio/src/player/index.ts +++ b/packages/studio/src/player/index.ts @@ -12,6 +12,7 @@ export { resolveIframe } from "./lib/timelineDOM"; // Store export { usePlayerStore, liveTime } from "./store/playerStore"; export type { SelectElementOptions, TimelineElement, ZoomMode } from "./store/playerStore"; +export type { TimelineTimeRange } from "./store/rangeSelectionSlice"; // Utils export { formatTime } from "./lib/time"; diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index 636c182b40..39c77f6aa4 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -20,6 +20,7 @@ import { createEditingModeSlice, type EditingModeSlice } from "./editingModeSlic import { createTimelineFocusRequest, type TimelineFocusRequest } from "./timelineFocusState"; import { createThumbnailSlice, type ThumbnailSlice } from "./thumbnailSlice"; import { createPlaybackReadinessSlice, resetPlaybackReadinessState } from "./readinessSlice"; +import { createRangeSelectionSlice, type RangeSelectionSlice } from "./rangeSelectionSlice"; export type { KeyframeCacheEntry } from "./keyframeSlice"; export { liveTime } from "./liveTime"; @@ -59,6 +60,7 @@ type PlayerStoreSlices = KeyframeSlice & AutomationSelectionSlice & ThumbnailSlice & EditingModeSlice & + RangeSelectionSlice & ReturnType; interface PlayerState extends PlayerStoreSlices { isPlaying: boolean; @@ -262,6 +264,7 @@ export function createTimelineResetState() { zEditVersion: 0, inPoint: null, outPoint: null, + rangeSelection: null, activeTool: "select" as const, activeKeyframePct: null, motionPathArmed: false, @@ -328,6 +331,7 @@ export const usePlayerStore = create((set, get) => ({ ...createAutomationSelectionSlice(set), ...createEditingModeSlice(set), + ...createRangeSelectionSlice(), ...createPlaybackReadinessSlice(set), activeKeyframePct: null, diff --git a/packages/studio/src/player/store/rangeSelectionSlice.ts b/packages/studio/src/player/store/rangeSelectionSlice.ts new file mode 100644 index 0000000000..e7aef3ab41 --- /dev/null +++ b/packages/studio/src/player/store/rangeSelectionSlice.ts @@ -0,0 +1,13 @@ +export interface TimelineTimeRange { + t0: number; + t1: number; +} + +export interface RangeSelectionSlice { + /** Shift-drag ruler range in seconds, normalized so t0 <= t1. */ + rangeSelection: TimelineTimeRange | null; +} + +export function createRangeSelectionSlice(): RangeSelectionSlice { + return { rangeSelection: null }; +} diff --git a/packages/studio/vite.config.ts b/packages/studio/vite.config.ts index af78da1b4c..300221bab5 100644 --- a/packages/studio/vite.config.ts +++ b/packages/studio/vite.config.ts @@ -1,14 +1,7 @@ import { defineConfig, type Plugin } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; -import { - copyFileSync, - readFileSync, - readdirSync, - existsSync, - lstatSync, - realpathSync, -} from "node:fs"; +import { readFileSync, readdirSync, existsSync, lstatSync, realpathSync } from "node:fs"; import { join, resolve } from "node:path"; import { readNodeRequestBody } from "./vite.request-body.js"; import { watch } from "chokidar"; @@ -34,30 +27,6 @@ async function loadRuntimeSourceForDev( const studioPkg = JSON.parse(readFileSync(resolve(__dirname, "package.json"), "utf-8")); -/** - * Copies the build's one CSS asset, unhashed, to `dist/styles.css` for the - * `./styles.css` export. Throws if the build ever emits more than one. - */ -export function stableStylesCssPlugin(): Plugin { - return { - name: "studio-stable-styles-css", - writeBundle(options, bundle) { - const cssAssets = Object.values(bundle).filter( - (item) => item.type === "asset" && item.fileName.endsWith(".css"), - ); - if (cssAssets.length !== 1) { - throw new Error( - `stableStylesCssPlugin: expected exactly one CSS asset for the ./styles.css ` + - `export, found ${cssAssets.length} (${cssAssets.map((a) => a.fileName).join(", ") || "none"}). ` + - `Scope this plugin to the entry stylesheet instead of assuming a single emit.`, - ); - } - const outDir = options.dir ?? "dist"; - copyFileSync(join(outDir, cssAssets[0]!.fileName), join(outDir, "styles.css")); - }, - }; -} - // ── Bridge Hono fetch → Node http response ─────────────────────────────────── async function bridgeHonoResponse( @@ -294,10 +263,12 @@ export default defineConfig({ outDir: "dist", emptyOutDir: true, rollupOptions: { - // /assets/* caches by filename alone, immutably, for a year - // (studioServer.ts). Keep every hash; copy one CSS file, unhashed, - // to the dist ROOT instead for the ./styles.css export. - plugins: [stableStylesCssPlugin()], + output: { + // The compiled stylesheet is a package export, so its path must stay + // stable across builds instead of following Vite's content hash. + assetFileNames: (assetInfo) => + assetInfo.name?.endsWith(".css") ? "assets/styles.css" : "assets/[name]-[hash][extname]", + }, }, }, optimizeDeps: {