Files
hyperframes/packages/studio/src/hooks/useStudioContextValue.ts
T
Miguel Ángel 5058236eda feat(studio): prompt to install FFmpeg before Export, not after (#3314)
Exporting without FFmpeg installed used to show "Server error (503). Check
the terminal for details." The server already knew the exact cause and sent
a per-platform install command in the response body; Studio discarded that
body and printed the status code. The user found out only after the
composition was finished.

Studio now asks the dev server on load whether this machine can encode, and
the Renders panel shows the cause plus a copyable install command when it
cannot, with a Recheck that avoids restarting Studio.

- New GET /api/environment/ffmpeg calls runEnvironmentChecks() with every
  optional check off, which is exactly the FFmpeg and ffprobe pair `doctor`
  runs, so Studio and the CLI cannot disagree. Only a passing result is
  cached.
- The refusal lives in startRender, not in a button. Studio renders from
  three places (the panel's Export, the header's, and each composition card
  in the sidebar), so a per-button check would leave the others free to
  queue a render that cannot finish. The header and sidebar controls reveal
  the prompt rather than going dead.
- A null probe result means "no answer", not "missing", so an older or
  unreachable dev server cannot lock a working setup.
- Failed render responses now surface the server's { error, hint }.
- getFFmpegInstallCommand() is the single owner of platform-to-command, with
  the prose hint derived from it. Windows gains a winget command and keeps
  the manual download route.

Accessibility: the prompt's explanatory line measured 2.2:1 on the card's
amber background against a 4.5:1 minimum, because the panel's usual grey for
secondary text does not survive the tint. Now 6.6:1. Keyboard focus was
invisible on all three controls and now matches the panel's focus ring.

Also folds in cleanups the repo's gates required: the Renders tab moves out
of StudioRightPanel (it was at the 600-line cap and every field it needed was
already on the shell context), StudioContextInput stops keeping a second copy
of the renderQueue shape, and the server tests share one temp-project helper.
2026-08-17 21:00:27 -04:00

152 lines
5.8 KiB
TypeScript

import { useCallback, useMemo, useRef, useState, type DragEvent } from "react";
import type { DomEditSelection } from "../components/editor/domEditing";
import type { StudioContextValue } from "../contexts/StudioContext";
import type { RightInspectorPanes } from "../utils/studioHelpers";
import type { TimelineFileDropHandler } from "./useTimelineEditingTypes";
import { usePlayerStore } from "../player";
interface StudioContextInput {
projectId: string;
activeCompPath: string | null;
setActiveCompPath: (path: string | null) => void;
showToast: (message: string, tone?: "error" | "info") => void;
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
captionEditMode: boolean;
compositionLoading: boolean;
refreshKey: number;
setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
timelineElements: StudioContextValue["timelineElements"];
isPlaying: boolean;
editHistory: { canUndo: boolean; canRedo: boolean; undoLabel: string; redoLabel: string };
handleUndo: StudioContextValue["handleUndo"];
handleRedo: StudioContextValue["handleRedo"];
// Was a second copy of the same shape, which meant every field added to the
// context had to be added here too or the build broke. Same idiom as the
// fields around it: the context type owns it.
renderQueue: StudioContextValue["renderQueue"];
compositionDimensions: { width: number; height: number } | null;
waitForPendingDomEditSaves: () => Promise<void>;
handlePreviewIframeRef: (iframe: HTMLIFrameElement | null) => void;
refreshPreviewDocumentVersion: () => void;
}
// fallow-ignore-next-line complexity
export function buildStudioContextValue(input: StudioContextInput): StudioContextValue {
return {
projectId: input.projectId,
activeCompPath: input.activeCompPath,
setActiveCompPath: input.setActiveCompPath,
showToast: input.showToast,
previewIframeRef: input.previewIframeRef,
captionEditMode: input.captionEditMode,
compositionLoading: input.compositionLoading,
refreshKey: input.refreshKey,
setRefreshKey: input.setRefreshKey,
timelineElements: input.timelineElements,
isPlaying: input.isPlaying,
editHistory: input.editHistory,
handleUndo: input.handleUndo,
handleRedo: input.handleRedo,
renderQueue: input.renderQueue,
compositionDimensions: input.compositionDimensions,
waitForPendingDomEditSaves: input.waitForPendingDomEditSaves,
handlePreviewIframeRef: input.handlePreviewIframeRef,
refreshPreviewDocumentVersion: input.refreshPreviewDocumentVersion,
};
}
export interface InspectorState {
layersPanelActive: boolean;
designPanelActive: boolean;
inspectorPanelActive: boolean;
inspectorButtonActive: boolean;
shouldShowMotionPath: boolean;
shouldShowSelectedDomBounds: boolean;
}
export function useInspectorState(
rightPanelTab: string,
rightInspectorPanes: RightInspectorPanes,
rightCollapsed: boolean,
isPlaying: boolean,
domEditSelection: DomEditSelection | null,
isGestureRecording?: boolean,
): InspectorState {
// fallow-ignore-next-line complexity
return useMemo(() => {
const inspectorTabActive = rightPanelTab === "design" || rightPanelTab === "layers";
const layersPanelActive = inspectorTabActive && rightInspectorPanes.layers;
const designPanelActive = inspectorTabActive && rightInspectorPanes.design;
const inspectorPanelActive = layersPanelActive || designPanelActive;
return {
layersPanelActive,
designPanelActive,
inspectorPanelActive,
inspectorButtonActive: !rightCollapsed && inspectorPanelActive,
// Deliberately wider than shouldShowSelectedDomBounds: the on-canvas path
// handles ARE the arc-drag affordance, so gating them on an open Inspector
// would make keyframe path editing reachable only from a side panel.
shouldShowMotionPath: !!domEditSelection && !isPlaying && !isGestureRecording,
// Keep the selection box drawn even when the Inspector is collapsed —
// closing the panel shouldn't visually deselect the element.
// The Variables tab also works against the canvas selection (bind card),
// so the selection outline stays visible there too.
shouldShowSelectedDomBounds:
(inspectorPanelActive || rightPanelTab === "variables") &&
!isPlaying &&
!isGestureRecording,
};
}, [
rightPanelTab,
rightInspectorPanes,
rightCollapsed,
isPlaying,
isGestureRecording,
domEditSelection,
]);
}
// fallow-ignore-next-line complexity
function useDragOverlay(onImportFiles: (files: FileList) => void) {
const [active, setActive] = useState(false);
const counterRef = useRef(0);
const onDragOver = useCallback((e: DragEvent) => {
if (!e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
}, []);
const onDragEnter = useCallback((e: DragEvent) => {
if (!e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
counterRef.current++;
setActive(true);
}, []);
const onDragLeave = useCallback(() => {
counterRef.current--;
if (counterRef.current === 0) setActive(false);
}, []);
const onDrop = useCallback(
(e: DragEvent) => {
counterRef.current = 0;
setActive(false);
if (e.defaultPrevented) return;
e.preventDefault();
if (e.dataTransfer.files.length) onImportFiles(e.dataTransfer.files);
},
[onImportFiles],
);
return { active, onDragOver, onDragEnter, onDragLeave, onDrop };
}
/** Global OS file drop: imports and places at the playhead position. */
export function useGlobalFileDrop(handleTimelineFileDrop: TimelineFileDropHandler) {
const onDrop = useCallback(
(files: FileList) => {
const start = usePlayerStore.getState().currentTime;
void handleTimelineFileDrop(Array.from(files), { start, track: 0 });
},
[handleTimelineFileDrop],
);
return useDragOverlay(onDrop);
}