mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 00:56:23 +00:00
C1: the FX button in the track/group header, and its popover — the
"reach FX from the timeline" entry point, last on purpose because it
targets a group or a single clip, never "a track" (N clips = N chains
is the ill-defined thing the design doc refuses to build).
The button (TimelineFxButton.tsx): renders on group rows and on track
rows holding exactly one audio clip, reading "FX" (or "FX n" once the
target's data-fx-chain has n enabled nodes). A multi-clip ungrouped
audio track gets a pointer instead ("Group these clips to add effects
to all of them" + a Group action) rather than silently hiding the
entry point — reuses B6's exact auto-grouping write
(useAudioGroupCarveAssignment, exposed as onGroupClips) with a minted
group id (mintGroupId, exported from useFxCarveGrouping.ts).
The popover (TimelineFxPopover.tsx, components/editor/): a thin
positioner around FxPresetMenu exactly as the property panel renders
it — same audition contract (useFxAudition), same preset-apply
computation (extracted into useApplyAudioFxPreset.ts's
applyPresetToChain, now shared with propertyPanelFxSection.tsx's own
applyPreset rather than duplicated). Escape closes without
deselecting whatever is behind it; an outside pointerdown dismisses.
Footer's "+ effect"/"Open rack ›" both select the target and hand off
to the property panel (a simplification from the step doc's two
distinct behaviors — remotely toggling the rack's own internal
"adding" state isn't plumbed anywhere, and building that plumbing
would be new UI-state wiring beyond what "reuse existing selection
dispatch" asks for).
Writes, one path per target kind, neither a new persistence mechanism:
- Group: B7/B5's existing onSetAudioGroupAttributeLive/Quiet
(data-fx-chain, same as data-volume/data-hidden already do).
- Clip: a NEW onSetElementAttributeLive/Quiet pair
(timelineElementFxAttribute.ts), addressed by the TimelineElement
itself rather than the current selection. This is the one real
architectural gap the step doc's assumption didn't survive: the
property panel's onSetAttributeQuiet closes over domEditSelection,
so writing a clip that isn't already selected has no synchronous
path through it. Extracted the shared live-patch-then-persist core
(persistElementAttribute, timelineEditingHelpers.ts) out of both
this new path and the existing setAudioGroupAttribute, which the
fallow duplication gate flagged as a 66-line clone on first pass —
now a single ~50-line core parameterized by patchLive/readLive, with
each caller a ~15-line wrapper resolving its own patch target
(buildPatchTarget({domId}) for a group, buildPatchTarget(element)
for an arbitrary clip) and live-DOM lookup.
Data plumbing: HfAudioGroup.fxChain (already on the B1 model) mirrored
onto TimelineElement.audioGroupFxChain (timelineDOM.ts's groupInfoFor
cache) and TimelineTrackGroupInfo.fxChain (useTimelineTrackDerivations.ts),
alongside the existing volume/hidden mirrors.
Deferred: the property panel's own rack doesn't (yet) expose a way to
remotely force its add-menu open, so "+ effect" and "Open rack ›"
converge on the same navigation rather than the step doc's two
distinct ones. A grouped multi-clip track (some clips already carry
data-audio-group) gets neither the chain button nor the pointer —
its members' own per-clip FX buttons still work individually, and the
group's own FX button on TimelineGroupHeader covers the group level.
Gates: bun run build clean; packages/studio full suite 4286/4304 (18
pre-existing todo, up from 4276/4294 — 10 new tests, 0 regressions);
new TimelineFxPopover.test.tsx (6) + TimelineFxButton.test.tsx (4)
cover exactly-one-write-per-apply, hover-audition-reverts-on-leave,
Escape-without-deselecting, outside/inside pointerdown dismissal, and
the group-pointer's Group action; oxfmt/oxlint clean on all 22 touched
files; fallow clean (0 new dead-code/unused-export/duplication
findings — the pointer test caught during the first commit attempt).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
299 lines
11 KiB
TypeScript
299 lines
11 KiB
TypeScript
import { useCallback, type ReactNode } from "react";
|
|
import { PreviewPane } from "./nle/PreviewPane";
|
|
import { TimelinePane } from "./nle/TimelinePane";
|
|
import { PreviewOverlays } from "./nle/PreviewOverlays";
|
|
import {
|
|
useTimelineEditCallbacks,
|
|
type TimelineEditCallbackDeps,
|
|
} from "./nle/useTimelineEditCallbacks";
|
|
import { NLEProvider, useNLEContext } from "./nle/NLEContext";
|
|
import { CaptionTimeline } from "../captions/components/CaptionTimeline";
|
|
import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
|
|
import { useDomEditActionsContext, useDomEditSelectionContext } from "../contexts/DomEditContext";
|
|
import { TimelineEditProvider } from "../contexts/TimelineEditContext";
|
|
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,
|
|
style: { clip: string; label: string },
|
|
) => ReactNode;
|
|
type TimelineDropPlacement = Pick<TimelineElement, "start" | "track">;
|
|
|
|
// The seven move/resize/split/razor handlers come from TimelineEditCallbackDeps
|
|
// (shared with useTimelineEditCallbacks); the rest are drop + wiring props.
|
|
export interface EditorShellProps extends TimelineEditCallbackDeps {
|
|
/** Left sidebar (media/library), rendered in the top row. */
|
|
left: ReactNode;
|
|
/** Right panel (inspector/design) or null when collapsed, in the top row. */
|
|
right: ReactNode;
|
|
/** Hide the whole shell (e.g. while the storyboard view is active). */
|
|
hidden?: boolean;
|
|
timelineToolbar: ReactNode;
|
|
renderClipContent: RenderClipContent;
|
|
handleTimelineElementDelete: (element: TimelineElement) => Promise<void> | void;
|
|
handleTimelineAssetDrop: (
|
|
assetPath: string,
|
|
placement: TimelineDropPlacement,
|
|
) => Promise<void> | void;
|
|
handleTimelineBlockDrop?: (
|
|
blockName: string,
|
|
placement: TimelineDropPlacement,
|
|
) => Promise<void> | void;
|
|
handleTimelineCompositionDrop?: (
|
|
sourcePath: string,
|
|
placement: TimelineDropPlacement,
|
|
) => Promise<void> | void;
|
|
handlePreviewBlockDrop?: (
|
|
blockName: string,
|
|
position: { left: number; top: number },
|
|
) => Promise<void> | void;
|
|
handleTimelineFileDrop: (
|
|
files: File[],
|
|
placement?: TimelineDropPlacement,
|
|
) => Promise<void> | void;
|
|
setCompIdToSrc: (map: Map<string, string>) => void;
|
|
setCompositionLoading: (loading: boolean) => void;
|
|
shouldShowMotionPath: boolean;
|
|
shouldShowSelectedDomBounds: boolean;
|
|
blockPreview?: BlockPreviewInfo | null;
|
|
isGestureRecording?: boolean;
|
|
recordingState?: GestureRecordingState;
|
|
onToggleRecording?: () => void;
|
|
gestureOverlay?: ReactNode;
|
|
}
|
|
|
|
// The CapCut-style shell: [left | preview | right] in a top row, with a
|
|
// full-width timeline spanning the bottom. Owns the shared player +
|
|
// composition-stack state via NLEProvider so both rows share one player.
|
|
export function EditorShell({
|
|
left,
|
|
right,
|
|
hidden,
|
|
timelineToolbar,
|
|
renderClipContent,
|
|
handleTimelineElementDelete,
|
|
handleTimelineAssetDrop,
|
|
handleTimelineBlockDrop,
|
|
handleTimelineCompositionDrop,
|
|
handlePreviewBlockDrop,
|
|
handleTimelineFileDrop,
|
|
handleTimelineElementMove,
|
|
handleTimelineElementsMove,
|
|
handleTimelineElementResize,
|
|
handleTimelineGroupResize,
|
|
handleToggleTrackHidden,
|
|
setAudioGroupAttribute,
|
|
handleGroupClips,
|
|
setElementFxAttribute,
|
|
handleBlockedTimelineEdit,
|
|
handleTimelineElementSplit,
|
|
handleRazorSplit,
|
|
handleRazorSplitAll,
|
|
setCompIdToSrc,
|
|
setCompositionLoading,
|
|
shouldShowMotionPath,
|
|
shouldShowSelectedDomBounds,
|
|
isGestureRecording,
|
|
recordingState,
|
|
onToggleRecording,
|
|
blockPreview,
|
|
gestureOverlay,
|
|
}: EditorShellProps) {
|
|
const { projectId, activeCompPath, setActiveCompPath, handlePreviewIframeRef, showToast } =
|
|
useStudioShellContext();
|
|
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,
|
|
handleTimelineElementsMove,
|
|
handleTimelineElementResize,
|
|
handleTimelineGroupResize,
|
|
handleToggleTrackHidden,
|
|
setAudioGroupAttribute,
|
|
handleGroupClips,
|
|
setElementFxAttribute,
|
|
handleBlockedTimelineEdit,
|
|
handleTimelineElementSplit,
|
|
handleRazorSplit,
|
|
handleRazorSplitAll,
|
|
});
|
|
|
|
return (
|
|
<div className={`flex flex-col flex-1 min-h-0${hidden ? " hidden" : ""}`}>
|
|
<TimelineEditProvider value={timelineEditCallbacks}>
|
|
<NLEProvider
|
|
projectId={projectId}
|
|
refreshKey={refreshKey}
|
|
activeCompositionPath={activeCompPath}
|
|
onIframeRef={handlePreviewIframeRef}
|
|
onCompIdToSrcChange={setCompIdToSrc}
|
|
onCompositionLoadingChange={setCompositionLoading}
|
|
onCompositionChange={(compPath) => {
|
|
// Sync activeCompPath when the user drills down via the timeline or
|
|
// navigates back — keeps sidebar + thumbnails in sync. Guard no-ops to
|
|
// avoid circular refresh cascades (activeCompPath → stack → onChange).
|
|
if (compPath !== activeCompPath) {
|
|
setActiveCompPath(compPath);
|
|
refreshPreviewDocumentVersion();
|
|
}
|
|
}}
|
|
>
|
|
<EditorShellBody
|
|
left={left}
|
|
right={right}
|
|
captionEditMode={captionEditMode}
|
|
onSelectTimelineElement={handleTimelineElementSelect}
|
|
onPreviewBlockDrop={handlePreviewBlockDrop}
|
|
timelineToolbar={timelineToolbar}
|
|
renderClipContent={renderClipContent}
|
|
onFileDrop={handleTimelineFileDrop}
|
|
onAssetDrop={handleTimelineAssetDrop}
|
|
onBlockDrop={handleTimelineBlockDrop}
|
|
onCompositionDrop={handleTimelineCompositionDrop}
|
|
onDeleteElement={handleTimelineElementDelete}
|
|
previewOverlay={
|
|
<PreviewOverlays
|
|
shouldShowMotionPath={shouldShowMotionPath}
|
|
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
|
|
blockPreview={blockPreview}
|
|
isGestureRecording={isGestureRecording}
|
|
recordingState={recordingState}
|
|
onToggleRecording={onToggleRecording}
|
|
gestureOverlay={gestureOverlay}
|
|
/>
|
|
}
|
|
/>
|
|
</NLEProvider>
|
|
</TimelineEditProvider>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface EditorShellBodyProps {
|
|
left: ReactNode;
|
|
right: ReactNode;
|
|
captionEditMode: boolean;
|
|
previewOverlay: ReactNode;
|
|
onSelectTimelineElement: (element: TimelineElement | null) => void;
|
|
onPreviewBlockDrop?: (
|
|
blockName: string,
|
|
position: { left: number; top: number },
|
|
) => Promise<void> | void;
|
|
timelineToolbar: ReactNode;
|
|
renderClipContent: RenderClipContent;
|
|
onFileDrop: (files: File[], placement?: TimelineDropPlacement) => Promise<void> | void;
|
|
onAssetDrop: (assetPath: string, placement: TimelineDropPlacement) => Promise<void> | void;
|
|
onBlockDrop?: (blockName: string, placement: TimelineDropPlacement) => Promise<void> | void;
|
|
onCompositionDrop?: (
|
|
sourcePath: string,
|
|
placement: TimelineDropPlacement,
|
|
) => Promise<void> | void;
|
|
onDeleteElement: (element: TimelineElement) => Promise<void> | void;
|
|
}
|
|
|
|
function EditorShellBody({
|
|
left,
|
|
right,
|
|
captionEditMode,
|
|
previewOverlay,
|
|
onSelectTimelineElement,
|
|
onPreviewBlockDrop,
|
|
timelineToolbar,
|
|
renderClipContent,
|
|
onFileDrop,
|
|
onAssetDrop,
|
|
onBlockDrop,
|
|
onCompositionDrop,
|
|
onDeleteElement,
|
|
}: EditorShellBodyProps) {
|
|
const { compositionStack, updateCompositionStack, containerRef } = useNLEContext();
|
|
|
|
// Keyboard: Escape to pop composition level
|
|
const handleKeyDown = useCallback(
|
|
(e: React.KeyboardEvent) => {
|
|
if (e.key === "Escape" && compositionStack.length > 1) {
|
|
updateCompositionStack((prev) => prev.slice(0, -1));
|
|
}
|
|
},
|
|
[compositionStack.length, updateCompositionStack],
|
|
);
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
// Shell canvas is a step LIGHTER than the near-black panel cards so the
|
|
// gaps between panels read as visible seams (CapCut-style).
|
|
className="flex flex-col flex-1 min-h-0 bg-[#18181B]"
|
|
onKeyDown={handleKeyDown}
|
|
tabIndex={-1}
|
|
>
|
|
{/* Top row: [left | preview | right] — outer padding + the 8px resize
|
|
seams give the panels CapCut-style separation on the dark canvas. */}
|
|
<div className="flex flex-row flex-1 min-h-0 px-px pt-px">
|
|
{left}
|
|
<div className="flex-1 min-w-0 flex flex-col relative">
|
|
<PreviewPane
|
|
previewOverlay={previewOverlay}
|
|
onSelectTimelineElement={onSelectTimelineElement}
|
|
onPreviewBlockDrop={onPreviewBlockDrop}
|
|
/>
|
|
</div>
|
|
{right}
|
|
</div>
|
|
|
|
{/* Full-width timeline row */}
|
|
<TimelinePane
|
|
timelineToolbar={timelineToolbar}
|
|
renderClipContent={renderClipContent}
|
|
onFileDrop={onFileDrop}
|
|
onAssetDrop={onAssetDrop}
|
|
onBlockDrop={onBlockDrop}
|
|
onCompositionDrop={onCompositionDrop}
|
|
onDeleteElement={onDeleteElement}
|
|
onSelectTimelineElement={onSelectTimelineElement}
|
|
timelineFooter={
|
|
captionEditMode ? (
|
|
<div className="border-t border-neutral-800/30 flex-shrink-0" style={{ height: 60 }}>
|
|
<div className="flex items-center gap-1.5 px-2 py-0.5">
|
|
<span className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider">
|
|
Captions
|
|
</span>
|
|
</div>
|
|
<CaptionTimeline pixelsPerSecond={100} />
|
|
</div>
|
|
) : undefined
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|