@hyperframes/studio 0.8.5 → 0.8.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-DcM3fVcp.js → hyperframes-player-bDcU464i.js} +1 -1
- package/dist/assets/index-Ba9zbpT9.css +1 -0
- package/dist/assets/index-CUXnyoIa.js +428 -0
- package/dist/assets/{index-DgJrvYGf.js → index-FkAs3zRu.js} +1 -1
- package/dist/assets/{index-Del6XWZj.js → index-MSOSP1-J.js} +1 -1
- package/dist/index.d.ts +98 -17
- package/dist/index.html +2 -2
- package/dist/index.js +10337 -7200
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +22 -23
- package/src/captions/components/CaptionAnimationPanel.tsx +46 -13
- package/src/captions/components/CaptionOverlay.tsx +172 -29
- package/src/captions/components/CaptionOverlayUtils.ts +54 -0
- package/src/captions/components/CaptionPropertyPanel.tsx +49 -23
- package/src/captions/components/CaptionTimeline.tsx +36 -79
- package/src/captions/components/shared.tsx +76 -1
- package/src/captions/hooks/useCaptionSync.ts +72 -13
- package/src/captions/keyboard.test.ts +12 -0
- package/src/captions/keyboard.ts +12 -1
- package/src/captions/store.ts +120 -14
- package/src/components/EditorShell.selectionSync.test.tsx +1 -1
- package/src/components/EditorShell.tsx +13 -1
- package/src/components/StudioRightPanel.tsx +2 -0
- package/src/components/StudioRightPanel.types.ts +1 -0
- package/src/components/editor/AnimationCard.tsx +2 -1
- package/src/components/editor/AnimationCardParts.tsx +6 -3
- package/src/components/editor/ArcPathControls.tsx +10 -4
- package/src/components/editor/BlockParamsPanel.test.tsx +170 -0
- package/src/components/editor/BlockParamsPanel.tsx +153 -11
- package/src/components/editor/BorderRadiusEditor.tsx +3 -22
- package/src/components/editor/FileTree.tsx +41 -12
- package/src/components/editor/FileTreeNodes.tsx +90 -20
- package/src/components/editor/GsapAnimationList.tsx +5 -0
- package/src/components/editor/KeyframeDiamond.tsx +3 -1
- package/src/components/editor/KeyframeEaseList.tsx +3 -2
- package/src/components/editor/KeyframeNavigation.tsx +6 -2
- package/src/components/editor/LayersPanel.tsx +11 -2
- package/src/components/editor/MotionPathNode.tsx +1 -0
- package/src/components/editor/PropertyPanel.test.tsx +7 -6
- package/src/components/editor/PropertyPanel.tsx +13 -5
- package/src/components/editor/PropertyPanelFlat.tsx +2 -0
- package/src/components/editor/SnapToolbar.tsx +20 -4
- package/src/components/editor/TimelineFxPopover.test.tsx +113 -0
- package/src/components/editor/TimelineFxPopover.tsx +138 -0
- package/src/components/editor/Transform3DCube.tsx +22 -1
- package/src/components/editor/manualOffsetDrag.test.ts +49 -0
- package/src/components/editor/manualOffsetDrag.ts +13 -1
- package/src/components/editor/propertyPanel3dTransform.tsx +11 -3
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +139 -3
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +4 -0
- package/src/components/editor/propertyPanelColor.tsx +9 -1
- package/src/components/editor/propertyPanelColorGradingControls.tsx +28 -7
- package/src/components/editor/propertyPanelCommitField.tsx +9 -1
- package/src/components/editor/propertyPanelFill.tsx +50 -2
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelFont.tsx +45 -9
- package/src/components/editor/propertyPanelFxPresetStyle.ts +18 -0
- package/src/components/editor/propertyPanelFxSection.test.tsx +3 -3
- package/src/components/editor/propertyPanelFxSection.tsx +8 -23
- package/src/components/editor/propertyPanelPrimitives.tsx +9 -12
- package/src/components/editor/propertyPanelStyleSections.tsx +21 -2
- package/src/components/editor/propertyPanelTransformCommit.ts +3 -3
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/useApplyAudioFxPreset.ts +36 -0
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxCarveGrouping.ts +132 -0
- package/src/components/nle/PreviewOverlays.tsx +69 -2
- package/src/components/nle/PreviewPane.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +27 -0
- package/src/components/panels/SlideshowPanel.tsx +81 -12
- package/src/components/panels/SlideshowSubPanels.tsx +61 -7
- package/src/components/sidebar/AssetCard.test.tsx +2 -2
- package/src/components/sidebar/AssetCard.tsx +94 -46
- package/src/components/sidebar/AssetContextMenu.tsx +196 -42
- package/src/components/sidebar/AssetsTab.tsx +156 -96
- package/src/components/sidebar/AudioRow.tsx +95 -40
- package/src/components/sidebar/BlocksTab.tsx +40 -128
- package/src/components/sidebar/CompositionsTab.tsx +46 -34
- package/src/components/sidebar/LeftSidebar.tsx +87 -75
- package/src/components/sidebar/PromptPreviewModal.tsx +126 -0
- package/src/components/sidebar/assetHelpers.ts +8 -0
- package/src/contexts/TimelineEditContext.tsx +5 -0
- package/src/hooks/timelineAudioGroupVolume.ts +148 -0
- package/src/hooks/timelineEditingHelpers.ts +78 -1
- package/src/hooks/timelineElementFxAttribute.ts +150 -0
- package/src/hooks/timelineTrackVisibility.test.ts +222 -1
- package/src/hooks/timelineTrackVisibility.ts +187 -4
- package/src/hooks/useAppHotkeys.ts +22 -0
- package/src/hooks/useAudioSoloBridge.ts +61 -0
- package/src/hooks/useCaptionDetection.ts +22 -2
- package/src/hooks/useDomEditCommits.test.tsx +24 -0
- package/src/hooks/useEffectiveTimelineDuration.ts +20 -0
- package/src/hooks/useGroupLevel.ts +36 -0
- package/src/hooks/useHydrateActiveCompPathFromUrl.ts +36 -0
- package/src/hooks/useMusicBeatAnalysis.ts +13 -0
- package/src/hooks/useTimelineDeleteOps.ts +170 -0
- package/src/hooks/useTimelineEditing.ts +53 -129
- package/src/player/components/AudioWaveform.tsx +29 -26
- package/src/player/components/BeatStrip.test.tsx +5 -9
- package/src/player/components/BeatStrip.tsx +12 -5
- package/src/player/components/ClipContextMenu.tsx +8 -2
- package/src/player/components/EditModal.tsx +48 -7
- package/src/player/components/KeyframeDiamondContextMenu.tsx +90 -10
- package/src/player/components/LayerDisclosureRow.tsx +12 -13
- package/src/player/components/Player.tsx +43 -8
- package/src/player/components/PlayerControls.tsx +107 -63
- package/src/player/components/ShortcutsPanel.tsx +23 -4
- package/src/player/components/SpeedMenu.tsx +34 -42
- package/src/player/components/Timeline.test.ts +7 -7
- package/src/player/components/Timeline.tsx +7 -10
- package/src/player/components/TimelineFxButton.test.tsx +81 -0
- package/src/player/components/TimelineFxButton.tsx +143 -0
- package/src/player/components/TimelineGroupBusStrip.test.tsx +146 -0
- package/src/player/components/TimelineGroupBusStrip.tsx +103 -0
- package/src/player/components/TimelineGroupHeader.tsx +176 -0
- package/src/player/components/TimelineGroupRow.tsx +146 -0
- package/src/player/components/TimelineLanes.test.tsx +5 -0
- package/src/player/components/TimelineLanes.tsx +39 -33
- package/src/player/components/TimelineOverlays.tsx +34 -0
- package/src/player/components/TimelineSoloButton.tsx +32 -0
- package/src/player/components/TimelineTrackHeader.tsx +74 -98
- package/src/player/components/TimelineTrackPlainHeader.tsx +119 -0
- package/src/player/components/VideoThumbnail.tsx +6 -1
- package/src/player/components/menuKeyboardNav.ts +47 -0
- package/src/player/components/timelineCallbacks.ts +26 -0
- package/src/player/components/timelineKeyboardNavigation.test.ts +8 -0
- package/src/player/components/timelineKeyboardNavigation.ts +147 -30
- package/src/player/components/timelineLaneProps.ts +3 -0
- package/src/player/components/timelineLayout.ts +1 -1
- package/src/player/components/timelineNavigationIdentity.ts +4 -0
- package/src/player/components/useTimelineLaneRowIndexes.ts +38 -0
- package/src/player/components/useTimelineLogicalFocus.ts +9 -0
- package/src/player/components/useTimelineLogicalRows.test.tsx +8 -0
- package/src/player/components/useTimelineLogicalRows.ts +17 -12
- package/src/player/components/useTimelineMultiDragActorWindows.ts +30 -0
- package/src/player/components/useTimelineTrackDerivations.ts +150 -6
- package/src/player/components/useTimelineTrackLayout.ts +61 -4
- package/src/player/hooks/previewMessageRouter.ts +127 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +64 -17
- package/src/player/hooks/useExpandedTimelineElements.ts +8 -11
- package/src/player/hooks/useTimelinePlayer.ts +9 -47
- package/src/player/lib/timelineDOM.ts +51 -0
- package/src/player/store/audioSoloSlice.test.ts +113 -0
- package/src/player/store/audioSoloSlice.ts +43 -0
- package/src/player/store/editingModeSlice.ts +45 -0
- package/src/player/store/groupLevels.ts +33 -0
- package/src/player/store/keyframeSlice.ts +26 -0
- package/src/player/store/playerStore.ts +21 -22
- package/src/player/store/timelineElement.ts +10 -0
- package/src/telemetry/events.ts +12 -0
- package/dist/assets/index-Cx_UrhoW.js +0 -428
- package/dist/assets/index-DcF4s1PG.css +0 -1
|
@@ -1,14 +1,22 @@
|
|
|
1
|
-
import { Eye, EyeSlash } from "@phosphor-icons/react";
|
|
2
1
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
|
-
import {
|
|
4
|
-
|
|
2
|
+
import {
|
|
3
|
+
HF_AUDIO_FX_ATTR,
|
|
4
|
+
serializeAudioFxChain,
|
|
5
|
+
type HfAudioFxChain,
|
|
6
|
+
} from "@hyperframes/core/audio-fx";
|
|
7
|
+
import { classifyAudioName } from "@hyperframes/core/audio-carve";
|
|
8
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
9
|
+
import { VisibilityButton, PlainTrackHeader } from "./TimelineTrackPlainHeader";
|
|
5
10
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
11
|
+
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
12
|
+
import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
|
|
13
|
+
import { mintGroupId } from "../../components/editor/useFxCarveGrouping";
|
|
14
|
+
import { TimelineFxButton } from "./TimelineFxButton";
|
|
6
15
|
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
|
|
7
16
|
import { groupAutomationLanes } from "./automationLaneData";
|
|
8
17
|
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
|
9
18
|
import { clipTimingStart } from "../../hooks/gsapShared";
|
|
10
19
|
import { LayerDisclosureRow } from "./LayerDisclosureRow";
|
|
11
|
-
import { TrackClipCount } from "./TrackClipCount";
|
|
12
20
|
import { LABEL_COL_W, LANE_H, getTimelineLaneTop } from "./timelineLayout";
|
|
13
21
|
import type { TimelineTheme } from "./timelineTheme";
|
|
14
22
|
import {
|
|
@@ -61,90 +69,6 @@ interface TimelineTrackHeaderProps {
|
|
|
61
69
|
onSeek?: (time: number) => void;
|
|
62
70
|
}
|
|
63
71
|
|
|
64
|
-
function VisibilityButton({
|
|
65
|
-
hidden,
|
|
66
|
-
trackNumber,
|
|
67
|
-
trackDisplayNumber,
|
|
68
|
-
visible,
|
|
69
|
-
onToggle,
|
|
70
|
-
}: {
|
|
71
|
-
hidden: boolean;
|
|
72
|
-
trackNumber: number;
|
|
73
|
-
trackDisplayNumber: number | null;
|
|
74
|
-
visible: boolean;
|
|
75
|
-
onToggle: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
76
|
-
}) {
|
|
77
|
-
if (!visible) return <span aria-hidden="true" className="h-6 w-6 shrink-0" />;
|
|
78
|
-
// Display number in the text, real key in the callback. The two must not be
|
|
79
|
-
// conflated in either direction.
|
|
80
|
-
const suffix = trackDisplaySuffix(trackDisplayNumber);
|
|
81
|
-
const label = hidden ? `Show track${suffix}` : `Hide track${suffix}`;
|
|
82
|
-
return (
|
|
83
|
-
<button
|
|
84
|
-
type="button"
|
|
85
|
-
aria-label={label}
|
|
86
|
-
title={label}
|
|
87
|
-
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
|
|
88
|
-
hidden ? "text-[#3CE6AC] hover:text-white" : "text-white/35 hover:text-white/75"
|
|
89
|
-
}`}
|
|
90
|
-
onPointerDown={(event) => event.stopPropagation()}
|
|
91
|
-
onClick={(event) => {
|
|
92
|
-
event.stopPropagation();
|
|
93
|
-
void onToggle?.(trackNumber, !hidden);
|
|
94
|
-
}}
|
|
95
|
-
>
|
|
96
|
-
{hidden ? (
|
|
97
|
-
<EyeSlash size={14} weight="bold" aria-hidden="true" />
|
|
98
|
-
) : (
|
|
99
|
-
<Eye size={14} weight="bold" aria-hidden="true" />
|
|
100
|
-
)}
|
|
101
|
-
</button>
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// The header a track gets when it has no keyframe clip to disclose: label, clip
|
|
106
|
-
// count, eye. Not deprecated — it is the live path for every track without lanes.
|
|
107
|
-
function PlainTrackHeader({
|
|
108
|
-
trackNumber,
|
|
109
|
-
trackDisplayNumber,
|
|
110
|
-
trackLabel,
|
|
111
|
-
clipCount,
|
|
112
|
-
showTrackLabel,
|
|
113
|
-
isTrackHidden,
|
|
114
|
-
isAudioTrack,
|
|
115
|
-
onToggleTrackHidden,
|
|
116
|
-
}: Pick<
|
|
117
|
-
TimelineTrackHeaderProps,
|
|
118
|
-
| "trackNumber"
|
|
119
|
-
| "trackDisplayNumber"
|
|
120
|
-
| "trackLabel"
|
|
121
|
-
| "clipCount"
|
|
122
|
-
| "isTrackHidden"
|
|
123
|
-
| "isAudioTrack"
|
|
124
|
-
| "onToggleTrackHidden"
|
|
125
|
-
> & { showTrackLabel: boolean }) {
|
|
126
|
-
return (
|
|
127
|
-
<>
|
|
128
|
-
{isAudioTrack && (
|
|
129
|
-
<Music size={12} weight="fill" aria-hidden="true" className="text-white/35" />
|
|
130
|
-
)}
|
|
131
|
-
{showTrackLabel && (
|
|
132
|
-
<span className="min-w-0 flex-1 truncate text-[11px]" title={trackLabel}>
|
|
133
|
-
{trackLabel}
|
|
134
|
-
</span>
|
|
135
|
-
)}
|
|
136
|
-
{showTrackLabel && <TrackClipCount clipCount={clipCount} />}
|
|
137
|
-
<VisibilityButton
|
|
138
|
-
hidden={isTrackHidden}
|
|
139
|
-
trackNumber={trackNumber}
|
|
140
|
-
trackDisplayNumber={trackDisplayNumber}
|
|
141
|
-
visible
|
|
142
|
-
onToggle={onToggleTrackHidden}
|
|
143
|
-
/>
|
|
144
|
-
</>
|
|
145
|
-
);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
72
|
// Figma layout: prev-keyframe ‹, the add/remove toggle (children), next ›.
|
|
149
73
|
function PropertyGroupNavigation({
|
|
150
74
|
navigation,
|
|
@@ -406,6 +330,7 @@ function AutomationLaneHeaderRow({
|
|
|
406
330
|
);
|
|
407
331
|
}
|
|
408
332
|
|
|
333
|
+
// fallow-ignore-next-line complexity
|
|
409
334
|
export function TimelineTrackHeader({
|
|
410
335
|
trackNumber,
|
|
411
336
|
trackDisplayNumber,
|
|
@@ -457,6 +382,38 @@ export function TimelineTrackHeader({
|
|
|
457
382
|
// left an audio clip's envelopes unreachable, since the track could not expand.
|
|
458
383
|
const disclosable = lanes.length > 0 || automationRows.length > 0;
|
|
459
384
|
const isKeyframeLayer = !!keyframeClip && disclosable;
|
|
385
|
+
// Solo is per-clip/per-group, never per track (design doc §2.2) — this header
|
|
386
|
+
// acts on the track's first clip as a pragmatic stand-in for "this track",
|
|
387
|
+
// the same simplification the mute button doesn't need to make (it patches
|
|
388
|
+
// every clip on the track at once).
|
|
389
|
+
const soloTargetId = trackElements[0] ? (trackElements[0].key ?? trackElements[0].id) : null;
|
|
390
|
+
const soloed = usePlayerStore((s) => s.soloed);
|
|
391
|
+
const toggleSolo = usePlayerStore((s) => s.toggleSolo);
|
|
392
|
+
|
|
393
|
+
// C1: the FX entry point. A single audio clip has one chain to point at; a
|
|
394
|
+
// track holding several ungrouped ones has no single chain — the design
|
|
395
|
+
// doc refuses to build "N clips = N chains", so that case gets a pointer
|
|
396
|
+
// at grouping (B6's normative rule) instead of a popover.
|
|
397
|
+
const { onGroupClips, onSetElementAttributeLive, onSetElementAttributeQuiet } =
|
|
398
|
+
useTimelineEditContextOptional();
|
|
399
|
+
const domEditActions = useDomEditActionsContextOptional();
|
|
400
|
+
const singleAudioClip =
|
|
401
|
+
isAudioTrack && clipCount === 1 && trackElements.length > 0 ? trackElements[0] : null;
|
|
402
|
+
const isTrackGrouped = trackElements.some((el) => el.audioGroup);
|
|
403
|
+
const writeClipFxChain = (clip: TimelineElement, next: HfAudioFxChain, live: boolean) => {
|
|
404
|
+
const value = next.nodes.length ? serializeAudioFxChain(next) : null;
|
|
405
|
+
if (live) onSetElementAttributeLive?.(clip, HF_AUDIO_FX_ATTR, value);
|
|
406
|
+
else void onSetElementAttributeQuiet?.(clip, HF_AUDIO_FX_ATTR, value, "Apply preset");
|
|
407
|
+
};
|
|
408
|
+
const openClipFxRack = (clip: TimelineElement) => {
|
|
409
|
+
void domEditActions?.handleTimelineElementSelect(clip);
|
|
410
|
+
};
|
|
411
|
+
const groupUngroupedClips = () => {
|
|
412
|
+
const doc = domEditActions?.previewIframeRef.current?.contentDocument;
|
|
413
|
+
if (!doc || !onGroupClips) return;
|
|
414
|
+
const clipIds = trackElements.map((el) => el.key ?? el.id);
|
|
415
|
+
void onGroupClips(clipIds, mintGroupId(doc));
|
|
416
|
+
};
|
|
460
417
|
|
|
461
418
|
return (
|
|
462
419
|
<div
|
|
@@ -476,16 +433,34 @@ export function TimelineTrackHeader({
|
|
|
476
433
|
}}
|
|
477
434
|
>
|
|
478
435
|
{!keyframeClip || !disclosable ? (
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
436
|
+
<>
|
|
437
|
+
<PlainTrackHeader
|
|
438
|
+
trackNumber={trackNumber}
|
|
439
|
+
trackDisplayNumber={trackDisplayNumber}
|
|
440
|
+
trackLabel={trackLabel}
|
|
441
|
+
clipCount={clipCount}
|
|
442
|
+
showTrackLabel={showTrackLabel}
|
|
443
|
+
isTrackHidden={isTrackHidden}
|
|
444
|
+
isAudioTrack={isAudioTrack}
|
|
445
|
+
isGroupMuted={trackElements.some((el) => el.audioGroupHidden)}
|
|
446
|
+
isSoloed={soloTargetId !== null && soloed.has(soloTargetId)}
|
|
447
|
+
onToggleSolo={soloTargetId ? (options) => toggleSolo(soloTargetId, options) : undefined}
|
|
448
|
+
onToggleTrackHidden={onToggleTrackHidden}
|
|
449
|
+
/>
|
|
450
|
+
{singleAudioClip && (
|
|
451
|
+
<TimelineFxButton
|
|
452
|
+
variant="chain"
|
|
453
|
+
fxChainRaw={singleAudioClip.fxChain}
|
|
454
|
+
trackKind={classifyAudioName(singleAudioClip.id, singleAudioClip.src)}
|
|
455
|
+
onChainChange={(next) => writeClipFxChain(singleAudioClip, next, false)}
|
|
456
|
+
onChainPreview={(next) => writeClipFxChain(singleAudioClip, next, true)}
|
|
457
|
+
onOpenRack={() => openClipFxRack(singleAudioClip)}
|
|
458
|
+
/>
|
|
459
|
+
)}
|
|
460
|
+
{isAudioTrack && clipCount > 1 && !isTrackGrouped && (
|
|
461
|
+
<TimelineFxButton variant="group-pointer" onGroupClips={groupUngroupedClips} />
|
|
462
|
+
)}
|
|
463
|
+
</>
|
|
489
464
|
) : (
|
|
490
465
|
<>
|
|
491
466
|
<LayerDisclosureRow
|
|
@@ -515,6 +490,7 @@ export function TimelineTrackHeader({
|
|
|
515
490
|
trackNumber={trackNumber}
|
|
516
491
|
trackDisplayNumber={trackDisplayNumber}
|
|
517
492
|
visible
|
|
493
|
+
isAudioTrack={isAudioTrack}
|
|
518
494
|
onToggle={onToggleTrackHidden}
|
|
519
495
|
/>
|
|
520
496
|
</LayerDisclosureRow>
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { Eye, EyeSlash, SpeakerHigh, SpeakerSlash } from "@phosphor-icons/react";
|
|
2
|
+
import { isCanaryEnabled } from "../../telemetry/canary";
|
|
3
|
+
import { Music } from "../../icons/SystemIcons";
|
|
4
|
+
import { TimelineSoloButton } from "./TimelineSoloButton";
|
|
5
|
+
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
6
|
+
import { TrackClipCount } from "./TrackClipCount";
|
|
7
|
+
import { trackDisplaySuffix } from "./timelineTrackDisplay";
|
|
8
|
+
|
|
9
|
+
// Audio tracks say "Mute", not "Hide" — the eye IS mute for sound-only rows.
|
|
10
|
+
// Gated: the relabel ships behind the canary, unlike the preview fix.
|
|
11
|
+
function visibilityButtonLabel(showAsMute: boolean, hidden: boolean, suffix: string): string {
|
|
12
|
+
if (showAsMute) return hidden ? "Muted" : "Mute";
|
|
13
|
+
return hidden ? `Show track${suffix}` : `Hide track${suffix}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function visibilityButtonIcon(showAsMute: boolean, hidden: boolean) {
|
|
17
|
+
const Icon = showAsMute ? (hidden ? SpeakerSlash : SpeakerHigh) : hidden ? EyeSlash : Eye;
|
|
18
|
+
return <Icon size={14} weight="bold" aria-hidden="true" />;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function VisibilityButton({
|
|
22
|
+
hidden,
|
|
23
|
+
trackNumber,
|
|
24
|
+
trackDisplayNumber,
|
|
25
|
+
visible,
|
|
26
|
+
isAudioTrack,
|
|
27
|
+
onToggle,
|
|
28
|
+
}: {
|
|
29
|
+
hidden: boolean;
|
|
30
|
+
trackNumber: number;
|
|
31
|
+
trackDisplayNumber: number | null;
|
|
32
|
+
visible: boolean;
|
|
33
|
+
isAudioTrack?: boolean;
|
|
34
|
+
onToggle: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
35
|
+
}) {
|
|
36
|
+
if (!visible) return <span aria-hidden="true" className="h-6 w-6 shrink-0" />;
|
|
37
|
+
// Display number in the text, real key in the callback. The two must not be
|
|
38
|
+
// conflated in either direction.
|
|
39
|
+
const suffix = trackDisplaySuffix(trackDisplayNumber);
|
|
40
|
+
const showAsMute = Boolean(isAudioTrack) && isCanaryEnabled("audio-track-mute");
|
|
41
|
+
const label = visibilityButtonLabel(showAsMute, hidden, suffix);
|
|
42
|
+
return (
|
|
43
|
+
<button
|
|
44
|
+
type="button"
|
|
45
|
+
aria-label={label}
|
|
46
|
+
title={label}
|
|
47
|
+
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
|
|
48
|
+
hidden ? "text-[#3CE6AC] hover:text-white" : "text-white/35 hover:text-white/75"
|
|
49
|
+
}`}
|
|
50
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
51
|
+
onClick={(event) => {
|
|
52
|
+
event.stopPropagation();
|
|
53
|
+
void onToggle?.(trackNumber, !hidden);
|
|
54
|
+
}}
|
|
55
|
+
>
|
|
56
|
+
{visibilityButtonIcon(showAsMute, hidden)}
|
|
57
|
+
</button>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The header a track gets when it has no keyframe clip to disclose: label, clip
|
|
62
|
+
// count, eye. Not deprecated — it is the live path for every track without lanes.
|
|
63
|
+
export function PlainTrackHeader({
|
|
64
|
+
trackNumber,
|
|
65
|
+
trackDisplayNumber,
|
|
66
|
+
trackLabel,
|
|
67
|
+
clipCount,
|
|
68
|
+
showTrackLabel,
|
|
69
|
+
isTrackHidden,
|
|
70
|
+
isAudioTrack,
|
|
71
|
+
isGroupMuted,
|
|
72
|
+
isSoloed,
|
|
73
|
+
onToggleSolo,
|
|
74
|
+
onToggleTrackHidden,
|
|
75
|
+
}: {
|
|
76
|
+
trackNumber: number;
|
|
77
|
+
trackDisplayNumber: number | null;
|
|
78
|
+
trackLabel: string;
|
|
79
|
+
clipCount: number;
|
|
80
|
+
isTrackHidden: boolean;
|
|
81
|
+
isAudioTrack: boolean;
|
|
82
|
+
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
83
|
+
showTrackLabel: boolean;
|
|
84
|
+
isGroupMuted: boolean;
|
|
85
|
+
isSoloed: boolean;
|
|
86
|
+
onToggleSolo?: (options?: { add?: boolean }) => void;
|
|
87
|
+
}) {
|
|
88
|
+
return (
|
|
89
|
+
<>
|
|
90
|
+
{isAudioTrack && (
|
|
91
|
+
<Music size={12} weight="fill" aria-hidden="true" className="text-white/35" />
|
|
92
|
+
)}
|
|
93
|
+
{showTrackLabel && (
|
|
94
|
+
<span
|
|
95
|
+
className={`min-w-0 flex-1 truncate text-[11px] ${
|
|
96
|
+
isAudioTrack && (isTrackHidden || isGroupMuted) && isCanaryEnabled("audio-track-mute")
|
|
97
|
+
? "line-through"
|
|
98
|
+
: ""
|
|
99
|
+
}`}
|
|
100
|
+
title={isGroupMuted && !isTrackHidden ? `${trackLabel} (group muted)` : trackLabel}
|
|
101
|
+
>
|
|
102
|
+
{trackLabel}
|
|
103
|
+
</span>
|
|
104
|
+
)}
|
|
105
|
+
{showTrackLabel && <TrackClipCount clipCount={clipCount} />}
|
|
106
|
+
<VisibilityButton
|
|
107
|
+
hidden={isTrackHidden}
|
|
108
|
+
trackNumber={trackNumber}
|
|
109
|
+
trackDisplayNumber={trackDisplayNumber}
|
|
110
|
+
visible
|
|
111
|
+
isAudioTrack={isAudioTrack}
|
|
112
|
+
onToggle={onToggleTrackHidden}
|
|
113
|
+
/>
|
|
114
|
+
{isAudioTrack && isCanaryEnabled("audio-track-mute") && onToggleSolo && (
|
|
115
|
+
<TimelineSoloButton isSoloed={isSoloed} onToggle={onToggleSolo} />
|
|
116
|
+
)}
|
|
117
|
+
</>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -110,13 +110,18 @@ export const VideoThumbnail = memo(function VideoThumbnail({
|
|
|
110
110
|
)}
|
|
111
111
|
{snapshot.status === "loading" && urls.length === 0 && (
|
|
112
112
|
<div
|
|
113
|
-
className="absolute inset-0 animate-pulse"
|
|
113
|
+
className="absolute inset-0 animate-pulse motion-reduce:animate-none"
|
|
114
114
|
style={{
|
|
115
115
|
background:
|
|
116
116
|
"linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
|
|
117
117
|
}}
|
|
118
118
|
/>
|
|
119
119
|
)}
|
|
120
|
+
{snapshot.status === "error" && (
|
|
121
|
+
<div className="absolute inset-0 flex items-center justify-center bg-neutral-900/60">
|
|
122
|
+
<span className="rounded bg-black/50 px-1 text-[8px] text-neutral-500">no preview</span>
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
120
125
|
{label && (
|
|
121
126
|
<div
|
|
122
127
|
className="absolute inset-x-0 bottom-0 z-10 px-1.5 pb-0.5 pt-3"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useEffect, type RefObject } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* APG menu keyboard basics for the timeline context menus: focuses the first
|
|
5
|
+
* menu item on open, moves focus with ArrowUp/ArrowDown/Home/End, and restores
|
|
6
|
+
* focus to the previously focused element when the menu unmounts. Pair with
|
|
7
|
+
* `role="menu"` on the container and `role="menuitem"` on the buttons
|
|
8
|
+
* (dismiss/Escape handling stays in useContextMenuDismiss).
|
|
9
|
+
*/
|
|
10
|
+
export function useMenuKeyboardNav(menuRef: RefObject<HTMLDivElement | null>): void {
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const menu = menuRef.current;
|
|
13
|
+
if (!menu) return;
|
|
14
|
+
const previouslyFocused = document.activeElement;
|
|
15
|
+
|
|
16
|
+
const items = () =>
|
|
17
|
+
Array.from(menu.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')).filter(
|
|
18
|
+
(el) => !el.disabled,
|
|
19
|
+
);
|
|
20
|
+
items()[0]?.focus();
|
|
21
|
+
|
|
22
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
23
|
+
if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End") {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const list = items();
|
|
27
|
+
if (list.length === 0) return;
|
|
28
|
+
e.preventDefault();
|
|
29
|
+
const idx = list.findIndex((el) => el === document.activeElement);
|
|
30
|
+
let next: number;
|
|
31
|
+
if (e.key === "ArrowDown") next = idx < 0 ? 0 : (idx + 1) % list.length;
|
|
32
|
+
else if (e.key === "ArrowUp")
|
|
33
|
+
next = idx < 0 ? list.length - 1 : (idx - 1 + list.length) % list.length;
|
|
34
|
+
else if (e.key === "Home") next = 0;
|
|
35
|
+
else next = list.length - 1;
|
|
36
|
+
list[next]?.focus();
|
|
37
|
+
};
|
|
38
|
+
menu.addEventListener("keydown", onKeyDown);
|
|
39
|
+
|
|
40
|
+
return () => {
|
|
41
|
+
menu.removeEventListener("keydown", onKeyDown);
|
|
42
|
+
if (previouslyFocused instanceof HTMLElement && document.contains(previouslyFocused)) {
|
|
43
|
+
previouslyFocused.focus();
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}, [menuRef]);
|
|
47
|
+
}
|
|
@@ -68,6 +68,32 @@ export interface TimelineEditCallbacks {
|
|
|
68
68
|
options?: { coalesceKey?: string },
|
|
69
69
|
) => Promise<void> | void;
|
|
70
70
|
onToggleTrackHidden?: (track: number, hidden: boolean) => Promise<void> | void;
|
|
71
|
+
/** B7's bus strip: live-write the group's own attribute while dragging. */
|
|
72
|
+
onSetAudioGroupAttributeLive?: (groupId: string, attr: string, value: string | null) => void;
|
|
73
|
+
/** ...and persist one undo entry on release. */
|
|
74
|
+
onSetAudioGroupAttributeQuiet?: (
|
|
75
|
+
groupId: string,
|
|
76
|
+
attr: string,
|
|
77
|
+
value: string | null,
|
|
78
|
+
label: string,
|
|
79
|
+
) => Promise<void>;
|
|
80
|
+
/** C1's ungrouped-track FX pointer: "Group these clips" — write
|
|
81
|
+
* `data-audio-group` on every one of them, atomically. Same shape B6's
|
|
82
|
+
* carve auto-grouping uses. */
|
|
83
|
+
onGroupClips?: (clipIds: readonly string[], groupId: string) => Promise<void>;
|
|
84
|
+
/** C1's single-clip FX write: addressed by the clip itself rather than the
|
|
85
|
+
* current selection, mirroring `onSetAudioGroupAttributeLive/Quiet`. */
|
|
86
|
+
onSetElementAttributeLive?: (
|
|
87
|
+
element: TimelineElement,
|
|
88
|
+
attr: string,
|
|
89
|
+
value: string | null,
|
|
90
|
+
) => void;
|
|
91
|
+
onSetElementAttributeQuiet?: (
|
|
92
|
+
element: TimelineElement,
|
|
93
|
+
attr: string,
|
|
94
|
+
value: string | null,
|
|
95
|
+
label: string,
|
|
96
|
+
) => Promise<void>;
|
|
71
97
|
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
|
72
98
|
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
73
99
|
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
@@ -58,6 +58,10 @@ function model(overrides: Partial<Parameters<typeof buildTimelineLogicalRows>[0]
|
|
|
58
58
|
selectedElementId: "active",
|
|
59
59
|
selectedElementIds: new Set(),
|
|
60
60
|
expandedClipIds: new Set(["active"]),
|
|
61
|
+
expandedGroupIds: new Set(),
|
|
62
|
+
expandedLaneOwnerIds: new Set(),
|
|
63
|
+
groups: [],
|
|
64
|
+
trackGroupOf: new Map(),
|
|
61
65
|
gsapAnimations: new Map([
|
|
62
66
|
[
|
|
63
67
|
"active",
|
|
@@ -244,6 +248,10 @@ describe("resolveTimelineNavigationTarget", () => {
|
|
|
244
248
|
selectedElementId: null,
|
|
245
249
|
selectedElementIds: new Set(),
|
|
246
250
|
expandedClipIds: new Set(),
|
|
251
|
+
expandedGroupIds: new Set(),
|
|
252
|
+
expandedLaneOwnerIds: new Set(),
|
|
253
|
+
groups: [],
|
|
254
|
+
trackGroupOf: new Map(),
|
|
247
255
|
gsapAnimations: new Map(),
|
|
248
256
|
});
|
|
249
257
|
|