@hyperframes/core 0.8.199 → 0.8.201
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/audioAnalysis.d.ts +2 -0
- package/dist/audioAnalysis.js +14 -5
- package/dist/audioGroups.d.ts +3 -7
- package/dist/audioGroups.js +4 -8
- package/dist/audioResample.d.ts +2 -0
- package/dist/audioResample.js +7 -0
- package/dist/audioRhythm.d.ts +3 -0
- package/dist/audioRhythm.js +37 -20
- package/dist/audioSteps.d.ts +3 -0
- package/dist/audioSteps.js +7 -0
- package/dist/beats/beatDetection.js +22 -7
- package/dist/compiler/htmlBundler.js +15 -40
- package/dist/compiler/inlineSubCompositions.d.ts +2 -0
- package/dist/compiler/inlineSubCompositions.js +64 -5
- package/dist/compiler/srcset.d.ts +1 -0
- package/dist/compiler/srcset.js +46 -0
- package/dist/generated/position-edits-render-inline.js +1 -1
- package/dist/generated/runtime-inline.js +1 -1
- package/dist/hyperframe.runtime.iife.js +66 -62
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/mediaGradeAnalyzer.d.ts +1 -0
- package/dist/mediaGradeAnalyzer.js +32 -20
- package/dist/previewProxyBox.d.ts +14 -0
- package/dist/previewProxyBox.js +15 -0
- package/dist/runtime/bridge.d.ts +1 -0
- package/dist/runtime/bridge.js +1 -0
- package/dist/runtime/clipReveal.d.ts +1 -1
- package/dist/runtime/diagnostics.d.ts +2 -0
- package/dist/runtime/diagnostics.js +40 -1
- package/dist/runtime/media.d.ts +1 -0
- package/dist/runtime/media.js +15 -21
- package/dist/runtime/timedClipHide.d.ts +2 -1
- package/dist/runtime/timedClipHide.js +8 -4
- package/dist/runtime/timeline.d.ts +8 -1
- package/dist/runtime/timeline.js +32 -13
- package/dist/runtime/types.d.ts +2 -1
- package/dist/runtime/validateVariables.js +6 -7
- package/dist/studioPreviewMark.d.ts +3 -1
- package/dist/studioPreviewMark.js +4 -0
- package/dist/variables.d.ts +1 -1
- package/package.json +4 -4
package/dist/audioAnalysis.d.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Pure functions of PCM samples, so they run the same in Node, Bun and the browser. Times are seconds into the signal.
|
|
4
4
|
*/
|
|
5
5
|
import type { AudioAnalysisProvider } from "./audioAnalysisLadder.js";
|
|
6
|
+
import { type Steps } from "./audioSteps.js";
|
|
6
7
|
/** Decoded audio: one Float32Array per channel, all the same length. */
|
|
7
8
|
export interface AudioSignal {
|
|
8
9
|
sampleRate: number;
|
|
@@ -102,6 +103,7 @@ export interface KindFeatures {
|
|
|
102
103
|
export declare const signalDuration: (signal: AudioSignal) => number;
|
|
103
104
|
/** Per-sample energy. Channels are squared first, so a reversed channel stays audible. */
|
|
104
105
|
export declare function energyMix(signal: AudioSignal): Float32Array;
|
|
106
|
+
export declare function energyMixSteps(signal: AudioSignal): Steps<Float32Array>;
|
|
105
107
|
export declare function envelope(signal: AudioSignal, options?: EnvelopeOptions): Envelope;
|
|
106
108
|
export declare function onsets(signal: AudioSignal, options?: OnsetOptions): Onsets;
|
|
107
109
|
export declare function moments(signal: AudioSignal, options?: MomentOptions): Moments | null;
|
package/dist/audioAnalysis.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Measurements of decoded audio over time: an energy envelope, onsets, the moments a sound lands, loudness and silence.
|
|
3
3
|
* Pure functions of PCM samples, so they run the same in Node, Bun and the browser. Times are seconds into the signal.
|
|
4
4
|
*/
|
|
5
|
+
import { finish } from "./audioSteps.js";
|
|
5
6
|
import { fft } from "./fft.js";
|
|
6
7
|
import { rhythm } from "./audioRhythm.js";
|
|
7
8
|
import { sections } from "./audioSections.js";
|
|
@@ -9,18 +10,26 @@ const DB_FLOOR = -120;
|
|
|
9
10
|
export const signalDuration = (signal) => (signal.channels[0]?.length ?? 0) / signal.sampleRate;
|
|
10
11
|
/** Per-sample energy. Channels are squared first, so a reversed channel stays audible. */
|
|
11
12
|
export function energyMix(signal) {
|
|
13
|
+
return finish(energyMixSteps(signal));
|
|
14
|
+
}
|
|
15
|
+
const MIX_SAMPLES_PER_STEP = 65536;
|
|
16
|
+
export function* energyMixSteps(signal) {
|
|
12
17
|
const [first, ...rest] = signal.channels;
|
|
13
18
|
if (!first)
|
|
14
19
|
return new Float32Array(0);
|
|
15
20
|
if (!rest.length)
|
|
16
21
|
return first;
|
|
17
22
|
const mixed = new Float32Array(first.length);
|
|
18
|
-
for (const channel of signal.channels)
|
|
19
|
-
for (let i = 0; i < mixed.length; i++)
|
|
20
|
-
mixed[i] += channel[i] * channel[i];
|
|
21
23
|
const scale = 1 / signal.channels.length;
|
|
22
|
-
for (let
|
|
23
|
-
|
|
24
|
+
for (let start = 0; start < mixed.length; start += MIX_SAMPLES_PER_STEP) {
|
|
25
|
+
const end = Math.min(mixed.length, start + MIX_SAMPLES_PER_STEP);
|
|
26
|
+
for (const channel of signal.channels)
|
|
27
|
+
for (let i = start; i < end; i++)
|
|
28
|
+
mixed[i] += channel[i] * channel[i];
|
|
29
|
+
for (let i = start; i < end; i++)
|
|
30
|
+
mixed[i] = Math.sqrt(mixed[i] * scale);
|
|
31
|
+
yield;
|
|
32
|
+
}
|
|
24
33
|
return mixed;
|
|
25
34
|
}
|
|
26
35
|
const toDb = (amplitude) => amplitude > 0 ? Math.max(DB_FLOOR, 20 * Math.log10(amplitude)) : DB_FLOOR;
|
package/dist/audioGroups.d.ts
CHANGED
|
@@ -100,13 +100,9 @@ export declare function audioGroupOf(el: Element): string | null;
|
|
|
100
100
|
/**
|
|
101
101
|
* Make `<hf-audio-group>` inert, once per document.
|
|
102
102
|
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
* (taking a `gap`, shifting `justify-content`, moving every `:nth-child` after
|
|
107
|
-
* it), and in inline formatting it can still open a line box. Authored layout
|
|
108
|
-
* would shift by adding a group, which is not something a mixing decision is
|
|
109
|
-
* allowed to do.
|
|
103
|
+
* An unknown custom element still makes a box (a flex or grid item, a line box), and a
|
|
104
|
+
* mixing decision must not shift authored layout. `contents`, not `none`: what sits
|
|
105
|
+
* inside one (a self-closed `<hf-audio-group />` takes in every later sibling) still renders, unless muted.
|
|
110
106
|
*
|
|
111
107
|
* `!important` because an author rule can outrank a bare type selector on
|
|
112
108
|
* specificity — inertness here is a contract, not a default. Emitted from the
|
package/dist/audioGroups.js
CHANGED
|
@@ -199,13 +199,9 @@ function isGroupableMember(el) {
|
|
|
199
199
|
/**
|
|
200
200
|
* Make `<hf-audio-group>` inert, once per document.
|
|
201
201
|
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
* (taking a `gap`, shifting `justify-content`, moving every `:nth-child` after
|
|
206
|
-
* it), and in inline formatting it can still open a line box. Authored layout
|
|
207
|
-
* would shift by adding a group, which is not something a mixing decision is
|
|
208
|
-
* allowed to do.
|
|
202
|
+
* An unknown custom element still makes a box (a flex or grid item, a line box), and a
|
|
203
|
+
* mixing decision must not shift authored layout. `contents`, not `none`: what sits
|
|
204
|
+
* inside one (a self-closed `<hf-audio-group />` takes in every later sibling) still renders, unless muted.
|
|
209
205
|
*
|
|
210
206
|
* `!important` because an author rule can outrank a bare type selector on
|
|
211
207
|
* specificity — inertness here is a contract, not a default. Emitted from the
|
|
@@ -217,6 +213,6 @@ export function ensureAudioGroupInertStyle(doc) {
|
|
|
217
213
|
return;
|
|
218
214
|
const style = doc.createElement("style");
|
|
219
215
|
style.id = styleId;
|
|
220
|
-
style.textContent = `${HF_AUDIO_GROUP_TAG}{display:none!important}`;
|
|
216
|
+
style.textContent = `${HF_AUDIO_GROUP_TAG}{display:contents!important}${HF_AUDIO_GROUP_TAG}[data-hidden]{display:none!important}`;
|
|
221
217
|
doc.head.appendChild(style);
|
|
222
218
|
}
|
package/dist/audioResample.d.ts
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
/** Band-limited resampling by windowed-sinc interpolation, so analysis reads the same at any decode rate. */
|
|
2
|
+
import { type Steps } from "./audioSteps.js";
|
|
2
3
|
export declare function resample(samples: Float32Array, from: number, to: number): Float32Array;
|
|
4
|
+
export declare function resampleSteps(samples: Float32Array, from: number, to: number): Steps<Float32Array>;
|
package/dist/audioResample.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Band-limited resampling by windowed-sinc interpolation, so analysis reads the same at any decode rate. */
|
|
2
|
+
import { finish } from "./audioSteps.js";
|
|
2
3
|
const ZERO_CROSSINGS = 8;
|
|
3
4
|
const STEPS_PER_CROSSING = 256;
|
|
4
5
|
/** Cutoff as a share of the lower Nyquist, leaving the window's transition band below it. */
|
|
@@ -19,6 +20,10 @@ function kernelAt(x) {
|
|
|
19
20
|
return KERNEL[i] * (1 - frac) + KERNEL[i + 1] * frac;
|
|
20
21
|
}
|
|
21
22
|
export function resample(samples, from, to) {
|
|
23
|
+
return finish(resampleSteps(samples, from, to));
|
|
24
|
+
}
|
|
25
|
+
const SAMPLES_PER_STEP = 4096;
|
|
26
|
+
export function* resampleSteps(samples, from, to) {
|
|
22
27
|
if (from === to)
|
|
23
28
|
return samples;
|
|
24
29
|
const ratio = to / from;
|
|
@@ -26,6 +31,8 @@ export function resample(samples, from, to) {
|
|
|
26
31
|
const reach = ZERO_CROSSINGS / scale;
|
|
27
32
|
const out = new Float32Array(Math.floor(samples.length * ratio));
|
|
28
33
|
for (let n = 0; n < out.length; n++) {
|
|
34
|
+
if (n % SAMPLES_PER_STEP === 0)
|
|
35
|
+
yield;
|
|
29
36
|
const t = n / ratio;
|
|
30
37
|
const last = Math.min(samples.length - 1, Math.floor(t + reach));
|
|
31
38
|
let sum = 0;
|
package/dist/audioRhythm.d.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* tempo with a log-normal prior, and dynamic-programming beat placement (Ellis 2007). Times are seconds into the signal.
|
|
4
4
|
*/
|
|
5
5
|
import type { AudioSignal } from "./audioAnalysis.js";
|
|
6
|
+
import { type Steps } from "./audioSteps.js";
|
|
6
7
|
export interface Beats {
|
|
7
8
|
/** Empty when there is no steady beat, with `reason` saying so. */
|
|
8
9
|
times: number[];
|
|
@@ -46,3 +47,5 @@ export declare function tempoOf(times: readonly number[]): Tempo;
|
|
|
46
47
|
/** Band energies of `signal`, computed once per signal for every capability that reads them. */
|
|
47
48
|
export declare function bandFramesOf(signal: AudioSignal): BandFrames;
|
|
48
49
|
export declare function rhythm(signal: AudioSignal): Rhythm;
|
|
50
|
+
/** `rhythm`, yielding between short pieces of work. */
|
|
51
|
+
export declare function rhythmSteps(signal: AudioSignal): Steps<Rhythm>;
|
package/dist/audioRhythm.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Beats, downbeats and tempo from signal processing alone: a log-band spectral-flux onset curve, an autocorrelation
|
|
3
3
|
* tempo with a log-normal prior, and dynamic-programming beat placement (Ellis 2007). Times are seconds into the signal.
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
5
|
+
import { resampleSteps } from "./audioResample.js";
|
|
6
|
+
import { finish } from "./audioSteps.js";
|
|
6
7
|
import { fft } from "./fft.js";
|
|
7
8
|
/** Every signal is analyzed at this rate and as the mean of its channels' power, so decode rate and layout cannot change a result. */
|
|
8
9
|
const ANALYSIS_RATE = 22050;
|
|
@@ -45,7 +46,7 @@ function addFramePower(samples, center, window, edges, into) {
|
|
|
45
46
|
}
|
|
46
47
|
}
|
|
47
48
|
/** Per-band log power of every frame, averaged over channels so a polarity-flipped channel cannot cancel. */
|
|
48
|
-
function bandPower(channels) {
|
|
49
|
+
function* bandPower(channels) {
|
|
49
50
|
const window = hann(FRAME_SIZE);
|
|
50
51
|
const edges = bandEdges();
|
|
51
52
|
const hop = ANALYSIS_RATE / FRAMES_PER_SECOND;
|
|
@@ -55,6 +56,7 @@ function bandPower(channels) {
|
|
|
55
56
|
for (const channel of channels)
|
|
56
57
|
addFramePower(channel, Math.round(f * hop), window, edges, power);
|
|
57
58
|
bands.push(power.map((p) => Math.log1p((1e9 * p) / channels.length)));
|
|
59
|
+
yield;
|
|
58
60
|
}
|
|
59
61
|
return bands;
|
|
60
62
|
}
|
|
@@ -65,7 +67,7 @@ const PITCH_CLASS = Array.from({ length: CHROMA_SIZE / 2 }, (_, bin) => {
|
|
|
65
67
|
return -1;
|
|
66
68
|
return (((Math.round(12 * Math.log2(hz / 440)) + 9) % 12) + 12) % 12;
|
|
67
69
|
});
|
|
68
|
-
function chromaFrames(channels) {
|
|
70
|
+
function* chromaFrames(channels) {
|
|
69
71
|
const window = hann(CHROMA_SIZE);
|
|
70
72
|
const hop = ANALYSIS_RATE / CHROMA_FPS;
|
|
71
73
|
const re = new Float64Array(CHROMA_SIZE);
|
|
@@ -87,15 +89,18 @@ function chromaFrames(channels) {
|
|
|
87
89
|
});
|
|
88
90
|
}
|
|
89
91
|
frames.push(chroma);
|
|
92
|
+
yield;
|
|
90
93
|
}
|
|
91
94
|
return frames;
|
|
92
95
|
}
|
|
93
|
-
function bandFrames(signal) {
|
|
94
|
-
const channels =
|
|
96
|
+
function* bandFrames(signal) {
|
|
97
|
+
const channels = [];
|
|
98
|
+
for (const c of signal.channels)
|
|
99
|
+
channels.push(yield* resampleSteps(c, signal.sampleRate, ANALYSIS_RATE));
|
|
95
100
|
return {
|
|
96
101
|
fps: FRAMES_PER_SECOND,
|
|
97
|
-
bands: bandPower(channels),
|
|
98
|
-
chroma: chromaFrames(channels),
|
|
102
|
+
bands: yield* bandPower(channels),
|
|
103
|
+
chroma: yield* chromaFrames(channels),
|
|
99
104
|
chromaFps: CHROMA_FPS,
|
|
100
105
|
};
|
|
101
106
|
}
|
|
@@ -182,7 +187,7 @@ function smoothed(curve, period) {
|
|
|
182
187
|
});
|
|
183
188
|
}
|
|
184
189
|
/** Ellis's recursion: each frame's best score from a predecessor about one period back, penalized by log deviation. */
|
|
185
|
-
function cumulativeScore(local, periodAt) {
|
|
190
|
+
function* cumulativeScore(local, periodAt) {
|
|
186
191
|
const score = new Float64Array(local.length);
|
|
187
192
|
const back = new Int32Array(local.length).fill(-1);
|
|
188
193
|
for (let i = 0; i < local.length; i++) {
|
|
@@ -196,6 +201,7 @@ function cumulativeScore(local, periodAt) {
|
|
|
196
201
|
}
|
|
197
202
|
score[i] = local[i] + Math.max(0, best);
|
|
198
203
|
back[i] = best > 0 ? from : -1;
|
|
204
|
+
yield;
|
|
199
205
|
}
|
|
200
206
|
return { score, back };
|
|
201
207
|
}
|
|
@@ -226,7 +232,7 @@ function trimmed(beats, local) {
|
|
|
226
232
|
}
|
|
227
233
|
const LOCAL_WINDOW_SECONDS = 8;
|
|
228
234
|
/** The period through the piece: eight-second windows a second apart, held within 10% of the global period, median-smoothed. */
|
|
229
|
-
function localPeriods(curve, fps, period) {
|
|
235
|
+
function* localPeriods(curve, fps, period) {
|
|
230
236
|
const window = Math.round(LOCAL_WINDOW_SECONDS * fps);
|
|
231
237
|
const hop = Math.round(fps);
|
|
232
238
|
const range = {
|
|
@@ -239,6 +245,7 @@ function localPeriods(curve, fps, period) {
|
|
|
239
245
|
const end = Math.min(curve.length, start + window);
|
|
240
246
|
periods.push(beatPeriod(curve.subarray(start, end), fps, range).period);
|
|
241
247
|
centres.push((start + end) / 2);
|
|
248
|
+
yield;
|
|
242
249
|
}
|
|
243
250
|
const median = periods.map((_, i) => {
|
|
244
251
|
const near = periods.slice(Math.max(0, i - 2), i + 3).sort((a, b) => a - b);
|
|
@@ -256,9 +263,10 @@ function interpolate(xs, ys, x) {
|
|
|
256
263
|
}
|
|
257
264
|
return ys[ys.length - 1];
|
|
258
265
|
}
|
|
259
|
-
function beatFrames(curve, fps, period) {
|
|
266
|
+
function* beatFrames(curve, fps, period) {
|
|
260
267
|
const local = smoothed(curve, period);
|
|
261
|
-
const
|
|
268
|
+
const periodAt = yield* localPeriods(curve, fps, period);
|
|
269
|
+
const { score, back } = yield* cumulativeScore(local, periodAt);
|
|
262
270
|
const beats = [];
|
|
263
271
|
for (let i = lastBeat(score); i >= 0; i = back[i])
|
|
264
272
|
beats.push(i);
|
|
@@ -352,14 +360,16 @@ export const STEADY_BEAT_CONFIDENCE = 0.15;
|
|
|
352
360
|
/** Clarity of the period, scaled down when the beats are under three times as strong as the onsets around them. */
|
|
353
361
|
const beatConfidence = (clarity, lift) => round(Math.max(0, Math.min(1, clarity)) * Math.max(0, Math.min(1, lift / 3)));
|
|
354
362
|
/** Beats and downbeats of `frames`, a signal's band energies and chroma. */
|
|
355
|
-
function rhythmOf(frames) {
|
|
363
|
+
function* rhythmOf(frames) {
|
|
356
364
|
const curves = onsetCurves(frames);
|
|
365
|
+
yield;
|
|
357
366
|
const { period, clarity } = beatPeriod(curves.broad, curves.fps);
|
|
367
|
+
yield;
|
|
358
368
|
if (curves.broad.length < SHORTEST_SECONDS * curves.fps)
|
|
359
369
|
return noRhythm(0, "too short for a steady beat");
|
|
360
370
|
if (!curves.broad.some((v) => v > 0) || curves.broad.length < 2 * period)
|
|
361
371
|
return noRhythm(0);
|
|
362
|
-
const { frames: beatList, local } = beatFrames(curves.broad, curves.fps, period);
|
|
372
|
+
const { frames: beatList, local } = yield* beatFrames(curves.broad, curves.fps, period);
|
|
363
373
|
const confidence = beatConfidence(clarity, onsetLift(beatList, local));
|
|
364
374
|
if (beatList.length < 4 || confidence < STEADY_BEAT_CONFIDENCE)
|
|
365
375
|
return noRhythm(confidence);
|
|
@@ -393,15 +403,22 @@ export function tempoOf(times) {
|
|
|
393
403
|
return { value: bpm(interval), curve };
|
|
394
404
|
}
|
|
395
405
|
const measured = new WeakMap();
|
|
396
|
-
|
|
397
|
-
export function bandFramesOf(signal) {
|
|
406
|
+
function* measuredSteps(signal) {
|
|
398
407
|
let entry = measured.get(signal);
|
|
399
408
|
if (!entry)
|
|
400
|
-
measured.set(signal, (entry = { frames: bandFrames(signal) }));
|
|
401
|
-
return entry
|
|
409
|
+
measured.set(signal, (entry = { frames: yield* bandFrames(signal) }));
|
|
410
|
+
return entry;
|
|
411
|
+
}
|
|
412
|
+
/** Band energies of `signal`, computed once per signal for every capability that reads them. */
|
|
413
|
+
export function bandFramesOf(signal) {
|
|
414
|
+
return finish(measuredSteps(signal)).frames;
|
|
402
415
|
}
|
|
403
416
|
export function rhythm(signal) {
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
417
|
+
return finish(rhythmSteps(signal));
|
|
418
|
+
}
|
|
419
|
+
/** `rhythm`, yielding between short pieces of work. */
|
|
420
|
+
export function* rhythmSteps(signal) {
|
|
421
|
+
const entry = yield* measuredSteps(signal);
|
|
422
|
+
entry.rhythm ??= yield* rhythmOf(entry.frames);
|
|
423
|
+
return entry.rhythm;
|
|
407
424
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { energyMixSteps } from "../audioAnalysis.js";
|
|
2
|
+
import { rhythmSteps, tempoOf } from "../audioRhythm.js";
|
|
3
|
+
import { finish } from "../audioSteps.js";
|
|
3
4
|
const STRENGTH_WINDOW_S = 0.05; // ±50ms RMS window
|
|
4
5
|
/** Local RMS amplitude at a given audio-file time. */
|
|
5
6
|
export function computeRmsAt(channelData, sampleRate, time) {
|
|
@@ -28,14 +29,27 @@ function beatStrengths(beats, levels, sampleRate) {
|
|
|
28
29
|
const peak = Math.max(...energies, 1e-6);
|
|
29
30
|
return { strengths: energies.map((e) => Math.min(1, e / peak)), peak };
|
|
30
31
|
}
|
|
32
|
+
/** Longest stretch the analysis runs before awaiting `pause`. */
|
|
33
|
+
const SLICE_MS = 8;
|
|
34
|
+
async function sliced(steps, pause) {
|
|
35
|
+
if (!pause)
|
|
36
|
+
return finish(steps);
|
|
37
|
+
let start = performance.now();
|
|
38
|
+
for (;;) {
|
|
39
|
+
const next = steps.next();
|
|
40
|
+
if (next.done)
|
|
41
|
+
return next.value;
|
|
42
|
+
if (performance.now() - start >= SLICE_MS) {
|
|
43
|
+
await pause();
|
|
44
|
+
start = performance.now();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
31
48
|
/** Beats, tempo and per-beat strength of decoded audio; no beats and a null bpm when it has no steady beat. */
|
|
32
49
|
export async function analyzeMusicFromSignal(signal, { pause } = {}) {
|
|
33
50
|
await pause?.();
|
|
34
|
-
|
|
35
|
-
await
|
|
36
|
-
const { beats } = rhythm(signal);
|
|
37
|
-
await pause?.();
|
|
38
|
-
const channelData = energyMix(signal);
|
|
51
|
+
const { beats } = await sliced(rhythmSteps(signal), pause);
|
|
52
|
+
const channelData = await sliced(energyMixSteps(signal), pause);
|
|
39
53
|
const confidence = beats.confidence ?? 0;
|
|
40
54
|
const times = beats.times;
|
|
41
55
|
const { strengths, peak } = beatStrengths(times, channelData, signal.sampleRate);
|
|
@@ -55,6 +69,7 @@ export function analyzeMusicFromBuffer(audioBuffer, options = {}) {
|
|
|
55
69
|
return analyzeMusicFromSignal({ sampleRate: audioBuffer.sampleRate, channels }, options);
|
|
56
70
|
}
|
|
57
71
|
export async function analyzeMusicFromUrl(url, options = {}) {
|
|
72
|
+
await options.pause?.();
|
|
58
73
|
const audioContext = new AudioContext();
|
|
59
74
|
try {
|
|
60
75
|
const response = await fetch(url);
|
|
@@ -16,6 +16,7 @@ import { resolve, relative, dirname, isAbsolute, sep } from "path";
|
|
|
16
16
|
import { extractStandaloneEntryFromIndex, isTemplateEntry } from "./standaloneEntry.js";
|
|
17
17
|
import { decodeCssEscapes, decodeWellFormedEscapes, encodeUrlPath, decodedUrlPath, splitUrlSuffix, } from "@hyperframes/parsers/asset-paths";
|
|
18
18
|
import { CSS_URL_RE, isNonRelativeUrl } from "./assetPaths.js";
|
|
19
|
+
import { rewriteSrcsetUrls } from "./srcset.js";
|
|
19
20
|
import { compileHtml } from "./htmlCompiler.js";
|
|
20
21
|
import { RUNTIME_BOOTSTRAP_ATTR, escapeInlineScriptSource, insertRuntimeTag, parseHTMLContent, stripEmbeddedRuntimeScripts, } from "./htmlDocument.js";
|
|
21
22
|
// rewriteSubCompPaths functions are used by inlineSubCompositions (shared module)
|
|
@@ -23,7 +24,7 @@ import { buildVariablesByCompScript, dedupeFontFaceRules, hoistFontFaceRules, sc
|
|
|
23
24
|
import { validateHyperframeHtmlContract } from "./staticGuard.js";
|
|
24
25
|
import { getHyperframeRuntimeScript } from "../generated/runtime-inline.js";
|
|
25
26
|
import { readDeclaredDefaults } from "../runtime/getVariables.js";
|
|
26
|
-
import { ensureExternalLinkTag, inlineSubCompositions, refuseSwapsReachedByRootScripts, } from "./inlineSubCompositions.js";
|
|
27
|
+
import { ensureExternalLinkTag, inlineSubCompositions, isLibraryCopy, refuseSwapsReachedByRootScripts, } from "./inlineSubCompositions.js";
|
|
27
28
|
import { isSafePath, resolveWithinProject } from "../safePath.js";
|
|
28
29
|
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
|
|
29
30
|
import { HF_COLOR_GRADING_ATTR } from "../colorGrading.js";
|
|
@@ -125,43 +126,6 @@ function rebaseRelativePath(urlValue, fromDir, toDir) {
|
|
|
125
126
|
const rebased = encodeUrlPath(relative(resolve(toDir), absolutePath).split(sep).join("/"));
|
|
126
127
|
return appendSuffixToUrl(rebased, suffix);
|
|
127
128
|
}
|
|
128
|
-
function rewriteSrcsetUrls(srcset, rewriteUrl) {
|
|
129
|
-
const parts = [];
|
|
130
|
-
let position = 0;
|
|
131
|
-
let written = 0;
|
|
132
|
-
// Preserve URL commas per https://html.spec.whatwg.org/multipage/images.html#parse-a-srcset-attribute
|
|
133
|
-
while (position < srcset.length) {
|
|
134
|
-
while (position < srcset.length && /[ \t\n\r\f,]/.test(srcset.charAt(position)))
|
|
135
|
-
position++;
|
|
136
|
-
const start = position;
|
|
137
|
-
while (position < srcset.length && !/[ \t\n\r\f]/.test(srcset.charAt(position)))
|
|
138
|
-
position++;
|
|
139
|
-
let end = position;
|
|
140
|
-
while (end > start && srcset.charAt(end - 1) === ",")
|
|
141
|
-
end--;
|
|
142
|
-
if (end > start) {
|
|
143
|
-
const url = srcset.slice(start, end);
|
|
144
|
-
const rewritten = rewriteUrl(url);
|
|
145
|
-
if (rewritten !== url) {
|
|
146
|
-
parts.push(srcset.slice(written, start), rewritten);
|
|
147
|
-
written = end;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
if (end < position)
|
|
151
|
-
continue;
|
|
152
|
-
let inParens = false;
|
|
153
|
-
while (position < srcset.length) {
|
|
154
|
-
const char = srcset.charAt(position++);
|
|
155
|
-
if (char === "(")
|
|
156
|
-
inParens = true;
|
|
157
|
-
else if (char === ")")
|
|
158
|
-
inParens = false;
|
|
159
|
-
else if (char === "," && !inParens)
|
|
160
|
-
break;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
return parts.length > 0 ? parts.join("") + srcset.slice(written) : srcset;
|
|
164
|
-
}
|
|
165
129
|
function rebaseColorGradingLutPath(value, fromDir, toDir) {
|
|
166
130
|
if (!value.trim().startsWith("{"))
|
|
167
131
|
return value;
|
|
@@ -966,7 +930,8 @@ async function bundleProject(projectDir, options) {
|
|
|
966
930
|
].map((el) => {
|
|
967
931
|
const src = el.getAttribute("src");
|
|
968
932
|
const path = src && isRelativeUrl(src) ? resolveEntryUrl(src) : null;
|
|
969
|
-
|
|
933
|
+
const text = src ? (path && safeReadFile(path)) || "" : el.textContent || "";
|
|
934
|
+
return isLibraryCopy(text) ? "" : text;
|
|
970
935
|
})
|
|
971
936
|
: [];
|
|
972
937
|
// Inline sub-compositions (via shared function)
|
|
@@ -1006,6 +971,10 @@ async function bundleProject(projectDir, options) {
|
|
|
1006
971
|
},
|
|
1007
972
|
flattenInnerRoot: prepareFlattenedInnerRoot,
|
|
1008
973
|
tagScenes: options?.sceneParts === true,
|
|
974
|
+
readProjectScript: (path) => {
|
|
975
|
+
const resolved = isRelativeUrl(path) ? resolveEntryUrl(path) : null;
|
|
976
|
+
return resolved ? safeReadFile(resolved) : null;
|
|
977
|
+
},
|
|
1009
978
|
readVariableDefaults: readDeclaredDefaults,
|
|
1010
979
|
parseHostVariables: parseHostVariableValues,
|
|
1011
980
|
buildScopeSelector: (compId) => cssAttributeSelector("data-composition-id", compId),
|
|
@@ -1025,6 +994,10 @@ async function bundleProject(projectDir, options) {
|
|
|
1025
994
|
...subCompResult.variablesByComp,
|
|
1026
995
|
};
|
|
1027
996
|
const seenCompScriptSrcs = new Set();
|
|
997
|
+
const scenesBySrc = new Map();
|
|
998
|
+
for (const item of subCompResult.scriptItems)
|
|
999
|
+
if (item.kind !== "inline")
|
|
1000
|
+
scenesBySrc.set(item.src, (scenesBySrc.get(item.src) ?? new Set()).add(item.scene));
|
|
1028
1001
|
for (const scriptItem of subCompResult.scriptItems) {
|
|
1029
1002
|
if (scriptItem.kind === "inline") {
|
|
1030
1003
|
pushRun(scriptRuns, scriptItem.scene, scriptItem.content);
|
|
@@ -1039,12 +1012,14 @@ async function bundleProject(projectDir, options) {
|
|
|
1039
1012
|
if (seenCompScriptSrcs.has(extSrc))
|
|
1040
1013
|
continue;
|
|
1041
1014
|
seenCompScriptSrcs.add(extSrc);
|
|
1015
|
+
// Run once for the film when scenes share it: a scene mounts without the one that loaded it first.
|
|
1016
|
+
const scene = (scenesBySrc.get(extSrc)?.size ?? 0) > 1 ? undefined : scriptItem.scene;
|
|
1042
1017
|
if (isRelativeUrl(extSrc) && scriptItem.type !== "module") {
|
|
1043
1018
|
const jsPath = resolveEntryUrl(extSrc);
|
|
1044
1019
|
const js = jsPath ? safeReadFile(jsPath) : null;
|
|
1045
1020
|
if (js != null) {
|
|
1046
1021
|
const chunk = () => preserveLocalScriptIntegrity(document, extSrc, resolveEntryUrl) ? "" : js;
|
|
1047
|
-
pushRun(scriptRuns,
|
|
1022
|
+
pushRun(scriptRuns, scene, chunk);
|
|
1048
1023
|
continue;
|
|
1049
1024
|
}
|
|
1050
1025
|
}
|
|
@@ -11,6 +11,7 @@ import { type CompositionStyle } from "./scriptRuns.js";
|
|
|
11
11
|
* `data-composition-file`).
|
|
12
12
|
*/
|
|
13
13
|
import { type AssetExists } from "./rewriteSubCompPaths.js";
|
|
14
|
+
export declare const isLibraryCopy: (script: string) => boolean;
|
|
14
15
|
/** Marks each scene whose nodes a script outside it names by selector, id or class: a swap would strand it. */
|
|
15
16
|
export declare function refuseSwapsReachedByRootScripts(document: Document, rootScripts: string[]): void;
|
|
16
17
|
export interface InlineSubCompositionsOptions {
|
|
@@ -98,6 +99,7 @@ export interface InlineSubCompositionsOptions {
|
|
|
98
99
|
onMissingComposition?: (srcPath: string, reason?: string) => void;
|
|
99
100
|
/** Tag each top-level host with `data-hf-scene` and report every part's scene, for preview swaps. */
|
|
100
101
|
tagScenes?: boolean;
|
|
102
|
+
readProjectScript?: (path: string) => string | null;
|
|
101
103
|
}
|
|
102
104
|
export interface ExternalLink {
|
|
103
105
|
href: string;
|
|
@@ -20,15 +20,74 @@ import { enumerateNestedCompositionHosts, planCompositionAssembly, extractedComp
|
|
|
20
20
|
import { SCENE_NO_SWAP_ATTR, SCENE_PART_ATTR } from "../sceneParts.js";
|
|
21
21
|
// Anything a scene script can leave running, pending or registered outside its timeline, or that
|
|
22
22
|
// throws when run again: only the timeline is torn down when a scene is swapped, so when unsure, refuse.
|
|
23
|
-
const SIDE_EFFECT_RE = /\b(addEventListener|requestAnimationFrame|requestIdleCallback|setTimeout|setInterval|queueMicrotask|getContext|WebGL\w*|WebGPU\w*|gpu|Worker|WebSocket|EventSource|Audio\w*|\w*Observer|fetch|import|eval|Function|Promise|async|await|delayedCall|ScrollTrigger|Draggable|anime|customElements|registerProperty|addListener|BroadcastChannel|pushState|replaceState|adoptedStyleSheets|documentElement|getElementsByTagName|lottie|THREE|__hf[A-Z]\w*)\b|\.then\s*\(|\.
|
|
23
|
+
const SIDE_EFFECT_RE = /\b(addEventListener|requestAnimationFrame|requestIdleCallback|setTimeout|setInterval|queueMicrotask|getContext|WebGL\w*|WebGPU\w*|gpu|Worker|WebSocket|EventSource|Audio\w*|\w*Observer|fetch|import|eval|Function|Promise|async|await|delayedCall|ScrollTrigger|Draggable|anime|customElements|registerProperty|addListener|BroadcastChannel|pushState|replaceState|adoptedStyleSheets|documentElement|getElementsByTagName|lottie|THREE|__hf[A-Z]\w*)\b|\.then\s*\(|\.ticker\b|repeat\s*:\s*-1|\.repeat\s*\(\s*-1|defineProperty\s*\(\s*(window|globalThis|self|document)\b|\bfonts\s*\.\s*add\b|\.on[a-z]+\s*=(?!=)|\bon(resize|scroll|message|key\w+|click|pointer\w+|mouse\w+|wheel|visibilitychange|hashchange|popstate|error|load)\s*=(?!=)|\[\s*["']on[a-z]+["']\s*\]|document\s*\.\s*(head|body)\b|querySelector(All)?\(\s*["'](head|body)["']|\bgsap\s*\.\s*(?:to|from|fromTo)\s*\(|\bgsap\s*\.\s*timeline\s*\((?:[^()]|\([^()]*\))*\)\s*\.\s*(?:to|from|fromTo|set|add|call)\s*\(/;
|
|
24
24
|
// npm packages that only define globals when loaded; lottie-web is absent because it scans the page on load.
|
|
25
25
|
const SWAP_SAFE_LIBRARY_URL = /^https:\/\/(cdn\.jsdelivr\.net\/npm|unpkg\.com)\/(gsap|three|d3|d3-[a-z-]+|topojson-client|clipper-lib)(@[^/]+)?\//;
|
|
26
26
|
const isSwapSafeLibrary = (src) => URL.canParse(src) && SWAP_SAFE_LIBRARY_URL.test(new URL(src).href);
|
|
27
|
+
// GSAP's published file copied into a project, known by its license banner: it only defines globals too.
|
|
28
|
+
const GSAP_BANNER_RE = /^\s*\/\*!\s*\*\s*GSAP \d+\.\d+\.\d+\s*\*\s*https:\/\/gsap\.com\b/;
|
|
29
|
+
export const isLibraryCopy = (script) => GSAP_BANNER_RE.test(script);
|
|
30
|
+
// Waits on the page's fonts, ready or loading named faces: each settles once, leaving nothing behind.
|
|
31
|
+
const STRING_ARG = String.raw `(?:"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*')`;
|
|
32
|
+
const FONT_LOAD = String.raw `document\s*\.\s*fonts\s*\.\s*load\s*\(\s*${STRING_ARG}\s*(?:,\s*${STRING_ARG}\s*)?\)`;
|
|
33
|
+
const FONT_PROMISE = String.raw `(?:document\s*\.\s*fonts\s*\.\s*ready\b|${FONT_LOAD}|Promise\s*\.\s*all\s*\(\s*\[\s*${FONT_LOAD}(?:\s*,\s*${FONT_LOAD})*\s*,?\s*\]\s*\))`;
|
|
34
|
+
const FONT_WAIT_RE = new RegExp(String.raw `\b${FONT_PROMISE}\s*\.\s*then\s*\(|\bawait\s+${FONT_PROMISE}`, "g");
|
|
35
|
+
// A canvas made only to measure text: never named, so nothing attaches it to the page or draws on it there.
|
|
36
|
+
const TEXT_MEASURE_CANVAS_RE = /\bdocument\s*\.\s*createElement\s*\(\s*(["'])canvas\1\s*\)\s*\.\s*getContext\s*\(\s*(["'])2d\2\s*\)/g;
|
|
37
|
+
// A timeline chained as it is registered: the registry holds it, so unmounting the scene stops it.
|
|
38
|
+
const REGISTERED_CHAIN_RE = /(\b(?:window\s*\.\s*)?__timelines\s*\[[^[\]]*\]\s*=\s*)gsap\s*\.\s*timeline\s*\(/g;
|
|
39
|
+
/** Whether a call of the browser's Element.animate may leave an animation: it gets timing, its second argument. */
|
|
40
|
+
function lastingAnimateCall(script) {
|
|
41
|
+
for (const call of script.matchAll(/\.animate\s*\(/g)) {
|
|
42
|
+
if (topLevelArgs(script, call.index + call[0].length) !== 1)
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
// Arguments of the call whose "(" ends before `from`; none, a spread or an unclosed call never count as one.
|
|
48
|
+
function topLevelArgs(script, from) {
|
|
49
|
+
let depth = 0;
|
|
50
|
+
let args = 1;
|
|
51
|
+
if (/^\s*(\.\.\.|\))/.test(script.slice(from)))
|
|
52
|
+
return Number.POSITIVE_INFINITY;
|
|
53
|
+
for (let i = from; i < script.length; i++) {
|
|
54
|
+
const ch = script[i];
|
|
55
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
56
|
+
const end = script.indexOf(ch, i + 1);
|
|
57
|
+
if (end < 0)
|
|
58
|
+
return Number.POSITIVE_INFINITY;
|
|
59
|
+
i = end;
|
|
60
|
+
}
|
|
61
|
+
else if ("([{".includes(ch))
|
|
62
|
+
depth++;
|
|
63
|
+
else if (")]}".includes(ch)) {
|
|
64
|
+
if (depth === 0)
|
|
65
|
+
return args;
|
|
66
|
+
depth--;
|
|
67
|
+
}
|
|
68
|
+
else if (ch === "," && depth === 0)
|
|
69
|
+
args++;
|
|
70
|
+
}
|
|
71
|
+
return Number.POSITIVE_INFINITY;
|
|
72
|
+
}
|
|
27
73
|
/** Why an authored scene script cannot be swapped out cleanly, or null when it can. */
|
|
28
74
|
function sceneScriptSwapRefusal(script) {
|
|
29
|
-
const
|
|
30
|
-
|
|
75
|
+
const withoutFontWaits = script.replace(FONT_WAIT_RE, "");
|
|
76
|
+
const awaitedFontsOnly = /\bawait\b/.test(script) && !/\bawait\b/.test(withoutFontWaits);
|
|
77
|
+
let checked = (awaitedFontsOnly ? withoutFontWaits.replace(/\basync\b/g, "") : withoutFontWaits).replace(REGISTERED_CHAIN_RE, "$1(");
|
|
78
|
+
// The context hands its canvas back through .canvas, which could then reach the page.
|
|
79
|
+
if (!/\.\s*canvas\b/.test(checked))
|
|
80
|
+
checked = checked.replace(TEXT_MEASURE_CANVAS_RE, "");
|
|
81
|
+
const match = SIDE_EFFECT_RE.exec(checked);
|
|
82
|
+
if (match)
|
|
83
|
+
return `its script uses ${match[0].trim()}`;
|
|
84
|
+
return lastingAnimateCall(checked) ? "its script uses .animate(" : null;
|
|
31
85
|
}
|
|
86
|
+
const projectScriptRefusal = (text) => text === null
|
|
87
|
+
? "it runs a script that is not a known library"
|
|
88
|
+
: isLibraryCopy(text)
|
|
89
|
+
? null
|
|
90
|
+
: sceneScriptSwapRefusal(text);
|
|
32
91
|
// A quoted string in a script, in any of the three quote styles.
|
|
33
92
|
const STRING_LITERAL_RE = /(["'`])((?:\\.|(?!\1)[^\\\n])*?)\1/g;
|
|
34
93
|
// Text ending where createElement takes its tag: that string makes a node, it selects none.
|
|
@@ -135,7 +194,7 @@ function defaultBuildScopeSelector(compId) {
|
|
|
135
194
|
*/
|
|
136
195
|
// fallow-ignore-next-line complexity
|
|
137
196
|
export function inlineSubCompositions(document, hosts, options) {
|
|
138
|
-
const { resolveHtml, parseHtml, hostIdentityMap, rewriteInlineStyles = false, flattenInnerRoot, compoundAuthoredRoot, readVariableDefaults, parseHostVariables, buildScopeSelector = defaultBuildScopeSelector, scriptErrorLabel = "[HyperFrames] composition script error:", onMissingComposition, assetExists, tagScenes = false, } = options;
|
|
197
|
+
const { resolveHtml, parseHtml, hostIdentityMap, rewriteInlineStyles = false, flattenInnerRoot, compoundAuthoredRoot, readVariableDefaults, parseHostVariables, buildScopeSelector = defaultBuildScopeSelector, scriptErrorLabel = "[HyperFrames] composition script error:", onMissingComposition, assetExists, tagScenes = false, readProjectScript, } = options;
|
|
139
198
|
const rootStyles = [...document.querySelectorAll("style")];
|
|
140
199
|
const styles = [];
|
|
141
200
|
const styleScenes = [];
|
|
@@ -338,7 +397,7 @@ export function inlineSubCompositions(document, hosts, options) {
|
|
|
338
397
|
? sceneScriptSwapRefusal(scriptEl.textContent || "")
|
|
339
398
|
: isSwapSafeLibrary(externalSrc)
|
|
340
399
|
? null
|
|
341
|
-
:
|
|
400
|
+
: projectScriptRefusal(readProjectScript?.(externalSrc) ?? null));
|
|
342
401
|
if (type === "importmap") {
|
|
343
402
|
const map = parseImportMap(scriptEl.textContent || "", (url) => {
|
|
344
403
|
// The rebase drops a leading "./" and a trailing "/"; an import map address needs both.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function rewriteSrcsetUrls(srcset: string, rewriteUrl: (url: string) => string): string;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
function skipSrcsetDescriptors(srcset, position) {
|
|
2
|
+
let inParens = false;
|
|
3
|
+
while (position < srcset.length) {
|
|
4
|
+
const char = srcset.charAt(position++);
|
|
5
|
+
if (char === "(")
|
|
6
|
+
inParens = true;
|
|
7
|
+
else if (char === ")")
|
|
8
|
+
inParens = false;
|
|
9
|
+
else if (char === "," && !inParens)
|
|
10
|
+
break;
|
|
11
|
+
}
|
|
12
|
+
return position;
|
|
13
|
+
}
|
|
14
|
+
function readSrcsetUrl(srcset, position) {
|
|
15
|
+
while (position < srcset.length && /[ \t\n\r\f,]/.test(srcset.charAt(position)))
|
|
16
|
+
position++;
|
|
17
|
+
const start = position;
|
|
18
|
+
while (position < srcset.length && !/[ \t\n\r\f]/.test(srcset.charAt(position)))
|
|
19
|
+
position++;
|
|
20
|
+
let end = position;
|
|
21
|
+
while (end > start && srcset.charAt(end - 1) === ",")
|
|
22
|
+
end--;
|
|
23
|
+
return { start, end, next: position };
|
|
24
|
+
}
|
|
25
|
+
export function rewriteSrcsetUrls(srcset, rewriteUrl) {
|
|
26
|
+
const parts = [];
|
|
27
|
+
let position = 0;
|
|
28
|
+
let written = 0;
|
|
29
|
+
// Preserve URL commas per https://html.spec.whatwg.org/multipage/images.html#parse-a-srcset-attribute
|
|
30
|
+
while (position < srcset.length) {
|
|
31
|
+
const { start, end, next } = readSrcsetUrl(srcset, position);
|
|
32
|
+
position = next;
|
|
33
|
+
if (end > start) {
|
|
34
|
+
const url = srcset.slice(start, end);
|
|
35
|
+
const rewritten = rewriteUrl(url);
|
|
36
|
+
if (rewritten !== url) {
|
|
37
|
+
parts.push(srcset.slice(written, start), rewritten);
|
|
38
|
+
written = end;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (end < position)
|
|
42
|
+
continue;
|
|
43
|
+
position = skipSrcsetDescriptors(srcset, position);
|
|
44
|
+
}
|
|
45
|
+
return parts.length > 0 ? parts.join("") + srcset.slice(written) : srcset;
|
|
46
|
+
}
|