Merge origin/main into the audio stack

Twelve of the stack's feature commits landed on main as squashes (#3274
through #3292, plus #3401's canary removal); the 96 review-and-fix commits
that followed them here did not, and main moved 64 commits on in the
meantime. This reconciles the two.

58 files conflicted. 44 were audio-only — main's side there is the
squashed form of commits this branch already carries and has since
superseded, so the branch side stands. The rest needed real work, in both
directions:

**Taken from main, absent here.**
- `ensureAudioGroupInertStyle` (#3278's review). An `<hf-audio-group>` is
  an unknown custom element, so it still takes a flex/grid slot and can
  open a line box — adding a group shifted authored layout. The helper and
  its `init.ts` call never came back to the branch, and this branch is
  what emits the element.
- `#3383`'s ended-audio replay: `canSeekEndedMediaBackward` and its five
  siblings in `media.ts`, with all six tests. Not present here in any
  form.
- `#3380`'s `asetpts=N/SR/TB` between `apad` and `atrim`. Also applied to
  `mixGroupMembers`, the group submix, which is new on this branch and so
  had the same bug in a path main's fix could not reach: delayed members
  padded then amix'd, where a group of four or more silently loses one.
- `#3401`'s `displayNumber` thread. The header derives its row from the
  group-aware order and the undo label from ascending element keys, so
  once a group exists the same click said "Hide track 2" and recorded
  "Hide track 1".
- `#3413`/`#3421`'s viewport handling — the popover's height cap and
  `inset()`, and `resolveFloatingPanelPosition` for the grouping dialog,
  which lives in a track header at the bottom of the window.
- Two extractions this branch had inline and at exactly the 600-line cap:
  `useTimelineDeleteOps` and `editingModeSlice`. Bodies were identical.

**Kept from the branch, against main.** Mute and solo are gone by
deliberate breaking change (`remove mute and solo from tracks and groups`,
`remove the group volume slider and level meter`), so eight files main
still carries are deleted again, `PlayerControls` keeps no
`previewIframeRef` (it existed only to feed `SoloBanner`), the
group-levels branch comes out of main's new `previewMessageRouter`, and
`STRIP_H` goes with the bus strip it sized. Main's
`TimelineTrackPlainHeader.test.tsx` is rewritten against the control that
actually exists — the visibility eye, withheld from an audible audio row
and offered back once hidden, which is the only way out of `data-hidden`.

**Unioned.** `TimelineFxPopover` — main's positioning, this branch's
audition telemetry (`auditionPresetChain`, `storedChain`,
`onAuditionTracked`); `SKILL.md` — main's #3416 "keep the carve group a
voice group" beside this branch's bus section, with the canary paragraph
dropped since the canaries no longer exist.

Every port is mutation-checked. core 2508, studio 4460, lint 528, engine
1630, cli 2813, sdk 549, producer green; tsc, oxlint, oxfmt, fallow and
the 600-line cap clean.
This commit is contained in:
Vance Ingalls
2026-08-23 03:04:47 -07:00
268 changed files with 10008 additions and 2807 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/core",
"version": "0.8.5",
"version": "0.8.10",
"description": "",
"repository": {
"type": "git",
+35
View File
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
audioGroupOf,
ensureAudioGroupInertStyle,
HF_AUDIO_GROUP_ATTR,
isMemberGroupHidden,
resolveAudioGroups,
@@ -225,3 +226,37 @@ describe("resolveCarveSourceIds — empty group", () => {
expect(resolveCarveSourceIds(d, ["voiceover"])).toEqual([]);
});
});
describe("ensureAudioGroupInertStyle", () => {
it("takes the group element out of layout", () => {
document.body.innerHTML = `<hf-audio-group id="voiceover"></hf-audio-group>`;
const el = document.getElementById("voiceover") as HTMLElement;
ensureAudioGroupInertStyle(document);
expect(getComputedStyle(el).display).toBe("none");
});
// An unknown custom element is an ordinary inline box, so in a flex or grid
// root it takes a slot: a gap, a justify-content share, and every
// :nth-child after it shifts. An author rule must not be able to put it
// back — and an id selector outranks this rule's type selector no matter
// which stylesheet came last, so `!important` is the only thing holding the
// contract. Dropping it makes this case fail.
it("beats an author rule that outranks it on specificity", () => {
document.head.insertAdjacentHTML(
"beforeend",
`<style id="author">#voiceover{display:flex}</style>`,
);
document.body.innerHTML = `<hf-audio-group id="voiceover"></hf-audio-group>`;
ensureAudioGroupInertStyle(document);
expect(getComputedStyle(document.getElementById("voiceover") as HTMLElement).display).toBe(
"none",
);
document.getElementById("author")?.remove();
});
it("injects once, however many times it is called", () => {
ensureAudioGroupInertStyle(document);
ensureAudioGroupInertStyle(document);
expect(document.querySelectorAll("#__hf-audio-group-inert")).toHaveLength(1);
});
});
+24
View File
@@ -199,3 +199,27 @@ export function audioGroupOf(el: Element): string | null {
if (el.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) return null;
return typeof el.getAttribute === "function" ? el.getAttribute(HF_AUDIO_GROUP_ATTR) : null;
}
/**
* Make `<hf-audio-group>` inert, once per document.
*
* The element is metadata — an id, a label, a chain, an automation lane — and
* carries no content, but "no content" is not "no box": it is still an unknown
* custom element, so in a flex or grid composition root it counts as an item
* (taking a `gap`, shifting `justify-content`, moving every `:nth-child` after
* it), and in inline formatting it can still open a line box. Authored layout
* would shift by adding a group, which is not something a mixing decision is
* allowed to do.
*
* `!important` because an author rule can outrank a bare type selector on
* specificity — inertness here is a contract, not a default. Emitted from the
* runtime rather than the compiler so preview and render share one source.
*/
export function ensureAudioGroupInertStyle(doc: Document): void {
const styleId = "__hf-audio-group-inert";
if (!doc?.head || doc.getElementById(styleId)) return;
const style = doc.createElement("style");
style.id = styleId;
style.textContent = `${HF_AUDIO_GROUP_TAG}{display:none!important}`;
doc.head.appendChild(style);
}
@@ -169,6 +169,40 @@ body { margin: 0; }
expect(fakeWindow.__captured).toEqual({ title: "Pro", price: "$29" });
});
it("hands native window methods back bound to the real window", () => {
// Regression: the scoped `window` proxy returned natives UNBOUND, so `this`
// at call time was the Proxy itself and Chrome rejected it with
// "Illegal invocation" — breaking window.addEventListener, setTimeout,
// matchMedia, getComputedStyle and requestAnimationFrame in every
// sub-composition, including the window.addEventListener("hf-seek", ...)
// form the Three.js and TypeGPU adapters document. The sibling document
// and gsap proxies in this file always bound; this one did not.
const { document } = parseHTML(`<div data-composition-id="scene"></div>`);
const fakeWindow: Record<string, unknown> = { document, __timelines: {} };
let boundToWindow = false;
// Method shorthand, not `function () {}`: like a real native method it has
// no `.prototype`, which is the property the proxy uses to tell a method
// apart from a class. Stands in for the native brand check, which throws
// when the method is invoked with anything else as `this`.
const natives = {
addEventListener(this: unknown) {
if (this !== fakeWindow) throw new TypeError("Illegal invocation");
boundToWindow = true;
},
};
fakeWindow.addEventListener = natives.addEventListener;
const wrapped = wrapScopedCompositionScript(
`window.addEventListener("hf-seek", function () {});`,
"scene",
);
new Function("window", wrapped)(fakeWindow);
// Asserted on the call landing, not on throwing: the wrapper's error
// boundary swallows the TypeError, which is why this shipped unnoticed.
expect(boundToWindow).toBe(true);
});
it("preserves non-getVariables members on window.__hyperframes (only getVariables is rescoped)", () => {
const { document } = parseHTML(`<div data-composition-id="card-1"></div>`);
let fitCalled = false;
@@ -485,7 +485,22 @@ export function wrapScopedCompositionScript(
// (__hfScopedHyperframes is a hoisted var assigned below, before any
// sub-comp script -- the only code that reads this -- runs.)
if (prop === "__hyperframes") return __hfScopedHyperframes;
return Reflect.get(target, prop, target);
// Native window methods must stay bound to the real window. Handed
// back unbound, "this" at call time is this Proxy and Chrome rejects
// it with "Illegal invocation", which broke window.addEventListener,
// setTimeout, matchMedia and getComputedStyle inside every
// sub-composition -- including the window.addEventListener("hf-seek",
// ...) form the Three.js and TypeGPU adapters document. The sibling
// document and gsap proxies here already bind.
//
// Only bind non-constructors. Function.prototype.bind drops static
// members, so binding a class exposed on window (window.Texts and
// friends) would silently strip its statics. Built-in methods have
// no .prototype; classes and constructor functions do.
var value = Reflect.get(target, prop, target);
return typeof value === "function" && value.prototype === undefined
? value.bind(target)
: value;
},
set: function(target, prop, value, receiver) {
if (prop === "__timelines") {
+47 -1
View File
@@ -1,5 +1,5 @@
// @vitest-environment node
import { mkdtempSync, writeFileSync, mkdirSync, symlinkSync } from "node:fs";
import { mkdtempSync, writeFileSync, mkdirSync, symlinkSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parseHTML } from "linkedom";
@@ -53,6 +53,17 @@ function tryCreateSymlink(target: string, path: string, type: "dir" | "file"): b
}
}
function makeSymlinkProject(
projectFiles: Record<string, string>,
secretCss: string,
): { dir: string; outsideDir: string } {
const outsideDir = mkdtempSync(join(tmpdir(), "hf-outside-"));
writeFileSync(join(outsideDir, "secret.css"), secretCss);
const dir = makeTempProject(projectFiles);
symlinkSync(join(outsideDir, "secret.css"), join(dir, "evil.css"));
return { dir, outsideDir };
}
describe("bundleToSingleHtml", () => {
it("bundles a direct composition entry with paths relative to its file", async () => {
const dir = makeTempProject({
@@ -1389,6 +1400,41 @@ describe("bundleToSingleHtml", () => {
warnSpy.mockRestore();
}
});
describe("symlink path traversal (security: F-005)", () => {
it("does not inline CSS from a symlink pointing outside projectDir", async () => {
const { dir, outsideDir } = makeSymlinkProject(
{
"index.html": `<!doctype html><html><head>
<link rel="stylesheet" href="evil.css"></head>
<body><div data-composition-id="root" data-width="320" data-height="180"></div></body></html>`,
},
".outside-secret { color: red; }",
);
try {
expect(await bundleToSingleHtml(dir)).not.toContain("outside-secret");
} finally {
rmSync(outsideDir, { recursive: true, force: true });
}
});
it("does not inline CSS via @import through a symlink pointing outside projectDir", async () => {
const { dir, outsideDir } = makeSymlinkProject(
{
"index.html": `<!doctype html><html><head>
<link rel="stylesheet" href="main.css"></head>
<body><div data-composition-id="root" data-width="320" data-height="180"></div></body></html>`,
"main.css": "@import './evil.css';",
},
".import-secret { color: blue; }",
);
try {
expect(await bundleToSingleHtml(dir)).not.toContain("import-secret");
} finally {
rmSync(outsideDir, { recursive: true, force: true });
}
});
});
});
/**
@@ -28,7 +28,10 @@
*/
import { readVariablesForElement } from "./variableScope";
import { isScalarVariableValue as isScalar } from "@hyperframes/parsers/composition";
import {
isScalarVariableValue as isScalar,
isSafeMediaUrl,
} from "@hyperframes/parsers/composition";
// data-var-src only rebinds media `src` on media elements. A user-controlled
// variable value assigned to a src is an XSS surface on tags whose src executes
@@ -45,28 +48,6 @@ function resolveUrl(value: unknown): string | null {
return null;
}
/**
* Protocol allowlist for a resolved media URL. Relative URLs (no scheme) resolve
* against the page origin and are always safe. Absolute URLs are restricted to
* http(s)/blob and image data: URIs defense-in-depth alongside VAR_SRC_TAGS,
* blocking `javascript:`, `data:text/html`, `file:`, etc. even if a future tag
* slips past the element guard. Control chars are stripped before the scheme
* test because browsers ignore them when parsing the URL (`java\tscript:`).
*/
function isSafeMediaUrl(url: string): boolean {
// Browsers ignore ASCII control chars/whitespace when parsing a URL, so strip
// them before reading the scheme (defeats `java\tscript:` style bypasses).
// oxlint-disable-next-line no-control-regex -- control chars are the target here
const normalized = url.replace(/[\u0000-\u0020]/g, "");
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(normalized);
if (!scheme) return true;
const proto = scheme[1]?.toLowerCase();
if (!proto) return false;
if (proto === "https" || proto === "http" || proto === "blob") return true;
if (proto === "data") return /^data:image\//i.test(normalized);
return false;
}
/**
* Strip characters that could smuggle additional declarations or markup out of
* a var() substitution site. A scalar value folded into `background: var(--x)`
+9 -1
View File
@@ -42,7 +42,11 @@ import { applyVariableBindings } from "./applyVariableBindings";
import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorGrading";
import { TransportClock } from "./clock";
import { WebAudioTransport } from "./webAudioTransport";
import { HF_AUDIO_GROUP_TAG, isMemberGroupHidden } from "../audioGroups";
import {
ensureAudioGroupInertStyle,
HF_AUDIO_GROUP_TAG,
isMemberGroupHidden,
} from "../audioGroups";
import { clampNativeMediaVolume } from "../audioGain";
import { quantizeTimeToFrame } from "../inline-scripts/parityContract";
import { STUDIO_MANUAL_EDIT_GESTURE_ATTR } from "../editing/draftMarkers";
@@ -133,6 +137,10 @@ export function initSandboxRuntimeModular(): void {
// custom props) — values are fixed for the page's lifetime, so applying
// once at init keeps renders deterministic and seeks safe.
applyVariableBindings(document);
// `<hf-audio-group>` is metadata, so it must not occupy a box — see
// ensureAudioGroupInertStyle. Injected here, before timelines bind, so no
// captured frame ever sees the group as a layout item.
ensureAudioGroupInertStyle(document);
const exportRenderFps = resolveExportRenderFps();
state.canonicalFps = exportRenderFps.fps ?? state.canonicalFps;
setRuntimeProtocolFps(state.canonicalFps);
+120 -2
View File
@@ -326,8 +326,11 @@ describe("syncRuntimeMedia", () => {
});
}
function createMockClip(overrides?: Partial<RuntimeMediaClip>): RuntimeMediaClip {
const el = document.createElement("video") as HTMLVideoElement;
function createMockClip(
overrides?: Partial<RuntimeMediaClip>,
mediaType: "audio" | "video" = "video",
): RuntimeMediaClip {
const el = document.createElement(mediaType);
document.body.appendChild(el);
Object.defineProperty(el, "paused", { value: true, writable: true, configurable: true });
el.play = vi.fn(() => Promise.resolve());
@@ -796,6 +799,97 @@ describe("syncRuntimeMedia", () => {
expect(clip.el.play).toHaveBeenCalledTimes(1);
});
it("seeks an ended audio clip backward into its playable source", () => {
const clip = createMockClip(
{ start: 3.12, end: 3.67, duration: 0.55, sourceDuration: 0.55 },
"audio",
);
Object.defineProperty(clip.el, "currentTime", {
value: 0.55,
writable: true,
configurable: true,
});
Object.defineProperty(clip.el, "ended", { value: true, writable: true, configurable: true });
syncRuntimeMedia({ clips: [clip], timeSeconds: 3.12, playing: true, playbackRate: 1 });
expect(clip.el.currentTime).toBe(0);
expect(clip.el.play).toHaveBeenCalledTimes(1);
});
it("does not restart ended audio past its source duration", () => {
const clip = createMockClip(
{ start: 3.12, end: 4.12, duration: 1, sourceDuration: 0.55 },
"audio",
);
Object.defineProperty(clip.el, "currentTime", {
value: 0.55,
writable: true,
configurable: true,
});
Object.defineProperty(clip.el, "ended", { value: true, writable: true, configurable: true });
syncRuntimeMedia({ clips: [clip], timeSeconds: 3.8, playing: true, playbackRate: 1 });
expect(clip.el.currentTime).toBe(0.55);
expect(clip.el.play).not.toHaveBeenCalled();
});
it("does not replay an audio tail when native EOF leads the runtime clock", () => {
const clip = createMockClip(
{ start: 3.12, end: 3.67, duration: 0.55, sourceDuration: 0.55 },
"audio",
);
Object.defineProperty(clip.el, "paused", { value: false, writable: true });
Object.defineProperty(clip.el, "currentTime", { value: 0.53, writable: true });
syncRuntimeMedia({ clips: [clip], timeSeconds: 3.65, playing: true, playbackRate: 1 });
clip.el.currentTime = 0.55;
Object.defineProperty(clip.el, "paused", { value: true, writable: true });
Object.defineProperty(clip.el, "ended", { value: true, writable: true, configurable: true });
syncRuntimeMedia({
clips: [clip],
timeSeconds: 3.66,
playing: true,
playbackRate: 1,
forceSync: true,
});
syncRuntimeMedia({
clips: [clip],
timeSeconds: 3.665,
playing: true,
playbackRate: 1,
forceSync: true,
});
expect(clip.el.currentTime).toBe(0.55);
expect(clip.el.play).not.toHaveBeenCalled();
});
it("rewinds ended audio after a backward seek within the active clip", () => {
const clip = createMockClip(
{ start: 3.12, end: 3.67, duration: 0.55, sourceDuration: 0.55 },
"audio",
);
Object.defineProperty(clip.el, "currentTime", { value: 0.5, writable: true });
syncRuntimeMedia({ clips: [clip], timeSeconds: 3.62, playing: true, playbackRate: 1 });
vi.mocked(clip.el.play).mockClear();
clip.el.currentTime = 0.55;
Object.defineProperty(clip.el, "paused", { value: true, writable: true });
Object.defineProperty(clip.el, "ended", { value: true, writable: true, configurable: true });
syncRuntimeMedia({
clips: [clip],
timeSeconds: 3.12,
playing: true,
playbackRate: 1,
forceSync: true,
});
expect(clip.el.currentTime).toBe(0);
expect(clip.el.play).toHaveBeenCalledTimes(1);
});
it("does restart a loop clip that has naturally ended while still within its active window", () => {
const clip = createMockClip({ start: 0, end: 68.6, loop: true, sourceDuration: 60 });
Object.defineProperty(clip.el, "paused", { value: true, writable: true });
@@ -1076,6 +1170,30 @@ describe("syncRuntimeMedia", () => {
expect(clip.el.currentTime).toBe(3);
});
it("rewinds stale short audio on its first tick after re-entry", () => {
const clip = createMockClip(
{ start: 3.12, end: 3.67, duration: 0.55, sourceDuration: 0.55 },
"audio",
);
Object.defineProperty(clip.el, "currentTime", { value: 0.49, writable: true });
syncRuntimeMedia({ clips: [clip], timeSeconds: 3.12, playing: true, playbackRate: 1 });
expect(clip.el.currentTime).toBe(0);
});
it("does not force cold audio forward on its first active tick", () => {
const clip = createMockClip(
{ start: 3.12, end: 3.67, duration: 0.55, sourceDuration: 0.55 },
"audio",
);
Object.defineProperty(clip.el, "currentTime", { value: 0, writable: true });
syncRuntimeMedia({ clips: [clip], timeSeconds: 3.61, playing: true, playbackRate: 1 });
expect(clip.el.currentTime).toBe(0);
});
it("sets per-element playbackRate × global rate", () => {
const clip = createMockClip({ start: 0, end: 10, playbackRate: 0.5 });
syncRuntimeMedia({ clips: [clip], timeSeconds: 5, playing: true, playbackRate: 2 });
+40 -11
View File
@@ -116,6 +116,10 @@ export function refreshRuntimeMediaCache(params?: {
// a scrub (where offset jumps in one tick). Cleared when a clip becomes
// inactive so the next activation gets a hard resync on its first tick.
const lastOffset = new WeakMap<HTMLMediaElement, number>();
// Desired source time from the previous active tick. Unlike `forceSync`, which
// also covers play/pause and rate changes, a decrease here identifies an actual
// backward transport seek within an audio clip.
const lastRelativeTime = new WeakMap<HTMLMediaElement, number>();
const strictDriftSamples = new WeakMap<HTMLMediaElement, number>();
@@ -168,6 +172,7 @@ function clampVolume(volume: number): number {
*/
export function evictMediaSyncState(el: HTMLMediaElement): void {
lastOffset.delete(el);
lastRelativeTime.delete(el);
strictDriftSamples.delete(el);
seekLoadRetried.delete(el);
lastRuntimeAppliedVolume.delete(el);
@@ -177,6 +182,7 @@ export function evictMediaSyncState(el: HTMLMediaElement): void {
export function hasMediaSyncStateForTest(el: HTMLMediaElement): boolean {
return (
lastOffset.has(el) ||
lastRelativeTime.has(el) ||
strictDriftSamples.has(el) ||
seekLoadRetried.has(el) ||
lastRuntimeAppliedVolume.has(el)
@@ -235,21 +241,29 @@ export function syncRuntimeMedia(params: {
if (isHeldVideoTail && clip.sourceDuration != null) {
relTime = clip.sourceDuration;
}
const canSeekEndedVideoBackward =
isNonLoopVideo &&
const previousRelativeTime = lastRelativeTime.get(el);
const audioReenteredAfterBackwardSeek =
el.tagName === "AUDIO" &&
(previousRelativeTime === undefined || relTime < previousRelativeTime - 0.04);
const canSeekEndedMediaBackward =
!clip.loop &&
clip.sourceDuration != null &&
relTime >= clip.mediaStart &&
relTime < clip.sourceDuration;
// Audio that ended naturally stays silent. A non-loop video remains an
// active visual through its authored window: tail seeks clamp to the final
// frame, and backward seeks can re-enter playable source without depending
// on the browser having reset `ended` first.
relTime < clip.sourceDuration &&
(isNonLoopVideo || audioReenteredAfterBackwardSeek);
// Ended media can re-enter playable source after a backward timeline seek
// without depending on the browser having reset `ended` first. Audio needs
// a fresh activation or a measured backward transport seek so ordinary EOF
// and non-seek force-sync transitions cannot replay its tail. A non-loop
// video additionally remains an active visual through
// its authored window, with tail seeks clamped to the final frame.
const isActive =
params.timeSeconds >= clip.start &&
params.timeSeconds < clip.end &&
relTime >= 0 &&
(!el.ended || clip.loop || isHeldVideoTail || canSeekEndedVideoBackward);
(!el.ended || clip.loop || isHeldVideoTail || canSeekEndedMediaBackward);
if (isActive) {
lastRelativeTime.set(el, relTime);
// Loop wrapping: when media reaches end, restart from mediaStart
if (clip.loop && clip.sourceDuration != null && clip.sourceDuration > 0) {
const loopLength = clip.sourceDuration - clip.mediaStart;
@@ -376,9 +390,17 @@ export function syncRuntimeMedia(params: {
const firstTickOfClip = prevOffset === undefined;
const offsetJumped = !firstTickOfClip && Math.abs(offset - prevOffset!) > 0.5;
const catastrophicDrift = drift > 3;
// A short audio clip can leave its native element paused just before EOF.
// When the timeline re-enters the clip, rewind stale forward state at the
// strict threshold; do not force cold audio forward while it buffers.
const staleAudioOnFirstTick =
el.tagName === "AUDIO" &&
firstTickOfClip &&
currentElTime - relTime > STRICT_DRIFT_THRESHOLD;
const hardSync =
(isHeldVideoTail && drift > 0.001) ||
(el.ended && canSeekEndedVideoBackward && drift > 0.001) ||
(el.ended && canSeekEndedMediaBackward && drift > 0.001) ||
staleAudioOnFirstTick ||
(drift > 0.5 && (firstTickOfClip || offsetJumped || catastrophicDrift));
// Playing video elements use the browser's native decoder pipeline for
// timing. Seeking a playing video resets the decoder, causing a ~150ms
@@ -476,9 +498,16 @@ export function syncRuntimeMedia(params: {
}
continue;
}
// Clip left its active window — drop the offset baseline so the next
// activation (e.g. re-entering a sub-composition) gets a hard resync.
// Drop drift state when the element is not playable. If native audio EOF
// arrived slightly before the authored boundary, preserve only its desired
// source time while the transport remains inside that boundary. Otherwise
// the next poll would mistake the cleared baseline for a fresh activation
// and replay the tail. A real backward seek still decreases relTime, and a
// true outside-window transition clears every baseline as before.
const remainsInsideAuthoredWindow =
params.timeSeconds >= clip.start && params.timeSeconds < clip.end;
evictMediaSyncState(el);
if (remainsInsideAuthoredWindow) lastRelativeTime.set(el, relTime);
if (!el.paused) el.pause();
}
}
+6
View File
@@ -37,6 +37,12 @@ declare global {
onSwallowed?: (label: string, err: unknown) => void;
seek?: (timeSeconds: number, options?: RuntimeSeekOptions) => void;
duration?: number;
/**
* Studio's "Hear only this" push: the full set of soloed clip/group ids,
* replaced wholesale on every change. Session-only by design never
* read from or written to any document attribute.
*/
setAudioSolo?: (ids: readonly string[]) => void;
};
__playerReady?: boolean;
__renderReady?: boolean;