@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/compiler/compositionScoping.d.ts +2 -0
- package/dist/compiler/compositionScoping.js +11 -1
- package/dist/compiler/htmlBundler.js +3 -1
- package/dist/compiler/inlineSubCompositions.js +2 -2
- package/dist/compiler/pngColourTags.d.ts +2 -0
- package/dist/compiler/pngColourTags.js +27 -0
- package/dist/compiler/selectorIdTokens.d.ts +1 -1
- package/dist/core.types.d.ts +2 -66
- package/dist/core.types.js +1 -102
- package/dist/fps.d.ts +17 -0
- package/dist/fps.js +58 -0
- package/dist/generated/runtime-inline.js +1 -1
- package/dist/generators/hyperframes.js +4 -1
- package/dist/hyperframe.runtime.iife.js +75 -68
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/removeTree.d.ts +24 -0
- package/dist/removeTree.js +35 -0
- package/dist/runtime/media.d.ts +6 -3
- package/dist/runtime/media.js +54 -52
- package/dist/runtime/mediaClock.d.ts +2 -0
- package/dist/runtime/mediaClock.js +4 -0
- package/dist/runtime/mediaSteer.d.ts +10 -0
- package/dist/runtime/mediaSteer.js +40 -0
- package/dist/runtime/preloadMedia.js +2 -1
- package/dist/runtime/protocol.js +2 -1
- package/dist/runtime/startResolver.js +6 -8
- package/dist/studioPreviewMark.d.ts +2 -0
- package/dist/studioPreviewMark.js +5 -0
- package/package.json +20 -4
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
|
+
}
|
package/dist/runtime/media.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/runtime/media.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
196
|
+
isSteering(el));
|
|
193
197
|
}
|
|
194
198
|
export const MEDIA_HARD_SYNC_SECONDS = 0.5;
|
|
195
|
-
export const
|
|
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
|
|
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 >
|
|
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
|
|
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
|
|
239
|
-
//
|
|
240
|
-
const
|
|
241
|
-
|
|
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
|
-
//
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
|
|
458
|
-
|
|
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 (!
|
|
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 = !
|
|
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
|
|
484
|
-
if (!
|
|
485
|
-
|
|
486
|
-
const rate = Math.max(MIN_NATIVE_PLAYBACK_RATE,
|
|
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 &&
|
|
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
|
-
|
|
584
|
+
prepareUpcomingMedia(clip, params.timeSeconds, params.playing, params.playbackRate);
|
|
583
585
|
}
|
|
584
586
|
}
|
|
@@ -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);
|
package/dist/runtime/protocol.js
CHANGED
|
@@ -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
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
//
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
405
|
-
"@hyperframes/studio-server": "0.8.
|
|
406
|
-
"@hyperframes/parsers": "0.8.
|
|
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",
|