@hyperframes/core 0.8.205 → 0.8.207

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/index.d.ts CHANGED
@@ -25,6 +25,7 @@ export { CSS_URL_RE, isNonRelativeUrl, isPathInside } from "./compiler/assetPath
25
25
  export { rewriteSrcsetUrls } from "./compiler/srcset.js";
26
26
  export { checkSubCompositionUsability, type ParsableDocumentLike, type SubCompositionValidity, type SubCompositionValidityReason, } from "./compiler/subCompositionValidity.js";
27
27
  export { RUNTIME_BOOTSTRAP_ATTR, stripEmbeddedRuntimeScripts } from "./compiler/htmlDocument.js";
28
+ export { AUTHORED_SOURCE_BEGIN, AUTHORED_SOURCE_END } from "./compiler/compositionScoping.js";
28
29
  export { COMPOSITION_SOURCE_URL } from "./compiler/scriptRuns.js";
29
30
  export { queryByAttr } from "./utils/cssSelector.js";
30
31
  export { AUDIBLE_MEDIA_SELECTOR, audibleVideoNeedsWebAudio, isAudibleVideoElement, } from "./audibleVideo.js";
@@ -55,6 +56,7 @@ export { parseStartExpression, parseNumeric, type ReferenceExpression, } from ".
55
56
  export { COMPOSITION_CONTRACT_VERSION, COMPOSITION_ATTRIBUTES, CANONICAL_AUTHORED_TIMING_ATTRIBUTES, DERIVED_TIMING_ATTRIBUTES, LEGACY_TIMING_ATTRIBUTES, readClipTiming, writeClipTiming, ClipTimingWriteError, type ClipTiming, type ClipTimingDiagnostic, type ClipTimingDiagnosticCode, type ClipTimingUpdate, } from "./compositionContract.js";
56
57
  export { createRuntimeStartTimeResolver } from "./runtime/startResolver.js";
57
58
  export { hasClipStarted, isClipVisibleAt, isInClipWindow } from "./runtime/clipWindow.js";
59
+ export { MEDIA_ELEMENT_LEAD_SECONDS, mediaElementTime } from "./runtime/mediaClock.js";
58
60
  export { normalizePlaybackRate, normalizeRateSpec, parseStrictFiniteTimingNumber, readElementPlaybackRate, readElementRateSpec, readMediaStart, resolveNaturalMediaTimelineDuration, resolveNaturalMediaTimelineDurationFromValues, } from "./runtime/playbackRate.js";
59
61
  export { shiftRateLane, sourceTimeAt, timeAtSourceTime, type RateSpec } from "./speedRamp.js";
60
62
  export { validateVariables, formatVariableValidationIssue, type VariableValidationIssue, } from "./runtime/validateVariables.js";
package/dist/index.js CHANGED
@@ -21,6 +21,7 @@ export { CSS_URL_RE, isNonRelativeUrl, isPathInside } from "./compiler/assetPath
21
21
  export { rewriteSrcsetUrls } from "./compiler/srcset.js";
22
22
  export { checkSubCompositionUsability, } from "./compiler/subCompositionValidity.js";
23
23
  export { RUNTIME_BOOTSTRAP_ATTR, stripEmbeddedRuntimeScripts } from "./compiler/htmlDocument.js";
24
+ export { AUTHORED_SOURCE_BEGIN, AUTHORED_SOURCE_END } from "./compiler/compositionScoping.js";
24
25
  export { COMPOSITION_SOURCE_URL } from "./compiler/scriptRuns.js";
25
26
  export { queryByAttr } from "./utils/cssSelector.js";
26
27
  export { AUDIBLE_MEDIA_SELECTOR, audibleVideoNeedsWebAudio, isAudibleVideoElement, } from "./audibleVideo.js";
