@hyperframes/studio 0.7.106 → 0.7.108
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-CvVcx_CV.js → hyperframes-player-QqavTWRK.js} +1 -1
- package/dist/assets/{index-CRzCCuPH.js → index-BQjXA1pz.js} +1 -1
- package/dist/assets/{index-BbYg_isc.js → index-BhHHMprP.js} +1 -1
- package/dist/assets/index-DNkh9mbV.css +1 -0
- package/dist/assets/index-GqONLcOl.js +428 -0
- package/dist/{chunk-OBAG3GWK.js → chunk-AZYHQC6V.js} +6 -7
- package/dist/chunk-AZYHQC6V.js.map +1 -0
- package/dist/{domEditingLayers-AT7G6F4L.js → domEditingLayers-7AMZ7GFI.js} +4 -2
- package/dist/index.d.ts +86 -3
- package/dist/index.html +2 -2
- package/dist/index.js +24325 -17831
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/EditorShell.tsx +0 -2
- package/src/components/StudioErrorBoundary.test.tsx +97 -0
- package/src/components/StudioErrorBoundary.tsx +7 -0
- package/src/components/StudioOverlays.tsx +15 -13
- package/src/components/StudioRightPanel.tsx +18 -1
- package/src/components/TimelineToolbar.test.tsx +46 -0
- package/src/components/TimelineToolbar.tsx +17 -0
- package/src/components/editor/DomEditOverlay.tsx +36 -18
- package/src/components/editor/DomEditSelectionChrome.test.tsx +85 -0
- package/src/components/editor/DomEditSelectionChrome.tsx +29 -3
- package/src/components/editor/InlineTextToolbar.test.tsx +296 -0
- package/src/components/editor/InlineTextToolbar.tsx +281 -0
- package/src/components/editor/OffCanvasIndicators.tsx +38 -0
- package/src/components/editor/PropertyPanel.test.tsx +5 -1
- package/src/components/editor/PropertyPanel.tsx +7 -23
- package/src/components/editor/PropertyPanelFlat.tsx +25 -0
- package/src/components/editor/audioFxSummary.test.ts +75 -0
- package/src/components/editor/audioFxSummary.ts +36 -0
- package/src/components/editor/audioFxTelemetry.test.ts +129 -0
- package/src/components/editor/audioFxTelemetry.ts +230 -0
- package/src/components/editor/domEditInlineText.test.ts +86 -0
- package/src/components/editor/domEditInlineText.ts +99 -0
- package/src/components/editor/domEditInlineTextElement.test.ts +59 -0
- package/src/components/editor/domEditing.ts +1 -0
- package/src/components/editor/domEditingLayers.ts +7 -6
- package/src/components/editor/inlineTextStyleRange.test.ts +743 -0
- package/src/components/editor/inlineTextStyleRange.ts +593 -0
- package/src/components/editor/inlineTextStyleRead.ts +47 -0
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +1830 -0
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +299 -0
- package/src/components/editor/propertyPanelAudioFxGroupUtils.ts +21 -0
- package/src/components/editor/propertyPanelAutomation.test.ts +60 -0
- package/src/components/editor/propertyPanelAutomation.ts +97 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +37 -9
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelFxAddMenu.tsx +209 -0
- package/src/components/editor/propertyPanelFxBandRuler.tsx +72 -0
- package/src/components/editor/propertyPanelFxCarveModule.tsx +382 -0
- package/src/components/editor/propertyPanelFxControls.test.tsx +115 -0
- package/src/components/editor/propertyPanelFxControls.tsx +356 -0
- package/src/components/editor/propertyPanelFxEqModule.tsx +196 -0
- package/src/components/editor/propertyPanelFxFamily.ts +83 -0
- package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +269 -0
- package/src/components/editor/propertyPanelFxNodeParams.tsx +112 -0
- package/src/components/editor/propertyPanelFxNodeRow.tsx +339 -0
- package/src/components/editor/propertyPanelFxPresetMenu.tsx +159 -0
- package/src/components/editor/propertyPanelFxPresetRun.tsx +215 -0
- package/src/components/editor/propertyPanelFxPresetStyle.test.ts +142 -0
- package/src/components/editor/propertyPanelFxPresetStyle.ts +216 -0
- package/src/components/editor/propertyPanelFxRackChain.tsx +202 -0
- package/src/components/editor/propertyPanelFxSection.test.tsx +1747 -0
- package/src/components/editor/propertyPanelFxSection.tsx +550 -0
- package/src/components/editor/propertyPanelFxSectionTypes.ts +91 -0
- package/src/components/editor/propertyPanelTypes.ts +5 -0
- package/src/components/editor/useDomEditNudge.ts +2 -2
- package/src/components/editor/useFxAudition.ts +95 -0
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxChainObserved.ts +83 -0
- package/src/components/editor/useFxLevelling.ts +235 -0
- package/src/components/editor/useInlineTextEditing.tsx +119 -0
- package/src/components/editor/useVolumeAutomation.test.tsx +122 -0
- package/src/components/editor/useVolumeAutomation.ts +54 -0
- package/src/components/feedback/CrashFeedbackPrompt.tsx +27 -0
- package/src/components/feedback/StudioFeedbackCard.tsx +373 -0
- package/src/components/feedback/feedbackTrigger.test.ts +168 -0
- package/src/components/feedback/feedbackTrigger.ts +269 -0
- package/src/components/feedback/projectProvenance.test.ts +120 -0
- package/src/components/feedback/projectProvenance.ts +83 -0
- package/src/components/renders/useRenderQueue.ts +62 -4
- package/src/components/storyboard/StoryboardFrameFocus.tsx +2 -2
- package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +19 -2
- package/src/contexts/DomEditContext.tsx +15 -0
- package/src/hooks/domEditPersistFailure.ts +0 -7
- package/src/hooks/useAppHotkeys.test.ts +198 -0
- package/src/hooks/useAppHotkeys.ts +31 -4
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +429 -0
- package/src/hooks/useAutomationSelectionKeyboard.ts +371 -0
- package/src/hooks/useDomEditAttributeCommits.ts +90 -10
- package/src/hooks/useDomEditCommits.test.tsx +141 -21
- package/src/hooks/useDomEditCommits.ts +4 -0
- package/src/hooks/useDomEditSession.ts +4 -0
- package/src/hooks/useDomEditTextCommits.ts +112 -11
- package/src/hooks/useElementLifecycleOps.ts +3 -0
- package/src/hooks/useFileTree.ts +8 -3
- package/src/hooks/useInlineTextEdit.test.tsx +555 -0
- package/src/hooks/useInlineTextEdit.ts +320 -0
- package/src/hooks/useLivePlayheadTime.ts +49 -0
- package/src/hooks/usePreviewPersistence.ts +7 -0
- package/src/hooks/useTimelineEditing.ts +3 -0
- package/src/player/components/AutomationSelectionMenu.tsx +76 -0
- package/src/player/components/AutomationValueInput.tsx +51 -0
- package/src/player/components/LayerDisclosureRow.tsx +5 -4
- package/src/player/components/Timeline.test.ts +84 -0
- package/src/player/components/TimelineAutomationLane.test.tsx +1720 -0
- package/src/player/components/TimelineAutomationLane.tsx +674 -0
- package/src/player/components/TimelineAutomationLaneSlot.test.tsx +193 -0
- package/src/player/components/TimelineLanes.test.tsx +24 -5
- package/src/player/components/TimelineLanes.tsx +142 -154
- package/src/player/components/TimelineTrackHeader.test.tsx +212 -1
- package/src/player/components/TimelineTrackHeader.tsx +166 -6
- package/src/player/components/TimelineTrackRow.tsx +8 -1
- package/src/player/components/automationClipboard.test.ts +94 -0
- package/src/player/components/automationClipboard.ts +136 -0
- package/src/player/components/automationGestureKeys.test.ts +49 -0
- package/src/player/components/automationGestureKeys.ts +49 -0
- package/src/player/components/automationLaneData.test.ts +270 -0
- package/src/player/components/automationLaneData.ts +258 -0
- package/src/player/components/automationLaneDragMath.ts +194 -0
- package/src/player/components/automationLaneGeometry.test.ts +239 -0
- package/src/player/components/automationLaneGeometry.ts +287 -0
- package/src/player/components/automationLaneHeight.ts +15 -0
- package/src/player/components/automationLanePointer.ts +20 -0
- package/src/player/components/automationLaneSelection.test.ts +183 -0
- package/src/player/components/automationLaneSelection.ts +135 -0
- package/src/player/components/automationShapes.test.ts +82 -0
- package/src/player/components/automationShapes.ts +70 -0
- package/src/player/components/automationSimplify.test.ts +48 -0
- package/src/player/components/automationSimplify.ts +51 -0
- package/src/player/components/timelineClipGestureHandlers.ts +223 -0
- package/src/player/components/timelineLaneProps.ts +19 -0
- package/src/player/components/timelineLayout.ts +11 -5
- package/src/player/components/useAutomationEdgeStretch.test.ts +27 -0
- package/src/player/components/useAutomationEdgeStretch.ts +269 -0
- package/src/player/components/useAutomationLaneGestures.ts +537 -0
- package/src/player/components/useAutomationLanes.test.tsx +112 -0
- package/src/player/components/useAutomationLanes.ts +170 -0
- package/src/player/components/useAutomationRangeDrag.ts +114 -0
- package/src/player/components/useTimelineTrackLayout.test.ts +107 -1
- package/src/player/components/useTimelineTrackLayout.ts +83 -7
- package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
- package/src/player/hooks/useTimelinePlayer.ts +13 -1
- package/src/player/lib/automationStoreSync.test.ts +83 -0
- package/src/player/lib/automationStoreSync.ts +56 -0
- package/src/player/lib/playbackShortcuts.ts +5 -4
- package/src/player/lib/timelineDOM.test.ts +33 -0
- package/src/player/lib/timelineDOM.ts +12 -0
- package/src/player/store/automationSelectionSlice.test.ts +30 -0
- package/src/player/store/automationSelectionSlice.ts +54 -0
- package/src/player/store/playerStore.test.ts +15 -0
- package/src/player/store/playerStore.ts +54 -65
- package/src/player/store/timelineElement.ts +78 -0
- package/src/styles/studio.css +89 -0
- package/src/telemetry/agentRuntime.test.ts +45 -0
- package/src/telemetry/agentRuntime.ts +62 -0
- package/src/telemetry/breadcrumbs.test.ts +68 -0
- package/src/telemetry/breadcrumbs.ts +70 -0
- package/src/telemetry/client.ts +5 -0
- package/src/telemetry/events.ts +84 -3
- package/src/telemetry/system.ts +8 -0
- package/src/utils/sourcePatcher.ts +5 -1
- package/src/utils/studioFileHistory.test.ts +49 -0
- package/src/utils/studioFileHistory.ts +17 -1
- package/src/utils/studioHelpers.ts +2 -5
- package/src/utils/studioTelemetry.ts +16 -10
- package/src/utils/timelineDiscovery.ts +3 -24
- package/src/utils/typingTarget.test.ts +54 -0
- package/src/utils/typingTarget.ts +43 -0
- package/dist/assets/index-DerI0ikN.js +0 -428
- package/dist/assets/index-tBPidglp.css +0 -1
- package/dist/chunk-OBAG3GWK.js.map +0 -1
- package/src/components/StudioFeedbackBar.tsx +0 -217
- /package/dist/{domEditingLayers-AT7G6F4L.js.map → domEditingLayers-7AMZ7GFI.js.map} +0 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeping the player store's automation attributes true.
|
|
3
|
+
*
|
|
4
|
+
* The store is what a lane draws from, and it is populated by element discovery — a
|
|
5
|
+
* message from the preview runtime, which only arrives on load. Anything that edits
|
|
6
|
+
* an envelope afterwards writes to the preview document and the source file, and the
|
|
7
|
+
* store would go on holding the value it was born with until a reload.
|
|
8
|
+
*
|
|
9
|
+
* One reader, called from the two places a change lands: the resync every dom-edit
|
|
10
|
+
* attribute commit already runs, and the soft restore an undo or redo applies. It
|
|
11
|
+
* reads the preview rather than being told, because those callers know a file
|
|
12
|
+
* changed, not which attribute — and because three separate writers shipped without
|
|
13
|
+
* remembering to sync, which is what a single sink prevents.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
|
|
17
|
+
import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
|
|
18
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
19
|
+
|
|
20
|
+
/** The preview node an element stands for, by dom id and then by `data-hf-id`. */
|
|
21
|
+
function previewNodeFor(doc: Document, element: TimelineElement): Element | null {
|
|
22
|
+
const domId = element.domId ?? element.id;
|
|
23
|
+
const byId = domId ? doc.getElementById(domId) : null;
|
|
24
|
+
if (byId) return byId;
|
|
25
|
+
return element.hfId ? doc.querySelector(`[data-hf-id="${element.hfId}"]`) : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Re-read every element's automation and FX-chain attributes from the preview
|
|
30
|
+
* document, for a change that reached the DOM without going through this store.
|
|
31
|
+
*
|
|
32
|
+
* That is undo and redo. A soft restore patches the reverted attributes onto the
|
|
33
|
+
* live preview and re-runs the timeline — deliberately, so the frame does not blank
|
|
34
|
+
* — but the store it does not touch is the one the lanes read, so an undone delete
|
|
35
|
+
* stayed invisible until a reload. A full restore already clears the store and waits
|
|
36
|
+
* for discovery, so it needs nothing from here.
|
|
37
|
+
*
|
|
38
|
+
* Reads rather than being told: an undo restores whole files, so the attribute it
|
|
39
|
+
* reverted is only known by looking.
|
|
40
|
+
*/
|
|
41
|
+
export function syncStoredAutomationFromPreview(doc: Document | null | undefined): void {
|
|
42
|
+
if (!doc) return;
|
|
43
|
+
usePlayerStore.setState((state) => {
|
|
44
|
+
let changed = false;
|
|
45
|
+
const elements = state.elements.map((element) => {
|
|
46
|
+
const node = previewNodeFor(doc, element);
|
|
47
|
+
if (!node) return element;
|
|
48
|
+
const automation = node.getAttribute(HF_AUDIO_AUTOMATION_ATTR) ?? undefined;
|
|
49
|
+
const fxChain = node.getAttribute(HF_AUDIO_FX_ATTR) ?? undefined;
|
|
50
|
+
if (automation === element.automation && fxChain === element.fxChain) return element;
|
|
51
|
+
changed = true;
|
|
52
|
+
return { ...element, automation, fxChain };
|
|
53
|
+
});
|
|
54
|
+
return changed ? { elements } : {};
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -6,15 +6,13 @@
|
|
|
6
6
|
* is active and the user is navigating caption segments).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { isTypingTarget } from "../../utils/typingTarget";
|
|
10
|
+
|
|
9
11
|
const PLAYBACK_FRAME_STEP_CODES = new Set(["ArrowLeft", "ArrowRight"]);
|
|
10
12
|
|
|
11
13
|
const PLAYBACK_SHORTCUT_IGNORED_SELECTOR = [
|
|
12
|
-
"input",
|
|
13
|
-
"textarea",
|
|
14
|
-
"select",
|
|
15
14
|
"button",
|
|
16
15
|
"a[href]",
|
|
17
|
-
"[contenteditable='true']",
|
|
18
16
|
"[role='button']",
|
|
19
17
|
"[role='checkbox']",
|
|
20
18
|
"[role='combobox']",
|
|
@@ -27,6 +25,9 @@ const PLAYBACK_SHORTCUT_IGNORED_SELECTOR = [
|
|
|
27
25
|
].join(",");
|
|
28
26
|
|
|
29
27
|
export function shouldIgnorePlaybackShortcutTarget(target: EventTarget | null): boolean {
|
|
28
|
+
// Anything the user is typing into owns its keys outright, editable elements
|
|
29
|
+
// included: a letter claimed here never reaches the text.
|
|
30
|
+
if (isTypingTarget(target)) return true;
|
|
30
31
|
if (!target || typeof target !== "object") return false;
|
|
31
32
|
const candidate = target as { closest?: unknown };
|
|
32
33
|
if (typeof candidate.closest !== "function") return false;
|
|
@@ -296,3 +296,36 @@ describe("mergeTimelineElementsPreservingDowngrades — genuine removal vs trans
|
|
|
296
296
|
).toEqual(["a", "c"]);
|
|
297
297
|
});
|
|
298
298
|
});
|
|
299
|
+
|
|
300
|
+
describe("audio FX attributes on parsed elements", () => {
|
|
301
|
+
const CHAIN = '{"version":1,"nodes":[{"type":"lowpass","id":"n1","params":{}}]}';
|
|
302
|
+
const LANE = '{"version":1,"lanes":[{"target":"volume","points":[{"t":0,"v":1}]}]}';
|
|
303
|
+
|
|
304
|
+
it("carries data-fx-chain and data-automation off the element", () => {
|
|
305
|
+
// The timeline row is what reserves automation height and draws the lanes;
|
|
306
|
+
// parsed straight from the DOM it used to arrive without either attribute,
|
|
307
|
+
// so the panel showed a chain the timeline could not.
|
|
308
|
+
const doc = new DOMParser().parseFromString(
|
|
309
|
+
`<div data-composition-id="main" data-start="0" data-duration="10">
|
|
310
|
+
<audio id="bgm" data-start="0" data-duration="10" data-fx-chain='${CHAIN}'
|
|
311
|
+
data-automation='${LANE}'></audio>
|
|
312
|
+
</div>`,
|
|
313
|
+
"text/html",
|
|
314
|
+
);
|
|
315
|
+
const [bgm] = parseTimelineFromDOM(doc, 10).filter((e) => e.domId === "bgm");
|
|
316
|
+
expect(bgm?.fxChain).toBe(CHAIN);
|
|
317
|
+
expect(bgm?.automation).toBe(LANE);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("leaves them unset on a track that carries neither", () => {
|
|
321
|
+
const doc = new DOMParser().parseFromString(
|
|
322
|
+
`<div data-composition-id="main" data-start="0" data-duration="10">
|
|
323
|
+
<audio id="bgm" data-start="0" data-duration="10"></audio>
|
|
324
|
+
</div>`,
|
|
325
|
+
"text/html",
|
|
326
|
+
);
|
|
327
|
+
const [bgm] = parseTimelineFromDOM(doc, 10).filter((e) => e.domId === "bgm");
|
|
328
|
+
expect(bgm?.fxChain).toBeUndefined();
|
|
329
|
+
expect(bgm?.automation).toBeUndefined();
|
|
330
|
+
});
|
|
331
|
+
});
|
|
@@ -138,6 +138,10 @@ export function createTimelineElementFromManifestClip(params: {
|
|
|
138
138
|
if (hostEl.hasAttribute("data-hidden")) entry.hidden = true;
|
|
139
139
|
const timelineRole = hostEl.getAttribute("data-timeline-role");
|
|
140
140
|
if (timelineRole) entry.timelineRole = timelineRole;
|
|
141
|
+
const fxChain = hostEl.getAttribute("data-fx-chain");
|
|
142
|
+
if (fxChain) entry.fxChain = fxChain;
|
|
143
|
+
const automation = hostEl.getAttribute("data-automation");
|
|
144
|
+
if (automation) entry.automation = automation;
|
|
141
145
|
entry.zIndex = readTimelineElementZIndex(hostEl);
|
|
142
146
|
}
|
|
143
147
|
if (clip.assetUrl) entry.src = clip.assetUrl;
|
|
@@ -334,6 +338,14 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
|
|
|
334
338
|
if (resolvedSrc) entry.src = resolvedSrc;
|
|
335
339
|
}
|
|
336
340
|
|
|
341
|
+
// Read from the element, like the manifest path does: without these an audio
|
|
342
|
+
// clip parsed straight from the DOM reserved no automation height and drew no
|
|
343
|
+
// lanes, while the property panel still showed its chain.
|
|
344
|
+
const domFxChain = el.getAttribute("data-fx-chain");
|
|
345
|
+
if (domFxChain) entry.fxChain = domFxChain;
|
|
346
|
+
const domAutomation = el.getAttribute("data-automation");
|
|
347
|
+
if (domAutomation) entry.automation = domAutomation;
|
|
348
|
+
|
|
337
349
|
if (el.hasAttribute("data-timeline-locked")) {
|
|
338
350
|
entry.timelineLocked = true;
|
|
339
351
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { usePlayerStore } from "./playerStore";
|
|
3
|
+
|
|
4
|
+
describe("automationSelectionSlice", () => {
|
|
5
|
+
it("stores one ordered selection box and clears it", () => {
|
|
6
|
+
const store = usePlayerStore.getState();
|
|
7
|
+
// Dragged up and to the left: both axes arrive backwards.
|
|
8
|
+
store.setAutomationSelection({
|
|
9
|
+
elementKey: "bgm",
|
|
10
|
+
target: "volume",
|
|
11
|
+
t0: 2,
|
|
12
|
+
t1: 1,
|
|
13
|
+
v0: 0.8,
|
|
14
|
+
v1: 0.3,
|
|
15
|
+
});
|
|
16
|
+
const sel = usePlayerStore.getState().automationSelection;
|
|
17
|
+
// Ordered on write, so every consumer can assume t0 <= t1 and v0 <= v1 —
|
|
18
|
+
// which is what lets a point test be two range checks rather than four.
|
|
19
|
+
expect(sel).toEqual({
|
|
20
|
+
elementKey: "bgm",
|
|
21
|
+
target: "volume",
|
|
22
|
+
t0: 1,
|
|
23
|
+
t1: 2,
|
|
24
|
+
v0: 0.3,
|
|
25
|
+
v1: 0.8,
|
|
26
|
+
});
|
|
27
|
+
usePlayerStore.getState().clearAutomationSelection();
|
|
28
|
+
expect(usePlayerStore.getState().automationSelection).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The active selection box on one automation lane.
|
|
3
|
+
*
|
|
4
|
+
* A store slice, not lane-local state, for the same reason keyframe selection
|
|
5
|
+
* is one: Delete/copy/paste handlers and the shape menu live outside the lane
|
|
6
|
+
* component and need to read it. Ephemeral by construction — nothing
|
|
7
|
+
* serializes store state, and the selection must never survive into a render.
|
|
8
|
+
*/
|
|
9
|
+
import type { StoreApi } from "zustand";
|
|
10
|
+
|
|
11
|
+
export interface AutomationSelection {
|
|
12
|
+
/** TimelineElement key (key ?? id) of the clip that owns the lane. */
|
|
13
|
+
elementKey: string;
|
|
14
|
+
/** Lane target: "volume" or "fx.<nodeId>.<param>". */
|
|
15
|
+
target: string;
|
|
16
|
+
/** Clip-local seconds; always t0 <= t1 (ordered on write). */
|
|
17
|
+
t0: number;
|
|
18
|
+
t1: number;
|
|
19
|
+
/**
|
|
20
|
+
* The box's value bounds, in the parameter's own units; always v0 <= v1.
|
|
21
|
+
*
|
|
22
|
+
* What makes the selection a box rather than a time span: a point is caught
|
|
23
|
+
* only if it falls inside both axes. Span operations — copy, paste, shape
|
|
24
|
+
* insert, simplify — still read t0/t1 alone, because they act on the envelope
|
|
25
|
+
* over a stretch of time rather than on a set of breakpoints.
|
|
26
|
+
*/
|
|
27
|
+
v0: number;
|
|
28
|
+
v1: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface AutomationSelectionSlice {
|
|
32
|
+
automationSelection: AutomationSelection | null;
|
|
33
|
+
setAutomationSelection: (sel: AutomationSelection) => void;
|
|
34
|
+
clearAutomationSelection: () => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createAutomationSelectionSlice(
|
|
38
|
+
set: StoreApi<AutomationSelectionSlice>["setState"],
|
|
39
|
+
): AutomationSelectionSlice {
|
|
40
|
+
return {
|
|
41
|
+
automationSelection: null,
|
|
42
|
+
setAutomationSelection: (sel) =>
|
|
43
|
+
set({
|
|
44
|
+
automationSelection: {
|
|
45
|
+
...sel,
|
|
46
|
+
t0: Math.min(sel.t0, sel.t1),
|
|
47
|
+
t1: Math.max(sel.t0, sel.t1),
|
|
48
|
+
v0: Math.min(sel.v0, sel.v1),
|
|
49
|
+
v1: Math.max(sel.v0, sel.v1),
|
|
50
|
+
},
|
|
51
|
+
}),
|
|
52
|
+
clearAutomationSelection: () => set({ automationSelection: null }),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -557,6 +557,21 @@ describe("usePlayerStore", () => {
|
|
|
557
557
|
expectResettableDefaults(usePlayerStore.getState());
|
|
558
558
|
});
|
|
559
559
|
|
|
560
|
+
it("drops an automation time selection on reset and on a project switch", () => {
|
|
561
|
+
const sel = { elementKey: "bgm", target: "volume", t0: 1, t1: 2 };
|
|
562
|
+
|
|
563
|
+
usePlayerStore.getState().setAutomationSelection(sel);
|
|
564
|
+
usePlayerStore.getState().reset();
|
|
565
|
+
expect(usePlayerStore.getState().automationSelection).toBeNull();
|
|
566
|
+
|
|
567
|
+
// The switch matters more than reset(): a stale elementKey can match a
|
|
568
|
+
// same-keyed clip in the new project and redirect a paste to its old t0.
|
|
569
|
+
usePlayerStore.getState().beginTimelineSession("project-a");
|
|
570
|
+
usePlayerStore.getState().setAutomationSelection(sel);
|
|
571
|
+
usePlayerStore.getState().beginTimelineSession("project-b");
|
|
572
|
+
expect(usePlayerStore.getState().automationSelection).toBeNull();
|
|
573
|
+
});
|
|
574
|
+
|
|
560
575
|
it("does not reset playbackRate, audioMuted, loopEnabled, zoomMode, or manualZoomPercent", () => {
|
|
561
576
|
const store = usePlayerStore.getState();
|
|
562
577
|
store.setPlaybackRate(2);
|
|
@@ -10,76 +10,19 @@ import {
|
|
|
10
10
|
} from "../../utils/studioUiPreferences";
|
|
11
11
|
import { clampTimelineZoomPercent, computePinnedZoomPercent } from "../components/timelineZoom";
|
|
12
12
|
import { createKeyframeSlice, type KeyframeCacheEntry, type KeyframeSlice } from "./keyframeSlice";
|
|
13
|
+
import {
|
|
14
|
+
createAutomationSelectionSlice,
|
|
15
|
+
type AutomationSelectionSlice,
|
|
16
|
+
} from "./automationSelectionSlice";
|
|
13
17
|
import { createTimelineFocusRequest, type TimelineFocusRequest } from "./timelineFocusState";
|
|
14
18
|
import { createThumbnailSlice, type ThumbnailSlice } from "./thumbnailSlice";
|
|
15
19
|
|
|
16
20
|
export type { KeyframeCacheEntry } from "./keyframeSlice";
|
|
17
21
|
export { liveTime } from "./liveTime";
|
|
18
22
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
key?: string;
|
|
23
|
-
kind?: ClipManifestClip["kind"];
|
|
24
|
-
tag: string;
|
|
25
|
-
start: number;
|
|
26
|
-
duration: number;
|
|
27
|
-
track: number;
|
|
28
|
-
/**
|
|
29
|
-
* The data-track-index as written in the source file. Set at the manifest
|
|
30
|
-
* translation boundary (createTimelineElementFromManifestClip) from the
|
|
31
|
-
* runtime clip's verbatim track, and preserved through display-lane remaps
|
|
32
|
-
* (normalizeToZones packs sparse authored tracks onto contiguous display
|
|
33
|
-
* lanes; expanded sub-comp children get synthetic display rows). Lane edits
|
|
34
|
-
* must persist THIS space — writing a display-lane number into a sparse file
|
|
35
|
-
* re-targets the wrong track. For an expanded child the value is in its OWN
|
|
36
|
-
* source file's coordinate space, not the host timeline's.
|
|
37
|
-
*/
|
|
38
|
-
authoredTrack?: number;
|
|
39
|
-
/** Resolved z-index for stacking-aware timeline ordering. */
|
|
40
|
-
zIndex?: number;
|
|
41
|
-
/** True when the effective z-index was authored inline or through CSS, not auto. */
|
|
42
|
-
hasExplicitZIndex?: boolean;
|
|
43
|
-
/** Canonical CSS stacking context this element's z-index participates in. */
|
|
44
|
-
stackingContextId?: string | null;
|
|
45
|
-
/** Nearest parent composition context, matching RuntimeTimelineClip. */
|
|
46
|
-
parentCompositionId?: string | null;
|
|
47
|
-
/** Composition ancestry from root to nearest parent, matching RuntimeTimelineClip. */
|
|
48
|
-
compositionAncestors?: string[];
|
|
49
|
-
domId?: string;
|
|
50
|
-
/** Stable `data-hf-id` attribute value — used as primary patch target when present */
|
|
51
|
-
hfId?: string;
|
|
52
|
-
/** Best-effort selector used when patching source HTML back from timeline edits */
|
|
53
|
-
selector?: string;
|
|
54
|
-
/** Zero-based occurrence index for non-unique selectors */
|
|
55
|
-
selectorIndex?: number;
|
|
56
|
-
/** Source composition file that owns this element, when known */
|
|
57
|
-
sourceFile?: string;
|
|
58
|
-
src?: string;
|
|
59
|
-
playbackStart?: number;
|
|
60
|
-
playbackStartAttr?: "media-start" | "playback-start";
|
|
61
|
-
playbackRate?: number;
|
|
62
|
-
sourceDuration?: number;
|
|
63
|
-
volume?: number;
|
|
64
|
-
/** Path from data-composition-src — identifies sub-composition elements */
|
|
65
|
-
compositionSrc?: string;
|
|
66
|
-
/** Whether this row came from authored clip timing or Studio's full-duration layer fallback. */
|
|
67
|
-
timingSource?: "authored" | "implicit";
|
|
68
|
-
/** Set by data-timeline-locked on the host element — disables move and trim in Studio. */
|
|
69
|
-
timelineLocked?: boolean;
|
|
70
|
-
/** Set by data-hidden on the host element — hides the clip in preview and render. */
|
|
71
|
-
hidden?: boolean;
|
|
72
|
-
/** Value of data-timeline-role attribute — used to identify music vs. voiceover. */
|
|
73
|
-
timelineRole?: string;
|
|
74
|
-
/**
|
|
75
|
-
* Set by useExpandedTimelineElements on an inline-expanded sub-composition
|
|
76
|
-
* child: the absolute master-timeline start of the sub-comp host the child
|
|
77
|
-
* lives in. Presence marks the element as expanded; edits subtract it to get
|
|
78
|
-
* the child's local (sourceFile-relative) time. Works at any nesting depth.
|
|
79
|
-
*/
|
|
80
|
-
expandedParentStart?: number;
|
|
81
|
-
expandedHostKey?: string;
|
|
82
|
-
}
|
|
23
|
+
import type { TimelineElement } from "./timelineElement";
|
|
24
|
+
|
|
25
|
+
export type { TimelineElement };
|
|
83
26
|
export type ZoomMode = "fit" | "manual";
|
|
84
27
|
type TimelineTool = "select" | "razor";
|
|
85
28
|
|
|
@@ -104,7 +47,7 @@ function resolveElementSelection(
|
|
|
104
47
|
};
|
|
105
48
|
}
|
|
106
49
|
|
|
107
|
-
interface PlayerState extends KeyframeSlice, ThumbnailSlice {
|
|
50
|
+
interface PlayerState extends KeyframeSlice, AutomationSelectionSlice, ThumbnailSlice {
|
|
108
51
|
isPlaying: boolean;
|
|
109
52
|
currentTime: number;
|
|
110
53
|
duration: number;
|
|
@@ -223,6 +166,32 @@ interface PlayerState extends KeyframeSlice, ThumbnailSlice {
|
|
|
223
166
|
requestSeek: (time: number) => void;
|
|
224
167
|
clearSeekRequest: () => void;
|
|
225
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Request the transport start or stop from outside the player loop.
|
|
171
|
+
*
|
|
172
|
+
* The FX rack auditions a preset by writing it to the running graph, which is
|
|
173
|
+
* silent while the transport is paused — so hovering one has to start
|
|
174
|
+
* playback, and leaving has to put the playhead back where it was. Hovering is
|
|
175
|
+
* not an edit and must not cost the author their place.
|
|
176
|
+
*
|
|
177
|
+
* A nonce rather than a bare boolean: two hovers in a row both want play, and
|
|
178
|
+
* without it the second request is indistinguishable from the first having
|
|
179
|
+
* already been served.
|
|
180
|
+
*/
|
|
181
|
+
playbackRequest: { playing: boolean; returnTo: number | null; nonce: number } | null;
|
|
182
|
+
requestPlayback: (playing: boolean, returnTo?: number | null) => void;
|
|
183
|
+
clearPlaybackRequest: () => void;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Request the timeline to scroll a clip into view (e.g. clicking an
|
|
187
|
+
* already-added asset card in the sidebar). Consumed and cleared by
|
|
188
|
+
* useTimelineRevealClip. The nonce makes repeat requests for the same
|
|
189
|
+
* clip observable so a second click re-reveals after the user scrolls away.
|
|
190
|
+
*/
|
|
191
|
+
clipRevealRequest: { elementId: string; nonce: number } | null;
|
|
192
|
+
requestClipReveal: (elementId: string) => void;
|
|
193
|
+
clearClipRevealRequest: () => void;
|
|
194
|
+
|
|
226
195
|
timelineFocus: TimelineFocusRequest | null;
|
|
227
196
|
timelineFocusNonce: number;
|
|
228
197
|
requestTimelineFocus: (id: string) => void;
|
|
@@ -294,6 +263,10 @@ export function createTimelineResetState() {
|
|
|
294
263
|
motionPathArmed: false,
|
|
295
264
|
motionPathCreateAvailable: false,
|
|
296
265
|
selectedKeyframes: new Set<string>(),
|
|
266
|
+
// Ephemeral like every other selection here. A range surviving a project
|
|
267
|
+
// switch can match a same-keyed clip in the new project and redirect a
|
|
268
|
+
// paste through `sel.elementKey === paste.elementKey` to a stale t0.
|
|
269
|
+
automationSelection: null,
|
|
297
270
|
expandedClipIds: new Set<string>(),
|
|
298
271
|
focusedEaseSegment: null,
|
|
299
272
|
selectedElementIds: new Set<string>(),
|
|
@@ -343,6 +316,8 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
343
316
|
})),
|
|
344
317
|
...createThumbnailSlice(set),
|
|
345
318
|
|
|
319
|
+
...createAutomationSelectionSlice(set),
|
|
320
|
+
|
|
346
321
|
activeKeyframePct: null,
|
|
347
322
|
setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }),
|
|
348
323
|
motionPathArmed: false,
|
|
@@ -373,6 +348,20 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
373
348
|
requestSeek: (time) => set({ requestedSeekTime: time }),
|
|
374
349
|
clearSeekRequest: () => set({ requestedSeekTime: null }),
|
|
375
350
|
|
|
351
|
+
playbackRequest: null,
|
|
352
|
+
requestPlayback: (playing, returnTo = null) =>
|
|
353
|
+
set((s) => ({
|
|
354
|
+
playbackRequest: { playing, returnTo, nonce: (s.playbackRequest?.nonce ?? 0) + 1 },
|
|
355
|
+
})),
|
|
356
|
+
clearPlaybackRequest: () => set({ playbackRequest: null }),
|
|
357
|
+
|
|
358
|
+
clipRevealRequest: null,
|
|
359
|
+
requestClipReveal: (elementId) =>
|
|
360
|
+
set((s) => ({
|
|
361
|
+
clipRevealRequest: { elementId, nonce: (s.clipRevealRequest?.nonce ?? 0) + 1 },
|
|
362
|
+
})),
|
|
363
|
+
clearClipRevealRequest: () => set({ clipRevealRequest: null }),
|
|
364
|
+
|
|
376
365
|
timelineFocus: null,
|
|
377
366
|
timelineFocusNonce: 0,
|
|
378
367
|
requestTimelineFocus: (id) =>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One row in the timeline: a clip as Studio needs it, translated from the
|
|
3
|
+
* runtime's manifest at createTimelineElementFromManifestClip.
|
|
4
|
+
*
|
|
5
|
+
* Split out of playerStore, which had reached the 600-line studio ceiling and
|
|
6
|
+
* could not carry another field. Re-exported from there, so every existing
|
|
7
|
+
* importer is unaffected.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ClipManifestClip } from "../lib/playbackTypes";
|
|
11
|
+
|
|
12
|
+
export interface TimelineElement {
|
|
13
|
+
id: string;
|
|
14
|
+
label?: string;
|
|
15
|
+
key?: string;
|
|
16
|
+
kind?: ClipManifestClip["kind"];
|
|
17
|
+
tag: string;
|
|
18
|
+
start: number;
|
|
19
|
+
duration: number;
|
|
20
|
+
track: number;
|
|
21
|
+
/**
|
|
22
|
+
* The data-track-index as written in the source file. Set at the manifest
|
|
23
|
+
* translation boundary (createTimelineElementFromManifestClip) from the
|
|
24
|
+
* runtime clip's verbatim track, and preserved through display-lane remaps
|
|
25
|
+
* (normalizeToZones packs sparse authored tracks onto contiguous display
|
|
26
|
+
* lanes; expanded sub-comp children get synthetic display rows). Lane edits
|
|
27
|
+
* must persist THIS space — writing a display-lane number into a sparse file
|
|
28
|
+
* re-targets the wrong track. For an expanded child the value is in its OWN
|
|
29
|
+
* source file's coordinate space, not the host timeline's.
|
|
30
|
+
*/
|
|
31
|
+
authoredTrack?: number;
|
|
32
|
+
/** Resolved z-index for stacking-aware timeline ordering. */
|
|
33
|
+
zIndex?: number;
|
|
34
|
+
/** True when the effective z-index was authored inline or through CSS, not auto. */
|
|
35
|
+
hasExplicitZIndex?: boolean;
|
|
36
|
+
/** Canonical CSS stacking context this element's z-index participates in. */
|
|
37
|
+
stackingContextId?: string | null;
|
|
38
|
+
/** Nearest parent composition context, matching RuntimeTimelineClip. */
|
|
39
|
+
parentCompositionId?: string | null;
|
|
40
|
+
/** Composition ancestry from root to nearest parent, matching RuntimeTimelineClip. */
|
|
41
|
+
compositionAncestors?: string[];
|
|
42
|
+
domId?: string;
|
|
43
|
+
/** Stable `data-hf-id` attribute value — used as primary patch target when present */
|
|
44
|
+
hfId?: string;
|
|
45
|
+
/** Best-effort selector used when patching source HTML back from timeline edits */
|
|
46
|
+
selector?: string;
|
|
47
|
+
/** Zero-based occurrence index for non-unique selectors */
|
|
48
|
+
selectorIndex?: number;
|
|
49
|
+
/** Source composition file that owns this element, when known */
|
|
50
|
+
sourceFile?: string;
|
|
51
|
+
src?: string;
|
|
52
|
+
playbackStart?: number;
|
|
53
|
+
playbackStartAttr?: "media-start" | "playback-start";
|
|
54
|
+
playbackRate?: number;
|
|
55
|
+
sourceDuration?: number;
|
|
56
|
+
volume?: number;
|
|
57
|
+
/** Verbatim `data-fx-chain` / `data-automation`; see automationLaneData. */
|
|
58
|
+
fxChain?: string;
|
|
59
|
+
automation?: string;
|
|
60
|
+
/** Path from data-composition-src — identifies sub-composition elements */
|
|
61
|
+
compositionSrc?: string;
|
|
62
|
+
/** Whether this row came from authored clip timing or Studio's full-duration layer fallback. */
|
|
63
|
+
timingSource?: "authored" | "implicit";
|
|
64
|
+
/** Set by data-timeline-locked on the host element — disables move and trim in Studio. */
|
|
65
|
+
timelineLocked?: boolean;
|
|
66
|
+
/** Set by data-hidden on the host element — hides the clip in preview and render. */
|
|
67
|
+
hidden?: boolean;
|
|
68
|
+
/** Value of data-timeline-role attribute — used to identify music vs. voiceover. */
|
|
69
|
+
timelineRole?: string;
|
|
70
|
+
/**
|
|
71
|
+
* Set by useExpandedTimelineElements on an inline-expanded sub-composition
|
|
72
|
+
* child: the absolute master-timeline start of the sub-comp host the child
|
|
73
|
+
* lives in. Presence marks the element as expanded; edits subtract it to get
|
|
74
|
+
* the child's local (sourceFile-relative) time. Works at any nesting depth.
|
|
75
|
+
*/
|
|
76
|
+
expandedParentStart?: number;
|
|
77
|
+
expandedHostKey?: string;
|
|
78
|
+
}
|
package/src/styles/studio.css
CHANGED
|
@@ -469,3 +469,92 @@ body {
|
|
|
469
469
|
animation: none;
|
|
470
470
|
}
|
|
471
471
|
}
|
|
472
|
+
|
|
473
|
+
/* Preset shelf: the hovered row, and the wave that says it is being heard.
|
|
474
|
+
*
|
|
475
|
+
* Hovering a preset auditions it on the running audio, which is invisible — the
|
|
476
|
+
* panel looks identical whether the audition is playing or the pointer just
|
|
477
|
+
* happens to be resting there. The bars are that feedback: they run only while a
|
|
478
|
+
* preset is actually sounding, so they are a readout of the audio rather than a
|
|
479
|
+
* hover decoration. */
|
|
480
|
+
.hf-fx-preset-item {
|
|
481
|
+
position: relative;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
485
|
+
.hf-fx-preset-item {
|
|
486
|
+
transition:
|
|
487
|
+
background-color 120ms ease,
|
|
488
|
+
color 120ms ease;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
.hf-fx-preset-item:hover,
|
|
493
|
+
.hf-fx-preset-item:focus-visible {
|
|
494
|
+
/* The accent at low alpha rather than a grey: the row is lit by the thing that
|
|
495
|
+
is playing, and it should read as the same event as the bars. */
|
|
496
|
+
background-color: rgba(60, 230, 172, 0.12);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
.hf-fx-preset-wave {
|
|
500
|
+
position: absolute;
|
|
501
|
+
top: 50%;
|
|
502
|
+
right: 0.5rem;
|
|
503
|
+
display: flex;
|
|
504
|
+
align-items: center;
|
|
505
|
+
gap: 2px;
|
|
506
|
+
height: 0.75rem;
|
|
507
|
+
transform: translateY(-50%);
|
|
508
|
+
opacity: 0;
|
|
509
|
+
transition: opacity 120ms ease;
|
|
510
|
+
pointer-events: none;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
.hf-fx-preset-item:hover .hf-fx-preset-wave,
|
|
514
|
+
.hf-fx-preset-item:focus-visible .hf-fx-preset-wave {
|
|
515
|
+
opacity: 1;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
.hf-fx-preset-wave span {
|
|
519
|
+
width: 2px;
|
|
520
|
+
height: 100%;
|
|
521
|
+
border-radius: 1px;
|
|
522
|
+
background: #3ce6ac;
|
|
523
|
+
transform: scaleY(0.25);
|
|
524
|
+
transform-origin: center;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
528
|
+
.hf-fx-preset-wave span {
|
|
529
|
+
animation: hf-fx-preset-wave 900ms ease-in-out infinite;
|
|
530
|
+
}
|
|
531
|
+
/* Staggered so it reads as a waveform travelling rather than four bars
|
|
532
|
+
pumping in unison, which reads as a loading spinner. */
|
|
533
|
+
.hf-fx-preset-wave span:nth-child(2) {
|
|
534
|
+
animation-delay: 150ms;
|
|
535
|
+
}
|
|
536
|
+
.hf-fx-preset-wave span:nth-child(3) {
|
|
537
|
+
animation-delay: 300ms;
|
|
538
|
+
}
|
|
539
|
+
.hf-fx-preset-wave span:nth-child(4) {
|
|
540
|
+
animation-delay: 450ms;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/* Still visible without motion — it is telling the author something is
|
|
545
|
+
playing, and that fact does not go away because the bars hold still. */
|
|
546
|
+
@media (prefers-reduced-motion: reduce) {
|
|
547
|
+
.hf-fx-preset-wave span {
|
|
548
|
+
transform: scaleY(0.7);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
@keyframes hf-fx-preset-wave {
|
|
553
|
+
0%,
|
|
554
|
+
100% {
|
|
555
|
+
transform: scaleY(0.25);
|
|
556
|
+
}
|
|
557
|
+
50% {
|
|
558
|
+
transform: scaleY(1);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
agentRuntimeProperty,
|
|
6
|
+
resetAgentRuntimeForTests,
|
|
7
|
+
resolveAgentRuntime,
|
|
8
|
+
} from "./agentRuntime";
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
resetAgentRuntimeForTests();
|
|
12
|
+
delete window.__HF_CLI_AGENT_RUNTIME;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("the agent driving this studio", () => {
|
|
16
|
+
it("reads what the CLI published", () => {
|
|
17
|
+
window.__HF_CLI_AGENT_RUNTIME = "claude_code";
|
|
18
|
+
expect(resolveAgentRuntime()).toBe("claude_code");
|
|
19
|
+
expect(agentRuntimeProperty()).toBe("claude_code");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("is null when nothing published one — a studio opened by hand", () => {
|
|
23
|
+
expect(resolveAgentRuntime()).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("treats an empty string as nothing, not as a value", () => {
|
|
27
|
+
// Two encodings of the same fact is what turns a breakdown into a phantom
|
|
28
|
+
// gap; "" and absent must not be separate rows.
|
|
29
|
+
window.__HF_CLI_AGENT_RUNTIME = "";
|
|
30
|
+
expect(resolveAgentRuntime()).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("reports 'none' rather than nothing as a property", () => {
|
|
34
|
+
// Most sessions are people, and that is a finding. An omitted property
|
|
35
|
+
// could not tell it apart from an event that predates the property.
|
|
36
|
+
expect(agentRuntimeProperty()).toBe("none");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("memoizes, so a later overwrite cannot split one session in two", () => {
|
|
40
|
+
window.__HF_CLI_AGENT_RUNTIME = "codex";
|
|
41
|
+
expect(resolveAgentRuntime()).toBe("codex");
|
|
42
|
+
window.__HF_CLI_AGENT_RUNTIME = "cursor";
|
|
43
|
+
expect(resolveAgentRuntime()).toBe("codex");
|
|
44
|
+
});
|
|
45
|
+
});
|