mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(core): a preview media load that fails is reported, not swallowed (#6666)
* fix(core): a preview media load that fails is reported, not swallowed * fix(core): an audio fed by source children reports its failed load; the shape lives in the protocol * fix(core): the audio load watcher finds elements from any realm * build(core): regenerate the inline position-edits render script
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
// Generated by scripts/build-position-edits-render.ts; do not edit.
|
||||
const POSITION_EDITS_RENDER_IIFE: string =
|
||||
'"use strict";(()=>{function x(){return globalThis}function P(e,t){if(typeof window>"u")return;let o=x(),r=o.__hf?.onSwallowed;if(r)try{r({label:e,error:t})}catch(s){}(o.__hfDebug||o.__HYPERFRAMES_DEBUG)&&console.debug(`[hyperframes] ${e} swallowed:`,t)}var T=null;function R(e,t){if(T)try{T({source:"hf-preview",type:"analytics",event:e,properties:t??{}})}catch(o){P("runtime.analytics.site1",o)}}var M="http://www.w3.org/1999/xhtml";function N(e){return e!=null&&e.nodeType===1}function _(e){return N(e)&&(e.namespaceURI===M||e.namespaceURI==="http://www.w3.org/2000/svg")}var y="data-hf-edit-base-x",w="data-hf-edit-base-y",m="data-hf-edit-original-translate",k=e=>{let t=parseFloat(e??"");return Number.isFinite(t)?t:0},C=e=>{let t=[],o=0,r="";for(let s of e.trim())s==="("&&(o+=1),s===")"&&(o=Math.max(0,o-1)),/\\s/.test(s)&&o===0?(r&&t.push(r),r=""):r+=s;return r&&t.push(r),t},$=/^-?(?:\\d+(?:\\.\\d+)?|\\.\\d+)px$/,E=(e,t)=>$.test(e)&&$.test(t)?`${parseFloat(e)+parseFloat(t)}px`:`calc(${e} + ${t})`,O=(e,t,o)=>{if(!e||e==="none")return`${t} ${o}`;let[r,s,a]=C(e);if(r===void 0)return`${t} ${o}`;if(s===void 0)return`${E(r,t)} ${o}`;let i=a===void 0?"":` ${a}`;return`${E(r,t)} ${E(s,o)}${i}`},j=e=>{try{e.ownerDocument.defaultView?.gsap?.getProperty?.(e,"x")}catch{}},V=e=>{let t=e.style.getPropertyValue("translate").trim();if(t)return t==="none"?"":t;try{let o=e.ownerDocument.defaultView,r=o?o.getComputedStyle(e).getPropertyValue("translate").trim():"";return r==="none"?"":r}catch{return""}},b=new WeakMap;function B(e,t){let o=b.get(e);if(!t?.force&&o!==void 0&&e.style.getPropertyValue("translate")!==o){R("position_edit_fold_skipped",{hfId:e.getAttribute("data-hf-id")});return}let r=k(e.getAttribute("data-x"))-k(e.getAttribute(y)),s=k(e.getAttribute("data-y"))-k(e.getAttribute(w));e.hasAttribute(m)||e.setAttribute(m,V(e)),o===void 0&&j(e);let a=e.getAttribute(m)??"",i=O(a,`${r}px`,`${s}px`);e.style.setProperty("translate",i),b.set(e,e.style.getPropertyValue("translate"))}function h(e,t){let o=e.querySelectorAll(`[${m}]:not([${y}]):not([${w}])`);for(let a=0;a<o.length;a++){let i=o[a];if(i===void 0||!_(i))continue;let g=i.getAttribute(m)??"";g===""?i.style.removeProperty("translate"):i.style.setProperty("translate",g),i.removeAttribute(m),b.delete(i)}let r=e.querySelectorAll(`[${y}], [${w}]`),s=0;for(let a=0;a<r.length;a++){let i=r[a];i===void 0||!_(i)||(B(i,t),s+=1)}return s}var L="__hfPositionEditsSeekReapplyWrapped",D=new WeakSet,F=new WeakMap,H=new WeakMap;function W(e){let t=e,o=()=>{try{h(t.document)}catch{}},r=n=>typeof n=="function"&&(D.has(n)||!!n[L]),s=n=>{D.add(n);try{Object.defineProperty(n,L,{value:!0})}catch{}},a=n=>{if(typeof n!="function"||r(n))return n;let l=function(...u){let d=n.apply(this,u);return o(),d};return s(l),l},i=(n,l)=>{let u=F.get(n);if(u?.has(l))return!0;let d=Object.getOwnPropertyDescriptor(n,l);if(d?.configurable===!1){let c=n[l];return typeof c=="function"&&(n[l]=a(c),o()),!1}let p=n[l],f=d?.set;return Object.defineProperty(n,l,{configurable:!0,enumerable:d?.enumerable??!0,get:()=>p,set:c=>{p=a(c),f?.call(n,c)}}),p=a(p),u??(u=new Set),u.add(l),F.set(n,u),o(),!0},g=(n,l)=>{let u=H.get(t),d=Object.getOwnPropertyDescriptor(t,n);if(!u?.has(n)){if(d?.configurable===!1){let c=t[n];return c?i(c,l):!1}let f=t[n];Object.defineProperty(t,n,{configurable:!0,enumerable:d?.enumerable??!0,get:()=>f,set:c=>{f=c,f&&i(f,l)}}),u??(u=new Set),u.add(n),H.set(t,u)}let p=t[n];return p?i(p,l):!1},S=()=>{let n=g("__hf","seek"),l=g("__player","renderSeek");return n&&l};if(S())return;let v=120,A=t.setInterval(()=>{if(S()){t.clearInterval(A);return}v-=1,v<=0&&t.clearInterval(A)},50)}function I(){document.querySelector(`[${y}], [${w}]`)&&(h(document),W(window))}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",I,{once:!0}):I();})();\n';
|
||||
'"use strict";(()=>{function x(){return globalThis}function T(e,t){if(typeof window>"u")return;let o=x(),r=o.__hf?.onSwallowed;if(r)try{r({label:e,error:t})}catch(s){}(o.__hfDebug||o.__HYPERFRAMES_DEBUG)&&console.debug(`[hyperframes] ${e} swallowed:`,t)}var P=null;function R(e,t){if(P)try{P({source:"hf-preview",type:"analytics",event:e,properties:t??{}})}catch(o){T("runtime.analytics.site1",o)}}var I="http://www.w3.org/1999/xhtml";function N(e){return e!=null&&e.nodeType===1}function _(e){return N(e)&&(e.namespaceURI===I||e.namespaceURI==="http://www.w3.org/2000/svg")}var y="data-hf-edit-base-x",w="data-hf-edit-base-y",m="data-hf-edit-original-translate",k=e=>{let t=parseFloat(e??"");return Number.isFinite(t)?t:0},C=e=>{let t=[],o=0,r="";for(let s of e.trim())s==="("&&(o+=1),s===")"&&(o=Math.max(0,o-1)),/\\s/.test(s)&&o===0?(r&&t.push(r),r=""):r+=s;return r&&t.push(r),t},$=/^-?(?:\\d+(?:\\.\\d+)?|\\.\\d+)px$/,E=(e,t)=>$.test(e)&&$.test(t)?`${parseFloat(e)+parseFloat(t)}px`:`calc(${e} + ${t})`,O=(e,t,o)=>{if(!e||e==="none")return`${t} ${o}`;let[r,s,a]=C(e);if(r===void 0)return`${t} ${o}`;if(s===void 0)return`${E(r,t)} ${o}`;let i=a===void 0?"":` ${a}`;return`${E(r,t)} ${E(s,o)}${i}`},j=e=>{try{e.ownerDocument.defaultView?.gsap?.getProperty?.(e,"x")}catch{}},V=e=>{let t=e.style.getPropertyValue("translate").trim();if(t)return t==="none"?"":t;try{let o=e.ownerDocument.defaultView,r=o?o.getComputedStyle(e).getPropertyValue("translate").trim():"";return r==="none"?"":r}catch{return""}},b=new WeakMap;function B(e,t){let o=b.get(e);if(!t?.force&&o!==void 0&&e.style.getPropertyValue("translate")!==o){R("position_edit_fold_skipped",{hfId:e.getAttribute("data-hf-id")});return}let r=k(e.getAttribute("data-x"))-k(e.getAttribute(y)),s=k(e.getAttribute("data-y"))-k(e.getAttribute(w));e.hasAttribute(m)||e.setAttribute(m,V(e)),o===void 0&&j(e);let a=e.getAttribute(m)??"",i=O(a,`${r}px`,`${s}px`);e.style.setProperty("translate",i),b.set(e,e.style.getPropertyValue("translate"))}function S(e,t){let o=e.querySelectorAll(`[${m}]:not([${y}]):not([${w}])`);for(let a=0;a<o.length;a++){let i=o[a];if(i===void 0||!_(i))continue;let g=i.getAttribute(m)??"";g===""?i.style.removeProperty("translate"):i.style.setProperty("translate",g),i.removeAttribute(m),b.delete(i)}let r=e.querySelectorAll(`[${y}], [${w}]`),s=0;for(let a=0;a<r.length;a++){let i=r[a];i===void 0||!_(i)||(B(i,t),s+=1)}return s}var L="__hfPositionEditsSeekReapplyWrapped",D=new WeakSet,F=new WeakMap,H=new WeakMap;function M(e){let t=e,o=()=>{try{S(t.document)}catch{}},r=n=>typeof n=="function"&&(D.has(n)||!!n[L]),s=n=>{D.add(n);try{Object.defineProperty(n,L,{value:!0})}catch{}},a=n=>{if(typeof n!="function"||r(n))return n;let l=function(...u){let d=n.apply(this,u);return o(),d};return s(l),l},i=(n,l)=>{let u=F.get(n);if(u?.has(l))return!0;let d=Object.getOwnPropertyDescriptor(n,l);if(d?.configurable===!1){let c=n[l];return typeof c=="function"&&(n[l]=a(c),o()),!1}let p=n[l],f=d?.set;return Object.defineProperty(n,l,{configurable:!0,enumerable:d?.enumerable??!0,get:()=>p,set:c=>{p=a(c),f?.call(n,c)}}),p=a(p),u??(u=new Set),u.add(l),F.set(n,u),o(),!0},g=(n,l)=>{let u=H.get(t),d=Object.getOwnPropertyDescriptor(t,n);if(!u?.has(n)){if(d?.configurable===!1){let c=t[n];return c?i(c,l):!1}let f=t[n];Object.defineProperty(t,n,{configurable:!0,enumerable:d?.enumerable??!0,get:()=>f,set:c=>{f=c,f&&i(f,l)}}),u??(u=new Set),u.add(n),H.set(t,u)}let p=t[n];return p?i(p,l):!1},h=()=>{let n=g("__hf","seek"),l=g("__player","renderSeek");return n&&l};if(h())return;let v=120,A=t.setInterval(()=>{if(h()){t.clearInterval(A);return}v-=1,v<=0&&t.clearInterval(A)},50)}function W(){document.querySelector(`[${y}], [${w}]`)&&(S(document),M(window))}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",W,{once:!0}):W();})();\n';
|
||||
|
||||
/** Returns the pre-built position-edits render IIFE as a string constant. */
|
||||
export function getPositionEditsRenderScript(): string {
|
||||
|
||||
@@ -76,6 +76,11 @@ export function isAudioElement(value: unknown): value is HTMLAudioElement {
|
||||
return isHtmlTag(value, "audio");
|
||||
}
|
||||
|
||||
/** `<source>`, from any realm. Mirrors `instanceof HTMLSourceElement`. */
|
||||
export function isSourceElement(value: unknown): value is HTMLSourceElement {
|
||||
return isHtmlTag(value, "source");
|
||||
}
|
||||
|
||||
/** `<video>` or `<audio>`, from any realm. Mirrors `instanceof HTMLMediaElement`. */
|
||||
export function isMediaElement(value: unknown): value is HTMLMediaElement {
|
||||
return isVideoElement(value) || isAudioElement(value);
|
||||
|
||||
@@ -167,6 +167,7 @@ import {
|
||||
} from "./captureMotionBlur";
|
||||
import { TransportClock } from "./clock";
|
||||
import { WebAudioTransport } from "./webAudioTransport";
|
||||
import { watchAudioLoadErrors } from "./mediaLoadFailure";
|
||||
import {
|
||||
classifyWebAudioMediaRoute,
|
||||
isRouteSelectionSettled,
|
||||
@@ -3601,6 +3602,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
runtimeCleanupCallbacks.push(() => {
|
||||
document.removeEventListener("play", onMediaPlayWakeTransport, true);
|
||||
});
|
||||
runtimeCleanupCallbacks.push(watchAudioLoadErrors(document));
|
||||
|
||||
let lastVisibilityPassSeconds: number | null = null;
|
||||
const syncMediaForCurrentState = (timingRevision?: number) => {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { postRuntimeMessage } from "./bridge";
|
||||
import { watchAudioLoadErrors } from "./mediaLoadFailure";
|
||||
import { RUNTIME_MEDIA_LOAD_FAILED } from "./protocol";
|
||||
import { WebAudioTransport } from "./webAudioTransport";
|
||||
|
||||
vi.mock("./bridge", () => ({ postRuntimeMessage: vi.fn() }));
|
||||
|
||||
const posted = vi.mocked(postRuntimeMessage);
|
||||
const failures = () =>
|
||||
posted.mock.calls
|
||||
.map(([message]) => message)
|
||||
.filter((m) => m.type === "diagnostic" && m.code === RUNTIME_MEDIA_LOAD_FAILED)
|
||||
.map((m) => (m as { details: Record<string, unknown> }).details);
|
||||
|
||||
function audio(src: string, id = "music"): HTMLAudioElement {
|
||||
const el = document.createElement("audio");
|
||||
el.id = id;
|
||||
el.setAttribute("src", src);
|
||||
return el;
|
||||
}
|
||||
|
||||
function transport(decode: () => Promise<AudioBuffer>) {
|
||||
const t = new WebAudioTransport();
|
||||
(t as unknown as { _ctx: unknown })._ctx = { state: "running", decodeAudioData: vi.fn(decode) };
|
||||
return t;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
posted.mockReset();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("a preview media load that fails is reported, not swallowed", () => {
|
||||
it("a fetch the server refuses says its status, once however often playback retries", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({ ok: false, status: 404 })),
|
||||
);
|
||||
const t = transport(async () => ({}) as AudioBuffer);
|
||||
const el = audio("assets/Don't (mix).mp3");
|
||||
await t.decodeAudioElement(el);
|
||||
await t.decodeAudioElement(el);
|
||||
expect(failures()).toEqual([
|
||||
expect.objectContaining({
|
||||
elementId: "music",
|
||||
kind: "audio",
|
||||
stage: "fetch",
|
||||
code: 404,
|
||||
extension: "mp3",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("bytes the browser cannot decode say decode and how many bytes came", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) })),
|
||||
);
|
||||
const t = transport(async () => {
|
||||
throw new Error("unsupported codec");
|
||||
});
|
||||
await t.decodeAudioElement(audio("assets/song.wav"));
|
||||
expect(failures()).toEqual([
|
||||
expect.objectContaining({ stage: "decode", code: null, bytes: 8, extension: "wav" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("an audio element that fails to load says its error code; a video's is left to the proxy", () => {
|
||||
const stop = watchAudioLoadErrors(document);
|
||||
const el = audio("assets/gone.mp3", "gone");
|
||||
Object.defineProperty(el, "error", { value: { code: 4 } });
|
||||
const video = document.createElement("video");
|
||||
document.body.append(el, video);
|
||||
el.dispatchEvent(new Event("error"));
|
||||
video.dispatchEvent(new Event("error"));
|
||||
stop();
|
||||
el.dispatchEvent(new Event("error"));
|
||||
expect(failures()).toEqual([
|
||||
expect.objectContaining({ elementId: "gone", stage: "element", code: 4 }),
|
||||
]);
|
||||
el.remove();
|
||||
video.remove();
|
||||
});
|
||||
it("an audio element fed by <source> children reports when a source fails to load", () => {
|
||||
const stop = watchAudioLoadErrors(document);
|
||||
const el = document.createElement("audio");
|
||||
el.id = "sourced";
|
||||
const source = document.createElement("source");
|
||||
source.setAttribute("src", "assets/gone.ogg");
|
||||
el.append(source);
|
||||
document.body.append(el);
|
||||
source.dispatchEvent(new Event("error"));
|
||||
stop();
|
||||
expect(failures()).toEqual([
|
||||
expect.objectContaining({ elementId: "sourced", stage: "element" }),
|
||||
]);
|
||||
el.remove();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { postRuntimeMessage } from "./bridge";
|
||||
import { isAudioElement, isSourceElement } from "./domRealm";
|
||||
import { isRenderMode } from "./mediaProxy";
|
||||
import { RUNTIME_MEDIA_LOAD_FAILED, type MediaLoadStage } from "./protocol";
|
||||
import type { RuntimeJson } from "./types";
|
||||
|
||||
const reported = new WeakMap<HTMLMediaElement, Set<MediaLoadStage>>();
|
||||
|
||||
/** The file's extension, lower-cased, from the URL path; never its name. */
|
||||
function extensionOf(el: HTMLMediaElement): string | null {
|
||||
const src = el.currentSrc || el.getAttribute("src") || "";
|
||||
try {
|
||||
const match = /\.([a-z0-9]{1,5})$/i.exec(new URL(src, document.baseURI).pathname);
|
||||
return match?.[1]?.toLowerCase() ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Tells the host once per element and stage that its media failed to load. `code` is the HTTP status of a fetch
|
||||
* that answered or the element's MediaError code; `bytes` the size fetched before a decode failed. */
|
||||
export function reportMediaLoadFailure(
|
||||
el: HTMLMediaElement,
|
||||
stage: MediaLoadStage,
|
||||
code: number | null,
|
||||
bytes: number | null = null,
|
||||
): void {
|
||||
if (isRenderMode(el)) return;
|
||||
const stages = reported.get(el) ?? new Set<MediaLoadStage>();
|
||||
if (stages.has(stage)) return;
|
||||
stages.add(stage);
|
||||
reported.set(el, stages);
|
||||
const asset = el.getAttribute("src") ?? "";
|
||||
const details: Record<string, RuntimeJson> = {
|
||||
asset,
|
||||
elementId: el.id || null,
|
||||
kind: el.tagName.toLowerCase(),
|
||||
stage,
|
||||
code,
|
||||
bytes,
|
||||
extension: extensionOf(el),
|
||||
};
|
||||
postRuntimeMessage({
|
||||
source: "hf-preview",
|
||||
type: "diagnostic",
|
||||
code: RUNTIME_MEDIA_LOAD_FAILED,
|
||||
details,
|
||||
});
|
||||
console.info(
|
||||
`[hyperframes] ${RUNTIME_MEDIA_LOAD_FAILED}: "${asset}" failed at ${stage}${code === null ? "" : ` (${code})`}.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Reports each `<audio>` that fails to load; returns the cleanup. `error` does not bubble, so this listens in capture.
|
||||
* A `<source>` child gets the error instead of its `<audio>`.
|
||||
* Video is left out: mediaProxy answers a video's load error with a lighter copy, so it is not yet a failure. */
|
||||
export function watchAudioLoadErrors(doc: Document): () => void {
|
||||
const onError = (event: Event) => {
|
||||
const target = event.target;
|
||||
const el = isSourceElement(target) ? target.parentElement : target;
|
||||
if (isAudioElement(el)) reportMediaLoadFailure(el, "element", el.error?.code ?? null);
|
||||
};
|
||||
doc.addEventListener("error", onError, true);
|
||||
return () => doc.removeEventListener("error", onError, true);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ export const RUNTIME_PROTOCOL_VERSION = 1 as const;
|
||||
|
||||
export const RUNTIME_FILLER = "hf-runtime-filler";
|
||||
|
||||
export const RUNTIME_MEDIA_LOAD_FAILED = "runtime_media_load_failed";
|
||||
|
||||
export const RUNTIME_PROTOCOL_CAPABILITIES = [
|
||||
"seconds-time",
|
||||
"rational-fps",
|
||||
@@ -106,3 +108,33 @@ export function inspectRuntimeProtocol(value: unknown, legacyFps = 30): RuntimeP
|
||||
metadata: message as RuntimeProtocolV1,
|
||||
};
|
||||
}
|
||||
|
||||
const STAGES = ["fetch", "decode", "element"] as const;
|
||||
export type MediaLoadStage = (typeof STAGES)[number];
|
||||
|
||||
export interface MediaLoadFailure {
|
||||
elementId: string | null;
|
||||
kind: string;
|
||||
stage: MediaLoadStage;
|
||||
code: number | null;
|
||||
bytes: number | null;
|
||||
extension: string | null;
|
||||
}
|
||||
|
||||
const numberOrNull = (n: unknown) => (typeof n === "number" ? n : null);
|
||||
const stringOrNull = (s: unknown) => (typeof s === "string" ? s : null);
|
||||
|
||||
export function parseMediaLoadFailure(value: unknown): MediaLoadFailure | null {
|
||||
if (typeof value !== "object" || value === null) return null;
|
||||
const { elementId, kind, stage, code, bytes, extension } = value as Record<string, unknown>;
|
||||
const known = STAGES.find((s) => s === stage);
|
||||
if (!known || typeof kind !== "string") return null;
|
||||
return {
|
||||
elementId: stringOrNull(elementId),
|
||||
kind,
|
||||
stage: known,
|
||||
code: numberOrNull(code),
|
||||
bytes: numberOrNull(bytes),
|
||||
extension: stringOrNull(extension),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1776,6 +1776,7 @@ describe("WebAudioTransport", () => {
|
||||
}
|
||||
const el = (src: string) =>
|
||||
({
|
||||
tagName: "AUDIO",
|
||||
getAttribute: (name: string) => (name === "src" ? src : null),
|
||||
currentSrc: "",
|
||||
}) as unknown as HTMLMediaElement;
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
resolveMasterBusElement,
|
||||
} from "../audioGroups.js";
|
||||
import { swallow } from "./diagnostics";
|
||||
import { reportMediaLoadFailure } from "./mediaLoadFailure";
|
||||
import { createLevelTap, type LevelTap, type StereoLevel } from "./levelTap.js";
|
||||
import { clampAudioGain } from "../audioGain.js";
|
||||
import { getDebugSurface } from "./globals.js";
|
||||
@@ -355,12 +356,12 @@ export class WebAudioTransport {
|
||||
if (!src) return Promise.resolve(null);
|
||||
const pending = this._decoding.get(src);
|
||||
if (pending) return pending;
|
||||
const decoding = this.fetchAndDecode(src).finally(() => this._decoding.delete(src));
|
||||
const decoding = this.fetchAndDecode(src, el).finally(() => this._decoding.delete(src));
|
||||
this._decoding.set(src, decoding);
|
||||
return decoding;
|
||||
}
|
||||
|
||||
private async fetchAndDecode(src: string): Promise<AudioBuffer | null> {
|
||||
private async fetchAndDecode(src: string, el: HTMLMediaElement): Promise<AudioBuffer | null> {
|
||||
if (this._bufferCache.has(src)) return this._bufferCache.get(src)!;
|
||||
if (this._failedSrcs.has(src)) return null;
|
||||
if (!this._ctx) return null;
|
||||
@@ -378,11 +379,13 @@ export class WebAudioTransport {
|
||||
const response = await fetch(src, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
swallow("webAudioTransport.fetch", new Error(`${response.status} ${src}`));
|
||||
reportMediaLoadFailure(el, "fetch", response.status);
|
||||
return null;
|
||||
}
|
||||
arrayBuffer = await response.arrayBuffer();
|
||||
} catch (err) {
|
||||
swallow("webAudioTransport.fetch", err);
|
||||
reportMediaLoadFailure(el, "fetch", null);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -396,6 +399,7 @@ export class WebAudioTransport {
|
||||
} catch (err) {
|
||||
this._failedSrcs.add(src);
|
||||
swallow("webAudioTransport.decode", err);
|
||||
reportMediaLoadFailure(el, "decode", null, arrayBuffer.byteLength);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,46 @@ describe("preview quality messages", () => {
|
||||
expect(usePlayerStore.getState().previewFacts).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps each failed media load the preview reports, and ignores other diagnostics", () => {
|
||||
const details = {
|
||||
asset: "assets/x.mp3",
|
||||
elementId: "music",
|
||||
kind: "audio",
|
||||
stage: "decode",
|
||||
code: null,
|
||||
bytes: 8,
|
||||
extension: "mp3",
|
||||
};
|
||||
routeMessage({
|
||||
source: "hf-preview",
|
||||
type: "diagnostic",
|
||||
code: "runtime_media_load_failed",
|
||||
details,
|
||||
});
|
||||
expect(usePlayerStore.getState().mediaLoadFailure).toEqual({
|
||||
elementId: "music",
|
||||
kind: "audio",
|
||||
stage: "decode",
|
||||
code: null,
|
||||
bytes: 8,
|
||||
extension: "mp3",
|
||||
});
|
||||
const kept = usePlayerStore.getState().mediaLoadFailure;
|
||||
routeMessage({
|
||||
source: "hf-preview",
|
||||
type: "diagnostic",
|
||||
code: "runtime_web_audio_bypass",
|
||||
details,
|
||||
});
|
||||
routeMessage({
|
||||
source: "hf-preview",
|
||||
type: "diagnostic",
|
||||
code: "runtime_media_load_failed",
|
||||
details: { ...details, stage: "?" },
|
||||
});
|
||||
expect(usePlayerStore.getState().mediaLoadFailure).toBe(kept);
|
||||
});
|
||||
|
||||
it("sends the chosen quality to a preview that has just loaded", () => {
|
||||
usePlayerStore.getState().setPreviewQuality(0.25);
|
||||
const postMessage = vi.fn();
|
||||
|
||||
@@ -13,6 +13,10 @@ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||
import type { ClipManifestClip, IframeWindow, PlaybackAdapter } from "../lib/playbackTypes";
|
||||
import { hasTimelinePerformanceFixtureLease } from "../lib/timelinePerformanceFixture";
|
||||
import { acceptStudioRuntimeMessage, postRuntimeControlMessage } from "../lib/runtimeProtocol";
|
||||
import {
|
||||
parseMediaLoadFailure,
|
||||
RUNTIME_MEDIA_LOAD_FAILED,
|
||||
} from "@hyperframes/core/runtime/protocol";
|
||||
import {
|
||||
parsePlaybackHealth,
|
||||
parsePreviewCopies,
|
||||
@@ -38,6 +42,8 @@ interface PreviewMessage {
|
||||
copies?: unknown;
|
||||
health?: unknown;
|
||||
facts?: unknown;
|
||||
code?: unknown;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
export interface PreviewMessageRouterDeps {
|
||||
@@ -136,6 +142,12 @@ function applyStoreReport(data: PreviewMessage): boolean {
|
||||
case "preview-facts":
|
||||
store.setPreviewFacts(parsePreviewFacts(data.facts));
|
||||
return true;
|
||||
case "diagnostic": {
|
||||
if (data.code !== RUNTIME_MEDIA_LOAD_FAILED) return false;
|
||||
const failure = parseMediaLoadFailure(data.details);
|
||||
if (failure) store.setMediaLoadFailure(failure);
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { StoreApi } from "zustand";
|
||||
import { isPreviewQuality, type PreviewQuality } from "@hyperframes/core/preview-quality";
|
||||
import type { MediaLoadFailure } from "@hyperframes/core/runtime/protocol";
|
||||
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
||||
|
||||
/** The lighter copies of heavy videos the preview wants at its current quality. */
|
||||
@@ -53,6 +54,8 @@ export interface PreviewQualitySlice {
|
||||
setPlaybackHealth: (health: PlaybackHealth | null) => void;
|
||||
previewFacts: PreviewFacts | null;
|
||||
setPreviewFacts: (facts: PreviewFacts | null) => void;
|
||||
mediaLoadFailure: MediaLoadFailure | null;
|
||||
setMediaLoadFailure: (failure: MediaLoadFailure | null) => void;
|
||||
/** Bumped by requestPreviewFacts; the preview owner forwards each bump and the answer lands in previewFacts. */
|
||||
previewFactsRequests: number;
|
||||
requestPreviewFacts: () => void;
|
||||
@@ -87,6 +90,8 @@ export function createPreviewQualitySlice(
|
||||
setPlaybackHealth: (playbackHealth) => set({ playbackHealth }),
|
||||
previewFacts: null,
|
||||
setPreviewFacts: (previewFacts) => set({ previewFacts }),
|
||||
mediaLoadFailure: null,
|
||||
setMediaLoadFailure: (mediaLoadFailure) => set({ mediaLoadFailure }),
|
||||
previewFactsRequests: 0,
|
||||
requestPreviewFacts: () =>
|
||||
set((state) => ({ previewFactsRequests: state.previewFactsRequests + 1 })),
|
||||
|
||||
Reference in New Issue
Block a user