@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
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { useState, useCallback, useEffect, useRef } from "react";
|
|
2
|
+
import { useDialogBehavior } from "../ui/useDialogBehavior";
|
|
3
|
+
|
|
4
|
+
export function PromptPreviewModal({
|
|
5
|
+
title,
|
|
6
|
+
prompt,
|
|
7
|
+
onClose,
|
|
8
|
+
}: {
|
|
9
|
+
title: string;
|
|
10
|
+
prompt: string;
|
|
11
|
+
onClose: () => void;
|
|
12
|
+
}) {
|
|
13
|
+
const [value, setValue] = useState(prompt);
|
|
14
|
+
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle");
|
|
15
|
+
const [entered, setEntered] = useState(false);
|
|
16
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
17
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
18
|
+
const valueRef = useRef(value);
|
|
19
|
+
valueRef.current = value;
|
|
20
|
+
|
|
21
|
+
// Escape / focus trap / focus restore. A dirty draft vetoes Escape and
|
|
22
|
+
// backdrop close so a stray click can't discard edits (the X still closes).
|
|
23
|
+
const { requestClose } = useDialogBehavior({
|
|
24
|
+
open: true,
|
|
25
|
+
onClose,
|
|
26
|
+
containerRef,
|
|
27
|
+
canClose: () => valueRef.current === prompt,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
requestAnimationFrame(() => {
|
|
32
|
+
setEntered(true);
|
|
33
|
+
textareaRef.current?.focus();
|
|
34
|
+
});
|
|
35
|
+
}, []);
|
|
36
|
+
|
|
37
|
+
const handleCopy = useCallback(async () => {
|
|
38
|
+
try {
|
|
39
|
+
await navigator.clipboard.writeText(valueRef.current);
|
|
40
|
+
setCopyState("copied");
|
|
41
|
+
} catch {
|
|
42
|
+
setCopyState("failed");
|
|
43
|
+
}
|
|
44
|
+
setTimeout(() => setCopyState("idle"), 1500);
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div
|
|
49
|
+
className={`fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm transition-opacity duration-150 ease-out ${
|
|
50
|
+
entered ? "opacity-100" : "opacity-0"
|
|
51
|
+
}`}
|
|
52
|
+
onClick={requestClose}
|
|
53
|
+
>
|
|
54
|
+
<div
|
|
55
|
+
ref={containerRef}
|
|
56
|
+
role="dialog"
|
|
57
|
+
aria-modal="true"
|
|
58
|
+
aria-label={`Ask agent — ${title}`}
|
|
59
|
+
tabIndex={-1}
|
|
60
|
+
className="w-[560px] max-h-[80vh] flex flex-col rounded-2xl border border-neutral-800 bg-neutral-950 shadow-2xl outline-none"
|
|
61
|
+
onClick={(e) => e.stopPropagation()}
|
|
62
|
+
>
|
|
63
|
+
<div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800/60">
|
|
64
|
+
<div>
|
|
65
|
+
<h3 className="text-sm font-medium text-neutral-200">Ask agent</h3>
|
|
66
|
+
<p className="text-xs text-neutral-500 mt-0.5">{title}</p>
|
|
67
|
+
</div>
|
|
68
|
+
<button
|
|
69
|
+
aria-label="Close"
|
|
70
|
+
className="p-1 rounded-md text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800/50 active:scale-[0.95]"
|
|
71
|
+
onClick={onClose}
|
|
72
|
+
>
|
|
73
|
+
<svg
|
|
74
|
+
width="14"
|
|
75
|
+
height="14"
|
|
76
|
+
viewBox="0 0 24 24"
|
|
77
|
+
fill="none"
|
|
78
|
+
stroke="currentColor"
|
|
79
|
+
strokeWidth="2"
|
|
80
|
+
strokeLinecap="round"
|
|
81
|
+
aria-hidden="true"
|
|
82
|
+
>
|
|
83
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
84
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
85
|
+
</svg>
|
|
86
|
+
</button>
|
|
87
|
+
</div>
|
|
88
|
+
<div className="flex-1 overflow-y-auto px-5 py-4">
|
|
89
|
+
<p className="text-[11px] text-neutral-500 mb-2">
|
|
90
|
+
Edit the prompt below, then copy and paste into your AI agent
|
|
91
|
+
</p>
|
|
92
|
+
<textarea
|
|
93
|
+
ref={textareaRef}
|
|
94
|
+
value={value}
|
|
95
|
+
onChange={(e) => setValue(e.target.value)}
|
|
96
|
+
onKeyDown={(e) => {
|
|
97
|
+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) void handleCopy();
|
|
98
|
+
}}
|
|
99
|
+
className="w-full min-h-[240px] text-[11px] text-neutral-200 leading-relaxed font-mono bg-neutral-900/60 rounded-lg p-3 border border-neutral-800 resize-y focus:outline-none focus:border-studio-accent/60 focus:ring-1 focus:ring-studio-accent/30"
|
|
100
|
+
/>
|
|
101
|
+
</div>
|
|
102
|
+
<div className="flex items-center justify-between px-5 py-3 border-t border-neutral-800/60">
|
|
103
|
+
<span className="text-[11px] text-neutral-600">
|
|
104
|
+
{navigator.platform.includes("Mac") ? "⌘" : "Ctrl"}+Enter to copy
|
|
105
|
+
</span>
|
|
106
|
+
<button
|
|
107
|
+
className={`px-4 py-1.5 rounded-lg text-xs font-medium transition-colors active:scale-[0.97] ${
|
|
108
|
+
copyState === "copied"
|
|
109
|
+
? "bg-emerald-500 text-white"
|
|
110
|
+
: copyState === "failed"
|
|
111
|
+
? "bg-red-500 text-white"
|
|
112
|
+
: "bg-studio-accent/90 text-neutral-950 hover:bg-studio-accent"
|
|
113
|
+
}`}
|
|
114
|
+
onClick={handleCopy}
|
|
115
|
+
>
|
|
116
|
+
{copyState === "copied"
|
|
117
|
+
? "Copied!"
|
|
118
|
+
: copyState === "failed"
|
|
119
|
+
? "Copy failed"
|
|
120
|
+
: "Copy prompt"}
|
|
121
|
+
</button>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
@@ -24,6 +24,14 @@ export function basename(path: string): string {
|
|
|
24
24
|
return dot > 0 ? name.slice(0, dot) : name;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/** Last path segment WITH its extension (basename() strips it). */
|
|
28
|
+
export function filename(path: string): string {
|
|
29
|
+
return path.split("/").pop() ?? path;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Feedback shown on an asset row/card after a copy-path attempt. */
|
|
33
|
+
export type CopyFeedback = { path: string; ok: boolean } | null;
|
|
34
|
+
|
|
27
35
|
export function ext(path: string): string {
|
|
28
36
|
const name = path.split("/").pop() ?? path;
|
|
29
37
|
const dot = name.lastIndexOf(".");
|
|
@@ -34,6 +34,11 @@ export function TimelineEditProvider({
|
|
|
34
34
|
value.onMoveElements,
|
|
35
35
|
value.onResizeElement,
|
|
36
36
|
value.onToggleTrackHidden,
|
|
37
|
+
value.onSetAudioGroupAttributeLive,
|
|
38
|
+
value.onSetAudioGroupAttributeQuiet,
|
|
39
|
+
value.onGroupClips,
|
|
40
|
+
value.onSetElementAttributeLive,
|
|
41
|
+
value.onSetElementAttributeQuiet,
|
|
37
42
|
value.onBlockedEditAttempt,
|
|
38
43
|
value.onSplitElement,
|
|
39
44
|
value.onRazorSplit,
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import {
|
|
3
|
+
buildPatchTarget,
|
|
4
|
+
persistElementAttribute,
|
|
5
|
+
type RecordEditInput,
|
|
6
|
+
} from "./timelineEditingHelpers";
|
|
7
|
+
import type {
|
|
8
|
+
MutableRef,
|
|
9
|
+
UseTimelineElementVisibilityEditingInput,
|
|
10
|
+
} from "./timelineTrackVisibility";
|
|
11
|
+
|
|
12
|
+
/** Direct DOM write on the group element for the gesture in progress — no
|
|
13
|
+
* file write, no history entry (mirrors FxParamRow's live/commit split). */
|
|
14
|
+
function patchLiveGroupAttribute(
|
|
15
|
+
iframe: HTMLIFrameElement | null,
|
|
16
|
+
groupId: string,
|
|
17
|
+
attr: string,
|
|
18
|
+
value: string | null,
|
|
19
|
+
): void {
|
|
20
|
+
const target = iframe?.contentDocument?.getElementById(groupId);
|
|
21
|
+
if (!target) return;
|
|
22
|
+
if (value === null) target.removeAttribute(attr);
|
|
23
|
+
else target.setAttribute(attr, value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface SetAudioGroupAttributeInput {
|
|
27
|
+
projectId: string;
|
|
28
|
+
activeCompPath: string | null;
|
|
29
|
+
groupId: string;
|
|
30
|
+
attr: string;
|
|
31
|
+
value: string | null;
|
|
32
|
+
label: string;
|
|
33
|
+
previewIframe: HTMLIFrameElement | null;
|
|
34
|
+
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
35
|
+
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
36
|
+
domEditSaveTimestampRef: MutableRef<number>;
|
|
37
|
+
pendingTimelineEditPathRef: MutableRef<Set<string>>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Persist one attribute on the group element itself — e.g. the bus strip's
|
|
42
|
+
* volume slider writing `data-volume` on release. One undo entry; mirrors
|
|
43
|
+
* `createAudioGroupAndAssignMembers`'s save shape but for a single element
|
|
44
|
+
* and attribute rather than a member-assignment sweep.
|
|
45
|
+
*/
|
|
46
|
+
async function setAudioGroupAttribute({
|
|
47
|
+
projectId,
|
|
48
|
+
activeCompPath,
|
|
49
|
+
groupId,
|
|
50
|
+
attr,
|
|
51
|
+
value,
|
|
52
|
+
label,
|
|
53
|
+
previewIframe,
|
|
54
|
+
writeProjectFile,
|
|
55
|
+
recordEdit,
|
|
56
|
+
domEditSaveTimestampRef,
|
|
57
|
+
pendingTimelineEditPathRef,
|
|
58
|
+
}: SetAudioGroupAttributeInput): Promise<string[]> {
|
|
59
|
+
const targetPath = activeCompPath || "index.html";
|
|
60
|
+
const patchTarget = buildPatchTarget({ domId: groupId });
|
|
61
|
+
if (!patchTarget) return [];
|
|
62
|
+
|
|
63
|
+
return persistElementAttribute({
|
|
64
|
+
projectId,
|
|
65
|
+
targetPath,
|
|
66
|
+
patchTarget,
|
|
67
|
+
attr,
|
|
68
|
+
value,
|
|
69
|
+
label,
|
|
70
|
+
writeProjectFile,
|
|
71
|
+
recordEdit,
|
|
72
|
+
domEditSaveTimestampRef,
|
|
73
|
+
pendingTimelineEditPathRef,
|
|
74
|
+
patchLive: (v) => patchLiveGroupAttribute(previewIframe, groupId, attr, v),
|
|
75
|
+
readLive: () =>
|
|
76
|
+
previewIframe?.contentDocument?.getElementById(groupId)?.getAttribute(attr) ?? null,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* B7's bus strip: live-write the group's own attribute (`data-volume`, so
|
|
82
|
+
* far — B5's mute will reuse this too) while dragging, persist one undo entry
|
|
83
|
+
* on release. Unlike `useAudioGroupCarveAssignment`, this never touches
|
|
84
|
+
* member elements — the group id doubles as its own DOM id, so no selection
|
|
85
|
+
* or expanded-rows resolution is needed to find it.
|
|
86
|
+
*/
|
|
87
|
+
export function useSetAudioGroupAttribute({
|
|
88
|
+
projectIdRef,
|
|
89
|
+
activeCompPath,
|
|
90
|
+
showToast,
|
|
91
|
+
writeProjectFile,
|
|
92
|
+
recordEdit,
|
|
93
|
+
domEditSaveTimestampRef,
|
|
94
|
+
previewIframeRef,
|
|
95
|
+
pendingTimelineEditPathRef,
|
|
96
|
+
isRecordingRef,
|
|
97
|
+
}: UseTimelineElementVisibilityEditingInput): {
|
|
98
|
+
setLive: (groupId: string, attr: string, value: string | null) => void;
|
|
99
|
+
setQuiet: (groupId: string, attr: string, value: string | null, label: string) => Promise<void>;
|
|
100
|
+
} {
|
|
101
|
+
const setLive = useCallback(
|
|
102
|
+
(groupId: string, attr: string, value: string | null) => {
|
|
103
|
+
patchLiveGroupAttribute(previewIframeRef.current, groupId, attr, value);
|
|
104
|
+
},
|
|
105
|
+
[previewIframeRef],
|
|
106
|
+
);
|
|
107
|
+
const setQuiet = useCallback(
|
|
108
|
+
async (groupId: string, attr: string, value: string | null, label: string) => {
|
|
109
|
+
if (isRecordingRef?.current) {
|
|
110
|
+
showToast("Cannot edit timeline while recording", "error");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const pid = projectIdRef.current;
|
|
114
|
+
if (!pid) return;
|
|
115
|
+
try {
|
|
116
|
+
await setAudioGroupAttribute({
|
|
117
|
+
projectId: pid,
|
|
118
|
+
activeCompPath,
|
|
119
|
+
groupId,
|
|
120
|
+
attr,
|
|
121
|
+
value,
|
|
122
|
+
label,
|
|
123
|
+
previewIframe: previewIframeRef.current,
|
|
124
|
+
writeProjectFile,
|
|
125
|
+
recordEdit,
|
|
126
|
+
domEditSaveTimestampRef,
|
|
127
|
+
pendingTimelineEditPathRef,
|
|
128
|
+
});
|
|
129
|
+
} catch (error) {
|
|
130
|
+
console.error("[Timeline] Failed to set group attribute", error);
|
|
131
|
+
const message = error instanceof Error ? error.message : "Failed to update group";
|
|
132
|
+
showToast(message);
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
[
|
|
136
|
+
activeCompPath,
|
|
137
|
+
previewIframeRef,
|
|
138
|
+
writeProjectFile,
|
|
139
|
+
recordEdit,
|
|
140
|
+
domEditSaveTimestampRef,
|
|
141
|
+
pendingTimelineEditPathRef,
|
|
142
|
+
isRecordingRef,
|
|
143
|
+
showToast,
|
|
144
|
+
projectIdRef,
|
|
145
|
+
],
|
|
146
|
+
);
|
|
147
|
+
return { setLive, setQuiet };
|
|
148
|
+
}
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { type TimelineElement, usePlayerStore } from "../player/store/playerStore";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
applyPatchByTarget,
|
|
4
|
+
findTagByTarget,
|
|
5
|
+
readAttributeByTarget,
|
|
6
|
+
readTagSnippetByTarget,
|
|
7
|
+
type PatchOperation,
|
|
8
|
+
} from "../utils/sourcePatcher";
|
|
3
9
|
import {
|
|
4
10
|
formatTimelineAttributeNumber,
|
|
5
11
|
type TimelineStackingReorderIntent,
|
|
@@ -390,3 +396,74 @@ export async function persistTimelineBatchEdit(
|
|
|
390
396
|
export { applyPatchByTarget, formatTimelineAttributeNumber };
|
|
391
397
|
|
|
392
398
|
export { patchDocumentRootDuration } from "./timelineEditingGsap";
|
|
399
|
+
|
|
400
|
+
export interface PersistElementAttributeInput {
|
|
401
|
+
projectId: string;
|
|
402
|
+
targetPath: string;
|
|
403
|
+
patchTarget: PatchTarget;
|
|
404
|
+
attr: string;
|
|
405
|
+
value: string | null;
|
|
406
|
+
label: string;
|
|
407
|
+
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
408
|
+
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
409
|
+
domEditSaveTimestampRef: { current: number };
|
|
410
|
+
pendingTimelineEditPathRef: { current: Set<string> };
|
|
411
|
+
/** Write the attribute directly on the live preview DOM node. */
|
|
412
|
+
patchLive: (value: string | null) => void;
|
|
413
|
+
/** Read the attribute's current value off the live preview DOM node. */
|
|
414
|
+
readLive: () => string | null;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* One attribute, persisted to source and optimistically patched onto the
|
|
419
|
+
* live preview, with a revert on save failure. The shared core behind
|
|
420
|
+
* `setAudioGroupAttribute` (a group id addressed by its own DOM id) and
|
|
421
|
+
* `useSetElementAttribute` (an arbitrary timeline clip) — same shape, only
|
|
422
|
+
* how the live node is found and where the patch target resolves to differs,
|
|
423
|
+
* which is exactly what `patchLive`/`readLive`/`patchTarget` parameterize.
|
|
424
|
+
*/
|
|
425
|
+
export async function persistElementAttribute({
|
|
426
|
+
projectId,
|
|
427
|
+
targetPath,
|
|
428
|
+
patchTarget,
|
|
429
|
+
attr,
|
|
430
|
+
value,
|
|
431
|
+
label,
|
|
432
|
+
writeProjectFile,
|
|
433
|
+
recordEdit,
|
|
434
|
+
domEditSaveTimestampRef,
|
|
435
|
+
pendingTimelineEditPathRef,
|
|
436
|
+
patchLive,
|
|
437
|
+
readLive,
|
|
438
|
+
}: PersistElementAttributeInput): Promise<string[]> {
|
|
439
|
+
const previousValue = readLive();
|
|
440
|
+
patchLive(value);
|
|
441
|
+
|
|
442
|
+
const before = await readFileContent(projectId, targetPath);
|
|
443
|
+
if (readTagSnippetByTarget(before, patchTarget) === undefined) {
|
|
444
|
+
throw new Error(`Unable to patch element in ${targetPath}`);
|
|
445
|
+
}
|
|
446
|
+
const operation: PatchOperation = { type: "attribute", property: attr, value };
|
|
447
|
+
const patched = applyPatchByTarget(before, patchTarget, operation);
|
|
448
|
+
|
|
449
|
+
pendingTimelineEditPathRef.current.add(targetPath);
|
|
450
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
451
|
+
try {
|
|
452
|
+
const changedPaths = await saveProjectFilesWithHistory({
|
|
453
|
+
projectId,
|
|
454
|
+
label,
|
|
455
|
+
kind: "timeline",
|
|
456
|
+
files: { [targetPath]: patched },
|
|
457
|
+
readFile: async (path) => (path === targetPath ? before : readFileContent(projectId, path)),
|
|
458
|
+
writeFile: writeProjectFile,
|
|
459
|
+
recordEdit,
|
|
460
|
+
});
|
|
461
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
462
|
+
return changedPaths;
|
|
463
|
+
} catch (error) {
|
|
464
|
+
// The optimistic live write already ran; unwind it on a save failure so
|
|
465
|
+
// the preview doesn't show a value that never reached disk.
|
|
466
|
+
patchLive(previousValue);
|
|
467
|
+
throw error;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* C1's clip-level FX write: persist one attribute directly on a specific
|
|
3
|
+
* timeline clip, addressed by the clip itself rather than the current
|
|
4
|
+
* selection — so applying a preset from the timeline FX popover doesn't
|
|
5
|
+
* depend on that clip already being selected in the property panel.
|
|
6
|
+
* Built on `persistElementAttribute` (`timelineEditingHelpers.ts`), the
|
|
7
|
+
* shared core `setAudioGroupAttribute` also uses.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useCallback } from "react";
|
|
11
|
+
import type { TimelineElement } from "../player";
|
|
12
|
+
import {
|
|
13
|
+
buildPatchTarget,
|
|
14
|
+
findTimelineElementInIframe,
|
|
15
|
+
persistElementAttribute,
|
|
16
|
+
} from "./timelineEditingHelpers";
|
|
17
|
+
import type {
|
|
18
|
+
MutableRef,
|
|
19
|
+
UseTimelineElementVisibilityEditingInput,
|
|
20
|
+
} from "./timelineTrackVisibility";
|
|
21
|
+
|
|
22
|
+
function patchLiveElementAttribute(
|
|
23
|
+
iframe: HTMLIFrameElement | null,
|
|
24
|
+
element: TimelineElement,
|
|
25
|
+
attr: string,
|
|
26
|
+
value: string | null,
|
|
27
|
+
activeCompPath: string | null,
|
|
28
|
+
): void {
|
|
29
|
+
const target = findTimelineElementInIframe(iframe, element, activeCompPath);
|
|
30
|
+
if (!target) return;
|
|
31
|
+
if (value === null) target.removeAttribute(attr);
|
|
32
|
+
else target.setAttribute(attr, value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface SetElementAttributeInput {
|
|
36
|
+
projectId: string;
|
|
37
|
+
activeCompPath: string | null;
|
|
38
|
+
element: TimelineElement;
|
|
39
|
+
attr: string;
|
|
40
|
+
value: string | null;
|
|
41
|
+
label: string;
|
|
42
|
+
previewIframe: HTMLIFrameElement | null;
|
|
43
|
+
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
44
|
+
recordEdit: Parameters<typeof persistElementAttribute>[0]["recordEdit"];
|
|
45
|
+
domEditSaveTimestampRef: MutableRef<number>;
|
|
46
|
+
pendingTimelineEditPathRef: MutableRef<Set<string>>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function setElementAttribute({
|
|
50
|
+
projectId,
|
|
51
|
+
activeCompPath,
|
|
52
|
+
element,
|
|
53
|
+
attr,
|
|
54
|
+
value,
|
|
55
|
+
label,
|
|
56
|
+
previewIframe,
|
|
57
|
+
writeProjectFile,
|
|
58
|
+
recordEdit,
|
|
59
|
+
domEditSaveTimestampRef,
|
|
60
|
+
pendingTimelineEditPathRef,
|
|
61
|
+
}: SetElementAttributeInput): Promise<string[]> {
|
|
62
|
+
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
|
63
|
+
const patchTarget = buildPatchTarget(element);
|
|
64
|
+
if (!patchTarget) return [];
|
|
65
|
+
|
|
66
|
+
return persistElementAttribute({
|
|
67
|
+
projectId,
|
|
68
|
+
targetPath,
|
|
69
|
+
patchTarget,
|
|
70
|
+
attr,
|
|
71
|
+
value,
|
|
72
|
+
label,
|
|
73
|
+
writeProjectFile,
|
|
74
|
+
recordEdit,
|
|
75
|
+
domEditSaveTimestampRef,
|
|
76
|
+
pendingTimelineEditPathRef,
|
|
77
|
+
patchLive: (v) => patchLiveElementAttribute(previewIframe, element, attr, v, activeCompPath),
|
|
78
|
+
readLive: () =>
|
|
79
|
+
findTimelineElementInIframe(previewIframe, element, activeCompPath)?.getAttribute(attr) ??
|
|
80
|
+
null,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function useSetElementAttribute({
|
|
85
|
+
projectIdRef,
|
|
86
|
+
activeCompPath,
|
|
87
|
+
showToast,
|
|
88
|
+
writeProjectFile,
|
|
89
|
+
recordEdit,
|
|
90
|
+
domEditSaveTimestampRef,
|
|
91
|
+
previewIframeRef,
|
|
92
|
+
pendingTimelineEditPathRef,
|
|
93
|
+
isRecordingRef,
|
|
94
|
+
}: UseTimelineElementVisibilityEditingInput): {
|
|
95
|
+
setLive: (element: TimelineElement, attr: string, value: string | null) => void;
|
|
96
|
+
setQuiet: (
|
|
97
|
+
element: TimelineElement,
|
|
98
|
+
attr: string,
|
|
99
|
+
value: string | null,
|
|
100
|
+
label: string,
|
|
101
|
+
) => Promise<void>;
|
|
102
|
+
} {
|
|
103
|
+
const setLive = useCallback(
|
|
104
|
+
(element: TimelineElement, attr: string, value: string | null) => {
|
|
105
|
+
patchLiveElementAttribute(previewIframeRef.current, element, attr, value, activeCompPath);
|
|
106
|
+
},
|
|
107
|
+
[previewIframeRef, activeCompPath],
|
|
108
|
+
);
|
|
109
|
+
const setQuiet = useCallback(
|
|
110
|
+
async (element: TimelineElement, attr: string, value: string | null, label: string) => {
|
|
111
|
+
if (isRecordingRef?.current) {
|
|
112
|
+
showToast("Cannot edit timeline while recording", "error");
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const pid = projectIdRef.current;
|
|
116
|
+
if (!pid) return;
|
|
117
|
+
try {
|
|
118
|
+
await setElementAttribute({
|
|
119
|
+
projectId: pid,
|
|
120
|
+
activeCompPath,
|
|
121
|
+
element,
|
|
122
|
+
attr,
|
|
123
|
+
value,
|
|
124
|
+
label,
|
|
125
|
+
previewIframe: previewIframeRef.current,
|
|
126
|
+
writeProjectFile,
|
|
127
|
+
recordEdit,
|
|
128
|
+
domEditSaveTimestampRef,
|
|
129
|
+
pendingTimelineEditPathRef,
|
|
130
|
+
});
|
|
131
|
+
} catch (error) {
|
|
132
|
+
console.error("[Timeline] Failed to set element attribute", error);
|
|
133
|
+
const message = error instanceof Error ? error.message : "Failed to update effect";
|
|
134
|
+
showToast(message);
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
[
|
|
138
|
+
activeCompPath,
|
|
139
|
+
previewIframeRef,
|
|
140
|
+
writeProjectFile,
|
|
141
|
+
recordEdit,
|
|
142
|
+
domEditSaveTimestampRef,
|
|
143
|
+
pendingTimelineEditPathRef,
|
|
144
|
+
isRecordingRef,
|
|
145
|
+
showToast,
|
|
146
|
+
projectIdRef,
|
|
147
|
+
],
|
|
148
|
+
);
|
|
149
|
+
return { setLive, setQuiet };
|
|
150
|
+
}
|