@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.
- package/dist/services/audioMixer.d.ts +1 -0
- package/dist/services/audioMixer.js +29 -39
- package/dist/services/browserManager.js +4 -4
- package/dist/services/drawElementService.js +1 -0
- package/dist/services/frameCapture.d.ts +18 -2
- package/dist/services/frameCapture.js +134 -24
- package/dist/services/pageMotionBlur.js +3 -1
- package/dist/services/renderFrameVideoHold.d.ts +2 -0
- package/dist/services/renderFrameVideoHold.js +19 -0
- 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>;
|
|
@@ -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
|
|
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(
|
|
530
|
-
...(inputSeek
|
|
537
|
+
...urlInputArgs(readFrom),
|
|
538
|
+
...(inputSeek
|
|
539
|
+
? ["-ss", String(mediaStart - warmUp), "-t", String(sourceDuration + warmUp)]
|
|
540
|
+
: []),
|
|
531
541
|
"-i",
|
|
532
|
-
|
|
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(
|
|
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
|
-
//
|
|
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 +
|
|
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
|
-
//
|
|
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
|
|
1170
|
-
//
|
|
1171
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
`${
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1311
|
+
`script(s) failed (${failed.join(", ")}), so ` +
|
|
1255
1312
|
`the timeline registration they carry can never arrive. ` +
|
|
1256
|
-
`Fix the script
|
|
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"))
|
|
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 =
|
|
1449
|
-
const scriptFailure =
|
|
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
|
|
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
|
-
|
|
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,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[];
|
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",
|