@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.
Files changed (42) hide show
  1. package/dist/audioAnalysis.d.ts +2 -0
  2. package/dist/audioAnalysis.js +14 -5
  3. package/dist/audioGroups.d.ts +3 -7
  4. package/dist/audioGroups.js +4 -8
  5. package/dist/audioResample.d.ts +2 -0
  6. package/dist/audioResample.js +7 -0
  7. package/dist/audioRhythm.d.ts +3 -0
  8. package/dist/audioRhythm.js +37 -20
  9. package/dist/audioSteps.d.ts +3 -0
  10. package/dist/audioSteps.js +7 -0
  11. package/dist/beats/beatDetection.js +22 -7
  12. package/dist/compiler/htmlBundler.js +15 -40
  13. package/dist/compiler/inlineSubCompositions.d.ts +2 -0
  14. package/dist/compiler/inlineSubCompositions.js +64 -5
  15. package/dist/compiler/srcset.d.ts +1 -0
  16. package/dist/compiler/srcset.js +46 -0
  17. package/dist/generated/position-edits-render-inline.js +1 -1
  18. package/dist/generated/runtime-inline.js +1 -1
  19. package/dist/hyperframe.runtime.iife.js +66 -62
  20. package/dist/index.d.ts +3 -1
  21. package/dist/index.js +3 -1
  22. package/dist/mediaGradeAnalyzer.d.ts +1 -0
  23. package/dist/mediaGradeAnalyzer.js +32 -20
  24. package/dist/previewProxyBox.d.ts +14 -0
  25. package/dist/previewProxyBox.js +15 -0
  26. package/dist/runtime/bridge.d.ts +1 -0
  27. package/dist/runtime/bridge.js +1 -0
  28. package/dist/runtime/clipReveal.d.ts +1 -1
  29. package/dist/runtime/diagnostics.d.ts +2 -0
  30. package/dist/runtime/diagnostics.js +40 -1
  31. package/dist/runtime/media.d.ts +1 -0
  32. package/dist/runtime/media.js +15 -21
  33. package/dist/runtime/timedClipHide.d.ts +2 -1
  34. package/dist/runtime/timedClipHide.js +8 -4
  35. package/dist/runtime/timeline.d.ts +8 -1
  36. package/dist/runtime/timeline.js +32 -13
  37. package/dist/runtime/types.d.ts +2 -1
  38. package/dist/runtime/validateVariables.js +6 -7
  39. package/dist/studioPreviewMark.d.ts +3 -1
  40. package/dist/studioPreviewMark.js +4 -0
  41. package/dist/variables.d.ts +1 -1
  42. package/package.json +4 -4
@@ -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;
@@ -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 i = 0; i < mixed.length; i++)
23
- mixed[i] = Math.sqrt(mixed[i] * scale);
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;
@@ -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
- * The element is metadata — an id, a label, a chain, an automation lane — and
104
- * carries no content, but "no content" is not "no box": it is still an unknown
105
- * custom element, so in a flex or grid composition root it counts as an item
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
@@ -199,13 +199,9 @@ function isGroupableMember(el) {
199
199
  /**
200
200
  * Make `<hf-audio-group>` inert, once per document.
201
201
  *
202
- * The element is metadata — an id, a label, a chain, an automation lane — and
203
- * carries no content, but "no content" is not "no box": it is still an unknown
204
- * custom element, so in a flex or grid composition root it counts as an item
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
  }
@@ -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>;
@@ -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;
@@ -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>;
@@ -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 { resample } from "./audioResample.js";
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 = signal.channels.map((c) => resample(c, signal.sampleRate, ANALYSIS_RATE));
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 { score, back } = cumulativeScore(local, localPeriods(curve, fps, period));
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
- /** Band energies of `signal`, computed once per signal for every capability that reads them. */
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.frames;
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
- const frames = bandFramesOf(signal);
405
- const entry = measured.get(signal);
406
- return (entry.rhythm ??= rhythmOf(frames));
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
  }
@@ -0,0 +1,3 @@
1
+ /** A long computation that yields between short pieces of work, so a caller can pause it. */
2
+ export type Steps<T> = Generator<void, T, void>;
3
+ export declare function finish<T>(steps: Steps<T>): T;
@@ -0,0 +1,7 @@
1
+ export function finish(steps) {
2
+ for (;;) {
3
+ const next = steps.next();
4
+ if (next.done)
5
+ return next.value;
6
+ }
7
+ }
@@ -1,5 +1,6 @@
1
- import { energyMix } from "../audioAnalysis.js";
2
- import { bandFramesOf, rhythm, tempoOf } from "../audioRhythm.js";
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
- bandFramesOf(signal);
35
- await pause?.();
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
- return src ? (path && safeReadFile(path)) || "" : el.textContent || "";
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, scriptItem.scene, chunk);
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*\(|\.animate\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*\(/;
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 match = SIDE_EFFECT_RE.exec(script);
30
- return match ? `its script uses ${match[0].trim()}` : null;
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
- : "it runs a script that is not a known library");
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
+ }