@hyperframes/studio 0.8.46 → 0.8.48
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-CtVX6jL_.js +464 -0
- package/dist/assets/{index-CbchRscN.js → index-B3-orIjW.js} +1 -1
- package/dist/assets/index-B9LfWNyM.css +1 -0
- package/dist/assets/index-C9gJKpqQ.js +441 -0
- package/dist/assets/{index-BywY2OM3.js → index-D_j-0PD3.js} +1 -1
- package/dist/index.d.ts +83 -26
- package/dist/index.html +2 -2
- package/dist/index.js +1118 -661
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +17 -26
- package/src/components/AskAgentModal.tsx +9 -7
- package/src/components/TimelineToolbar.tsx +26 -1
- package/src/components/editor/PropertyPanelFlat.tsx +8 -8
- package/src/components/editor/domEditing.ts +7 -1
- package/src/components/editor/domEditingAgentPrompt.test.ts +199 -0
- package/src/components/editor/domEditingAgentPrompt.ts +136 -34
- package/src/components/editor/manualEditingAvailability.test.ts +19 -0
- package/src/components/editor/manualEditingAvailability.ts +9 -5
- package/src/components/editor/propertyPanelAutomation.test.ts +82 -1
- package/src/components/editor/propertyPanelAutomation.ts +15 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +35 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +23 -9
- package/src/components/editor/useVolumeAutomation.test.tsx +81 -10
- package/src/components/editor/useVolumeAutomation.ts +23 -2
- package/src/components/panels/VariablesPanel.tsx +5 -8
- package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +59 -0
- package/src/components/sidebar/CompositionsTab.tsx +15 -0
- package/src/components/sidebar/LeftSidebar.masterComposition.test.tsx +47 -0
- package/src/components/sidebar/LeftSidebar.tsx +7 -0
- package/src/contexts/FileManagerContext.tsx +0 -3
- package/src/hooks/appHotkeysDispatch.ts +256 -0
- package/src/hooks/domEditDeleteMembers.test.ts +30 -0
- package/src/hooks/domEditDeleteMembers.ts +19 -0
- package/src/hooks/useActiveComposition.ts +69 -0
- package/src/hooks/useAppHotkeys.test.ts +23 -1
- package/src/hooks/useAppHotkeys.ts +6 -239
- package/src/hooks/useAutoOpenRootComposition.test.tsx +151 -0
- package/src/hooks/useAutoOpenRootComposition.ts +33 -0
- package/src/hooks/useDomEditCommits.ts +13 -15
- package/src/hooks/useDomEditSession.test.tsx +90 -1
- package/src/hooks/useDomEditSession.ts +31 -34
- package/src/hooks/useDomSelectionTypes.ts +7 -2
- package/src/hooks/useElementLifecycleOps.ts +0 -8
- package/src/hooks/useFileManager.projectOwnership.test.tsx +0 -1
- package/src/hooks/useFileManager.ts +0 -2
- package/src/hooks/useFileTree.test.tsx +214 -0
- package/src/hooks/useFileTree.ts +66 -24
- package/src/hooks/useGroupCommits.test.ts +98 -0
- package/src/hooks/useGroupCommits.ts +66 -9
- package/src/hooks/useResetSelectionOnProjectSwitch.test.tsx +234 -0
- package/src/hooks/useResetSelectionOnProjectSwitch.ts +34 -0
- package/src/hooks/useSdkSession.lifecycle.test.tsx +76 -0
- package/src/hooks/useSdkSession.ts +21 -3
- package/src/hooks/useTimelineAssetDropOps.test.tsx +72 -0
- package/src/hooks/useTimelineAssetDropOps.ts +20 -16
- package/src/hooks/useTimelineDeleteOps.test.tsx +119 -0
- package/src/hooks/useTimelineDeleteOps.ts +103 -9
- package/src/hooks/useTimelineEditing.ts +1 -0
- package/src/hooks/useTimelineGroupEditing.test.tsx +74 -0
- package/src/hooks/useTimelineGroupEditing.ts +19 -5
- package/src/index.ts +10 -0
- package/src/player/components/Timeline.test.ts +0 -4
- package/src/player/components/Timeline.tsx +0 -1
- package/src/player/components/TimelineAutomationLaneSlot.tsx +4 -2
- package/src/player/components/TimelineClipDiamonds.test.tsx +13 -1
- package/src/player/components/TimelineDiamondConnectors.test.ts +16 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +17 -1
- package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
- package/src/player/components/automationLaneData.test.ts +11 -3
- package/src/player/components/automationLaneData.ts +5 -4
- package/src/player/components/automationLaneGeometry.ts +15 -0
- package/src/player/components/timelineDragDrop.test.tsx +21 -4
- package/src/player/components/timelineDragDrop.ts +9 -25
- package/src/player/components/timelineGapCommit.test.ts +96 -0
- package/src/player/components/timelineGapCommit.ts +56 -11
- package/src/player/components/timelineGaps.ts +2 -2
- package/src/player/components/timelineLayout.test.ts +0 -1
- package/src/player/components/timelineLayout.ts +1 -6
- package/src/player/components/timelineZones.test.ts +20 -1
- package/src/player/components/timelineZones.ts +10 -3
- package/src/player/components/useAutomationLaneGestures.ts +2 -10
- package/src/player/components/useTimelineTrackLayout.ts +7 -6
- package/src/player/hooks/usePlaybackKeyboard.test.ts +34 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +13 -0
- package/src/player/hooks/useTimelinePlayer.ts +9 -4
- package/src/player/hooks/useTimelineSyncCallbacks.ts +7 -4
- package/src/player/lib/timelineIframeHelpers.test.ts +22 -0
- package/src/player/lib/timelineIframeHelpers.ts +1 -0
- package/src/player/store/playerStore.test.ts +42 -0
- package/src/player/store/playerStore.ts +25 -28
- package/src/player/store/readinessSlice.ts +42 -0
- package/src/utils/razorSplitTransaction.test.ts +10 -0
- package/src/utils/razorSplitTransaction.ts +5 -0
- package/src/utils/sdkCutover.gate.test.ts +5 -4
- package/src/utils/sdkCutover.test.ts +93 -0
- package/src/utils/sdkCutover.ts +46 -25
- package/src/utils/sdkCutoverEligibility.ts +21 -0
- package/src/utils/sdkCutoverPolicy.test.ts +11 -2
- package/src/utils/sdkCutoverPolicy.ts +5 -3
- package/src/utils/sdkEditTransaction.decline.test.ts +46 -0
- package/src/utils/sdkEditTransaction.ts +22 -1
- package/src/utils/sdkResolverShadow.ts +10 -1
- package/src/utils/studioHelpers.test.ts +18 -0
- package/src/utils/studioHelpers.ts +41 -5
- package/src/utils/studioUiPreferences.test.ts +14 -0
- package/src/utils/studioUiPreferences.ts +6 -0
- package/src/utils/studioUrlState.ts +15 -7
- package/src/utils/timelineInspector.ts +10 -0
- package/dist/assets/hyperframes-player-CsV2rywT.js +0 -464
- package/dist/assets/index-DCblmTX3.js +0 -441
- package/dist/assets/index-DnRfAiK2.css +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.48",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -48,11 +48,11 @@
|
|
|
48
48
|
"gsap": "^3.13.0",
|
|
49
49
|
"marked": "^14.1.4",
|
|
50
50
|
"mediabunny": "^1.45.3",
|
|
51
|
-
"@hyperframes/core": "0.8.
|
|
52
|
-
"@hyperframes/parsers": "0.8.
|
|
53
|
-
"@hyperframes/player": "0.8.
|
|
54
|
-
"@hyperframes/studio-server": "0.8.
|
|
55
|
-
"@hyperframes/sdk": "0.8.
|
|
51
|
+
"@hyperframes/core": "0.8.48",
|
|
52
|
+
"@hyperframes/parsers": "0.8.48",
|
|
53
|
+
"@hyperframes/player": "0.8.48",
|
|
54
|
+
"@hyperframes/studio-server": "0.8.48",
|
|
55
|
+
"@hyperframes/sdk": "0.8.48"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@types/react": "19",
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
"vite": "^6.4.2",
|
|
70
70
|
"vitest": "^4.1.11",
|
|
71
71
|
"zustand": "^5.0.0",
|
|
72
|
-
"@hyperframes/producer": "0.8.
|
|
72
|
+
"@hyperframes/producer": "0.8.48"
|
|
73
73
|
},
|
|
74
74
|
"peerDependencies": {
|
|
75
75
|
"react": "19",
|
package/src/App.tsx
CHANGED
|
@@ -36,7 +36,6 @@ import { useFrameCapture } from "./hooks/useFrameCapture";
|
|
|
36
36
|
import { useLintModal } from "./hooks/useLintModal";
|
|
37
37
|
import { useCompositionDimensions } from "./hooks/useCompositionDimensions";
|
|
38
38
|
import { useToast } from "./hooks/useToast";
|
|
39
|
-
import { useCompositionContentLoader } from "./hooks/useCompositionContentLoader";
|
|
40
39
|
import { useStudioUrlState } from "./hooks/useStudioUrlState";
|
|
41
40
|
import { useEffectiveTimelineDuration } from "./hooks/useEffectiveTimelineDuration";
|
|
42
41
|
import {
|
|
@@ -63,7 +62,7 @@ import { useServerConnection } from "./hooks/useServerConnection";
|
|
|
63
62
|
import { useStudioSessionStart } from "./hooks/useStudioSessionStart";
|
|
64
63
|
import { useTimelineAddAtPlayhead } from "./hooks/useTimelineAddAtPlayhead";
|
|
65
64
|
import { readStudioUrlStateFromWindow, resolveMasterCompositionPath } from "./utils/studioUrlState";
|
|
66
|
-
import {
|
|
65
|
+
import { useActiveComposition } from "./hooks/useActiveComposition";
|
|
67
66
|
const getTimelineSelectionSet = () => usePlayerStore.getState().selectedElementIds;
|
|
68
67
|
// fallow-ignore-next-line complexity
|
|
69
68
|
export function StudioApp() {
|
|
@@ -71,10 +70,6 @@ export function StudioApp() {
|
|
|
71
70
|
const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
|
|
72
71
|
const viewModeValue = useViewModeState();
|
|
73
72
|
useStudioSessionStart(projectId, resolving, waitingForServer);
|
|
74
|
-
const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
|
|
75
|
-
const [activeCompPathHydrated, setActiveCompPathHydrated] = useState(
|
|
76
|
-
() => initialUrlStateRef.current.activeCompPath == null,
|
|
77
|
-
);
|
|
78
73
|
const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
|
|
79
74
|
const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
|
|
80
75
|
const [compositionLoading, setCompositionLoading] = useState(true);
|
|
@@ -82,10 +77,7 @@ export function StudioApp() {
|
|
|
82
77
|
const [previewDocumentVersion, refreshPreviewDocumentVersion] = usePreviewDocumentVersion();
|
|
83
78
|
const [blockPreview, setBlockPreview] = useState<BlockPreviewInfo | null>(null);
|
|
84
79
|
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
85
|
-
const activeCompPathRef = useRef(activeCompPath);
|
|
86
|
-
activeCompPathRef.current = activeCompPath;
|
|
87
80
|
const leftSidebarRef = useRef<LeftSidebarHandle>(null);
|
|
88
|
-
const renderQueue = useRenderQueue(projectId, activeCompPathRef);
|
|
89
81
|
const captionEditMode = useCaptionStore((s) => s.isEditMode);
|
|
90
82
|
const captionHasSelection = useCaptionStore((s) => s.selectedSegmentIds.size > 0);
|
|
91
83
|
const captionSync = useCaptionSync(projectId);
|
|
@@ -115,22 +107,27 @@ export function StudioApp() {
|
|
|
115
107
|
setRefreshKey,
|
|
116
108
|
});
|
|
117
109
|
const masterCompPath = useMemo(
|
|
118
|
-
() => resolveMasterCompositionPath(fileManager.
|
|
119
|
-
[fileManager.
|
|
110
|
+
() => resolveMasterCompositionPath(fileManager.compositions),
|
|
111
|
+
[fileManager.compositions],
|
|
120
112
|
);
|
|
113
|
+
const { activeCompPath, activeCompPathHydrated, setActiveCompPath, handleSelectComposition } =
|
|
114
|
+
useActiveComposition({
|
|
115
|
+
projectId,
|
|
116
|
+
initialUrlStateRef,
|
|
117
|
+
fileTree: fileManager.fileTree,
|
|
118
|
+
fileTreeLoaded: fileManager.fileTreeLoaded,
|
|
119
|
+
masterCompPath,
|
|
120
|
+
setEditingFile: fileManager.setEditingFile,
|
|
121
|
+
showToast,
|
|
122
|
+
});
|
|
121
123
|
const { sdkHandle, editFlowSdkSession } = useStudioSdkSessions(
|
|
122
124
|
projectId,
|
|
123
125
|
activeCompPath,
|
|
124
126
|
masterCompPath,
|
|
125
127
|
);
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
fileTree: fileManager.fileTree,
|
|
130
|
-
initialUrlStateRef,
|
|
131
|
-
setActiveCompPath,
|
|
132
|
-
setHydrated: setActiveCompPathHydrated,
|
|
133
|
-
});
|
|
128
|
+
const activeCompPathRef = useRef(activeCompPath);
|
|
129
|
+
activeCompPathRef.current = activeCompPath;
|
|
130
|
+
const renderQueue = useRenderQueue(projectId, activeCompPathRef);
|
|
134
131
|
const previewPersistence = usePreviewPersistence({
|
|
135
132
|
showToast,
|
|
136
133
|
readOptionalProjectFile: fileManager.readOptionalProjectFile,
|
|
@@ -295,6 +292,7 @@ export function StudioApp() {
|
|
|
295
292
|
sdkSession: editFlowSdkSession,
|
|
296
293
|
publishSdkSession: sdkHandle.publish,
|
|
297
294
|
forceReloadSdkSession: sdkHandle.forceReload,
|
|
295
|
+
handleTimelineElementsDelete: timelineEditing.handleTimelineElementsDelete,
|
|
298
296
|
});
|
|
299
297
|
domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
|
|
300
298
|
handleDomZIndexReorderCommitRef.current = domEditSession.handleDomZIndexReorderCommit;
|
|
@@ -372,13 +370,6 @@ export function StudioApp() {
|
|
|
372
370
|
},
|
|
373
371
|
[appHotkeys, resetConsoleErrors, refreshPreviewDocumentVersion],
|
|
374
372
|
);
|
|
375
|
-
const { setEditingFile } = fileManager;
|
|
376
|
-
const handleSelectComposition = useCompositionContentLoader({
|
|
377
|
-
projectId,
|
|
378
|
-
setEditingFile,
|
|
379
|
-
setActiveCompPath,
|
|
380
|
-
showToast,
|
|
381
|
-
});
|
|
382
373
|
const {
|
|
383
374
|
designPanelActive,
|
|
384
375
|
inspectorPanelActive,
|
|
@@ -25,19 +25,21 @@ function getAgentModalPositionStyle(
|
|
|
25
25
|
return { left, top, transform: "translateX(-50%)" };
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
export interface AskAgentModalProps {
|
|
29
|
+
selectionLabel: string;
|
|
30
|
+
contextPreview?: string;
|
|
31
|
+
anchorPoint?: AgentModalAnchorPoint | null;
|
|
32
|
+
onSubmit: (instruction: string) => void;
|
|
33
|
+
onClose: () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
28
36
|
export function AskAgentModal({
|
|
29
37
|
selectionLabel,
|
|
30
38
|
contextPreview,
|
|
31
39
|
anchorPoint = null,
|
|
32
40
|
onSubmit,
|
|
33
41
|
onClose,
|
|
34
|
-
}: {
|
|
35
|
-
selectionLabel: string;
|
|
36
|
-
contextPreview?: string;
|
|
37
|
-
anchorPoint?: AgentModalAnchorPoint | null;
|
|
38
|
-
onSubmit: (instruction: string) => void;
|
|
39
|
-
onClose: () => void;
|
|
40
|
-
}) {
|
|
42
|
+
}: AskAgentModalProps) {
|
|
41
43
|
const [value, setValue] = useState("");
|
|
42
44
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
43
45
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { useEffect, useRef } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
Image,
|
|
4
|
+
Magnet,
|
|
5
|
+
MagnifyingGlassMinus,
|
|
6
|
+
MagnifyingGlassPlus,
|
|
7
|
+
Waves,
|
|
8
|
+
} from "@phosphor-icons/react";
|
|
3
9
|
import {
|
|
4
10
|
useEnableKeyframes,
|
|
5
11
|
isPlayheadWithinTween,
|
|
@@ -126,6 +132,8 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
|
|
|
126
132
|
const setActiveTool = usePlayerStore((s) => s.setActiveTool);
|
|
127
133
|
const timelineSnapEnabled = usePlayerStore((s) => s.timelineSnapEnabled);
|
|
128
134
|
const setTimelineSnapEnabled = usePlayerStore((s) => s.setTimelineSnapEnabled);
|
|
135
|
+
const rippleEditEnabled = usePlayerStore((s) => s.rippleEditEnabled);
|
|
136
|
+
const setRippleEditEnabled = usePlayerStore((s) => s.setRippleEditEnabled);
|
|
129
137
|
const autoKeyframeEnabled = usePlayerStore((s) => s.autoKeyframeEnabled);
|
|
130
138
|
const setAutoKeyframeEnabled = usePlayerStore((s) => s.setAutoKeyframeEnabled);
|
|
131
139
|
const thumbnailMode = usePlayerStore((s) => s.thumbnailMode);
|
|
@@ -228,6 +236,23 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
|
|
|
228
236
|
<Magnet size={16} weight="bold" aria-hidden="true" />
|
|
229
237
|
</button>
|
|
230
238
|
</Tooltip>
|
|
239
|
+
<Tooltip
|
|
240
|
+
label={
|
|
241
|
+
rippleEditEnabled
|
|
242
|
+
? "Ripple on — keeps the main track gapless"
|
|
243
|
+
: "Ripple off — deleting a main-track clip leaves a gap"
|
|
244
|
+
}
|
|
245
|
+
>
|
|
246
|
+
<button
|
|
247
|
+
type="button"
|
|
248
|
+
onClick={() => setRippleEditEnabled(!rippleEditEnabled)}
|
|
249
|
+
aria-label="Toggle ripple edit"
|
|
250
|
+
aria-pressed={rippleEditEnabled}
|
|
251
|
+
className={rippleEditEnabled ? flatActive : flatIdle}
|
|
252
|
+
>
|
|
253
|
+
<Waves size={16} weight="bold" aria-hidden="true" />
|
|
254
|
+
</button>
|
|
255
|
+
</Tooltip>
|
|
231
256
|
{/* Always rendered (CapCut-style): with no keyframeable selection the
|
|
232
257
|
button fades to a disabled state instead of unmounting, so the
|
|
233
258
|
toolbar layout never shifts. */}
|
|
@@ -285,17 +285,17 @@ export function PropertyPanelFlat({
|
|
|
285
285
|
}
|
|
286
286
|
: null;
|
|
287
287
|
const audioSelection = isAudioDomElement(element.element);
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
// transform, an opacity or a box for a tween to move. Gated on the TAG, not on
|
|
292
|
-
// `sections.animation` (`animationCount > 0`): a div with no tweens yet must
|
|
293
|
-
// still offer "+ Add", so "has none" and "can have none" are different
|
|
294
|
-
// questions and only the second one belongs here.
|
|
288
|
+
// Gated on the tag, not `sections.animation` (`animationCount > 0`): an audio
|
|
289
|
+
// clip/bus has no tween to move, but a fresh div with no tweens yet must
|
|
290
|
+
// still offer "+ Add" — "has none" and "can have none" differ.
|
|
295
291
|
const showMotionEffects = gsapEffectHandlers !== null && !audioSelection;
|
|
296
292
|
const showMotionGroup = showMotionTiming || showMotionEffects;
|
|
297
293
|
|
|
298
|
-
const volumeAutomation = useVolumeAutomation(
|
|
294
|
+
const volumeAutomation = useVolumeAutomation(
|
|
295
|
+
element,
|
|
296
|
+
currentTime,
|
|
297
|
+
onSetAttributeQuiet ?? onSetAttributeLive,
|
|
298
|
+
);
|
|
299
299
|
|
|
300
300
|
// The group this clip belongs to, if any — the Audio FX summary reads
|
|
301
301
|
// "in Voiceover" for a member (see `audioFxSummary`). Membership lives on the
|
|
@@ -45,4 +45,10 @@ export {
|
|
|
45
45
|
} from "./domEditingLayers";
|
|
46
46
|
|
|
47
47
|
// Agent prompt
|
|
48
|
-
export {
|
|
48
|
+
export {
|
|
49
|
+
buildElementAgentPrompt,
|
|
50
|
+
buildAgentContextPreview,
|
|
51
|
+
buildPickerAgentPrompt,
|
|
52
|
+
buildPickerAgentContextPreview,
|
|
53
|
+
} from "./domEditingAgentPrompt";
|
|
54
|
+
export type { AgentPromptElementInfo } from "./domEditingAgentPrompt";
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import type { HyperframePickerElementInfo } from "@hyperframes/core";
|
|
5
|
+
import {
|
|
6
|
+
buildAgentContextPreview,
|
|
7
|
+
buildElementAgentPrompt,
|
|
8
|
+
buildPickerAgentContextPreview,
|
|
9
|
+
buildPickerAgentPrompt,
|
|
10
|
+
} from "./domEditingAgentPrompt";
|
|
11
|
+
import type { DomEditSelection } from "./domEditingTypes";
|
|
12
|
+
|
|
13
|
+
function makeSelection(overrides: Partial<DomEditSelection> = {}): DomEditSelection {
|
|
14
|
+
return {
|
|
15
|
+
element: document.createElement("div"),
|
|
16
|
+
label: "Headline",
|
|
17
|
+
tagName: "div",
|
|
18
|
+
sourceFile: "hero.html",
|
|
19
|
+
compositionPath: "hero.html",
|
|
20
|
+
isCompositionHost: false,
|
|
21
|
+
isInsideLockedComposition: false,
|
|
22
|
+
id: "headline-1",
|
|
23
|
+
selector: "#headline-1",
|
|
24
|
+
selectorIndex: 0,
|
|
25
|
+
boundingBox: { x: 10, y: 20, width: 300, height: 40 },
|
|
26
|
+
textContent: "Hello world",
|
|
27
|
+
dataAttributes: {},
|
|
28
|
+
inlineStyles: {},
|
|
29
|
+
computedStyles: {},
|
|
30
|
+
textFields: [],
|
|
31
|
+
capabilities: {
|
|
32
|
+
canSelect: true,
|
|
33
|
+
canEditStyles: true,
|
|
34
|
+
canCrop: false,
|
|
35
|
+
canMove: true,
|
|
36
|
+
canResize: true,
|
|
37
|
+
canApplyManualOffset: false,
|
|
38
|
+
canApplyManualSize: false,
|
|
39
|
+
canApplyManualRotation: false,
|
|
40
|
+
},
|
|
41
|
+
...overrides,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const PICKER_SELECTION: HyperframePickerElementInfo = {
|
|
46
|
+
id: "headline-1",
|
|
47
|
+
tagName: "div",
|
|
48
|
+
selector: "#headline-1",
|
|
49
|
+
label: "Headline",
|
|
50
|
+
boundingBox: { x: 10, y: 20, width: 300, height: 40 },
|
|
51
|
+
textContent: "Hello world",
|
|
52
|
+
src: null,
|
|
53
|
+
dataAttributes: {},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
describe("buildElementAgentPrompt", () => {
|
|
57
|
+
it("produces the documented v1 schema", () => {
|
|
58
|
+
const prompt = buildElementAgentPrompt({
|
|
59
|
+
selection: makeSelection(),
|
|
60
|
+
currentTime: 1.5,
|
|
61
|
+
userInstruction: "Make this bigger",
|
|
62
|
+
});
|
|
63
|
+
expect(prompt).toBe(
|
|
64
|
+
[
|
|
65
|
+
"## HyperFrames element edit request v1",
|
|
66
|
+
"Schema version: 1",
|
|
67
|
+
"",
|
|
68
|
+
"Make this bigger",
|
|
69
|
+
"",
|
|
70
|
+
"Composition: hero.html",
|
|
71
|
+
"Playback time: 00:01",
|
|
72
|
+
"Source file: hero.html",
|
|
73
|
+
"DOM id: headline-1",
|
|
74
|
+
"Selector: #headline-1",
|
|
75
|
+
"Selector index: 0",
|
|
76
|
+
"Tag: <div>",
|
|
77
|
+
"Bounds: x=10, y=20, width=300, height=40",
|
|
78
|
+
"Text: Hello world",
|
|
79
|
+
"",
|
|
80
|
+
"Guardrails:",
|
|
81
|
+
"- Make a targeted change to this element only.",
|
|
82
|
+
"- Preserve the rest of the composition and its timing.",
|
|
83
|
+
"- Do not modify other elements' data-* attributes or positioning.",
|
|
84
|
+
"- Prefer existing inline styles or existing CSS rules for this element over adding unrelated selectors.",
|
|
85
|
+
].join("\n"),
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("includes text fields, inline styles, computed styles and a target snippet when present", () => {
|
|
90
|
+
const prompt = buildElementAgentPrompt({
|
|
91
|
+
selection: makeSelection({
|
|
92
|
+
inlineStyles: { color: "red" },
|
|
93
|
+
computedStyles: { "font-size": "16px" },
|
|
94
|
+
textFields: [
|
|
95
|
+
{
|
|
96
|
+
key: "title",
|
|
97
|
+
label: "Title",
|
|
98
|
+
value: "Hello",
|
|
99
|
+
tagName: "h1",
|
|
100
|
+
attributes: [],
|
|
101
|
+
inlineStyles: {},
|
|
102
|
+
computedStyles: {},
|
|
103
|
+
source: "self",
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
}),
|
|
107
|
+
currentTime: 0,
|
|
108
|
+
tagSnippet: "<div id='headline-1'>Hello world</div>",
|
|
109
|
+
selectionContext: "Nested inside the hero card",
|
|
110
|
+
});
|
|
111
|
+
expect(prompt).toContain("Selection context:\nNested inside the hero card");
|
|
112
|
+
expect(prompt).toContain('Text fields:\n- key=title; tag=<h1>; source=self; text="Hello"');
|
|
113
|
+
expect(prompt).toContain("Inline styles:\ncolor: red");
|
|
114
|
+
expect(prompt).toContain("Computed styles (browser-resolved):\nfont-size: 16px");
|
|
115
|
+
expect(prompt).toContain("Target HTML:\n<div id='headline-1'>Hello world</div>");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("buildAgentContextPreview", () => {
|
|
120
|
+
it("summarizes composition, source, selector, tag and text", () => {
|
|
121
|
+
expect(buildAgentContextPreview(makeSelection(), "hero.html")).toBe(
|
|
122
|
+
[
|
|
123
|
+
"Composition: hero.html",
|
|
124
|
+
"Source: hero.html",
|
|
125
|
+
"Selector: #headline-1 Tag: <div>",
|
|
126
|
+
"Text: Hello world",
|
|
127
|
+
].join("\n"),
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("buildPickerAgentPrompt", () => {
|
|
133
|
+
it("produces a plain comment prompt when nothing is selected", () => {
|
|
134
|
+
const prompt = buildPickerAgentPrompt({ selection: null, userInstruction: "Add a border" });
|
|
135
|
+
expect(prompt).toBe(
|
|
136
|
+
["## HyperFrames element edit request v1", "Schema version: 1", "", "Add a border"].join(
|
|
137
|
+
"\n",
|
|
138
|
+
),
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("produces identical shared-field text to buildElementAgentPrompt for equivalent input", () => {
|
|
143
|
+
const domPrompt = buildElementAgentPrompt({
|
|
144
|
+
selection: makeSelection({ compositionPath: "", sourceFile: "" }),
|
|
145
|
+
currentTime: 0,
|
|
146
|
+
});
|
|
147
|
+
const pickerPrompt = buildPickerAgentPrompt({ selection: PICKER_SELECTION });
|
|
148
|
+
|
|
149
|
+
const sharedFields = [
|
|
150
|
+
"DOM id: headline-1",
|
|
151
|
+
"Selector: #headline-1",
|
|
152
|
+
"Tag: <div>",
|
|
153
|
+
"Bounds: x=10, y=20, width=300, height=40",
|
|
154
|
+
"Text: Hello world",
|
|
155
|
+
];
|
|
156
|
+
for (const line of sharedFields) {
|
|
157
|
+
expect(domPrompt).toContain(line);
|
|
158
|
+
expect(pickerPrompt).toContain(line);
|
|
159
|
+
}
|
|
160
|
+
// Both end on the identical guardrails block, byte for byte.
|
|
161
|
+
const guardrails = [
|
|
162
|
+
"Guardrails:",
|
|
163
|
+
"- Make a targeted change to this element only.",
|
|
164
|
+
"- Preserve the rest of the composition and its timing.",
|
|
165
|
+
"- Do not modify other elements' data-* attributes or positioning.",
|
|
166
|
+
"- Prefer existing inline styles or existing CSS rules for this element over adding unrelated selectors.",
|
|
167
|
+
].join("\n");
|
|
168
|
+
expect(domPrompt.endsWith(guardrails)).toBe(true);
|
|
169
|
+
expect(pickerPrompt.endsWith(guardrails)).toBe(true);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("surfaces the picker's label and media src — a host app has no DOM to read them from otherwise", () => {
|
|
173
|
+
const prompt = buildPickerAgentPrompt({
|
|
174
|
+
selection: { ...PICKER_SELECTION, tagName: "img", src: "assets/hero.png" },
|
|
175
|
+
});
|
|
176
|
+
expect(prompt).toContain("Label: Headline");
|
|
177
|
+
expect(prompt).toContain("Source (media): assets/hero.png");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("omits the label and source lines when the picker didn't report them", () => {
|
|
181
|
+
const prompt = buildPickerAgentPrompt({
|
|
182
|
+
selection: { ...PICKER_SELECTION, label: "", src: null },
|
|
183
|
+
});
|
|
184
|
+
expect(prompt).not.toContain("Label:");
|
|
185
|
+
expect(prompt).not.toContain("Source (media):");
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("buildPickerAgentContextPreview", () => {
|
|
190
|
+
it("returns an empty string when nothing is selected", () => {
|
|
191
|
+
expect(buildPickerAgentContextPreview(null)).toBe("");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("matches buildAgentContextPreview's selector/tag/text line format", () => {
|
|
195
|
+
expect(buildPickerAgentContextPreview(PICKER_SELECTION)).toBe(
|
|
196
|
+
["Selector: #headline-1 Tag: <div>", "Text: Hello world"].join("\n"),
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -1,10 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Agent prompt builder for HyperFrames element edit requests.
|
|
3
3
|
*/
|
|
4
|
+
import type { HyperframePickerElementInfo } from "@hyperframes/core";
|
|
4
5
|
import { formatTime } from "../../player/lib/time";
|
|
5
6
|
import type { DomEditSelection, DomEditTextField } from "./domEditingTypes";
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
/**
|
|
9
|
+
* The subset of an element selection shared by the Studio DOM editor (`DomEditSelection`) and the
|
|
10
|
+
* runtime picker (`HyperframePickerElementInfo`); a field absent from a caller's type is simply omitted.
|
|
11
|
+
*/
|
|
12
|
+
export interface AgentPromptElementInfo {
|
|
13
|
+
id: string | null;
|
|
14
|
+
selector?: string | null;
|
|
15
|
+
/** Disambiguates duplicate selectors in `DomEditSelection`. The picker
|
|
16
|
+
* payload has no such concept (one picked node, not a candidate list), so
|
|
17
|
+
* a picker-built prompt always prints index 0 — expected, not a gap. */
|
|
18
|
+
selectorIndex?: number;
|
|
19
|
+
tagName: string;
|
|
20
|
+
label?: string;
|
|
21
|
+
boundingBox: { x: number; y: number; width: number; height: number };
|
|
22
|
+
textContent: string | null;
|
|
23
|
+
src?: string | null;
|
|
24
|
+
textFields?: DomEditTextField[];
|
|
25
|
+
inlineStyles?: Record<string, string>;
|
|
26
|
+
computedStyles?: Record<string, string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const GUARDRAIL_LINES = [
|
|
30
|
+
"Guardrails:",
|
|
31
|
+
"- Make a targeted change to this element only.",
|
|
32
|
+
"- Preserve the rest of the composition and its timing.",
|
|
33
|
+
"- Do not modify other elements' data-* attributes or positioning.",
|
|
34
|
+
"- Prefer existing inline styles or existing CSS rules for this element over adding unrelated selectors.",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
function formatBoundingBox(bounds: AgentPromptElementInfo["boundingBox"]): string {
|
|
8
38
|
return `x=${Math.round(bounds.x)}, y=${Math.round(bounds.y)}, width=${Math.round(bounds.width)}, height=${Math.round(bounds.height)}`;
|
|
9
39
|
}
|
|
10
40
|
|
|
@@ -24,6 +54,41 @@ function formatTextFields(fields: DomEditTextField[]): string {
|
|
|
24
54
|
.join("\n");
|
|
25
55
|
}
|
|
26
56
|
|
|
57
|
+
function formatSelectorTagLine(info: Pick<AgentPromptElementInfo, "selector" | "tagName">): string {
|
|
58
|
+
return `Selector: ${info.selector ?? "(none)"} Tag: <${info.tagName}>`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function formatTextLine(textContent: string | null): string {
|
|
62
|
+
return textContent ? `Text: ${textContent}` : "";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Core identity fields, plus the text line when the element has text content. */
|
|
66
|
+
function buildElementInfoLines(info: AgentPromptElementInfo): string[] {
|
|
67
|
+
const lines = [
|
|
68
|
+
`DOM id: ${info.id ?? "(none)"}`,
|
|
69
|
+
`Selector: ${info.selector ?? "(none)"}`,
|
|
70
|
+
`Selector index: ${info.selectorIndex ?? 0}`,
|
|
71
|
+
`Tag: <${info.tagName}>`,
|
|
72
|
+
];
|
|
73
|
+
if (info.label) lines.push(`Label: ${info.label}`);
|
|
74
|
+
lines.push(`Bounds: ${formatBoundingBox(info.boundingBox)}`);
|
|
75
|
+
if (info.src) lines.push(`Source (media): ${info.src}`);
|
|
76
|
+
if (info.textContent) lines.push(`Text: ${info.textContent}`);
|
|
77
|
+
return lines;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Text fields, inline styles, computed styles — present when the caller supplies them. */
|
|
81
|
+
function buildElementDetailLines(info: AgentPromptElementInfo): string[] {
|
|
82
|
+
const lines: string[] = [];
|
|
83
|
+
const textFieldsBlock = info.textFields ? formatTextFields(info.textFields) : "";
|
|
84
|
+
if (textFieldsBlock) lines.push("", "Text fields:", textFieldsBlock);
|
|
85
|
+
const inlineStyleBlock = info.inlineStyles ? formatStyleBlock(info.inlineStyles) : "";
|
|
86
|
+
if (inlineStyleBlock) lines.push("", "Inline styles:", inlineStyleBlock);
|
|
87
|
+
const computedStyleBlock = info.computedStyles ? formatStyleBlock(info.computedStyles) : "";
|
|
88
|
+
if (computedStyleBlock) lines.push("", "Computed styles (browser-resolved):", computedStyleBlock);
|
|
89
|
+
return lines;
|
|
90
|
+
}
|
|
91
|
+
|
|
27
92
|
export function buildElementAgentPrompt({
|
|
28
93
|
selection,
|
|
29
94
|
currentTime,
|
|
@@ -40,6 +105,18 @@ export function buildElementAgentPrompt({
|
|
|
40
105
|
sourceFilePath?: string;
|
|
41
106
|
}): string {
|
|
42
107
|
const displayedSourceFile = sourceFilePath?.trim() || selection.sourceFile;
|
|
108
|
+
const info: AgentPromptElementInfo = {
|
|
109
|
+
id: selection.id ?? null,
|
|
110
|
+
selector: selection.selector,
|
|
111
|
+
selectorIndex: selection.selectorIndex,
|
|
112
|
+
tagName: selection.tagName,
|
|
113
|
+
boundingBox: selection.boundingBox,
|
|
114
|
+
textContent: selection.textContent,
|
|
115
|
+
textFields: selection.textFields,
|
|
116
|
+
inlineStyles: selection.inlineStyles,
|
|
117
|
+
computedStyles: selection.computedStyles,
|
|
118
|
+
};
|
|
119
|
+
|
|
43
120
|
const lines = [
|
|
44
121
|
"## HyperFrames element edit request v1",
|
|
45
122
|
"Schema version: 1",
|
|
@@ -49,53 +126,31 @@ export function buildElementAgentPrompt({
|
|
|
49
126
|
`Composition: ${selection.compositionPath}`,
|
|
50
127
|
`Playback time: ${formatTime(currentTime)}`,
|
|
51
128
|
`Source file: ${displayedSourceFile}`,
|
|
52
|
-
|
|
53
|
-
`Selector: ${selection.selector ?? "(none)"}`,
|
|
54
|
-
`Selector index: ${selection.selectorIndex ?? 0}`,
|
|
55
|
-
`Tag: <${selection.tagName}>`,
|
|
56
|
-
`Bounds: ${formatBoundingBox(selection.boundingBox)}`,
|
|
129
|
+
...buildElementInfoLines(info),
|
|
57
130
|
];
|
|
58
131
|
|
|
59
|
-
if (selection.textContent) {
|
|
60
|
-
lines.push(`Text: ${selection.textContent}`);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
132
|
const trimmedSelectionContext = selectionContext?.trim();
|
|
64
133
|
if (trimmedSelectionContext) {
|
|
65
134
|
lines.push("", "Selection context:", trimmedSelectionContext);
|
|
66
135
|
}
|
|
67
136
|
|
|
68
|
-
|
|
69
|
-
if (textFieldsBlock) {
|
|
70
|
-
lines.push("", "Text fields:", textFieldsBlock);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const inlineStyleBlock = formatStyleBlock(selection.inlineStyles);
|
|
74
|
-
if (inlineStyleBlock) {
|
|
75
|
-
lines.push("", "Inline styles:", inlineStyleBlock);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const computedStyleBlock = formatStyleBlock(selection.computedStyles);
|
|
79
|
-
if (computedStyleBlock) {
|
|
80
|
-
lines.push("", "Computed styles (browser-resolved):", computedStyleBlock);
|
|
81
|
-
}
|
|
137
|
+
lines.push(...buildElementDetailLines(info));
|
|
82
138
|
|
|
83
139
|
if (tagSnippet) {
|
|
84
140
|
lines.push("", "Target HTML:", tagSnippet);
|
|
85
141
|
}
|
|
86
142
|
|
|
87
|
-
lines.push(
|
|
88
|
-
"",
|
|
89
|
-
"Guardrails:",
|
|
90
|
-
"- Make a targeted change to this element only.",
|
|
91
|
-
"- Preserve the rest of the composition and its timing.",
|
|
92
|
-
"- Do not modify other elements' data-* attributes or positioning.",
|
|
93
|
-
"- Prefer existing inline styles or existing CSS rules for this element over adding unrelated selectors.",
|
|
94
|
-
);
|
|
143
|
+
lines.push("", ...GUARDRAIL_LINES);
|
|
95
144
|
|
|
96
145
|
return lines.join("\n");
|
|
97
146
|
}
|
|
98
147
|
|
|
148
|
+
function buildSelectorAndTextLines(
|
|
149
|
+
info: Pick<AgentPromptElementInfo, "selector" | "tagName" | "textContent">,
|
|
150
|
+
): string[] {
|
|
151
|
+
return [formatSelectorTagLine(info), formatTextLine(info.textContent)];
|
|
152
|
+
}
|
|
153
|
+
|
|
99
154
|
export function buildAgentContextPreview(
|
|
100
155
|
selection: DomEditSelection,
|
|
101
156
|
activeCompPath: string | null,
|
|
@@ -103,9 +158,56 @@ export function buildAgentContextPreview(
|
|
|
103
158
|
return [
|
|
104
159
|
`Composition: ${selection.compositionPath}`,
|
|
105
160
|
`Source: ${selection.sourceFile || activeCompPath || "index.html"}`,
|
|
106
|
-
|
|
107
|
-
selection.textContent ? `Text: ${selection.textContent}` : "",
|
|
161
|
+
...buildSelectorAndTextLines(selection),
|
|
108
162
|
]
|
|
109
163
|
.filter(Boolean)
|
|
110
164
|
.join("\n");
|
|
111
165
|
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Same convention as `buildElementAgentPrompt`, for a host app embedding only the player: built from
|
|
169
|
+
* the runtime picker's own payload, not `DomEditSelection`. Null selection yields a bare prompt, no guardrails.
|
|
170
|
+
*/
|
|
171
|
+
export function buildPickerAgentPrompt({
|
|
172
|
+
selection,
|
|
173
|
+
userInstruction,
|
|
174
|
+
}: {
|
|
175
|
+
selection: HyperframePickerElementInfo | null;
|
|
176
|
+
userInstruction?: string;
|
|
177
|
+
}): string {
|
|
178
|
+
const lines = [
|
|
179
|
+
"## HyperFrames element edit request v1",
|
|
180
|
+
"Schema version: 1",
|
|
181
|
+
"",
|
|
182
|
+
userInstruction?.trim() || "Edit this selected HyperFrames element.",
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
if (!selection) return lines.join("\n");
|
|
186
|
+
|
|
187
|
+
const info: AgentPromptElementInfo = {
|
|
188
|
+
id: selection.id,
|
|
189
|
+
selector: selection.selector,
|
|
190
|
+
tagName: selection.tagName,
|
|
191
|
+
label: selection.label,
|
|
192
|
+
boundingBox: selection.boundingBox,
|
|
193
|
+
textContent: selection.textContent,
|
|
194
|
+
src: selection.src,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
lines.push(
|
|
198
|
+
"",
|
|
199
|
+
...buildElementInfoLines(info),
|
|
200
|
+
...buildElementDetailLines(info),
|
|
201
|
+
"",
|
|
202
|
+
...GUARDRAIL_LINES,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
return lines.join("\n");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function buildPickerAgentContextPreview(
|
|
209
|
+
selection: HyperframePickerElementInfo | null,
|
|
210
|
+
): string {
|
|
211
|
+
if (!selection) return "";
|
|
212
|
+
return buildSelectorAndTextLines(selection).filter(Boolean).join("\n");
|
|
213
|
+
}
|