mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-11 19:09:20 +00:00
fix(studio): publish desktop compatibility exports
Expose range selection and the canonical loader from the built Studio package while retaining timeline editing seams.
This commit is contained in:
@@ -21,7 +21,7 @@
|
||||
},
|
||||
"./styles.css": {
|
||||
"source": "./src/styles/studio.css",
|
||||
"runtime": "./dist/styles.css",
|
||||
"runtime": "./dist/assets/styles.css",
|
||||
"types": null,
|
||||
"environments": ["browser"]
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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> | 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}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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> | 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<void>;
|
||||
onDuplicateClip?: () => Promise<boolean>;
|
||||
|
||||
@@ -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(<Harness selection={selection} />));
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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<TimelineTimeRange | null>(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 });
|
||||
},
|
||||
[],
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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<typeof createPlaybackReadinessSlice>;
|
||||
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<PlayerState>((set, get) => ({
|
||||
|
||||
...createAutomationSelectionSlice(set),
|
||||
...createEditingModeSlice(set),
|
||||
...createRangeSelectionSlice(),
|
||||
...createPlaybackReadinessSlice(set),
|
||||
|
||||
activeKeyframePct: null,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user