@@ -57,6 +58,7 @@ export { COMPOSITION_CONTRACT_VERSION, COMPOSITION_ATTRIBUTES, CANONICAL_AUTHORE
57
58
  export { createRuntimeStartTimeResolver } from "./runtime/startResolver.js";
58
59
  // Also exposed via the ./runtime/clip-window subpath; re-exported here for the same dist-emit reason.
59
60
  export { hasClipStarted, isClipVisibleAt, isInClipWindow } from "./runtime/clipWindow.js";
61
+ export { MEDIA_ELEMENT_LEAD_SECONDS, mediaElementTime } from "./runtime/mediaClock.js";
60
62
  export { normalizePlaybackRate, normalizeRateSpec, parseStrictFiniteTimingNumber, readElementPlaybackRate, readElementRateSpec, readMediaStart, resolveNaturalMediaTimelineDuration, resolveNaturalMediaTimelineDurationFromValues, } from "./runtime/playbackRate.js";
61
63
  export { shiftRateLane, sourceTimeAt, timeAtSourceTime } from "./speedRamp.js";
62
64
  // Variable validation (CLI / tooling-side)
@@ -0,0 +1,24 @@
1
+ declare const RETRY: {
2
+ readonly recursive: true;
3
+ readonly force: true;
4
+ readonly maxRetries: 10;
5
+ readonly retryDelay: 100;
6
+ };
7
+ type RemoveSync = (path: string, options: typeof RETRY) => void;
8
+ type RemoveAsync = (path: string, options: typeof RETRY) => Promise<void>;
9
+ type Warn = (message: string) => void;
10
+ /** Removes a tree the next step needs gone, riding out transient locks; throws if it still fails. */
11
+ export declare function removeTreeWithRetry(path: string, remove?: RemoveSync): void;
12
+ /** Async twin of {@link removeTreeWithRetry}. */
13
+ export declare function removeTreeWithRetryAsync(path: string, remove?: RemoveAsync): Promise<void>;
14
+ /** Cleans up after finished work: a refusal that outlasts the retries is warned about, never thrown. */
15
+ export declare function removeTreeBestEffort(path: string, options?: {
16
+ remove?: RemoveSync;
17
+ warn?: Warn;
18
+ }): void;
19
+ /** Async twin of {@link removeTreeBestEffort}. */
20
+ export declare function removeTreeBestEffortAsync(path: string, options?: {
21
+ remove?: RemoveAsync;
22
+ warn?: Warn;
23
+ }): Promise<void>;
24
+ export {};
@@ -0,0 +1,35 @@
1
+ import { rmSync } from "node:fs";
2
+ import { rm } from "node:fs/promises";
3
+ // Windows holds files for a moment after a process exits or while antivirus scans them (EBUSY,
4
+ // EPERM, ENOTEMPTY). Node retries those itself when asked: about 5.5 s in all.
5
+ const RETRY = { recursive: true, force: true, maxRetries: 10, retryDelay: 100 };
6
+ function refusal(path, error) {
7
+ const reason = error instanceof Error ? error.message : String(error);
8
+ return `[cleanup] could not remove ${path}: ${reason}`;
9
+ }
10
+ /** Removes a tree the next step needs gone, riding out transient locks; throws if it still fails. */
11
+ export function removeTreeWithRetry(path, remove = rmSync) {
12
+ remove(path, RETRY);
13
+ }
14
+ /** Async twin of {@link removeTreeWithRetry}. */
15
+ export async function removeTreeWithRetryAsync(path, remove = rm) {
16
+ await remove(path, RETRY);
17
+ }
18
+ /** Cleans up after finished work: a refusal that outlasts the retries is warned about, never thrown. */
19
+ export function removeTreeBestEffort(path, options = {}) {
20
+ try {
21
+ removeTreeWithRetry(path, options.remove);
22
+ }
23
+ catch (error) {
24
+ (options.warn ?? console.warn)(refusal(path, error));
25
+ }
26
+ }
27
+ /** Async twin of {@link removeTreeBestEffort}. */
28
+ export async function removeTreeBestEffortAsync(path, options = {}) {
29
+ try {
30
+ await removeTreeWithRetryAsync(path, options.remove);
31
+ }
32
+ catch (error) {
33
+ (options.warn ?? console.warn)(refusal(path, error));
34
+ }
35
+ }
@@ -2,6 +2,7 @@ import { type VolumeKeyframe } from "./mediaVolumeEnvelope.js";
2
2
  import { type AudioFades } from "../audioFade.js";
3
3
  import { type RateSpec } from "../speedRamp.js";
4
4
  import { type HostSlot } from "../mediaTiming.js";
5
+ export { MEDIA_SYNC_TOLERANCE_SECONDS } from "./mediaSteer.js";
5
6
  export { readElementPlaybackRate, readElementRateSpec, resolveNaturalMediaTimelineDuration, } from "./playbackRate.js";
6
7
  export declare function readElementPlaybackStart(el: Element): number;
7
8
  export declare const HOLD_CAP_MS = 5000;
@@ -80,10 +81,8 @@ export declare function evictMediaSyncState(el: HTMLMediaElement): void;
80
81
  /** Test-only seam: whether any per-source sync state is still tracked for `el`. */
81
82
  export declare function hasMediaSyncStateForTest(el: HTMLMediaElement): boolean;
82
83
  export declare const MEDIA_HARD_SYNC_SECONDS = 0.5;
83
- export declare const VIDEO_PRESEEK_SECONDS = 1.5;
84
+ export declare const MEDIA_PRESEEK_SECONDS = 1.5;
84
85
  export declare function mediaSourceTimeAt(clip: RuntimeMediaClip, timeSeconds: number): number;
85
- /** Drift a playing audio element may carry before sync pulls it back onto the playhead. */
86
- export declare const MEDIA_SYNC_TOLERANCE_SECONDS = 0.04;
87
86
  export declare function syncRuntimeMedia(params: {
88
87
  clips: RuntimeMediaClip[];
89
88
  timeSeconds: number;
@@ -120,8 +119,12 @@ export declare function syncRuntimeMedia(params: {
120
119
  * unity; do not mistake that transport write for an authored volume edit. */
121
120
  isWebAudioRouted?: (el: HTMLMediaElement) => boolean;
122
121
  forceSync?: boolean;
122
+ /** False while the playhead is not moving (audio output has not started): a paused video waits for it. */
123
+ clockAdvancing?: boolean;
123
124
  /** How far the next tick will move the playhead: an audio clip due within it starts now. */
124
125
  cueAheadSeconds?: number;
126
+ /** Seeks a sounding element under a gain dip; false when it has no dip stage and the sync seeks it plainly. */
127
+ seekUnderDip?: (el: HTMLMediaElement, target: number) => boolean;
125
128
  /** Lets a video clip that runs to the composition end hold its last frame at the terminal time.
126
129
  * A thunk, because deriving the duration is only worth it for a clip past its own end. */
127
130
  getCompositionDuration: () => number;
@@ -1,5 +1,6 @@
1
1
  import { swallow } from "./diagnostics.js";
2
2
  import { isClipVisibleAt, isInClipWindow } from "./clipWindow.js";
3
+ import { mediaElementTime } from "./mediaClock.js";
3
4
  import { sameInstant } from "../clipFacts.js";
4
5
  import { interpolateVolumeGain } from "./mediaVolumeEnvelope.js";
5
6
  import { elementVolumeLaneGain } from "./audioAutomationVolume.js";
@@ -12,6 +13,8 @@ import { findInjectedRenderFrame } from "./renderFrameSibling.js";
12
13
  import { isRenderFrameVideo } from "./preloadMedia.js";
13
14
  import { registerSeekCompletion } from "./adapters/seek-dispatch.js";
14
15
  import { cutToHostSlots } from "../mediaTiming.js";
16
+ import { forgetSteering, isSteering, MEDIA_SYNC_TOLERANCE_SECONDS, steerMedia, } from "./mediaSteer.js";
17
+ export { MEDIA_SYNC_TOLERANCE_SECONDS } from "./mediaSteer.js";
15
18
  export { readElementPlaybackRate, readElementRateSpec, resolveNaturalMediaTimelineDuration, } from "./playbackRate.js";
16
19
  export function readElementPlaybackStart(el) {
17
20
  return readMediaStart(el);
@@ -180,7 +183,8 @@ export function evictMediaSyncState(el) {
180
183
  strictDriftSamples.delete(el);
181
184
  seekLoadRetried.delete(el);
182
185
  lastRuntimeAppliedVolume.delete(el);
183
- videoSteering.delete(el);
186
+ forgetSteering(el);
187
+ heldAhead.delete(el);
184
188
  }
185
189
  /** Test-only seam: whether any per-source sync state is still tracked for `el`. */
186
190
  export function hasMediaSyncStateForTest(el) {
@@ -189,10 +193,10 @@ export function hasMediaSyncStateForTest(el) {
189
193
  strictDriftSamples.has(el) ||
190
194
  seekLoadRetried.has(el) ||
191
195
  lastRuntimeAppliedVolume.has(el) ||
192
- videoSteering.has(el));
196
+ isSteering(el));
193
197
  }
194
198
  export const MEDIA_HARD_SYNC_SECONDS = 0.5;
195
- export const VIDEO_PRESEEK_SECONDS = 1.5;
199
+ export const MEDIA_PRESEEK_SECONDS = 1.5;
196
200
  export function mediaSourceTimeAt(clip, timeSeconds) {
197
201
  const elapsed = Math.max(0, Math.min(timeSeconds, clip.end) - (clip.origin ?? clip.start));
198
202
  const target = clip.mediaStart + sourceTimeAt(clip.rate ?? clip.playbackRate, elapsed);
@@ -206,13 +210,12 @@ export function mediaSourceTimeAt(clip, timeSeconds) {
206
210
  return Math.min(target, clip.sourceDuration);
207
211
  return target;
208
212
  }
209
- function prepareUpcomingVideo(clip, time, playing, rate) {
213
+ function prepareUpcomingMedia(clip, time, playing, rate) {
210
214
  const { el } = clip;
211
215
  const dueIn = clip.start - time;
212
216
  if (!playing ||
213
- el.tagName !== "VIDEO" ||
214
217
  dueIn <= 0 ||
215
- dueIn > VIDEO_PRESEEK_SECONDS * rate ||
218
+ dueIn > MEDIA_PRESEEK_SECONDS * rate ||
216
219
  !el.paused ||
217
220
  el.seeking ||
218
221
  el.readyState < el.HAVE_METADATA ||
@@ -223,43 +226,20 @@ function prepareUpcomingVideo(clip, time, playing, rate) {
223
226
  if (!Number.isFinite(target) || target < 0 || Math.abs(el.currentTime - target) <= 0.001)
224
227
  return;
225
228
  try {
226
- // A trimmed video exposed during its in-point seek has no current frame at the cut.
229
+ // A trimmed clip seeked as it opens shows no frame and plays late, then is seeked again while it sounds.
227
230
  // Hidden preparation must not hold the visible-frame seek barrier.
228
- el.currentTime = target;
231
+ el.currentTime = mediaElementTime(target);
229
232
  }
230
233
  catch (err) {
231
234
  swallow("runtime.media.preseek", err);
232
235
  }
233
236
  }
234
- /** Drift a playing audio element may carry before sync pulls it back onto the playhead. */
235
- export const MEDIA_SYNC_TOLERANCE_SECONDS = 0.04;
236
237
  // Chromium refuses a slower playbackRate (NotSupportedError).
237
238
  const MIN_NATIVE_PLAYBACK_RATE = 1 / 16;
238
- // A playing video is steered back by rate, not seeked (a seek resets its decoder).
239
- // Its rate is written only when steering starts or stops: every write costs a frame.
240
- const VIDEO_STEER = 0.03;
241
- // A video that starts a cut late closes its lag over one clip at 3%; a bigger lag steers harder, in steps so the rate is written rarely.
242
- const VIDEO_STEER_STEPS = [
243
- [0.3, 0.25],
244
- [0.1, 0.1],
245
- ];
246
- const VIDEO_STEER_RELEASE_SECONDS = 0.01;
247
- /** Direction (+1 fast, -1 slow) a video is being steered in; absent when it plays at its authored rate. */
248
- const videoSteering = new WeakMap();
249
- /** Rate for a playing video `offset` seconds behind (+) or ahead (-) of the playhead. */
250
- function steeredVideoRate(el, offset, baseRate) {
251
- const direction = Math.sign(offset);
252
- const steering = videoSteering.get(el);
253
- if (Math.abs(offset) > MEDIA_SYNC_TOLERANCE_SECONDS)
254
- videoSteering.set(el, direction);
255
- else if (steering !== direction || Math.abs(offset) <= VIDEO_STEER_RELEASE_SECONDS) {
256
- videoSteering.delete(el);
257
- return baseRate;
258
- }
259
- const lag = Math.abs(offset);
260
- const steer = VIDEO_STEER_STEPS.find(([from]) => lag > from)?.[1] ?? VIDEO_STEER;
261
- return baseRate * (1 + direction * steer);
262
- }
239
+ // A video this far ahead of the playhead, or at its out-point, is held until the playhead reaches it:
240
+ // steering a lead of a few hundred ms takes seconds and shows frames past the cut.
241
+ const VIDEO_LEAD_HOLD_SECONDS = 0.1;
242
+ const heldAhead = new WeakSet();
263
243
  // fallow-ignore-next-line complexity
264
244
  export function syncRuntimeMedia(params) {
265
245
  const forceMuteAll = !!(params.outputMuted || params.userMuted);
@@ -450,12 +430,24 @@ export function syncRuntimeMedia(params) {
450
430
  (el.ended && canSeekEndedMediaBackward && drift > 0.001) ||
451
431
  staleAudioOnFirstTick ||
452
432
  (drift > MEDIA_HARD_SYNC_SECONDS && (firstTickOfClip || offsetJumped || catastrophicDrift));
453
- // Playing videos use the browser's decoder for timing. Seeking one resets the decoder: a
454
- // ~150ms freeze while it re-buffers, as the monotonic clock advances, which loops into a
455
- // seek→freeze→drift→seek stutter. So a playing video skips strict and force sync; only hard
456
- // sync (>0.5s) warrants the decoder-reset cost. A paused transport pauses this video below,
457
- // so a seek that pauses mid-playback still lands it.
458
- const isPlayingVideo = el.tagName === "VIDEO" && !el.paused && playing;
433
+ // Seeking playing media resets a video's decoder (a ~150ms freeze that loops into a
434
+ // seek→freeze→drift→seek stutter) and jumps sounding audio (a click). So playing media skips
435
+ // strict and force sync and is steered by rate; hard sync (>0.5s) and audio past its steer
436
+ // limit seek. A paused transport pauses it below, so a seek that pauses mid-playback lands it.
437
+ if (el.tagName === "VIDEO" && !isHeldVideoTail) {
438
+ // A loop's playhead wraps before its element, so its offset jumps by a lap: steering owns loops.
439
+ const ahead = !clip.loop &&
440
+ (offset < -VIDEO_LEAD_HOLD_SECONDS ||
441
+ currentElTime >= mediaSourceTimeAt(clip, clip.end) - MEDIA_SYNC_TOLERANCE_SECONDS);
442
+ if (!playing || (!ahead && offset >= -MEDIA_SYNC_TOLERANCE_SECONDS))
443
+ heldAhead.delete(el);
444
+ else if (ahead && !el.paused) {
445
+ heldAhead.add(el);
446
+ el.pause();
447
+ }
448
+ }
449
+ const holdingAhead = heldAhead.has(el);
450
+ const isPlayingMedia = !el.paused && playing;
459
451
  // Only apply strict sync when offset has stabilized (not growing).
460
452
  // During initial buffering, offset grows ~16ms/tick as the timeline
461
453
  // advances while media stays at 0. Accumulated drift from pause/play
@@ -463,7 +455,8 @@ export function syncRuntimeMedia(params) {
463
455
  const heldBelowFloor = params.playing && !playing;
464
456
  const offsetStabilized = heldBelowFloor || (prevOffset !== undefined && Math.abs(offset - prevOffset) < 0.004);
465
457
  let strictSync = false;
466
- if (!isPlayingVideo &&
458
+ if (!isPlayingMedia &&
459
+ !holdingAhead &&
467
460
  !hardSync &&
468
461
  !firstTickOfClip &&
469
462
  offsetStabilized &&
@@ -478,12 +471,15 @@ export function syncRuntimeMedia(params) {
478
471
  else if (drift <= MEDIA_SYNC_TOLERANCE_SECONDS) {
479
472
  strictDriftSamples.set(el, 0);
480
473
  }
481
- const forceSync = !isPlayingVideo && params.forceSync && drift > 0.02;
474
+ const forceSync = !isPlayingMedia && !holdingAhead && params.forceSync && drift > 0.02;
475
+ const steered = isPlayingMedia && !hardSync ? steerMedia(el, offset, baseRate, offsetStabilized) : null;
476
+ const steerSeeks = steered?.seek === true;
477
+ const dipped = steerSeeks && params.seekUnderDip?.(el, relTime) === true;
482
478
  try {
483
- // A hard sync lands the video on the playhead, so its pre-seek offset says nothing.
484
- if (!isPlayingVideo || hardSync)
485
- videoSteering.delete(el);
486
- const rate = Math.max(MIN_NATIVE_PLAYBACK_RATE, isPlayingVideo && !hardSync ? steeredVideoRate(el, offset, baseRate) : baseRate);
479
+ // A hard sync lands the element on the playhead, so its pre-seek offset says nothing.
480
+ if (!steered)
481
+ forgetSteering(el);
482
+ const rate = Math.max(MIN_NATIVE_PLAYBACK_RATE, steered?.rate ?? baseRate);
487
483
  // Some engines read a rate back at lower precision; an equal-enough rate is not rewritten.
488
484
  if (Math.abs(el.playbackRate - rate) > 1e-6)
489
485
  el.playbackRate = rate;
@@ -492,7 +488,7 @@ export function syncRuntimeMedia(params) {
492
488
  // ignore unsupported playbackRate
493
489
  swallow("runtime.media.site1", err);
494
490
  }
495
- if (hardSync || strictSync || forceSync) {
491
+ if (hardSync || strictSync || forceSync || (steerSeeks && !dipped)) {
496
492
  // Skip the per-tick seek (and the `el.load()` drift-recovery retry
497
493
  // below) for `<video>` elements that have a sibling
498
494
  // `<img id="__render_frame_<id>__">`. The sibling is created only
@@ -507,7 +503,7 @@ export function syncRuntimeMedia(params) {
507
503
  const skipForInjectedVideo = el.tagName === "VIDEO" && !!findInjectedRenderFrame(el);
508
504
  if (!skipForInjectedVideo) {
509
505
  try {
510
- el.currentTime = relTime;
506
+ el.currentTime = mediaElementTime(relTime);
511
507
  }
512
508
  catch (err) {
513
509
  swallow("runtime.media.site2", err);
@@ -516,7 +512,7 @@ export function syncRuntimeMedia(params) {
516
512
  seekLoadRetried.add(el);
517
513
  el.load();
518
514
  try {
519
- el.currentTime = relTime;
515
+ el.currentTime = mediaElementTime(relTime);
520
516
  }
521
517
  catch (err) {
522
518
  swallow("runtime.media.site3", err);
@@ -525,6 +521,7 @@ export function syncRuntimeMedia(params) {
525
521
  holdSeekBarrierUntilVideoLands(el);
526
522
  }
527
523
  playRequested.delete(el);
524
+ heldAhead.delete(el);
528
525
  }
529
526
  else if (!playing) {
530
527
  holdSeekBarrierUntilVideoLands(el);
@@ -533,7 +530,12 @@ export function syncRuntimeMedia(params) {
533
530
  if (!el.paused)
534
531
  el.pause();
535
532
  }
536
- else if (playing && el.paused && !playRequested.has(el) && !isUnplayable(el)) {
533
+ else if (playing &&
534
+ !heldAhead.has(el) &&
535
+ params.clockAdvancing !== false &&
536
+ el.paused &&
537
+ !playRequested.has(el) &&
538
+ !isUnplayable(el)) {
537
539
  // `HTMLMediaElement.play()` is spec'd to queue playback and resolve
538
540
  // once enough data is buffered, so we can unconditionally call it —
539
541
  // no need to gate on `readyState` or defer to a `canplay` listener.
@@ -579,6 +581,6 @@ export function syncRuntimeMedia(params) {
579
581
  lastRelativeTime.set(el, relTime);
580
582
  if (!el.paused)
581
583
  el.pause();
582
- prepareUpcomingVideo(clip, params.timeSeconds, params.playing, params.playbackRate);
584
+ prepareUpcomingMedia(clip, params.timeSeconds, params.playing, params.playbackRate);
583
585
  }
584
586
  }
@@ -0,0 +1,2 @@
1
+ export declare const MEDIA_ELEMENT_LEAD_SECONDS = 0.00001;
2
+ export declare function mediaElementTime(sourceSeconds: number): number;
@@ -0,0 +1,4 @@
1
+ export const MEDIA_ELEMENT_LEAD_SECONDS = 1e-5;
2
+ export function mediaElementTime(sourceSeconds) {
3
+ return sourceSeconds + MEDIA_ELEMENT_LEAD_SECONDS;
4
+ }
@@ -0,0 +1,10 @@
1
+ /** Drift a playing element may carry before sync pulls it back onto the playhead. */
2
+ export declare const MEDIA_SYNC_TOLERANCE_SECONDS = 0.04;
3
+ /** Rate or seek for media `offset` s behind (+) or ahead (-); a lag still growing (`settled` false) never seeks. */
4
+ export declare function steerMedia(el: HTMLMediaElement, offset: number, baseRate: number, settled: boolean, audioOnly?: boolean): {
5
+ rate: number;
6
+ seek: boolean;
7
+ };
8
+ /** Drop `el`'s steering: it was seeked, paused or replaced, so its old offset says nothing. */
9
+ export declare function forgetSteering(el: HTMLMediaElement): void;
10
+ export declare function isSteering(el: HTMLMediaElement): boolean;
@@ -0,0 +1,40 @@
1
+ /** Drift a playing element may carry before sync pulls it back onto the playhead. */
2
+ export const MEDIA_SYNC_TOLERANCE_SECONDS = 0.04;
3
+ // Steered by rate, not seeked (a seek resets a video's decoder and clicks in audio); steps keep rate writes rare.
4
+ const MEDIA_STEER = 0.03;
5
+ const MEDIA_STEER_STEPS = [
6
+ [0.3, 0.25],
7
+ [0.1, 0.1],
8
+ ];
9
+ const MEDIA_STEER_RELEASE_SECONDS = 0.01;
10
+ // Audio never steers past 10%, where speech changes tempo; a lag held past 100 ms is seeked instead.
11
+ const AUDIO_MAX_STEER = 0.1;
12
+ const AUDIO_STEER_LIMIT_SECONDS = 0.1;
13
+ /** Direction (+1 fast, -1 slow) an element is being steered in; absent when it plays at its authored rate. */
14
+ const steering = new WeakMap();
15
+ /** Rate or seek for media `offset` s behind (+) or ahead (-); a lag still growing (`settled` false) never seeks. */
16
+ export function steerMedia(el, offset, baseRate, settled, audioOnly = el.tagName === "AUDIO") {
17
+ const lag = Math.abs(offset);
18
+ if (audioOnly && settled && lag > AUDIO_STEER_LIMIT_SECONDS) {
19
+ steering.delete(el);
20
+ return { rate: baseRate, seek: true };
21
+ }
22
+ const direction = Math.sign(offset);
23
+ const current = steering.get(el);
24
+ if (lag > MEDIA_SYNC_TOLERANCE_SECONDS)
25
+ steering.set(el, direction);
26
+ else if (current !== direction || lag <= MEDIA_STEER_RELEASE_SECONDS) {
27
+ steering.delete(el);
28
+ return { rate: baseRate, seek: false };
29
+ }
30
+ const step = MEDIA_STEER_STEPS.find(([from]) => lag > from)?.[1] ?? MEDIA_STEER;
31
+ const steer = audioOnly ? Math.min(step, AUDIO_MAX_STEER) : step;
32
+ return { rate: baseRate * (1 + direction * steer), seek: false };
33
+ }
34
+ /** Drop `el`'s steering: it was seeked, paused or replaced, so its old offset says nothing. */
35
+ export function forgetSteering(el) {
36
+ steering.delete(el);
37
+ }
38
+ export function isSteering(el) {
39
+ return steering.has(el);
40
+ }
@@ -3,6 +3,7 @@ import { createRuntimeStartTimeResolver } from "./startResolver.js";
3
3
  import { readMediaRenderId } from "./renderFrameSibling.js";
4
4
  import { defersMedia } from "./timedClipHide.js";
5
5
  import { swallow } from "./diagnostics.js";
6
+ import { mediaElementTime } from "./mediaClock.js";
6
7
  export function isRenderFrameVideo(media) {
7
8
  const ids = window
8
9
  .__HF_RENDER_FRAME_VIDEO_IDS;
@@ -33,7 +34,7 @@ export function prepareUpcomingMedia(media, isUpcoming) {
33
34
  if (Math.abs(media.currentTime - start) <= 0.02)
34
35
  return;
35
36
  try {
36
- media.currentTime = start;
37
+ media.currentTime = mediaElementTime(start);
37
38
  }
38
39
  catch (error) {
39
40
  swallow("runtime.preload.playbackStart", error);
@@ -20,7 +20,8 @@ function greatestCommonDivisor(a, b) {
20
20
  }
21
21
  export function runtimeProtocolFpsFromNumber(value) {
22
22
  const safe = Number.isFinite(value) && value > 0 ? value : 30;
23
- const denominator = Number.isInteger(safe) ? 1 : 1_000_000;
23
+ const isNtscRate = Math.abs(safe * 1001 - Math.round(safe * 1001)) < 1e-6;
24
+ const denominator = Number.isInteger(safe) ? 1 : isNtscRate ? 1001 : 1_000_000;
24
25
  const numerator = Math.round(safe * denominator);
25
26
  const divisor = greatestCommonDivisor(numerator, denominator);
26
27
  return { numerator: numerator / divisor, denominator: denominator / divisor };
@@ -60,18 +60,16 @@ export function createRuntimeStartTimeResolver(params) {
60
60
  durationCache.set(element, resolved);
61
61
  return resolved;
62
62
  };
63
- const isCompositionHost = (element) => element.hasAttribute("data-composition-src") ||
64
- element.hasAttribute("data-composition-id") ||
65
- element.hasAttribute("data-composition-file");
66
- // A mounted composition root without its own `data-start` takes its timing from the host it was
67
- // loaded into: the host may use a different id than the file, or none (an anonymous host).
63
+ const COMPOSITION_HOST_SELECTOR = "[data-composition-src], [data-composition-id], [data-composition-file]";
64
+ const isCompositionHost = (element) => element.matches(COMPOSITION_HOST_SELECTOR);
65
+ // A mounted composition root without its own `data-start` takes its timing from the nearest host
66
+ // or timed wrapper above it, the clip that shows it; the host may use another id than the file.
68
67
  const inheritedTimingHost = (element) => {
69
- const parent = element.parentElement;
70
- if (!parent || !element.hasAttribute("data-composition-id"))
68
+ if (!element.hasAttribute("data-composition-id"))
71
69
  return null;
72
70
  if (parseStartExpression(element.getAttribute("data-start")))
73
71
  return null;
74
- return isCompositionHost(parent) ? parent : null;
72
+ return element.parentElement?.closest(`[data-start], ${COMPOSITION_HOST_SELECTOR}`) ?? null;
75
73
  };
76
74
  const resolveCompositionOrigin = (compositionRoot, fallback) => compositionOriginSeconds(resolveStartForElementInternal(compositionRoot, fallback), inheritedTimingHost(compositionRoot) ?? compositionRoot);
77
75
  const resolveHostOffsetForElement = (element, fallback) => {
@@ -3,6 +3,8 @@ export declare const STUDIO_PREVIEW_CAPTURE_MODE = "capture";
3
3
  export declare const STUDIO_PREVIEW_UPCOMING_ATTR = "data-hf-upcoming";
4
4
  export declare const STUDIO_PREVIEW_LAZY_ATTR = "data-hf-preview-lazy";
5
5
  export declare const STUDIO_PREVIEW_ERRORS = "__hfPreviewErrors";
6
+ export declare const refusedPreviewMessage: (status: number) => string;
7
+ export declare function refusedPreviewStatus(message: string): number | null;
6
8
  export declare const PREVIEW_RASTER_ATTR = "data-hf-preview-raster";
7
9
  export declare const RUNTIME_STAMPED_ATTR = "data-hf-autostamped";
8
10
  export declare const CLIP_HIDDEN_ATTR = "data-hf-clip-hidden";
@@ -3,6 +3,11 @@ export const STUDIO_PREVIEW_CAPTURE_MODE = "capture";
3
3
  export const STUDIO_PREVIEW_UPCOMING_ATTR = "data-hf-upcoming";
4
4
  export const STUDIO_PREVIEW_LAZY_ATTR = "data-hf-preview-lazy";
5
5
  export const STUDIO_PREVIEW_ERRORS = "__hfPreviewErrors";
6
+ export const refusedPreviewMessage = (status) => `The preview page could not be loaded (HTTP ${status}).`;
7
+ export function refusedPreviewStatus(message) {
8
+ const status = /^The preview page could not be loaded \(HTTP (\d{3})\)\.$/.exec(message)?.[1];
9
+ return status ? Number(status) : null;
10
+ }
6
11
  export const PREVIEW_RASTER_ATTR = "data-hf-preview-raster";
7
12
  export const RUNTIME_STAMPED_ATTR = "data-hf-autostamped";
8
13
  export const CLIP_HIDDEN_ATTR = "data-hf-clip-hidden";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/core",
3
- "version": "0.8.205",
3
+ "version": "0.8.207",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,6 +60,10 @@
60
60
  "import": "./dist/atomicFile.js",
61
61
  "types": "./dist/atomicFile.d.ts"
62
62
  },
63
+ "./remove-tree": {
64
+ "import": "./dist/removeTree.js",
65
+ "types": "./dist/removeTree.d.ts"
66
+ },
63
67
  "./project-folders": {
64
68
  "import": "./dist/projectFolders.js",
65
69
  "types": "./dist/projectFolders.d.ts"
@@ -269,6 +273,14 @@
269
273
  "import": "./dist/runtime/clipWindow.js",
270
274
  "types": "./dist/runtime/clipWindow.d.ts"
271
275
  },
276
+ "./runtime/media-clock": {
277
+ "import": "./dist/runtime/mediaClock.js",
278
+ "types": "./dist/runtime/mediaClock.d.ts"
279
+ },
280
+ "./runtime/media-steer": {
281
+ "import": "./dist/runtime/mediaSteer.js",
282
+ "types": "./dist/runtime/mediaSteer.d.ts"
283
+ },
272
284
  "./runtime/clip-reveal": {
273
285
  "import": "./dist/runtime/clipReveal.js",
274
286
  "types": "./dist/runtime/clipReveal.d.ts"
@@ -289,6 +301,10 @@
289
301
  "import": "./dist/compiler/htmlDocument.js",
290
302
  "types": "./dist/compiler/htmlDocument.d.ts"
291
303
  },
304
+ "./compiler/png-colour-tags": {
305
+ "import": "./dist/compiler/pngColourTags.js",
306
+ "types": "./dist/compiler/pngColourTags.d.ts"
307
+ },
292
308
  "./runtime/position-edits": {
293
309
  "import": "./dist/runtime/positionEdits.js",
294
310
  "types": "./dist/runtime/positionEdits.d.ts"
@@ -401,9 +417,9 @@
401
417
  "linkedom": "^0.18.12",
402
418
  "postcss": "^8.5.8",
403
419
  "wawoff2": "^2.0.1",
404
- "@hyperframes/lint": "0.8.205",
405
- "@hyperframes/studio-server": "0.8.205",
406
- "@hyperframes/parsers": "0.8.205"
420
+ "@hyperframes/lint": "0.8.207",
421
+ "@hyperframes/studio-server": "0.8.207",
422
+ "@hyperframes/parsers": "0.8.207"
407
423
  },
408
424
  "devDependencies": {
409
425
  "@fontsource/archivo-black": "^5.2.8",