@hyperframes/studio 0.8.81 → 0.8.83
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-C68UawR3.js → hyperframes-player-BAerMCex.js} +1 -1
- package/dist/assets/{index-CMzPGv9P.js → index-BUhcVhNy.js} +1 -1
- package/dist/assets/{index-hNVM9Pd2.js → index-BYhK_KMc.js} +187 -187
- package/dist/assets/{index-CS5SImRT.js → index-Iv0HI7eM.js} +1 -1
- package/dist/{chunk-I7XNAIOF.js → chunk-3IOCVWB6.js} +6 -97
- package/dist/chunk-3IOCVWB6.js.map +1 -0
- package/dist/chunk-62DDWOSH.js +189 -0
- package/dist/chunk-62DDWOSH.js.map +1 -0
- package/dist/chunk-LPZMAGS7.js +1871 -0
- package/dist/chunk-LPZMAGS7.js.map +1 -0
- package/dist/chunk-QZ6HNNFG.js +934 -0
- package/dist/chunk-QZ6HNNFG.js.map +1 -0
- package/dist/chunk-S65AQH6M.js +101 -0
- package/dist/chunk-S65AQH6M.js.map +1 -0
- package/dist/{domEditingLayers-MHFDKAQ7.js → domEditingLayers-ZOOMN7CQ.js} +3 -2
- package/dist/index.d.ts +834 -330
- package/dist/index.html +1 -1
- package/dist/index.js +26378 -28802
- package/dist/index.js.map +1 -1
- package/dist/player.d.ts +30 -0
- package/dist/player.js +10 -0
- package/dist/player.js.map +1 -0
- package/dist/ui.d.ts +294 -0
- package/dist/ui.js +66 -0
- package/dist/ui.js.map +1 -0
- package/package.json +16 -8
- package/src/components/StudioHeader.tsx +1 -121
- package/src/components/TimelineHistoryButtons.dom.test.tsx +38 -1
- package/src/components/TimelineHistoryButtons.tsx +26 -14
- package/src/components/TimelineToolbar.test.tsx +9 -1
- package/src/components/TimelineToolbar.tsx +13 -5
- package/src/components/editor/BlockParamsPanel.tsx +14 -10
- package/src/components/editor/CanvasContextMenu.test.tsx +3 -3
- package/src/components/editor/CanvasContextMenu.tsx +1 -1
- package/src/components/editor/ConnectedDomEditOverlay.test.tsx +163 -0
- package/src/components/editor/ConnectedDomEditOverlay.tsx +206 -0
- package/src/components/editor/DomEditOverlay.tsx +36 -21
- package/src/components/editor/LayersPanel.tsx +1 -1
- package/src/components/editor/MarqueeOverlay.tsx +1 -1
- package/src/components/editor/SnapGuideOverlay.tsx +1 -1
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +2 -6
- package/src/components/editor/clipAudioClock.test.ts +61 -0
- package/src/components/editor/clipAudioClock.ts +78 -0
- package/src/components/editor/manualEdits.test.ts +24 -0
- package/src/components/editor/manualEdits.ts +6 -2
- package/src/components/editor/manualEditsDom.ts +0 -65
- package/src/components/editor/manualEditsSeekReapply.ts +136 -0
- package/src/components/editor/marqueeCommit.ts +100 -37
- package/src/components/editor/previewReadOnly.overlay.test.tsx +220 -17
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +206 -32
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +62 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +4 -6
- package/src/components/editor/propertyPanelHelpers.ts +15 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +3 -5
- package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +1 -1
- package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +117 -0
- package/src/components/editor/useFxCarve.ts +11 -15
- package/src/components/editor/useFxCarveNodes.ts +27 -15
- package/src/components/editor/useFxLevelling.ts +20 -31
- package/src/components/editor/useInlineTextEditing.tsx +35 -19
- package/src/components/feedback/projectProvenance.ts +2 -3
- package/src/components/nle/AudioMeterStrip.test.tsx +26 -3
- package/src/components/nle/AudioMeterStrip.tsx +23 -12
- package/src/components/nle/PreviewOverlays.tsx +7 -188
- package/src/components/nle/PreviewOverlaysTestMocks.ts +2 -2
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +1 -1
- package/src/components/pointerTargetSize.test.tsx +1 -1
- package/src/components/studioColorGradingScope.ts +12 -36
- package/src/components/ui/HyperframesLogo.tsx +120 -0
- package/src/contexts/TimelineEditContext.test.tsx +49 -0
- package/src/contexts/TimelineEditContext.tsx +27 -23
- package/src/domEditExports.test.tsx +69 -0
- package/src/editorGesturesExports.test.tsx +236 -0
- package/src/hooks/canvasQuickSaves.test.tsx +195 -0
- package/src/hooks/gsapMutationClient.ts +116 -0
- package/src/hooks/resizeObserverTestUtils.ts +14 -0
- package/src/hooks/timelineAudioGroupCreate.ts +18 -40
- package/src/hooks/timelineEditingHelpers.test.ts +21 -0
- package/src/hooks/timelineEditingHelpers.ts +66 -61
- package/src/hooks/timelineQuickSaves.test.ts +195 -0
- package/src/hooks/timelineTimingSync.test.ts +12 -0
- package/src/hooks/timelineTimingSync.ts +31 -123
- package/src/hooks/timelineTrackVisibility.ts +15 -29
- package/src/hooks/useAskAgentModal.test.tsx +78 -0
- package/src/hooks/useAskAgentModal.ts +3 -1
- package/src/hooks/useClipboard.source.test.ts +32 -0
- package/src/hooks/useClipboard.ts +21 -20
- package/src/hooks/useDomEditCommits.test.tsx +13 -9
- package/src/hooks/useDomEditCommits.ts +88 -97
- package/src/hooks/useDomEditCommitsHelpers.ts +69 -6
- package/src/hooks/useEditorSave.ts +1 -1
- package/src/hooks/useElementLifecycleOps.ts +37 -38
- package/src/hooks/useFileManager.projectOwnership.test.tsx +51 -4
- package/src/hooks/useFileManager.ts +21 -12
- package/src/hooks/useGroupCommits.ts +23 -24
- package/src/hooks/useGsapAnimationOps.ts +26 -8
- package/src/hooks/useGsapScriptCommits.ts +1 -0
- package/src/hooks/useKeyframeKeyboard.test.tsx +99 -0
- package/src/hooks/useKeyframeKeyboard.ts +9 -71
- package/src/hooks/usePreviewPersistence.ts +1 -1
- package/src/hooks/useThumbnailStripSize.test.tsx +47 -0
- package/src/hooks/useThumbnailStripSize.ts +31 -0
- package/src/hooks/useTimelineAssetDropOps.ts +38 -35
- package/src/hooks/useTimelineDeleteOps.ts +53 -56
- package/src/hooks/useTimelineEditing.ts +3 -1
- package/src/hooks/useTimelineGroupEditing.ts +5 -1
- package/src/index.ts +53 -32
- package/src/lightEntries.test.ts +32 -0
- package/src/player/components/CompositionThumbnail.test.ts +40 -6
- package/src/player/components/CompositionThumbnail.tsx +6 -21
- package/src/player/components/ImageThumbnail.test.tsx +27 -6
- package/src/player/components/ImageThumbnail.tsx +4 -18
- package/src/player/components/PlayerControls.test.tsx +52 -0
- package/src/player/components/PlayerControls.tsx +2 -1
- package/src/player/components/Timeline.test.ts +28 -3
- package/src/player/components/TimelineCanvas.tsx +5 -3
- package/src/player/components/TimelineClipFades.test.tsx +31 -10
- package/src/player/components/TimelineClipFades.tsx +2 -1
- package/src/player/components/TimelineTypes.ts +4 -2
- package/src/player/components/VideoThumbnail.test.tsx +33 -11
- package/src/player/components/VideoThumbnail.tsx +7 -29
- package/src/player/components/studioShortcuts.ts +8 -5
- package/src/player/components/thumbnailUtils.test.ts +16 -20
- package/src/player/components/thumbnailUtils.ts +6 -7
- package/src/player/components/timelineClipChildren.test.ts +34 -1
- package/src/player/components/timelineClipChildren.tsx +7 -4
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +141 -85
- package/src/player/components/timelineClipDragGestureLifecycle.ts +2 -0
- package/src/player/components/timelineEditing.test.ts +55 -0
- package/src/player/components/timelineGroupEditing.ts +13 -6
- package/src/player/components/timelinePlayheadTransform.test.ts +33 -0
- package/src/player/components/timelinePlayheadTransform.ts +17 -0
- package/src/player/components/timelinePointerRelease.ts +4 -0
- package/src/player/components/useTimelinePlayhead.ts +26 -8
- package/src/player/components/useTimelineProviderState.tsx +4 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +46 -0
- package/src/player/components/useTimelineStackingSync.ts +18 -11
- package/src/player/hooks/timelinePlayerSync.test.ts +20 -0
- package/src/player/hooks/timelinePlayerSync.ts +18 -8
- package/src/player/hooks/useShadowPreviewReload.ts +15 -4
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +19 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +93 -1
- package/src/player/lib/playbackShortcuts.ts +18 -0
- package/src/player/lib/playbackTypes.ts +1 -0
- package/src/player/lib/timelineDOM.test.ts +92 -0
- package/src/player/lib/timelineDOM.ts +0 -2
- package/src/player/lib/timelineElementHelpers.ts +47 -10
- package/src/player/lib/timelineViewportBudgets.ts +1 -1
- package/src/player/store/timelineElement.ts +2 -1
- package/src/playerEntry.ts +1 -0
- package/src/timelineChromeExports.test.tsx +79 -0
- package/src/timelineStackingSyncExport.test.tsx +67 -0
- package/src/ui.ts +31 -0
- package/src/utils/authoredSource.ts +15 -0
- package/src/utils/blockInstaller.ts +32 -27
- package/src/utils/mediaTypes.test.ts +33 -0
- package/src/utils/mediaTypes.ts +12 -5
- package/src/utils/studioFileHistory.test.ts +14 -14
- package/src/utils/studioFileHistory.ts +31 -2
- package/dist/chunk-I7XNAIOF.js.map +0 -1
- /package/dist/{domEditingLayers-MHFDKAQ7.js.map → domEditingLayers-ZOOMN7CQ.js.map} +0 -0
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
} from "@hyperframes/core/audio-carve";
|
|
24
24
|
import { fxAutomationTarget, type HfAutomationLane } from "@hyperframes/core/audio-automation";
|
|
25
25
|
import { clipStart } from "./propertyPanelAudioFxGroupUtils.js";
|
|
26
|
+
import { clipAudioOnItsClock, type ClipClock } from "./clipAudioClock.js";
|
|
26
27
|
|
|
27
28
|
/** Decode rate every carve measurement shares — see `measureCarve`. */
|
|
28
29
|
const DECODE_SAMPLE_RATE = 48000;
|
|
@@ -60,6 +61,13 @@ export function mintCarveNodes(
|
|
|
60
61
|
};
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
/** A clip to decode, where it starts on the timeline, and how it plays its file. */
|
|
65
|
+
export interface CarveClip {
|
|
66
|
+
src: string;
|
|
67
|
+
start: string | null | undefined;
|
|
68
|
+
clock: ClipClock;
|
|
69
|
+
}
|
|
70
|
+
|
|
63
71
|
/**
|
|
64
72
|
* Decode every voice, mix them onto the bed's own clock, and measure the
|
|
65
73
|
* bands (and, if the profile calls for it, the ducking envelope) from that
|
|
@@ -68,10 +76,9 @@ export function mintCarveNodes(
|
|
|
68
76
|
*/
|
|
69
77
|
export async function measureCarve(
|
|
70
78
|
doc: Document,
|
|
71
|
-
voices:
|
|
79
|
+
voices: CarveClip[],
|
|
72
80
|
strength: number,
|
|
73
|
-
|
|
74
|
-
bedSrc: string | null | undefined,
|
|
81
|
+
bed: Omit<CarveClip, "src"> & { src: string | null | undefined },
|
|
75
82
|
): Promise<{
|
|
76
83
|
bands: ReturnType<typeof analyseCarveBands>;
|
|
77
84
|
carved: HfAudioFxChain;
|
|
@@ -86,11 +93,15 @@ export async function measureCarve(
|
|
|
86
93
|
(window as unknown as { webkitOfflineAudioContext?: typeof OfflineAudioContext })
|
|
87
94
|
.webkitOfflineAudioContext;
|
|
88
95
|
if (!Ctor) return null;
|
|
89
|
-
|
|
96
|
+
// Each clip as it plays: trimmed to its in-point and duration, and at its own speed.
|
|
97
|
+
const decode = async (relative: string, clock: ClipClock): Promise<Float32Array> => {
|
|
90
98
|
const res = await fetch(new URL(relative, doc.baseURI).href);
|
|
91
|
-
|
|
99
|
+
const buffer = await new Ctor(1, 1, DECODE_SAMPLE_RATE).decodeAudioData(
|
|
100
|
+
await res.arrayBuffer(),
|
|
101
|
+
);
|
|
102
|
+
return clipAudioOnItsClock(buffer.getChannelData(0), DECODE_SAMPLE_RATE, clock);
|
|
92
103
|
};
|
|
93
|
-
const bedStart = clipStart(
|
|
104
|
+
const bedStart = clipStart(bed.start);
|
|
94
105
|
// Every voice, summed onto the bed's own clock. One question — where and
|
|
95
106
|
// when is speech masking this bed — with one answer, even when the answer
|
|
96
107
|
// comes from three people talking at different times. Doing this before the
|
|
@@ -98,23 +109,24 @@ export async function measureCarve(
|
|
|
98
109
|
// the chain is fixed, so there is no per-voice filter to switch between.
|
|
99
110
|
const decoded = await Promise.all(
|
|
100
111
|
voices.map(async (voice) => ({
|
|
101
|
-
samples:
|
|
112
|
+
samples: await decode(voice.src, voice.clock),
|
|
102
113
|
offsetSeconds: clipStart(voice.start) - bedStart,
|
|
103
114
|
})),
|
|
104
115
|
);
|
|
105
|
-
const
|
|
106
|
-
if (
|
|
116
|
+
const mixed = mixCarveSources(decoded, DECODE_SAMPLE_RATE);
|
|
117
|
+
if (mixed.length === 0) return null;
|
|
107
118
|
// Strength is what the author set; these are the numbers it means.
|
|
108
119
|
const profile = carveProfile(strength);
|
|
109
|
-
// The bed as
|
|
110
|
-
// "how far over the voice is this bed"
|
|
111
|
-
|
|
112
|
-
|
|
120
|
+
// The bed as it plays: its length bounds the speech worth carving, and the duck
|
|
121
|
+
// compares against it ("how far over the voice is this bed" needs both).
|
|
122
|
+
const bedSamples = bed.src ? await decode(bed.src, bed.clock).catch(() => null) : null;
|
|
123
|
+
// Speech after the bed stops has no bed to carve.
|
|
124
|
+
const voiceMix = bedSamples ? mixed.subarray(0, bedSamples.length) : mixed;
|
|
113
125
|
const bands = analyseCarveBands(voiceMix, DECODE_SAMPLE_RATE, profile);
|
|
114
126
|
// The level half of the carve, measured against the speech it has to sit
|
|
115
127
|
// under. No offset to apply: the mix is already on the bed's clock.
|
|
116
|
-
const duck =
|
|
117
|
-
? analyseCarveDuck(voiceMix,
|
|
128
|
+
const duck = bedSamples
|
|
129
|
+
? analyseCarveDuck(voiceMix, bedSamples, DECODE_SAMPLE_RATE, profile, 0)
|
|
118
130
|
: [];
|
|
119
131
|
return { bands, carved: carveBandsToChain(bands), duck, voiceMix };
|
|
120
132
|
}
|
|
@@ -14,7 +14,8 @@ import {
|
|
|
14
14
|
type HfAudioFxChain,
|
|
15
15
|
} from "@hyperframes/core/audio-fx";
|
|
16
16
|
import { levellingResult, removeLevelling } from "@hyperframes/core/audio-leveller";
|
|
17
|
-
import type { HfAutomation } from "@hyperframes/core/audio-automation";
|
|
17
|
+
import type { HfAutomation, HfAutomationPoint } from "@hyperframes/core/audio-automation";
|
|
18
|
+
import { timeAtSourceTime } from "@hyperframes/core/speed-ramp";
|
|
18
19
|
import {
|
|
19
20
|
automationAttrValue,
|
|
20
21
|
HF_AUDIO_AUTOMATION_ATTR,
|
|
@@ -23,6 +24,7 @@ import {
|
|
|
23
24
|
} from "./propertyPanelAutomation";
|
|
24
25
|
import { trackLeveller } from "./audioFxTelemetry.js";
|
|
25
26
|
import type { DomEditSelection } from "./domEditingTypes";
|
|
27
|
+
import { clipSourceWindow, readClipClock } from "./clipAudioClock.js";
|
|
26
28
|
import { useAuditionTransport } from "./useAuditionTransport.js";
|
|
27
29
|
|
|
28
30
|
/**
|
|
@@ -31,11 +33,6 @@ import { useAuditionTransport } from "./useAuditionTransport.js";
|
|
|
31
33
|
*/
|
|
32
34
|
const DECODE_SAMPLE_RATE = 48000;
|
|
33
35
|
|
|
34
|
-
/** A parsed attribute, kept only when it is a usable positive number. */
|
|
35
|
-
function positiveFinite(n: number): number | null {
|
|
36
|
-
return Number.isFinite(n) && n > 0 ? n : null;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
36
|
export function useFxLevelling(
|
|
40
37
|
element: DomEditSelection,
|
|
41
38
|
chain: HfAudioFxChain,
|
|
@@ -75,27 +72,19 @@ export function useFxLevelling(
|
|
|
75
72
|
return next;
|
|
76
73
|
};
|
|
77
74
|
|
|
78
|
-
/**
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
? Math.min(audio.samples.length, Math.floor(mediaStart * audio.sampleRate))
|
|
92
|
-
: 0;
|
|
93
|
-
const to = duration
|
|
94
|
-
? Math.min(audio.samples.length, from + Math.ceil(duration * audio.sampleRate))
|
|
95
|
-
: audio.samples.length;
|
|
96
|
-
return from === 0 && to === audio.samples.length
|
|
97
|
-
? audio.samples
|
|
98
|
-
: audio.samples.subarray(from, to);
|
|
75
|
+
/** The levelled chain and its lane, with the lane moved from the source's clock onto the clip's. */
|
|
76
|
+
const measureLevelling = (audio: { samples: Float32Array; sampleRate: number }) => {
|
|
77
|
+
// Measured on the part of the file the clip plays, so lane times map from that window's zero.
|
|
78
|
+
const clock = readClipClock((name) => element.dataAttributes?.[name.slice(5)]);
|
|
79
|
+
const played = clipSourceWindow(audio.samples, audio.sampleRate, clock);
|
|
80
|
+
const result = levellingResult(chain, played, audio.sampleRate);
|
|
81
|
+
if (!result) return null;
|
|
82
|
+
const lane = result.automation.lanes[0];
|
|
83
|
+
const toClipTime = (point: HfAutomationPoint) => ({
|
|
84
|
+
...point,
|
|
85
|
+
t: Number(timeAtSourceTime(clock.rate, point.t).toFixed(3)),
|
|
86
|
+
});
|
|
87
|
+
return { chain: result.chain, lane: lane && { ...lane, points: lane.points.map(toClipTime) } };
|
|
99
88
|
};
|
|
100
89
|
|
|
101
90
|
const runLeveller = async (): Promise<void> => {
|
|
@@ -103,14 +92,14 @@ export function useFxLevelling(
|
|
|
103
92
|
try {
|
|
104
93
|
const audio = await decodeTrack();
|
|
105
94
|
if (!audio) return;
|
|
106
|
-
const result =
|
|
95
|
+
const result = measureLevelling(audio);
|
|
107
96
|
if (!result) return;
|
|
108
97
|
trackLeveller("run");
|
|
109
98
|
await onSetAttributeQuiet(HF_AUDIO_FX_ATTR, serializeAudioFxChain(result.chain));
|
|
110
99
|
// Merged by target, never written wholesale: the script describes its own
|
|
111
100
|
// lane only, and replacing the attribute would take the carve's lanes and
|
|
112
101
|
// the volume lane with it.
|
|
113
|
-
const lane = result.
|
|
102
|
+
const lane = result.lane;
|
|
114
103
|
if (lane) {
|
|
115
104
|
void onSetAttributeQuiet(
|
|
116
105
|
HF_AUDIO_AUTOMATION_ATTR,
|
|
@@ -160,10 +149,10 @@ export function useFxLevelling(
|
|
|
160
149
|
const audio = await decodeTrack();
|
|
161
150
|
// Gone, or superseded by a later hover. Either way this result is stale.
|
|
162
151
|
if (!audio || run !== auditionRun.current) return;
|
|
163
|
-
const result =
|
|
152
|
+
const result = measureLevelling(audio);
|
|
164
153
|
if (!result || run !== auditionRun.current) return;
|
|
165
154
|
void onSetAttributeLive(HF_AUDIO_FX_ATTR, serializeAudioFxChain(result.chain));
|
|
166
|
-
const lane = result.
|
|
155
|
+
const lane = result.lane;
|
|
167
156
|
if (lane) {
|
|
168
157
|
void onSetAttributeLive(
|
|
169
158
|
HF_AUDIO_AUTOMATION_ATTR,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useRef, type ReactNode, type RefObject } from "react";
|
|
1
|
+
import { useEffect, useRef, type ReactNode, type RefObject } from "react";
|
|
2
2
|
import { InlineTextToolbar } from "./InlineTextToolbar";
|
|
3
3
|
import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
|
|
4
4
|
import { useInlineTextEdit } from "../../hooks/useInlineTextEdit";
|
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
} from "./domEditInlineText";
|
|
13
13
|
import type { DomEditSelection } from "./domEditingTypes";
|
|
14
14
|
|
|
15
|
+
type KeyChord = Pick<KeyboardEvent, "key" | "shiftKey" | "ctrlKey" | "metaKey" | "altKey">;
|
|
16
|
+
|
|
15
17
|
/**
|
|
16
18
|
* The canvas' side of editing text where it sits.
|
|
17
19
|
*
|
|
@@ -23,18 +25,15 @@ import type { DomEditSelection } from "./domEditingTypes";
|
|
|
23
25
|
* props, the same way the agent surfaces in that overlay read it, and it is
|
|
24
26
|
* absent in standalone player mounts, which have no project to edit.
|
|
25
27
|
*/
|
|
26
|
-
export function useInlineTextEditing(
|
|
28
|
+
export function useInlineTextEditing(
|
|
29
|
+
selectionRef: RefObject<DomEditSelection | null>,
|
|
30
|
+
{ enterFromWindow = false }: { enterFromWindow?: boolean } = {},
|
|
31
|
+
): {
|
|
27
32
|
editing: boolean;
|
|
28
33
|
/** Open an edit when this press pairs with the last one. */
|
|
29
34
|
startFromPress: (event: { clientX: number; clientY: number }) => boolean;
|
|
30
35
|
/** Handle a key on the canvas. Returns true when it opened an edit. */
|
|
31
|
-
handleKeyDown: (event:
|
|
32
|
-
key: string;
|
|
33
|
-
shiftKey: boolean;
|
|
34
|
-
ctrlKey: boolean;
|
|
35
|
-
metaKey: boolean;
|
|
36
|
-
altKey: boolean;
|
|
37
|
-
}) => boolean;
|
|
36
|
+
handleKeyDown: (event: KeyChord) => boolean;
|
|
38
37
|
/**
|
|
39
38
|
* The styling controls for the current selection, for the caller to render.
|
|
40
39
|
*
|
|
@@ -84,6 +83,32 @@ export function useInlineTextEditing(selectionRef: RefObject<DomEditSelection |
|
|
|
84
83
|
return { x: (event.clientX - box.left) / scale, y: (event.clientY - box.top) / scale };
|
|
85
84
|
};
|
|
86
85
|
|
|
86
|
+
// Enter opens the selected element's text, the way every design tool does,
|
|
87
|
+
// and is the dependable way in: a double press has to survive the canvas'
|
|
88
|
+
// gesture machinery, while this is one key on a selection that has settled.
|
|
89
|
+
const handleKeyDown = (event: KeyChord) => {
|
|
90
|
+
if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey)
|
|
91
|
+
return false;
|
|
92
|
+
const target = selectionRef.current;
|
|
93
|
+
if (inlineText.session || !target || !canEditTextInline(target)) return false;
|
|
94
|
+
return inlineText.start(target.element);
|
|
95
|
+
};
|
|
96
|
+
const handleKeyDownRef = useRef(handleKeyDown);
|
|
97
|
+
handleKeyDownRef.current = handleKeyDown;
|
|
98
|
+
// A host-mode canvas takes no presses of its own, so it is rarely focused;
|
|
99
|
+
// with nothing focused, Enter comes via the window.
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (!enterFromWindow) return;
|
|
102
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
103
|
+
const { body, documentElement } = window.document;
|
|
104
|
+
if (event.defaultPrevented || (event.target !== body && event.target !== documentElement))
|
|
105
|
+
return;
|
|
106
|
+
if (handleKeyDownRef.current(event)) event.preventDefault();
|
|
107
|
+
};
|
|
108
|
+
window.addEventListener("keydown", onKeyDown);
|
|
109
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
110
|
+
}, [enterFromWindow]);
|
|
111
|
+
|
|
87
112
|
return {
|
|
88
113
|
editing: inlineText.session !== null,
|
|
89
114
|
toolbar: (
|
|
@@ -92,16 +117,7 @@ export function useInlineTextEditing(selectionRef: RefObject<DomEditSelection |
|
|
|
92
117
|
iframe={actions?.previewIframeRef?.current ?? null}
|
|
93
118
|
/>
|
|
94
119
|
),
|
|
95
|
-
|
|
96
|
-
// and is the dependable way in: a double press has to survive the canvas'
|
|
97
|
-
// gesture machinery, while this is one key on a selection that has settled.
|
|
98
|
-
handleKeyDown: (event) => {
|
|
99
|
-
if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey)
|
|
100
|
-
return false;
|
|
101
|
-
const target = selectionRef.current;
|
|
102
|
-
if (inlineText.session || !target || !canEditTextInline(target)) return false;
|
|
103
|
-
return inlineText.start(target.element);
|
|
104
|
-
},
|
|
120
|
+
handleKeyDown,
|
|
105
121
|
startFromPress: (event) => {
|
|
106
122
|
if (inlineText.session) return false;
|
|
107
123
|
const element = elementUnderPress(event);
|
|
@@ -16,6 +16,7 @@ import { buildProjectApiPath } from "../../utils/projectRouting";
|
|
|
16
16
|
// ---------------------------------------------------------------------------
|
|
17
17
|
|
|
18
18
|
import type { FeedbackContext } from "./feedbackTrigger";
|
|
19
|
+
import { isMediaFile } from "../../utils/mediaTypes";
|
|
19
20
|
|
|
20
21
|
/** Written by `hyperframes init`; absent in a hand-made or copied project. */
|
|
21
22
|
const CONFIG_FILE = "hyperframes.json";
|
|
@@ -23,12 +24,10 @@ const CONFIG_FILE = "hyperframes.json";
|
|
|
23
24
|
/** Matches the CLI's own slug gate, so a hand-edited value cannot leak text. */
|
|
24
25
|
const SKILL_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
25
26
|
|
|
26
|
-
const MEDIA_EXTENSIONS = /\.(mp4|mov|webm|m4v|mp3|wav|m4a|aac|ogg|png|jpe?g|gif|webp|svg|avif)$/i;
|
|
27
|
-
|
|
28
27
|
let snapshot: FeedbackContext = {};
|
|
29
28
|
|
|
30
29
|
function countMedia(files: string[]): number {
|
|
31
|
-
return files.filter(
|
|
30
|
+
return files.filter(isMediaFile).length;
|
|
32
31
|
}
|
|
33
32
|
|
|
34
33
|
/**
|
|
@@ -11,6 +11,7 @@ import { useAudioMetersVisible } from "../../utils/audioMeterVisibility";
|
|
|
11
11
|
import { SILENT_CHANNEL } from "../../utils/audioMeterMath";
|
|
12
12
|
import {
|
|
13
13
|
AudioMeterStrip,
|
|
14
|
+
type AudioMeterStripProps,
|
|
14
15
|
evictGoneMeterState,
|
|
15
16
|
followMeterHook,
|
|
16
17
|
stepAndPaintStrips,
|
|
@@ -20,8 +21,9 @@ import {
|
|
|
20
21
|
|
|
21
22
|
const iframe = { contentWindow: null as unknown };
|
|
22
23
|
const previewIframeRef = { current: iframe };
|
|
24
|
+
let shell: { previewIframeRef: typeof previewIframeRef } | null = { previewIframeRef };
|
|
23
25
|
vi.mock("../../contexts/StudioContext", () => ({
|
|
24
|
-
|
|
26
|
+
useStudioShellContextOptional: () => shell,
|
|
25
27
|
}));
|
|
26
28
|
|
|
27
29
|
const onSetAudioGroupAttributeLive = vi.fn();
|
|
@@ -88,6 +90,7 @@ const tick = () => {
|
|
|
88
90
|
};
|
|
89
91
|
|
|
90
92
|
beforeEach(() => {
|
|
93
|
+
shell = { previewIframeRef };
|
|
91
94
|
frames = [];
|
|
92
95
|
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => frames.push(cb));
|
|
93
96
|
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
@@ -103,11 +106,11 @@ afterEach(() => {
|
|
|
103
106
|
vi.unstubAllGlobals();
|
|
104
107
|
});
|
|
105
108
|
|
|
106
|
-
function mount() {
|
|
109
|
+
function mount(props: AudioMeterStripProps = {}) {
|
|
107
110
|
const host = document.createElement("div");
|
|
108
111
|
document.body.append(host);
|
|
109
112
|
const root = createRoot(host);
|
|
110
|
-
act(() => root.render(<AudioMeterStrip />));
|
|
113
|
+
act(() => root.render(<AudioMeterStrip {...props} />));
|
|
111
114
|
roots.push(root);
|
|
112
115
|
return { host, root };
|
|
113
116
|
}
|
|
@@ -177,6 +180,26 @@ describe("AudioMeterStrip", () => {
|
|
|
177
180
|
expect(second.read).toHaveBeenCalled();
|
|
178
181
|
});
|
|
179
182
|
|
|
183
|
+
it("reads a host's preview iframe over the shell's", () => {
|
|
184
|
+
usePlayerStore.setState({ elements: [clip({ audioGroup: "vo" })] });
|
|
185
|
+
const shellHook = makeHook();
|
|
186
|
+
setHook(shellHook);
|
|
187
|
+
const hostHook = makeHook();
|
|
188
|
+
const hostIframe = { contentWindow: { __hf: { audioMeter: hostHook } } };
|
|
189
|
+
mount({ previewIframeRef: { current: hostIframe as unknown as HTMLIFrameElement } });
|
|
190
|
+
tick();
|
|
191
|
+
expect(hostHook.start).toHaveBeenCalledTimes(1);
|
|
192
|
+
expect(shellHook.start).not.toHaveBeenCalled();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("mounts with no shell and no preview iframe", () => {
|
|
196
|
+
shell = null;
|
|
197
|
+
usePlayerStore.setState({ elements: [clip({ audioGroup: "vo" })] });
|
|
198
|
+
const { host } = mount();
|
|
199
|
+
tick();
|
|
200
|
+
expect(host.querySelector("[data-testid=audio-meter-strip]")).not.toBeNull();
|
|
201
|
+
});
|
|
202
|
+
|
|
180
203
|
it("keeps authored gain 2 above unity and writes exactly 1 at the midpoint", () => {
|
|
181
204
|
usePlayerStore.setState({ elements: [clip({ audioGroup: "vo", audioGroupVolume: 2 })] });
|
|
182
205
|
const { host } = mount();
|
|
@@ -20,7 +20,7 @@ import type { StereoLevel } from "@hyperframes/core/runtime/levelTap";
|
|
|
20
20
|
import { usePlayerStore } from "../../player";
|
|
21
21
|
import { clampNumber } from "../../utils/studioHelpers";
|
|
22
22
|
import { useAudioMetersVisible } from "../../utils/audioMeterVisibility";
|
|
23
|
-
import {
|
|
23
|
+
import { useStudioShellContextOptional } from "../../contexts/StudioContext";
|
|
24
24
|
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
25
25
|
import {
|
|
26
26
|
METER_DB_MARKS,
|
|
@@ -107,7 +107,7 @@ function useVolumeHandlers(): {
|
|
|
107
107
|
|
|
108
108
|
type PreviewWindow = (Window & { __hf?: { audioMeter?: AudioMeterHook } }) | null | undefined;
|
|
109
109
|
|
|
110
|
-
function readHook(iframe: HTMLIFrameElement | null): AudioMeterHook | null {
|
|
110
|
+
function readHook(iframe: HTMLIFrameElement | null | undefined): AudioMeterHook | null {
|
|
111
111
|
try {
|
|
112
112
|
return (iframe?.contentWindow as PreviewWindow)?.__hf?.audioMeter ?? null;
|
|
113
113
|
} catch {
|
|
@@ -123,9 +123,8 @@ function paintPeak(el: HTMLElement | null, peak: number): void {
|
|
|
123
123
|
el.style.setProperty("transform", peak >= 1 ? "translateY(1px)" : "none");
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
/** The fill is a fixed green/amber/red backdrop; painting only moves the dark
|
|
127
|
-
*
|
|
128
|
-
* band instead of tinting a flat colour brighter. */
|
|
126
|
+
/** The fill is a fixed green/amber/red backdrop; painting only moves the dark mask that covers the unlit top
|
|
127
|
+
* portion, so a loud peak lights the real red band instead of tinting a flat colour brighter. */
|
|
129
128
|
function paint(bars: StripBars | undefined, channels: Pair): void {
|
|
130
129
|
channels.forEach((ch, i) => {
|
|
131
130
|
bars?.[i]?.mask?.style.setProperty("height", `${(1 - ch.level) * 100}%`);
|
|
@@ -179,8 +178,13 @@ export function stepAndPaintStrips(
|
|
|
179
178
|
}
|
|
180
179
|
|
|
181
180
|
/** One rAF loop re-reads the hook off the live preview window, so a reloaded iframe is followed. */
|
|
182
|
-
function useMeterLoop(
|
|
183
|
-
|
|
181
|
+
function useMeterLoop(
|
|
182
|
+
strips: Strip[],
|
|
183
|
+
bars: RefObject<Map<string | null, StripBars>>,
|
|
184
|
+
iframeRef: RefObject<HTMLIFrameElement | null> | undefined,
|
|
185
|
+
) {
|
|
186
|
+
const shell = useStudioShellContextOptional();
|
|
187
|
+
const previewIframeRef = iframeRef ?? shell?.previewIframeRef;
|
|
184
188
|
const stripsRef = useRef(strips);
|
|
185
189
|
stripsRef.current = strips;
|
|
186
190
|
useEffect(() => {
|
|
@@ -190,7 +194,7 @@ function useMeterLoop(strips: Strip[], bars: RefObject<Map<string | null, StripB
|
|
|
190
194
|
const state = new Map<string | null, Pair>();
|
|
191
195
|
const tick = (now: number) => {
|
|
192
196
|
raf = requestAnimationFrame(tick);
|
|
193
|
-
active = followMeterHook(active, readHook(previewIframeRef
|
|
197
|
+
active = followMeterHook(active, readHook(previewIframeRef?.current));
|
|
194
198
|
const levels = active?.read();
|
|
195
199
|
const dt = now - last;
|
|
196
200
|
last = now;
|
|
@@ -395,14 +399,21 @@ function MeterStrip({
|
|
|
395
399
|
);
|
|
396
400
|
}
|
|
397
401
|
|
|
398
|
-
export
|
|
402
|
+
export interface AudioMeterStripProps {
|
|
403
|
+
/** Pass a stable ref (useRef): a new object each render restarts the meter loop. */
|
|
404
|
+
previewIframeRef?: RefObject<HTMLIFrameElement | null>;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export const AudioMeterStrip = memo(function AudioMeterStrip({
|
|
408
|
+
previewIframeRef,
|
|
409
|
+
}: AudioMeterStripProps) {
|
|
399
410
|
const visible = useAudioMetersVisible((s) => s.visible);
|
|
400
411
|
const projectHasAudio = useProjectHasAudio();
|
|
401
412
|
if (!visible || !projectHasAudio) return null;
|
|
402
|
-
return <MeterStripBody />;
|
|
413
|
+
return <MeterStripBody previewIframeRef={previewIframeRef} />;
|
|
403
414
|
});
|
|
404
415
|
|
|
405
|
-
function MeterStripBody() {
|
|
416
|
+
function MeterStripBody({ previewIframeRef }: AudioMeterStripProps) {
|
|
406
417
|
const strips = useStrips();
|
|
407
418
|
const bars = useRef(new Map<string | null, StripBars>());
|
|
408
419
|
const register = useRef((id: string | null, b: StripBars | null) => {
|
|
@@ -410,7 +421,7 @@ function MeterStripBody() {
|
|
|
410
421
|
else bars.current.delete(id);
|
|
411
422
|
}).current;
|
|
412
423
|
const { onLive, onCommit } = useVolumeHandlers();
|
|
413
|
-
useMeterLoop(strips, bars);
|
|
424
|
+
useMeterLoop(strips, bars, previewIframeRef);
|
|
414
425
|
return (
|
|
415
426
|
<div
|
|
416
427
|
data-testid="audio-meter-strip"
|