@hyperframes/engine 0.8.190 → 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>;
@@ -503,9 +503,10 @@ export function parseAudioElements(html) {
503
503
  return elements;
504
504
  }
505
505
  const FAIL_ON_READ_ERROR_INSTEAD_OF_ENDING_THERE = ["-xerror"];
506
+ const SIDECAR_WARM_UP_SECONDS = 1;
506
507
  /** Decode `duration` s of audio from `mediaStart` in Chrome's media time. Until every stream has
507
508
  * started, an input seek snaps audio to the video's first keyframe, so read by timestamp instead. */
508
- 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) {
509
510
  const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
510
511
  const outputDir = dirname(outputPath);
511
512
  if (!existsSync(outputDir))
@@ -527,13 +528,18 @@ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, durat
527
528
  const timestampTrim = inputSeek
528
529
  ? null
529
530
  : `atrim=start=${start}:end=${formatFilterNumber(mediaStart + sourceDuration)},asetpts=PTS-${start}/TB,aresample=async=1:first_pts=0`;
530
- 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;
531
535
  const args = [
532
536
  ...(readsUrl ? FAIL_ON_READ_ERROR_INSTEAD_OF_ENDING_THERE : []),
533
- ...urlInputArgs(srcPath),
534
- ...(inputSeek ? ["-ss", String(mediaStart), "-t", String(sourceDuration)] : []),
537
+ ...urlInputArgs(readFrom),
538
+ ...(inputSeek
539
+ ? ["-ss", String(mediaStart - warmUp), "-t", String(sourceDuration + warmUp)]
540
+ : []),
535
541
  "-i",
536
- srcPath,
542
+ readFrom,
537
543
  ];
538
544
  args.push("-vn", "-acodec", "pcm_s16le", "-ar", "48000", ...outputArgs);
539
545
  if (normalizedPlaybackRate !== 1)
@@ -1062,11 +1068,13 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1062
1068
  try {
1063
1069
  let srcPath = element.src;
1064
1070
  let readInPlace = false;
1071
+ let audioSidecarUrl;
1065
1072
  if (!isHttpUrl(srcPath)) {
1066
1073
  // Same browser-URL path semantics as videos.
1067
1074
  srcPath = resolveProjectRelativeSrc(element.src, baseDir, compiledDir);
1068
1075
  const remoteUrl = config?.urlByAbsentLocalPath?.get(srcPath);
1069
1076
  if (remoteUrl) {
1077
+ audioSidecarUrl = config?.audioSidecarUrlByLocalPath?.get(srcPath);
1070
1078
  srcPath = remoteUrl;
1071
1079
  readInPlace = true;
1072
1080
  }
@@ -1123,7 +1131,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
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 }
@@ -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>;
@@ -1202,16 +1202,61 @@ async function listUnregisteredTimelineIds(page) {
1202
1202
  // Builds a warning, so it must never throw: normalise a loosely typed result.
1203
1203
  return Array.isArray(evaluated) ? evaluated.map((id) => String(id)) : [];
1204
1204
  }
1205
- 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 = {}) {
1206
1246
  const memo = session.subTimelineWaitMemo;
1207
1247
  session.subTimelineWaitOutcome = await pollSubCompositionTimelines(page, timeoutMs, {
1208
1248
  getScriptLoadFailures: () => session.scriptLoadFailures,
1249
+ getPageErrors: () => session.pageErrors,
1250
+ scriptErrorGraceMs: options.scriptErrorGraceMs,
1209
1251
  onPending: (ids) => {
1210
1252
  session.pendingTimelineIds = [...ids];
1211
1253
  },
1212
1254
  shouldStop: () => session.vfxFailure !== undefined,
1213
1255
  knownUnregisteredIds: memo.unregisteredIds,
1214
1256
  });
1257
+ if (isScriptFailure(session) && session.scriptLoadFailures.length === 0) {
1258
+ session.isolatedCompositions = await isolateUnregisteredCompositions(page);
1259
+ }
1215
1260
  // Only a clean full-length timeout is replayed: a VFX stop ends early, and page or script
1216
1261
  // errors must reach each session so it can still classify the wait as a script failure.
1217
1262
  const cleanTimeout = session.subTimelineWaitOutcome === "timeout" &&
@@ -1222,7 +1267,7 @@ export async function waitForSubCompositionTimelines(session, page, timeoutMs) {
1222
1267
  memo.unregisteredIds = session.pendingTimelineIds ?? [];
1223
1268
  }
1224
1269
  }
1225
- 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 = [], } = {}) {
1226
1271
  // Hosts may opt out of the timeline wait with `data-no-timeline` —
1227
1272
  // compositions driven purely by CSS animations / rAF (the render-compat
1228
1273
  // contract) never register window.__timelines[id], and without the opt-out
@@ -1244,6 +1289,8 @@ export async function pollSubCompositionTimelines(page, timeoutMs, { intervalMs
1244
1289
  const deadline = start + timeoutMs;
1245
1290
  let ready = false;
1246
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;
1247
1294
  for (;;) {
1248
1295
  ready = Boolean(await page.evaluate(expression));
1249
1296
  if (ready || shouldStop?.())
@@ -1252,12 +1299,18 @@ export async function pollSubCompositionTimelines(page, timeoutMs, { intervalMs
1252
1299
  if (now >= deadline)
1253
1300
  break;
1254
1301
  const failures = getScriptLoadFailures?.() ?? [];
1255
- 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) {
1256
1309
  scriptFailureBail = true;
1257
1310
  console.warn(`[FrameCapture] Sub-composition timeline wait cut short after ${now - start}ms: ` +
1258
- `script resource(s) failed to load (${failures.join(", ")}), so ` +
1311
+ `script(s) failed (${failed.join(", ")}), so ` +
1259
1312
  `the timeline registration they carry can never arrive. ` +
1260
- `Fix the script reference; the render proceeds without those animations.`);
1313
+ `Fix the script.`);
1261
1314
  break;
1262
1315
  }
1263
1316
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
@@ -1294,11 +1347,13 @@ export async function pollSubCompositionTimelines(page, timeoutMs, { intervalMs
1294
1347
  }
1295
1348
  return scriptFailureBail ? "script_failure" : "timeout";
1296
1349
  }
1297
- async function pollVideosReady(page, skipIds, timeoutMs, intervalMs = 100) {
1350
+ export async function pollVideosReady(page, skipIds, timeoutMs, intervalMs = 100) {
1298
1351
  const check = async () => {
1299
1352
  return Boolean(await page.evaluate((skipIdList) => {
1300
1353
  const skip = new Set(skipIdList);
1301
- 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]"));
1302
1357
  return (vids.length === 0 ||
1303
1358
  vids.every((v) => {
1304
1359
  const ve = v;
@@ -1449,16 +1504,33 @@ export function recordSubTimelineWarning(session, timeoutMs) {
1449
1504
  const outcome = session.subTimelineWaitOutcome;
1450
1505
  if (outcome === "ready" || !outcome)
1451
1506
  return;
1452
- const threwThenTimedOut = outcome === "timeout" && session.pageErrors.length > 0;
1453
- const scriptFailure = outcome === "script_failure" || threwThenTimedOut;
1507
+ const threwThenTimedOut = session.pageErrors.length > 0 && session.scriptLoadFailures.length === 0;
1508
+ const scriptFailure = isScriptFailure(session);
1454
1509
  const sources = [...session.scriptLoadFailures, ...session.pageErrors];
1455
1510
  const pending = session.pendingTimelineIds ?? [];
1456
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
+ }
1457
1529
  recordCaptureWarnings(session, [
1458
1530
  {
1459
1531
  code: scriptFailure ? "sub_timeline_script_failure" : "sub_timeline_readiness_timeout",
1460
1532
  message: threwThenTimedOut
1461
- ? `A composition script threw and a timeline did not register within ${timeoutMs}ms` +
1533
+ ? `A composition script threw before its timeline registered` +
1462
1534
  `${pendingSuffix} (${sources.join(", ")}). Fix the error; a composition animated by ` +
1463
1535
  `CSS or rAF rather than a GSAP timeline must mark its host with data-no-timeline.`
1464
1536
  : scriptFailure
@@ -1802,6 +1874,7 @@ export async function initializeSession(session) {
1802
1874
  const skip = new Set(skipIdList);
1803
1875
  return Array.from(document.querySelectorAll("video"))
1804
1876
  .filter((v) => !skip.has(v.id))
1877
+ .filter((v) => !v.closest("[data-hidden]"))
1805
1878
  .filter((v) => v.readyState < 2 && !v.error)
1806
1879
  .map((v) => v.src || v.getAttribute("src") || "(no src)")
1807
1880
  .join(", ");
@@ -1914,6 +1987,7 @@ export async function initializeSession(session) {
1914
1987
  const skip = new Set(skipIdList);
1915
1988
  return Array.from(document.querySelectorAll("video"))
1916
1989
  .filter((v) => !skip.has(v.id))
1990
+ .filter((v) => !v.closest("[data-hidden]"))
1917
1991
  .filter((v) => v.readyState < 2 && !v.error)
1918
1992
  .map((v) => v.src || v.getAttribute("src") || "(no src)")
1919
1993
  .join(", ");
@@ -2925,6 +2999,50 @@ export function resolveSessionMotionBlur(session) {
2925
2999
  }
2926
3000
  return plan;
2927
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
+ }
2928
3046
  /** Seek to `time` and capture one surface with the session's capture mode. */
2929
3047
  async function captureFrameSurface(session, frameIndex, time, seekOptions, heldVideoTime) {
2930
3048
  const { page, options } = session;
@@ -2976,20 +3094,8 @@ async function captureFrameSurface(session, frameIndex, time, seekOptions, heldV
2976
3094
  session.beginFrameTimeTicks === 0);
2977
3095
  // A tiny JPEG may be a dropped paint record. Never screenshot this page:
2978
3096
  // its injected canvas can still hold the preceding frame's bitmap.
2979
- // Restart on a fresh screenshot page even if this was a simple valid frame.
2980
3097
  if ((options.format ?? "jpeg") !== "png" && process.env.HF_FORCE_DRAWELEMENT !== "1") {
2981
- const sizes = (session.deFrameSizes ??= []);
2982
- const sorted = sizes.length >= 12 ? [...sizes].sort((a, b) => a - b) : null;
2983
- const median = sorted ? (sorted[sorted.length >> 1] ?? 0) : 0;
2984
- const floor = Math.max(20000, median * 0.12);
2985
- if (screenshotBuffer.length < floor) {
2986
- throw new DrawElementCaptureError(frameIndex, `suspect small frame (${screenshotBuffer.length}B < ${Math.round(floor)}B)`);
2987
- }
2988
- else {
2989
- if (sizes.length >= 60)
2990
- sizes.shift();
2991
- sizes.push(screenshotBuffer.length);
2992
- }
3098
+ screenshotBuffer = await judgeSmallDrawElementFrame(session, frameIndex, screenshotBuffer, () => redrawDrawElementFrame(session, frameIndex, time, seekOptions, heldVideoTime));
2993
3099
  }
2994
3100
  }
2995
3101
  catch (err) {
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.190",
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.190",
54
- "@hyperframes/parsers": "^0.8.190"
53
+ "@hyperframes/parsers": "^0.8.191",
54
+ "@hyperframes/core": "^0.8.191"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/node": "^25.0.10",