@hyperframes/engine 0.8.189 → 0.8.191

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.
@@ -37,5 +37,6 @@ export declare function processCompositionAudio(elements: AudioElement[], baseDi
37
37
  /** Serialised master bus chain, from `parseMasterFxChain`. */
38
38
  masterFxChain?: string;
39
39
  urlByAbsentLocalPath?: ReadonlyMap<string, string>;
40
+ audioSidecarUrlByLocalPath?: ReadonlyMap<string, string>;
40
41
  keepEncoderInputAt?: string;
41
42
  }, compiledDir?: string): Promise<MixResult>;
@@ -11,6 +11,8 @@ import { clampFadesToDuration, readElementFades } from "@hyperframes/core/audio-
11
11
  import { downloadToTemp, isHttpUrl, UrlDownloadError, writeUrlDownloadTelemetry, } from "../utils/urlDownloader.js";
12
12
  import { DEFAULT_CONFIG } from "../config.js";
13
13
  import { describeFfmpegFailure, runFfmpeg } from "../utils/runFfmpeg.js";
14
+ import { cpus } from "node:os";
15
+ import { createConcurrencyLimit } from "../utils/concurrencyLimit.js";
14
16
  import { encodeAac } from "./aacEncode.js";
15
17
  import { audioSubprocessEvidence } from "./audioFailureDiagnostic.js";
16
18
  import { unwrapTemplate } from "../utils/htmlTemplate.js";
@@ -129,6 +131,8 @@ function legacyFilterScriptOptionIsUnsupported(stderr) {
129
131
  * no audio). 32 segments keeps a wide safety margin and is far more resolution
130
132
  * than a piecewise-linear volume envelope needs.
131
133
  */
134
+ // Caps the ffmpeg runs that prepare clips; all of a long mix's clips at once starved the host (200 clips, 200 ffmpeg).
135
+ const inPrepareSlot = createConcurrencyLimit(Math.max(2, Math.floor(cpus().length / 2)));
132
136
  const MAX_VOLUME_SEGMENTS = 32;
133
137
  /**
134
138
  * Volume delta below which a keyframe is collinear enough to drop. Kept tight
@@ -499,9 +503,10 @@ export function parseAudioElements(html) {
499
503
  return elements;
500
504
  }
501
505
  const FAIL_ON_READ_ERROR_INSTEAD_OF_ENDING_THERE = ["-xerror"];
506
+ const SIDECAR_WARM_UP_SECONDS = 1;
502
507
  /** Decode `duration` s of audio from `mediaStart` in Chrome's media time. Until every stream has
503
508
  * started, an input seek snaps audio to the video's first keyframe, so read by timestamp instead. */
504
- async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, duration, playbackRate = 1, signal, config) {
509
+ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, duration, playbackRate = 1, signal, config, audioSidecarUrl) {
505
510
  const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
506
511
  const outputDir = dirname(outputPath);
507
512
  if (!existsSync(outputDir))
@@ -523,13 +528,18 @@ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, durat
523
528
  const timestampTrim = inputSeek
524
529
  ? null
525
530
  : `atrim=start=${start}:end=${formatFilterNumber(mediaStart + sourceDuration)},asetpts=PTS-${start}/TB,aresample=async=1:first_pts=0`;
526
- const outputArgs = preparedAudioOutputArgs(metadata, normalizedPlaybackRate, duration, timestampTrim);
531
+ const fromSidecar = audioSidecarUrl !== undefined && inputSeek && normalizedPlaybackRate === 1;
532
+ const warmUp = fromSidecar ? Math.min(SIDECAR_WARM_UP_SECONDS, mediaStart) : 0;
533
+ const outputArgs = preparedAudioOutputArgs(metadata, normalizedPlaybackRate, duration, warmUp > 0 ? `atrim=start=${warmUp},asetpts=PTS-STARTPTS` : timestampTrim);
534
+ const readFrom = fromSidecar ? audioSidecarUrl : srcPath;
527
535
  const args = [
528
536
  ...(readsUrl ? FAIL_ON_READ_ERROR_INSTEAD_OF_ENDING_THERE : []),
529
- ...urlInputArgs(srcPath),
530
- ...(inputSeek ? ["-ss", String(mediaStart), "-t", String(sourceDuration)] : []),
537
+ ...urlInputArgs(readFrom),
538
+ ...(inputSeek
539
+ ? ["-ss", String(mediaStart - warmUp), "-t", String(sourceDuration + warmUp)]
540
+ : []),
531
541
  "-i",
532
- srcPath,
542
+ readFrom,
533
543
  ];
534
544
  args.push("-vn", "-acodec", "pcm_s16le", "-ar", "48000", ...outputArgs);
535
545
  if (normalizedPlaybackRate !== 1)
@@ -1043,7 +1053,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1043
1053
  else
1044
1054
  signal.addEventListener("abort", () => internalController.abort(), { once: true });
1045
1055
  }
