mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hyperframes/core",
|
||||
"version": "0.8.5",
|
||||
"version": "0.8.10",
|
||||
"description": "",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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)`
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user