@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.
- package/dist/services/audioMixer.d.ts +1 -0
- package/dist/services/audioMixer.js +14 -6
- package/dist/services/drawElementService.js +1 -0
- package/dist/services/frameCapture.d.ts +18 -2
- package/dist/services/frameCapture.js +129 -23
- package/dist/types.d.ts +5 -1
- package/dist/utils/ffprobe.d.ts +2 -0
- package/dist/utils/ffprobe.js +3 -2
- package/package.json +3 -3
|
@@ -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
|
|
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(
|
|
534
|
-
...(inputSeek
|
|
537
|
+
...urlInputArgs(readFrom),
|
|
538
|
+
...(inputSeek
|
|
539
|
+
? ["-ss", String(mediaStart - warmUp), "-t", String(sourceDuration + warmUp)]
|
|
540
|
+
: []),
|
|
535
541
|
"-i",
|
|
536
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1311
|
+
`script(s) failed (${failed.join(", ")}), so ` +
|
|
1259
1312
|
`the timeline registration they carry can never arrive. ` +
|
|
1260
|
-
`Fix the script
|
|
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"))
|
|
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 =
|
|
1453
|
-
const scriptFailure =
|
|
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
|
|
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
|
-
|
|
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[];
|
package/dist/utils/ffprobe.d.ts
CHANGED
|
@@ -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;
|
package/dist/utils/ffprobe.js
CHANGED
|
@@ -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
|
-
/**
|
|
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.
|
|
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/
|
|
54
|
-
"@hyperframes/
|
|
53
|
+
"@hyperframes/parsers": "^0.8.191",
|
|
54
|
+
"@hyperframes/core": "^0.8.191"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/node": "^25.0.10",
|