1046
- await Promise.all(elements.map(async (element, index) => {
1056
+ await Promise.all(elements.map((element, index) => inPrepareSlot(async () => {
1047
1057
  if (effectiveSignal.aborted) {
1048
1058
  failures.push({
1049
1059
  stage: "cancelled",
@@ -1058,11 +1068,13 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1058
1068
  try {
1059
1069
  let srcPath = element.src;
1060
1070
  let readInPlace = false;
1071
+ let audioSidecarUrl;
1061
1072
  if (!isHttpUrl(srcPath)) {
1062
1073
  // Same browser-URL path semantics as videos.
1063
1074
  srcPath = resolveProjectRelativeSrc(element.src, baseDir, compiledDir);
1064
1075
  const remoteUrl = config?.urlByAbsentLocalPath?.get(srcPath);
1065
1076
  if (remoteUrl) {
1077
+ audioSidecarUrl = config?.audioSidecarUrlByLocalPath?.get(srcPath);
1066
1078
  srcPath = remoteUrl;
1067
1079
  readInPlace = true;
1068
1080
  }
@@ -1089,12 +1101,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1089
1101
  });
1090
1102
  return;
1091
1103
  }
1092
- // STUDIO-5433: an audio src that resolved to a text document (an
1093
- // unresolved nested-composition preview URL, or a 403/404 body served
1094
- // with a 200) never reaches the probe below when the element carries an
1095
- // authored duration or `loop`. It then fails inside ffmpeg as
1096
- // `prepare/ffmpeg_failed` with owner "system" — an authoring bug paged
1097
- // as a platform fault, after every frame has already been captured.
1104
+ // A src that resolved to a text document is an authoring error, not an ffmpeg failure.
1098
1105
  if (await isNotMediaPayload(srcPath)) {
1099
1106
  failures.push({
1100
1107
  stage: "source",
@@ -1118,12 +1125,13 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1118
1125
  }
1119
1126
  const effectiveDuration = timeAtSourceTime(normalizeRateSpec(element.playbackRate), metadata.durationSeconds - element.mediaStart);
1120
1127
  element.end =
1121
- element.start + (effectiveDuration > 0 ? effectiveDuration : metadata.durationSeconds);
1128
+ element.start +
1129
+ (effectiveDuration > 0 ? effectiveDuration : metadata.durationSeconds);
1122
1130
  }
1123
1131
  let audioSrcPath = srcPath;
1124
1132
  if (element.type === "video") {
1125
1133
  const extractedPath = join(workDir, `${element.id}-extracted.wav`);
1126
- const extractResult = await extractAudioSegment(srcPath, extractedPath, "extract", element.mediaStart, element.end - element.start, element.playbackRate, effectiveSignal, config);
1134
+ const extractResult = await extractAudioSegment(srcPath, extractedPath, "extract", element.mediaStart, element.end - element.start, element.playbackRate, effectiveSignal, config, audioSidecarUrl);
1127
1135
  if (!extractResult.success) {
1128
1136
  failures.push(extractResult.failure
1129
1137
  ? { ...extractResult.failure, elementId: element.id }
@@ -1157,24 +1165,14 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1157
1165
  }
1158
1166
  audioSrcPath = trimmedPath;
1159
1167
  }
1160
- // Apply the track's FX chain to the dry, trimmed audio, before volume
1161
- // automation is baked in: effects should see the raw signal, and the
1162
- // envelope belongs on their output. A missing or broken chain is fatal
1163
- // for the whole mix rather than a per-track warning — quietly rendering
1164
- // the dry signal ships a mix that sounds plausible and is wrong.
1168
+ // The FX chain runs on the dry trimmed audio, before the envelope. A broken chain fails the mix.
1165
1169
  const chain = element.fxChain ? parseAudioFxChain(element.fxChain) : undefined;
1166
1170
  const automation = element.automation
1167
1171
  ? resolveAutomation(parseAutomation(element.automation), chain)
1168
1172
  : null;
1169
- // Computed before the chain runs, not after: the FX pass bakes the
1170
- // envelope into its float output so the duck lands before the ±1 clamp
1171
- // in writeWav, instead of after it.
1172
- //
1173
- // A volume lane supersedes keyframes probed from the timeline: the two
1174
- // would fight, and the lane is the explicit one. `lint` warns when a
1175
- // track carries both.
1176
- //
1177
- // With clip gain ahead of the chain, the lane carries its share of the capped product.
1173
+ // Computed before the chain so the FX pass bakes the envelope in ahead of the ±1 clamp.
1174
+ // A volume lane supersedes probed keyframes. With clip gain ahead of the chain, the lane
1175
+ // carries its share of the capped product.
1178
1176
  const gainBeforeFx = chain !== undefined && enabledAudioFxNodes(chain).length > 0;
1179
1177
  const laneKeyframes = automation
1180
1178
  ? volumeLaneKeyframes(automation, element.start, element.end - element.start, element.volume ?? 1, gainBeforeFx)
@@ -1191,11 +1189,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1191
1189
  let bakedEnvelope = false;
1192
1190
  let tailSeconds = 0;
1193
1191
  if (chain) {
1194
- // The chain is serialised into the attribute, the same way colour
1195
- // grading carries its config, so there is no side-car file to find,
1196
- // resolve or lose.
1197
- // The rendered WAV is longer than the input by exactly this much, so
1198
- // the mix has to be told to let it through.
1192
+ // The rendered WAV is longer than the input by the chain's tail; the mix lets it through.
1199
1193
  tailSeconds = chainTailSeconds(chain, automation ?? undefined);
1200
1194
  const fxResult = await applyAudioFxChain(audioSrcPath, chain, join(workDir, `${element.id}-fx.wav`), {
1201
1195
  trackId: element.id,
@@ -1207,11 +1201,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1207
1201
  audioSrcPath = fxResult.path;
1208
1202
  bakedEnvelope = fxResult.envelopeBaked;
1209
1203
  }
1210
- // Primary volume-automation path for a track the FX pass did not bake:
1211
- // multiply the envelope into the PCM samples (sample-accurate, no
1212
- // keyframe ceiling). If the WAV isn't the expected 16-bit PCM, fall
1213
- // back to the ffmpeg expression path by leaving the keyframes on the
1214
- // track for buildVolumeExpression to handle.
1204
+ // Bake the envelope into the PCM samples; a non-16-bit WAV falls back to the ffmpeg expression.
1215
1205
  if (envelope && !bakedEnvelope) {
1216
1206
  bakedEnvelope = applyVolumeEnvelopeToWav(audioSrcPath, envelope.keyframes, envelope.trackStart, envelope.baseVolume);
1217
1207
  }
@@ -1247,7 +1237,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1247
1237
  detail: boundedDetail(`Audio processing failed for element ${element.id}: ${err instanceof Error ? err.message : String(err)}`),
1248
1238
  });
1249
1239
  }
1250
- })).catch((err) => {
1240
+ }, effectiveSignal))).catch((err) => {
1251
1241
  // Promise.all rejected on the first fatal failure without waiting for its
1252
1242
  // siblings; abort them so their ffmpeg stops before workDir disappears.
1253
1243
  internalController.abort();
@@ -244,7 +244,6 @@ const BEGINFRAME_ONLY_FLAGS = new Set([
244
244
  function stripBeginFrameFlags(args) {
245
245
  return args.filter((a) => !BEGINFRAME_ONLY_FLAGS.has(a));
246
246
  }
247
- const BEGINFRAME_SCREENSHOT_PROBE_ATTEMPTS = 10;
248
247
  const BEGINFRAME_PROBE_TIMEOUT_MS = 2000;
249
248
  const BEGINFRAME_PROBE_CLEANUP_TIMEOUT_MS = 250;
250
249
  async function awaitBeforeDeadline(operation, deadline, label) {
@@ -325,7 +324,8 @@ async function probeBeginFrameSupport(browser, timeoutMs = BEGINFRAME_PROBE_TIME
325
324
  let bytes = Buffer.alloc(0);
326
325
  let isPng = false;
327
326
  let attempts = 0;
328
- for (attempts = 1; attempts <= BEGINFRAME_SCREENSHOT_PROBE_ATTEMPTS; attempts += 1) {
327
+ while (!isPng && Date.now() < deadline) {
328
+ attempts += 1;
329
329
  const response = await awaitBeforeDeadline(client.send("HeadlessExperimental.beginFrame", {
330
330
  frameTimeTicks: 1000 + (attempts - 1) * 33,
331
331
  interval: 33,
@@ -339,13 +339,13 @@ async function probeBeginFrameSupport(browser, timeoutMs = BEGINFRAME_PROBE_TIME
339
339
  bytes[1] === 0x50 &&
340
340
  bytes[2] === 0x4e &&
341
341
  bytes[3] === 0x47;
342
- if (isPng)
342
+ if (isPng || Date.now() + 10 >= deadline)
343
343
  break;
344
344
  await awaitBeforeDeadline(new Promise((resolveDelay) => setTimeout(resolveDelay, 10)), deadline, `screenshot retry delay ${attempts}`);
345
345
  }
346
346
  if (!isPng) {
347
347
  throw new Error(`beginFrame screenshot returned ${bytes.length} bytes after ` +
348
- `${BEGINFRAME_SCREENSHOT_PROBE_ATTEMPTS} attempts with signature ` +
348
+ `${attempts} attempts in ${Date.now() - started}ms with signature ` +
349
349
  `${bytes.length >= 4 ? bytes.subarray(0, 4).toString("hex") : "<empty>"}`);
350
350
  }
351
351
  await awaitBeforeDeadline(client.detach(), deadline, "CDP detach").catch(() => { });
@@ -934,6 +934,7 @@ export async function produceDrawElementFrameBatch(page, times, width, height, q
934
934
  try {
935
935
  if (aw.__hf && typeof aw.__hf.seek === "function")
936
936
  aw.__hf.seek(t);
937
+ await aw.__hfWaitForSeekCompletion?.();
937
938
  await aw.__hfDecodeDynamicCssBackgroundImages?.();
938
939
  aw.__hf3d?.update();
939
940
  const accel = (aw.__hf_accel_canvases ?? []).filter((c) => root.contains(c));
@@ -98,6 +98,7 @@ export interface CaptureSession {
98
98
  * our console output.
99
99
  */
100
100
  pendingTimelineIds?: string[];
101
+ isolatedCompositions?: IsolatedComposition[];
101
102
  /** The render's shared memo when the caller passed one, else this session's own. */
102
103
  subTimelineWaitMemo: SubTimelineWaitMemo;
103
104
  /** Structured readiness warnings surfaced to the producer's render policy. */
@@ -164,6 +165,12 @@ export interface CaptureSession {
164
165
  * drawElement intermittently returns an anomalously small (blank) frame with no
165
166
  * throw; a frame far below the running median is re-captured via screenshot. */
166
167
  deFrameSizes?: number[];
168
+ /** Absolute floor of the serial drawElement size guard. Starts at 20000 bytes and drops to
169
+ * half of a small frame that redraws byte-identically. */
170
+ deAbsFloor?: number;
171
+ /** The last small drawElement frame that redrew byte-identically; an equal frame is kept
172
+ * without a redraw. */
173
+ deAcceptedSmall?: Buffer;
167
174
  /** Last non-deduped encode result, reused for a static frame on the drawElement
168
175
  * worker-encode path (mirrors `lastFrameBuffer` on the screenshot path). Only set
169
176
  * when static-frame dedup is armed on the drawElement path. */
@@ -404,16 +411,25 @@ export interface HfDiagnostic {
404
411
  }
405
412
  export declare function buildZeroDurationDiagnostic(diag: HfDiagnostic): string;
406
413
  export declare function pollHfReady(page: Page, timeoutMs: number, intervalMs?: number): Promise<void>;
407
- export declare function waitForSubCompositionTimelines(session: CaptureSession, page: Page, timeoutMs: number): Promise<void>;
414
+ export declare function waitForSubCompositionTimelines(session: CaptureSession, page: Page, timeoutMs: number, options?: {
415
+ scriptErrorGraceMs?: number;
416
+ }): Promise<void>;
417
+ export interface IsolatedComposition {
418
+ compositionId: string;
419
+ elementId: string | null;
420
+ }
408
421
  export interface SubTimelinePollOptions {
409
422
  intervalMs?: number;
410
423
  getScriptLoadFailures?: () => readonly string[];
411
424
  scriptFailureGraceMs?: number;
425
+ getPageErrors?: () => readonly string[];
426
+ scriptErrorGraceMs?: number;
412
427
  onPending?: (ids: readonly string[]) => void;
413
428
  shouldStop?: () => boolean;
414
429
  knownUnregisteredIds?: readonly string[];
415
430
  }
416
- export declare function pollSubCompositionTimelines(page: Page, timeoutMs: number, { intervalMs, getScriptLoadFailures, scriptFailureGraceMs, onPending, shouldStop, knownUnregisteredIds, }?: SubTimelinePollOptions): Promise<SubTimelineWaitOutcome>;
431
+ export declare function pollSubCompositionTimelines(page: Page, timeoutMs: number, { intervalMs, getScriptLoadFailures, scriptFailureGraceMs, getPageErrors, scriptErrorGraceMs, onPending, shouldStop, knownUnregisteredIds, }?: SubTimelinePollOptions): Promise<SubTimelineWaitOutcome>;
432
+ export declare function pollVideosReady(page: Page, skipIds: readonly string[], timeoutMs: number, intervalMs?: number): Promise<boolean>;
417
433
  /** @internal exported for unit testing only */
418
434
  export declare function pollImagesReady(page: Page, timeoutMs: number, intervalMs?: number): Promise<boolean>;
419
435
  export declare function waitForImagesReady(page: Page, timeoutMs: number, onUnloaded: (sources: string) => void): Promise<boolean>;
@@ -9,7 +9,8 @@
9
9
  */
10
10
  import { existsSync, mkdirSync, writeFileSync } from "fs";
11
11
  import { join } from "path";
12
- import { COMPOSITION_SOURCE_URL, quantizeSeekTime, quantizeTimeToFrame, fpsToNumber, resolveAuthoredTimingWindow, } from "@hyperframes/core";
12
+ import { COMPOSITION_SOURCE_URL, MEDIA_RENDER_ID_ATTR, quantizeSeekTime, quantizeTimeToFrame, fpsToNumber, resolveAuthoredTimingWindow, } from "@hyperframes/core";
13
+ import { holdRenderFrameVideos } from "./renderFrameVideoHold.js";
13
14
  import { DrawElementCaptureError } from "./drawElementCaptureError.js";
14
15
  import { encodePng } from "../utils/alphaBlit.js";
15
16
  import { DEFAULT_SAMPLES_PER_FRAME, MotionBlurAccumulator, SPATIAL_TWEEN_PROPERTIES, adaptiveSampleCount, motionBlurProbeTimes, motionBlurSampleTimes, motionBlurWindowIsStatic, probeDiffMagnitude, resolveMotionBlurPlan, } from "./motionBlur.js";
@@ -902,6 +903,9 @@ async function constructCaptureSession(input) {
902
903
  // Media elements are addressed by a document-unique render id, not by the
903
904
  // per-file element id. Install the resolvers before any page script runs.
904
905
  await installMediaRenderIdBridge(page);
906
+ if (options.skipReadinessVideoIds?.length) {
907
+ await page.evaluateOnNewDocument(holdRenderFrameVideos, options.skipReadinessVideoIds, MEDIA_RENDER_ID_ATTR);
908
+ }
905
909
  // Fast capture: record accelerated canvases (webgl/webgl2/webgpu) and force
906
910
  // preserveDrawingBuffer before any page script can create a context — their
907
911
  // paint records freeze at the first frame, so captureDrawElementFrame
@@ -1198,16 +1202,61 @@ async function listUnregisteredTimelineIds(page) {
1198
1202
  // Builds a warning, so it must never throw: normalise a loosely typed result.
1199
1203
  return Array.isArray(evaluated) ? evaluated.map((id) => String(id)) : [];
1200
1204
  }
1201
- export async function waitForSubCompositionTimelines(session, page, timeoutMs) {
1205
+ function isScriptFailure(session) {
1206
+ const outcome = session.subTimelineWaitOutcome;
1207
+ return outcome === "script_failure" || (outcome === "timeout" && session.pageErrors.length > 0);
1208
+ }
1209
+ /** A nested composition whose script threw gets an empty timeline, but only while another nested timeline is live. */
1210
+ async function isolateUnregisteredCompositions(page) {
1211
+ const isolated = await page.evaluate(`(function() {
1212
+ var timelines = (window.__timelines = window.__timelines || {});
1213
+ var out = [];
1214
+ if (!window.gsap) return out;
1215
+ var broken = [];
1216
+ var live = 0;
1217
+ var hosts = document.querySelectorAll("[data-composition-id]");
1218
+ for (var i = 0; i < hosts.length; i++) {
1219
+ var host = hosts[i];
1220
+ var id = host.getAttribute("data-composition-id");
1221
+ if (!id || host.hasAttribute("data-no-timeline")) continue;
1222
+ var isRoot = !host.parentElement || !host.parentElement.closest("[data-composition-id]");
1223
+ if (timelines[id]) {
1224
+ if (!isRoot) live++;
1225
+ } else if (isRoot) {
1226
+ return out;
1227
+ } else {
1228
+ broken.push(host);
1229
+ }
1230
+ }
1231
+ if (live === 0) return out;
1232
+ broken.forEach(function(host) {
1233
+ var tl = window.gsap.timeline({ paused: true });
1234
+ var duration = parseFloat(host.getAttribute("data-duration"));
1235
+ if (duration > 0) tl.set({}, {}, duration);
1236
+ var id = host.getAttribute("data-composition-id");
1237
+ timelines[id] = tl;
1238
+ out.push({ compositionId: id, elementId: host.id || host.getAttribute("data-hf-id") || null });
1239
+ });
1240
+ if (out.length && typeof window.__hfForceTimelineRebind === "function") window.__hfForceTimelineRebind();
1241
+ return out;
1242
+ })()`);
1243
+ return Array.isArray(isolated) ? isolated : [];
1244
+ }
1245
+ export async function waitForSubCompositionTimelines(session, page, timeoutMs, options = {}) {
1202
1246
  const memo = session.subTimelineWaitMemo;
1203
1247
  session.subTimelineWaitOutcome = await pollSubCompositionTimelines(page, timeoutMs, {
1204
1248
  getScriptLoadFailures: () => session.scriptLoadFailures,
1249
+ getPageErrors: () => session.pageErrors,
1250
+ scriptErrorGraceMs: options.scriptErrorGraceMs,
1205
1251
  onPending: (ids) => {
1206
1252
  session.pendingTimelineIds = [...ids];
1207
1253
  },
1208
1254
  shouldStop: () => session.vfxFailure !== undefined,
1209
1255
  knownUnregisteredIds: memo.unregisteredIds,
1210
1256
  });
1257
+ if (isScriptFailure(session) && session.scriptLoadFailures.length === 0) {
1258
+ session.isolatedCompositions = await isolateUnregisteredCompositions(page);
1259
+ }
1211
1260
  // Only a clean full-length timeout is replayed: a VFX stop ends early, and page or script
1212
1261
  // errors must reach each session so it can still classify the wait as a script failure.
1213
1262
  const cleanTimeout = session.subTimelineWaitOutcome === "timeout" &&
@@ -1218,7 +1267,7 @@ export async function waitForSubCompositionTimelines(session, page, timeoutMs) {
1218
1267
  memo.unregisteredIds = session.pendingTimelineIds ?? [];
1219
1268
  }
1220
1269
  }
1221
- export async function pollSubCompositionTimelines(page, timeoutMs, { intervalMs = 150, getScriptLoadFailures, scriptFailureGraceMs = 2_000, onPending, shouldStop, knownUnregisteredIds = [], } = {}) {
1270
+ export async function pollSubCompositionTimelines(page, timeoutMs, { intervalMs = 150, getScriptLoadFailures, scriptFailureGraceMs = 2_000, getPageErrors, scriptErrorGraceMs = 5_000, onPending, shouldStop, knownUnregisteredIds = [], } = {}) {
1222
1271
  // Hosts may opt out of the timeline wait with `data-no-timeline` —
1223
1272
  // compositions driven purely by CSS animations / rAF (the render-compat
1224
1273
  // contract) never register window.__timelines[id], and without the opt-out
@@ -1240,6 +1289,8 @@ export async function pollSubCompositionTimelines(page, timeoutMs, { intervalMs
1240
1289
  const deadline = start + timeoutMs;
1241
1290
  let ready = false;
1242
1291
  let scriptFailureBail = false;
1292
+ // A page error is given its grace from when it first appeared, not from when the poll began.
1293
+ let errorSeenAt;
1243
1294
  for (;;) {
1244
1295
  ready = Boolean(await page.evaluate(expression));
1245
1296
  if (ready || shouldStop?.())
@@ -1248,12 +1299,18 @@ export async function pollSubCompositionTimelines(page, timeoutMs, { intervalMs
1248
1299
  if (now >= deadline)
1249
1300
  break;
1250
1301
  const failures = getScriptLoadFailures?.() ?? [];
1251
- if (failures.length > 0 && now - start >= scriptFailureGraceMs) {
1302
+ const errors = getPageErrors?.() ?? [];
1303
+ const failed = failures.length > 0 ? failures : errors;
1304
+ const graceMs = failures.length > 0 ? scriptFailureGraceMs : scriptErrorGraceMs;
1305
+ if (failed.length > 0)
1306
+ errorSeenAt ??= now;
1307
+ const sinceFailure = failures.length > 0 ? now - start : now - (errorSeenAt ?? now);
1308
+ if (failed.length > 0 && sinceFailure >= graceMs) {
1252
1309
  scriptFailureBail = true;
1253
1310
  console.warn(`[FrameCapture] Sub-composition timeline wait cut short after ${now - start}ms: ` +
1254
- `script resource(s) failed to load (${failures.join(", ")}), so ` +
1311
+ `script(s) failed (${failed.join(", ")}), so ` +
1255
1312
  `the timeline registration they carry can never arrive. ` +
1256
- `Fix the script reference; the render proceeds without those animations.`);
1313
+ `Fix the script.`);
1257
1314
  break;
1258
1315
  }
1259
1316
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
@@ -1290,11 +1347,13 @@ export async function pollSubCompositionTimelines(page, timeoutMs, { intervalMs
1290
1347
  }
1291
1348
  return scriptFailureBail ? "script_failure" : "timeout";
1292
1349
  }
1293
- async function pollVideosReady(page, skipIds, timeoutMs, intervalMs = 100) {
1350
+ export async function pollVideosReady(page, skipIds, timeoutMs, intervalMs = 100) {
1294
1351
  const check = async () => {
1295
1352
  return Boolean(await page.evaluate((skipIdList) => {
1296
1353
  const skip = new Set(skipIdList);
1297
- const vids = Array.from(document.querySelectorAll("video")).filter((v) => !skip.has(v.id));
1354
+ const vids = Array.from(document.querySelectorAll("video"))
1355
+ .filter((v) => !skip.has(v.id))
1356
+ .filter((v) => !v.closest("[data-hidden]"));
1298
1357
  return (vids.length === 0 ||
1299
1358
  vids.every((v) => {
1300
1359
  const ve = v;
@@ -1445,16 +1504,33 @@ export function recordSubTimelineWarning(session, timeoutMs) {
1445
1504
  const outcome = session.subTimelineWaitOutcome;
1446
1505
  if (outcome === "ready" || !outcome)
1447
1506
  return;
1448
- const threwThenTimedOut = outcome === "timeout" && session.pageErrors.length > 0;
1449
- const scriptFailure = outcome === "script_failure" || threwThenTimedOut;
1507
+ const threwThenTimedOut = session.pageErrors.length > 0 && session.scriptLoadFailures.length === 0;
1508
+ const scriptFailure = isScriptFailure(session);
1450
1509
  const sources = [...session.scriptLoadFailures, ...session.pageErrors];
1451
1510
  const pending = session.pendingTimelineIds ?? [];
1452
1511
  const pendingSuffix = pending.length > 0 ? ` (still unregistered: ${pending.join(", ")})` : "";
1512
+ const isolated = session.isolatedCompositions ?? [];
1513
+ if (isolated.length > 0) {
1514
+ recordCaptureWarnings(session, [
1515
+ {
1516
+ code: "script_error_isolated",
1517
+ message: `A composition script failed, so its animation was skipped and the render went on: ` +
1518
+ `${isolated.map((c) => c.elementId ?? c.compositionId).join(", ")} (${sources.join(", ")}).`,
1519
+ details: {
1520
+ sources,
1521
+ pendingCompositionIds: [...pending],
1522
+ isolatedElements: isolated,
1523
+ failureOwner: "user",
1524
+ },
1525
+ },
1526
+ ]);
1527
+ return;
1528
+ }
1453
1529
  recordCaptureWarnings(session, [
1454
1530
  {
1455
1531
  code: scriptFailure ? "sub_timeline_script_failure" : "sub_timeline_readiness_timeout",
1456
1532
  message: threwThenTimedOut
1457
- ? `A composition script threw and a timeline did not register within ${timeoutMs}ms` +
1533
+ ? `A composition script threw before its timeline registered` +
1458
1534
  `${pendingSuffix} (${sources.join(", ")}). Fix the error; a composition animated by ` +
1459
1535
  `CSS or rAF rather than a GSAP timeline must mark its host with data-no-timeline.`
1460
1536
  : scriptFailure
@@ -1798,6 +1874,7 @@ export async function initializeSession(session) {
1798
1874
  const skip = new Set(skipIdList);
1799
1875
  return Array.from(document.querySelectorAll("video"))
1800
1876
  .filter((v) => !skip.has(v.id))
1877
+ .filter((v) => !v.closest("[data-hidden]"))
1801
1878
  .filter((v) => v.readyState < 2 && !v.error)
1802
1879
  .map((v) => v.src || v.getAttribute("src") || "(no src)")
1803
1880
  .join(", ");
@@ -1910,6 +1987,7 @@ export async function initializeSession(session) {
1910
1987
  const skip = new Set(skipIdList);
1911
1988
  return Array.from(document.querySelectorAll("video"))
1912
1989
  .filter((v) => !skip.has(v.id))
1990
+ .filter((v) => !v.closest("[data-hidden]"))
1913
1991
  .filter((v) => v.readyState < 2 && !v.error)
1914
1992
  .map((v) => v.src || v.getAttribute("src") || "(no src)")
1915
1993
  .join(", ");
@@ -2921,6 +2999,50 @@ export function resolveSessionMotionBlur(session) {
2921
2999
  }
2922
3000
  return plan;
2923
3001
  }
3002
+ /**
3003
+ * Size guard for a serial drawElement frame. A frame far below the rolling median may be a
3004
+ * dropped paint record, which is intermittent, so the frame is drawn once more: a byte-identical
3005
+ * redraw is real content (a flat or dark frame) and is kept, as the streaming drain guard in the
3006
+ * producer keeps it. Segmented capture reaches only this guard, so it must judge a small frame
3007
+ * the same way, or a render that passes in one pass fails when segmented (GH #5685). A redraw
3008
+ * that differs and is still small throws, and the caller restarts on a fresh screenshot page.
3009
+ */
3010
+ async function judgeSmallDrawElementFrame(session, frameIndex, buffer, redraw) {
3011
+ const sizes = (session.deFrameSizes ??= []);
3012
+ const sorted = sizes.length >= 12 ? [...sizes].sort((a, b) => a - b) : null;
3013
+ const median = sorted ? (sorted[sorted.length >> 1] ?? 0) : 0;
3014
+ const floor = Math.max(session.deAbsFloor ?? 20000, median * 0.12);
3015
+ let kept = buffer;
3016
+ if (buffer.length < floor && !session.deAcceptedSmall?.equals(buffer)) {
3017
+ const suspect = () => new DrawElementCaptureError(frameIndex, `suspect small frame (${buffer.length}B < ${Math.round(floor)}B)`);
3018
+ // Under BeginFrame control the page paints only on the frame already sent for this index,
3019
+ // so a second draw reads the same snapshot and cannot tell a drop from real content.
3020
+ if (session.beginFrameTimeTicks > 0)
3021
+ throw suspect();
3022
+ const retry = await redraw();
3023
+ if (retry.equals(buffer)) {
3024
+ // Halving the floor keeps a run of similar flat frames from redrawing every frame.
3025
+ session.deAbsFloor = Math.min(session.deAbsFloor ?? 20000, Math.floor(buffer.length / 2));
3026
+ session.deAcceptedSmall = buffer;
3027
+ }
3028
+ else if (retry.length < floor) {
3029
+ throw suspect();
3030
+ }
3031
+ else {
3032
+ kept = retry;
3033
+ }
3034
+ }
3035
+ if (sizes.length >= 60)
3036
+ sizes.shift();
3037
+ sizes.push(kept.length);
3038
+ return kept;
3039
+ }
3040
+ /** Seek to `time` again and draw the frame once more, for {@link judgeSmallDrawElementFrame}. */
3041
+ async function redrawDrawElementFrame(session, frameIndex, time, seekOptions, heldVideoTime) {
3042
+ const { page, options } = session;
3043
+ await prepareFrameForCapture(session, frameIndex, time, seekOptions, heldVideoTime);
3044
+ return captureDrawElementFrame(page, options.width, options.height, options.format ?? "jpeg", options.quality ?? 80, true);
3045
+ }
2924
3046
  /** Seek to `time` and capture one surface with the session's capture mode. */
2925
3047
  async function captureFrameSurface(session, frameIndex, time, seekOptions, heldVideoTime) {
2926
3048
  const { page, options } = session;
@@ -2972,20 +3094,8 @@ async function captureFrameSurface(session, frameIndex, time, seekOptions, heldV
2972
3094
  session.beginFrameTimeTicks === 0);
2973
3095
  // A tiny JPEG may be a dropped paint record. Never screenshot this page:
2974
3096
  // its injected canvas can still hold the preceding frame's bitmap.
2975
- // Restart on a fresh screenshot page even if this was a simple valid frame.
2976
3097
  if ((options.format ?? "jpeg") !== "png" && process.env.HF_FORCE_DRAWELEMENT !== "1") {
2977
- const sizes = (session.deFrameSizes ??= []);
2978
- const sorted = sizes.length >= 12 ? [...sizes].sort((a, b) => a - b) : null;
2979
- const median = sorted ? (sorted[sorted.length >> 1] ?? 0) : 0;
2980
- const floor = Math.max(20000, median * 0.12);
2981
- if (screenshotBuffer.length < floor) {
2982
- throw new DrawElementCaptureError(frameIndex, `suspect small frame (${screenshotBuffer.length}B < ${Math.round(floor)}B)`);
2983
- }
2984
- else {
2985
- if (sizes.length >= 60)
2986
- sizes.shift();
2987
- sizes.push(screenshotBuffer.length);
2988
- }
3098
+ screenshotBuffer = await judgeSmallDrawElementFrame(session, frameIndex, screenshotBuffer, () => redrawDrawElementFrame(session, frameIndex, time, seekOptions, heldVideoTime));
2989
3099
  }
2990
3100
  }
2991
3101
  catch (err) {
@@ -33,15 +33,17 @@ async function readPageMotionBlur(page) {
33
33
  const got = typeof report === "function" ? report() : null;
34
34
  if (!got || typeof got !== "object")
35
35
  return null;
36
+ const tier = Reflect.get(got, "tier");
36
37
  return {
37
38
  state: String(Reflect.get(got, "state")),
38
39
  code: String(Reflect.get(got, "reasonCode")),
39
40
  movers: Number(Reflect.get(got, "movers")),
40
41
  failedFrames: Number(Reflect.get(got, "failedFrames")) || 0,
42
+ tier: typeof tier === "string" ? tier : null,
41
43
  };
42
44
  });
43
45
  }
44
- const formatPageMotionBlur = (r) => `[motion-blur] ${r.state} reasonCode=${r.code} movers=${r.movers} failedFrames=${r.failedFrames}`;
46
+ const formatPageMotionBlur = (r) => `[motion-blur] ${r.state} reasonCode=${r.code} movers=${r.movers} failedFrames=${r.failedFrames}${r.tier ? ` tier=${r.tier}` : ""}`;
45
47
  /** One line saying what the page's blur did for this session; null when the page never asked for blur. */
46
48
  export async function describePageMotionBlur(page) {
47
49
  const r = await readPageMotionBlur(page);
@@ -0,0 +1,2 @@
1
+ /** In-page, before any script: holds replaced videos at preload none from parse (Chrome ignores a later none). */
2
+ export declare function holdRenderFrameVideos(ids: readonly string[], renderIdAttr: string): void;
@@ -0,0 +1,19 @@
1
+ /** In-page, before any script: holds replaced videos at preload none from parse (Chrome ignores a later none). */
2
+ export function holdRenderFrameVideos(ids, renderIdAttr) {
3
+ const held = new Set(ids);
4
+ window.__HF_RENDER_FRAME_VIDEO_IDS = ids;
5
+ const hold = (el) => {
6
+ if (el instanceof HTMLVideoElement && held.has(el.getAttribute(renderIdAttr) || el.id))
7
+ el.preload = "none";
8
+ };
9
+ new MutationObserver((records) => {
10
+ for (const record of records)
11
+ for (const node of record.addedNodes) {
12
+ if (node.nodeType !== 1)
13
+ continue;
14
+ hold(node);
15
+ for (const video of node.querySelectorAll("video"))
16
+ hold(video);
17
+ }
18
+ }).observe(document, { childList: true, subtree: true });
19
+ }
package/dist/types.d.ts CHANGED
@@ -22,7 +22,7 @@ export type SubTimelineWaitOutcome = "ready" | "timeout" | "script_failure";
22
22
  export interface SubTimelineWaitMemo {
23
23
  unregisteredIds?: readonly string[];
24
24
  }
25
- export type CaptureWarningCode = "media_readiness_timeout" | "media_load_failed" | "audio_processing_failed" | "sub_timeline_readiness_timeout" | "sub_timeline_script_failure" | "vfx_failure" | "live_map_detected";
25
+ export type CaptureWarningCode = "media_readiness_timeout" | "media_load_failed" | "audio_processing_failed" | "sub_timeline_readiness_timeout" | "sub_timeline_script_failure" | "script_error_isolated" | "vfx_failure" | "live_map_detected";
26
26
  /** Structured correctness warning produced while preparing a capture session. */
27
27
  export interface CaptureWarning {
28
28
  code: CaptureWarningCode;
@@ -31,6 +31,10 @@ export interface CaptureWarning {
31
31
  mediaType?: "image" | "video" | "audio";
32
32
  sources?: string[];
33
33
  pendingCompositionIds?: string[];
34
+ isolatedElements?: Array<{
35
+ compositionId: string;
36
+ elementId: string | null;
37
+ }>;
34
38
  timeoutMs?: number;
35
39
  audioFailures?: AudioFailureDiagnostic[];
36
40
  failureReasons?: string[];
@@ -51,6 +51,8 @@ export interface AudioMetadata {
51
51
  bitrate?: number;
52
52
  /** Seconds after the file's media time 0 at which its last audio or video stream starts. */
53
53
  latestStreamLeadSeconds?: number;
54
+ audioStreamLeadSeconds?: number;
55
+ audioStreamCount?: number;
54
56
  }
55
57
  interface FFProbeStream {
56
58
  codec_type: string;
@@ -774,6 +774,8 @@ export async function extractAudioMetadata(filePath, options) {
774
774
  audioCodec,
775
775
  bitrate: output.format.bit_rate ? parseInt(output.format.bit_rate) : undefined,
776
776
  latestStreamLeadSeconds: latestStreamLeadSeconds(output),
777
+ audioStreamLeadSeconds: streamLeadSeconds(output, audioStream),
778
+ audioStreamCount: output.streams.filter((s) => s.codec_type === "audio").length,
777
779
  };
778
780
  })();
779
781
  if (options?.signal)
@@ -858,8 +860,7 @@ async function probeDecodedAudioDuration(filePath, sampleRate, startSeconds, con
858
860
  return lastFrame.timestamp + lastFrame.nbSamples / sampleRate - startSeconds;
859
861
  }
860
862
  const keyframeCache = new Map();
861
- /** Intervals beyond this cause seeking issues in the headless renderer and
862
- * audio/video desync — the sole threshold for `isProblematic` below. */
863
+ /** Advisory threshold for seeking cost; GOP length alone does not establish incorrect output. */
863
864
  const PROBLEMATIC_KEYFRAME_INTERVAL_SECONDS = 2;
864
865
  /**
865
866
  * Checks keyframe intervals in a video stream; intervals over the threshold below flag `isProblematic`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/engine",
3
- "version": "0.8.189",
3
+ "version": "0.8.191",
4
4
  "description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
5
5
  "repository": {
6
6
  "type": "git",
@@ -50,8 +50,8 @@
50
50
  "linkedom": "^0.18.12",
51
51
  "puppeteer": "^25.8.0",
52
52
  "puppeteer-core": "^25.8.0",
53
- "@hyperframes/core": "^0.8.189",
54
- "@hyperframes/parsers": "^0.8.189"
53
+ "@hyperframes/parsers": "^0.8.191",
54
+ "@hyperframes/core": "^0.8.191"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/node": "^25.0.10",