From dd647964d88570fe5422e2207737060745dd3a08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 20 Jun 2026 18:22:07 -0400 Subject: [PATCH] fix: allow standalone timeline rendering --- .../src/contexts/FileManagerContext.tsx | 4 ++ .../studio/src/hooks/useMusicBeatAnalysis.ts | 30 ++++++++++---- .../src/player/components/Timeline.test.ts | 41 +++++++++++++++++++ .../src/player/components/TimelineCanvas.tsx | 4 +- .../useResolvedTimelineEditCallbacks.ts | 4 +- 5 files changed, 72 insertions(+), 11 deletions(-) diff --git a/packages/studio/src/contexts/FileManagerContext.tsx b/packages/studio/src/contexts/FileManagerContext.tsx index 51fe9308b9..13e61f51ac 100644 --- a/packages/studio/src/contexts/FileManagerContext.tsx +++ b/packages/studio/src/contexts/FileManagerContext.tsx @@ -11,6 +11,10 @@ export function useFileManagerContext(): FileManagerValue { return ctx; } +export function useFileManagerContextOptional(): FileManagerValue | null { + return useContext(FileManagerContext); +} + export function FileManagerProvider({ value: { editingFile, diff --git a/packages/studio/src/hooks/useMusicBeatAnalysis.ts b/packages/studio/src/hooks/useMusicBeatAnalysis.ts index 1e3e206e41..73a143f700 100644 --- a/packages/studio/src/hooks/useMusicBeatAnalysis.ts +++ b/packages/studio/src/hooks/useMusicBeatAnalysis.ts @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef } from "react"; import { usePlayerStore } from "../player/store/playerStore"; import { isMusicTrack } from "../utils/timelineInspector"; import { analyzeMusicFromUrl } from "@hyperframes/core/beats"; -import { useFileManagerContext } from "../contexts/FileManagerContext"; +import { useFileManagerContextOptional } from "../contexts/FileManagerContext"; import { mergeUserBeats } from "../utils/beatEditing"; import { audioRelPathForSrc, @@ -58,11 +58,18 @@ export function useMusicBeatAnalysis(): void { const setBeatEdits = usePlayerStore((s) => s.setBeatEdits); const setBeatPersist = usePlayerStore((s) => s.setBeatPersist); const resetBeatHistory = usePlayerStore((s) => s.resetBeatHistory); - const { readOptionalProjectFile, writeProjectFile } = useFileManagerContext(); + const fileManager = useFileManagerContextOptional(); + const readOptionalProjectFile = fileManager?.readOptionalProjectFile; + const writeProjectFile = fileManager?.writeProjectFile; // File IO via ref so the effects only re-run when the track changes. - const ioRef = useRef({ readOptionalProjectFile, writeProjectFile }); - ioRef.current = { readOptionalProjectFile, writeProjectFile }; + const ioRef = useRef< + (ProjectIo & { writeProjectFile: (p: string, c: string) => Promise }) | null + >(null); + ioRef.current = + readOptionalProjectFile && writeProjectFile + ? { readOptionalProjectFile, writeProjectFile } + : null; const musicSrc = useMemo(() => { const el = elements.find((e) => isMusicTrack(e)); @@ -78,6 +85,12 @@ export function useMusicBeatAnalysis(): void { resetBeatHistory(); return; } + if (!ioRef.current) { + setBeatAnalysis(null); + setBeatEdits(null); + resetBeatHistory(); + return; + } let cancelled = false; let promise = analysisCache.get(musicSrc); @@ -90,7 +103,9 @@ export function useMusicBeatAnalysis(): void { promise .then(async (analysis) => { const detected = { times: analysis.beatTimes, strengths: analysis.beatStrengths }; - const { times, strengths, hasFile } = await resolveBeats(beatPath, detected, ioRef.current); + const io = ioRef.current; + if (!io) return; + const { times, strengths, hasFile } = await resolveBeats(beatPath, detected, io); if (cancelled) return; setBeatEdits(null); resetBeatHistory(); @@ -114,10 +129,11 @@ export function useMusicBeatAnalysis(): void { // Flushes any pending write on cleanup so the last edit is never lost. ── useEffect(() => { const beatPath = beatFilePathForSrc(musicSrc); - if (!musicSrc || !beatPath) { + if (!musicSrc || !beatPath || !ioRef.current) { setBeatPersist(null); return; } + const io = ioRef.current; const audio = audioRelPathForSrc(musicSrc) ?? "audio"; let timer: ReturnType | null = null; let pending: string | null = null; @@ -126,7 +142,7 @@ export function useMusicBeatAnalysis(): void { if (pending === null) return; const content = pending; pending = null; - void ioRef.current.writeProjectFile(beatPath, content).catch(() => {}); + void io.writeProjectFile(beatPath, content).catch(() => {}); }; const persist = () => { diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index 1594fc36b6..524966d5c4 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -1,5 +1,11 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach } from "vitest"; import { describe, it, expect } from "vitest"; import { + Timeline, formatTimelineTickLabel, generateTicks, getDefaultDroppedTrack, @@ -14,6 +20,41 @@ import { } from "./Timeline"; import { RULER_H, TRACK_H } from "./timelineLayout"; import { formatTime } from "../lib/time"; +import { usePlayerStore } from "../store/playerStore"; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; + usePlayerStore.getState().reset(); +}); + +describe("Timeline provider boundary", () => { + it("renders the public Timeline export without TimelineEditProvider", () => { + const host = document.createElement("div"); + document.body.append(host); + Object.defineProperty(host, "clientWidth", { + configurable: true, + value: 640, + }); + + usePlayerStore.setState({ + duration: 4, + timelineReady: true, + elements: [{ id: "clip-1", tag: "div", start: 0, duration: 2, track: 0 }], + }); + + const root = createRoot(host); + + expect(() => { + act(() => { + root.render(React.createElement(Timeline)); + }); + }).not.toThrow(); + + act(() => root.unmount()); + }); +}); describe("generateTicks", () => { it("returns empty arrays for duration <= 0", () => { diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index 558aaa8e24..fa12a9f1e1 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -21,7 +21,7 @@ import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./us import type { TrackVisualStyle } from "./timelineIcons"; import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability"; import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit"; -import { useTimelineEditContext } from "../../contexts/TimelineEditContext"; +import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext"; import { isMusicTrack } from "../../utils/timelineInspector"; function ClipLabel({ element, color }: { element: TimelineElement; color: string }) { @@ -151,7 +151,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({ beatAnalysis, }: TimelineCanvasProps) { const { onResizeElement, onMoveElement, onRazorSplit, onRazorSplitAll } = - useTimelineEditContext(); + useTimelineEditContextOptional(); const beatDragging = usePlayerStore((s) => s.beatDragging); const draggedElement = draggedClip?.element ?? null; const activeDraggedElement = diff --git a/packages/studio/src/player/components/useResolvedTimelineEditCallbacks.ts b/packages/studio/src/player/components/useResolvedTimelineEditCallbacks.ts index 255d67dbfc..9b41494d85 100644 --- a/packages/studio/src/player/components/useResolvedTimelineEditCallbacks.ts +++ b/packages/studio/src/player/components/useResolvedTimelineEditCallbacks.ts @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { useTimelineEditContext } from "../../contexts/TimelineEditContext"; +import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext"; import type { TimelineEditCallbacks } from "./timelineCallbacks"; // Props a parent (e.g. NLELayout) may pass to to intercept edits — @@ -15,7 +15,7 @@ export type TimelineEditOverrides = Pick< export function useResolvedTimelineEditCallbacks( overrides: TimelineEditOverrides, ): TimelineEditCallbacks { - const ctx = useTimelineEditContext(); + const ctx = useTimelineEditContextOptional(); const { onMoveElement, onResizeElement, onBlockedEditAttempt, onSplitElement } = overrides; return useMemo( () => ({