@hyperframes/engine 0.8.208 → 0.8.209

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/index.d.ts CHANGED
@@ -73,8 +73,8 @@ export { aacSegmentCount, encodeAac } from "./services/aacEncode.js";
73
73
  export { ManagedChildProcess, type ManagedChildProcessOptions, type ManagedChildProcessOutcome, type ManagedProcessTerminationReason, } from "./utils/managedChildProcess.js";
74
74
  export { assertConfiguredFfmpegBinariesExist, getFfmpegBinary, getFfprobeBinary, FFMPEG_PATH_ENV, FFPROBE_PATH_ENV, } from "./utils/ffmpegBinaries.js";
75
75
  export { trackChildProcess, killTrackedProcesses } from "./utils/processTracker.js";
76
- export { renameWithRetry, renameWithRetryAsync } from "./utils/renameWithRetry.js";
77
- export { removeScratch, collectScratchLeftovers } from "./utils/removeScratch.js";
76
+ export { renameWithRetry, renameWithRetryAsync } from "@hyperframes/core/rename-retry";
77
+ export { removeScratch, collectScratchLeftovers } from "@hyperframes/core/remove-scratch";
78
78
  export { psnrDb, resolveDeVerifyMinDb } from "./utils/psnr.js";
79
79
  export { decodePng, decodePngToRgb48le, blitRgba8OverRgb48le, blitRgb48leRegion, blitRgb48leAffine, parseTransformMatrix, roundedRectAlpha, resampleRgb48leObjectFit, normalizeObjectFit, type ObjectFit, } from "./utils/alphaBlit.js";
80
80
  export { groupIntoLayers, type CompositeLayer } from "./utils/layerCompositor.js";
@@ -87,3 +87,4 @@ export { isHdrColorSpace, detectTransfer, getHdrEncoderColorParams, analyzeCompo
87
87
  export type { VideoColorSpace } from "./utils/ffprobe.js";
88
88
  export { renderProvenanceArgs, appendRenderProvenanceArgs, readRenderProvenance, PROVENANCE_RENDERER_TAG, PROVENANCE_VERSION_TAG, PROVENANCE_RENDERER_NAME, PROVENANCE_VERSION, type RenderProvenance, } from "./utils/renderProvenance.js";
89
89
  export { DrawElementCaptureError, isDrawElementCaptureError, } from "./services/drawElementCaptureError.js";
90
+ export { inAgentSandbox, isOneProcessOverPipe, machServiceDenied, oneProcessOverPipe, pipeLaunchClosedOnMac, sandboxDeniedRemediation, } from "./services/sandboxLaunch.js";
package/dist/index.js CHANGED
@@ -77,8 +77,8 @@ export { aacSegmentCount, encodeAac } from "./services/aacEncode.js";
77
77
  export { ManagedChildProcess, } from "./utils/managedChildProcess.js";
78
78
  export { assertConfiguredFfmpegBinariesExist, getFfmpegBinary, getFfprobeBinary, FFMPEG_PATH_ENV, FFPROBE_PATH_ENV, } from "./utils/ffmpegBinaries.js";
79
79
  export { trackChildProcess, killTrackedProcesses } from "./utils/processTracker.js";
80
- export { renameWithRetry, renameWithRetryAsync } from "./utils/renameWithRetry.js";
81
- export { removeScratch, collectScratchLeftovers } from "./utils/removeScratch.js";
80
+ export { renameWithRetry, renameWithRetryAsync } from "@hyperframes/core/rename-retry";
81
+ export { removeScratch, collectScratchLeftovers } from "@hyperframes/core/remove-scratch";
82
82
  // drawElement self-verify comparison — shared by the streaming drain
83
83
  // (producer) and the parallel disk-path verify (parallelCoordinator).
84
84
  export { psnrDb, resolveDeVerifyMinDb } from "./utils/psnr.js";
@@ -93,3 +93,4 @@ export { hideVideoElements, showVideoElements, queryVideoElementBounds, queryEle
93
93
  export { isHdrColorSpace, detectTransfer, getHdrEncoderColorParams, analyzeCompositionHdr, findHdrAutoPromotion, formatHdrAutoPromotionWarning, sanitizeHdrAutoPromotionAsset, DEFAULT_HDR10_MASTERING, HDR_AUTO_PROMOTION_PIPELINE, } from "./utils/hdr.js";
94
94
  export { renderProvenanceArgs, appendRenderProvenanceArgs, readRenderProvenance, PROVENANCE_RENDERER_TAG, PROVENANCE_VERSION_TAG, PROVENANCE_RENDERER_NAME, PROVENANCE_VERSION, } from "./utils/renderProvenance.js";
95
95
  export { DrawElementCaptureError, isDrawElementCaptureError, } from "./services/drawElementCaptureError.js";
96
+ export { inAgentSandbox, isOneProcessOverPipe, machServiceDenied, oneProcessOverPipe, pipeLaunchClosedOnMac, sandboxDeniedRemediation, } from "./services/sandboxLaunch.js";
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { execFile } from "node:child_process";
6
6
  import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
7
- import { removeScratch } from "../utils/removeScratch.js";
7
+ import { removeScratch } from "@hyperframes/core/remove-scratch";
8
8
  import { dirname, join } from "node:path";
9
9
  import { getFfmpegBinary } from "../utils/ffmpegBinaries.js";
10
10
  import { runFfmpeg } from "../utils/runFfmpeg.js";
@@ -18,8 +18,8 @@ import { pathToFileURL } from "node:url";
18
18
  import { getAudioFxRuntimeScript } from "@hyperframes/core/audio-fx-runtime";
19
19
  import { enabledAudioFxNodes } from "@hyperframes/core/audio-fx";
20
20
  import { serializeAutomation } from "@hyperframes/core/audio-automation";
21
- import { removeScratch } from "../utils/removeScratch.js";
22
- import { acquireBrowser } from "./browserManager.js";
21
+ import { removeScratch } from "@hyperframes/core/remove-scratch";
22
+ import { acquireBrowser, buildChromeArgs } from "./browserManager.js";
23
23
  import { createEnvelopeWalker } from "./audioVolumeEnvelope.js";
24
24
  import { riffChunks, wavFormatTag } from "./wavChunks.js";
25
25
  import { windowPrerollSeconds } from "./audioFxWarmup.js";
@@ -439,6 +439,9 @@ export async function applyAudioFxChain(inputWav, chain, outputWav, options) {
439
439
  }
440
440
  });
441
441
  }
442
+ /** Audio processing needs no GPU, so software mode; every other launch detail is the shared one. */
443
+ let fxBrowserArgs;
444
+ const fxChromeArgs = () => (fxBrowserArgs ??= buildChromeArgs({ width: 1280, height: 720, captureMode: "screenshot" }, { browserGpuMode: "software" }));
442
445
  /** Run `body` with a leased browser and a way to open pages on the runtime; both are released in the same try that takes them. */
443
446
  async function withFxBrowser(options, body) {
444
447
  const hostDir = mkdtempSync(join(tmpdir(), "hf-fx-host-"));
@@ -451,7 +454,7 @@ async function withFxBrowser(options, body) {
451
454
  if (options.signal?.aborted) {
452
455
  throw new AudioFxRenderError(`Audio FX cancelled for track ${options.trackId}`);
453
456
  }
454
- lease = await acquireBrowser(["--no-sandbox", "--autoplay-policy=no-user-gesture-required"]);
457
+ lease = await acquireBrowser(fxChromeArgs());
455
458
  const { browser } = lease;
456
459
  return await body(() => openFxPage(browser, hostDir));
457
460
  }
@@ -1,8 +1,8 @@
1
1
  // fallow-ignore-file complexity code-duplication
2
2
  import { isSelfOrAncestorHidden, memberGroupKey, isMemberGroupHidden } from "./mediaHidden.js";
3
3
  import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, rmSync, writeFileSync } from "fs";
4
- import { renameWithRetry } from "../utils/renameWithRetry.js";
5
- import { removeScratch } from "../utils/removeScratch.js";
4
+ import { renameWithRetry } from "@hyperframes/core/rename-retry";
5
+ import { removeScratch } from "@hyperframes/core/remove-scratch";
6
6
  import { join, dirname, isAbsolute, relative } from "path";
7
7
  import { parseHTML } from "linkedom";
8
8
  import { extractAudioMetadata } from "../utils/ffprobe.js";
@@ -78,11 +78,11 @@ function buildAtempoFilter(playbackRate) {
78
78
  const RAMP_SLICE_SECONDS = 0.25;
79
79
  const MAX_RAMP_SLICES = 240;
80
80
  /** Bake a rate lane as consecutive source slices, each stretched by its mean `sourceTimeAt` rate, keeping pitch. */
81
- function buildRampFilterComplex(lane, duration, headFilter, tailFilter) {
81
+ function buildRampFilterComplex(lane, duration, headFilter, tailFilter, input) {
82
82
  const slices = Math.min(MAX_RAMP_SLICES, Math.max(1, Math.ceil(duration / RAMP_SLICE_SECONDS)));
83
83
  const step = duration / slices;
84
84
  const split = Array.from({ length: slices }, (_, i) => `[s${i}]`).join("");
85
- const parts = [`[0:a]${headFilter ? `${headFilter},` : ""}asplit=${slices}${split}`];
85
+ const parts = [`[${input}]${headFilter ? `${headFilter},` : ""}asplit=${slices}${split}`];
86
86
  for (let i = 0; i < slices; i += 1) {
87
87
  const from = sourceTimeAt(lane, i * step);
88
88
  const to = sourceTimeAt(lane, (i + 1) * step);
@@ -103,17 +103,17 @@ function buildRampFilterComplex(lane, duration, headFilter, tailFilter) {
103
103
  parts.push(`[cat]${tailFilter}[out]`);
104
104
  return parts.join(";");
105
105
  }
106
- function preparedAudioOutputArgs(metadata, playbackRate, duration, headFilter) {
106
+ function preparedAudioOutputArgs(metadata, playbackRate, duration, headFilter, stream) {
107
107
  const channelFilter = metadata?.channels === 1 ? STEREO_CHANNEL_FILTER : null;
108
108
  const outputArgs = channelFilter ? [] : ["-ac", "2"];
109
109
  if (typeof playbackRate === "object") {
110
- const graph = buildRampFilterComplex(playbackRate, duration, headFilter, channelFilter);
110
+ const graph = buildRampFilterComplex(playbackRate, duration, headFilter, channelFilter, stream ?? "0:a");
111
111
  return ["-filter_complex", graph, "-map", "[out]", ...outputArgs];
112
112
  }
113
113
  const filters = [headFilter, channelFilter, buildAtempoFilter(playbackRate)].filter(Boolean);
114
114
  if (filters.length > 0)
115
115
  outputArgs.push("-af", filters.join(","));
116
- return outputArgs;
116
+ return [...(stream ? ["-map", stream] : []), ...outputArgs];
117
117
  }
118
118
  function escapeExpressionCommas(expression) {
119
119
  return expression.replace(/\\/g, "\\\\").replace(/,/g, "\\,");
@@ -545,7 +545,7 @@ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, durat
545
545
  const cycle = await extractAudioSegment(srcPath, cyclePath, stage, mediaStart, loopDuration, 1, signal, config, audioSidecarUrl, { padToDuration: true });
546
546
  if (!cycle.success)
547
547
  return { ...cycle, outputPath };
548
- const outputArgs = preparedAudioOutputArgs(metadata, normalizedPlaybackRate, duration, null);
548
+ const outputArgs = preparedAudioOutputArgs(metadata, normalizedPlaybackRate, duration, null, null);
549
549
  const result = await runFfmpeg([
550
550
  "-stream_loop",
551
551
  "-1",
@@ -591,7 +591,7 @@ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, durat
591
591
  ...(options?.padToDuration ? ["apad"] : []),
592
592
  ]
593
593
  .filter(Boolean)
594
- .join(",") || null);
594
+ .join(",") || null, metadata ? `0:a:${metadata.audioStream}` : null);
595
595
  const readFrom = fromSidecar ? audioSidecarUrl : srcPath;
596
596
  const args = [
597
597
  ...(readsUrl ? FAIL_ON_READ_ERROR_INSTEAD_OF_ENDING_THERE : []),
@@ -15,7 +15,7 @@
15
15
  * the caller can fall back to the expression path rather than corrupting audio.
16
16
  */
17
17
  import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
18
- import { renameWithRetry } from "../utils/renameWithRetry.js";
18
+ import { renameWithRetry } from "@hyperframes/core/rename-retry";
19
19
  import { dirname, join } from "path";
20
20
  import { normaliseEnvelope } from "@hyperframes/core/media-volume-envelope";
21
21
  import { riffChunks, wavFormatTag } from "./wavChunks.js";
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { explainChromeLaunchFailure } from "./chromeStartup.js";
9
9
  import { withHyperframesBrowserMarker } from "@hyperframes/core/browser-marker";
10
+ import { inAgentSandbox, machServiceDenied, oneProcessOverPipe, pipeLaunchClosedOnMac, sandboxDeniedRemediation, } from "./sandboxLaunch.js";
10
11
  import { scanHtmlOpeningTags } from "@hyperframes/parsers/html-attribute-spans";
11
12
  import { execSync } from "child_process";
12
13
  import { existsSync, readdirSync, statSync } from "fs";
@@ -666,14 +667,27 @@ export async function acquireBrowser(chromeArgs, config) {
666
667
  const enablePool = config?.enableBrowserPool ?? DEFAULT_CONFIG.enableBrowserPool;
667
668
  return browserLeasePool.acquire(createBrowserLaunchFingerprint(chromeArgs, config), enablePool);
668
669
  }
670
+ /** An agent's macOS seatbelt allows one launch shape only: Chrome in one process over a pipe. */
671
+ async function launchInsideSandbox(ppt, options) {
672
+ const sandboxed = inAgentSandbox();
673
+ try {
674
+ return await ppt.launch(sandboxed ? oneProcessOverPipe(options) : options);
675
+ }
676
+ catch (error) {
677
+ const message = error instanceof Error ? error.message : "";
678
+ const refused = machServiceDenied(message) || pipeLaunchClosedOnMac(error);
679
+ if (process.platform !== "darwin" || sandboxed || !refused)
680
+ throw error;
681
+ return ppt.launch(oneProcessOverPipe(options));
682
+ }
683
+ }
669
684
  // fallow-ignore-next-line complexity
670
685
  async function launchBrowser(fingerprint) {
671
686
  const ppt = await getPuppeteer();
672
687
  const env = launchEnv(fingerprint);
673
688
  let captureMode = fingerprint.requestedCaptureMode;
674
689
  let browser;
675
- const launch = (args) => ppt
676
- .launch({
690
+ const launch = (args) => launchInsideSandbox(ppt, {
677
691
  ...ownedLaunchOptions(),
678
692
  headless: true,
679
693
  args: withHyperframesBrowserMarker(args),
@@ -682,8 +696,11 @@ async function launchBrowser(fingerprint) {
682
696
  env,
683
697
  timeout: fingerprint.browserTimeoutMs,
684
698
  protocolTimeout: fingerprint.protocolTimeoutMs,
685
- })
686
- .catch(async (error) => {
699
+ }).catch(async (error) => {
700
+ const remediation = process.platform === "darwin" &&
701
+ sandboxDeniedRemediation(error instanceof Error ? error.message : "");
702
+ if (remediation)
703
+ throw new Error(remediation, { cause: error });
687
704
  const defaults = await ppt.defaultArgs({
688
705
  headless: true,
689
706
  args: withHyperframesBrowserMarker(args),
@@ -33,7 +33,7 @@
33
33
  */
34
34
  import { createHash, randomUUID } from "node:crypto";
35
35
  import { createReadStream, existsSync, lstatSync, mkdirSync, readdirSync, rmSync, statSync, utimesSync, writeFileSync, } from "node:fs";
36
- import { renameWithRetry } from "../utils/renameWithRetry.js";
36
+ import { renameWithRetry } from "@hyperframes/core/rename-retry";
37
37
  import { join } from "node:path";
38
38
  import { FRAME_FILENAME_PREFIX, framePathsFromDirectory } from "./extractedFrameIndex.js";
39
39
  /** Filename prefix for extracted frames. Shared with the extractor. */
@@ -7,7 +7,7 @@
7
7
  import { cpus, freemem } from "os";
8
8
  import { existsSync, mkdirSync, readdirSync } from "fs";
9
9
  import { copyFile, readFile } from "fs/promises";
10
- import { renameWithRetryAsync } from "../utils/renameWithRetry.js";
10
+ import { renameWithRetryAsync } from "@hyperframes/core/rename-retry";
11
11
  import { join } from "path";
12
12
  import { getHeapStatistics } from "v8";
13
13
  import { createCaptureSession, initializeSession, closeCaptureSession, captureFrame, captureFrameToBufferPipelined, captureFrameToBuffer, getCapturePerfSummary, DrawElementVerificationError, } from "./frameCapture.js";
@@ -0,0 +1,10 @@
1
+ import type { LaunchOptions } from "puppeteer-core";
2
+ export declare function machServiceDenied(errorMessage: string): boolean;
3
+ export declare function pipeLaunchClosedOnMac(error: unknown, platform?: NodeJS.Platform): boolean;
4
+ /** Codex runs agent commands in a macOS seatbelt, which names itself in the environment. */
5
+ export declare function inAgentSandbox(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
6
+ /** The one launch a seatbelt allows: no child process registers a mach port, nothing listens, no GPU composites. */
7
+ export declare function oneProcessOverPipe(options: LaunchOptions): LaunchOptions;
8
+ export declare function isOneProcessOverPipe(options: LaunchOptions): boolean;
9
+ /** A process sandbox (an agent's macOS seatbelt) refused Chrome its mach service, so no browser starts here. */
10
+ export declare function sandboxDeniedRemediation(errorMessage: string): string | undefined;
@@ -0,0 +1,31 @@
1
+ const MACH_SERVICE_DENIED = /bootstrap_check_in\b[^\n]*Permission denied/;
2
+ const SINGLE_PROCESS = "--single-process";
3
+ const CPU_COMPOSITING = "--disable-gpu-compositing";
4
+ export function machServiceDenied(errorMessage) {
5
+ return MACH_SERVICE_DENIED.test(errorMessage);
6
+ }
7
+ export function pipeLaunchClosedOnMac(error, platform = process.platform) {
8
+ return platform === "darwin" && error instanceof Error && error.name === "TargetCloseError";
9
+ }
10
+ /** Codex runs agent commands in a macOS seatbelt, which names itself in the environment. */
11
+ export function inAgentSandbox(env = process.env, platform = process.platform) {
12
+ return platform === "darwin" && env.CODEX_SANDBOX === "seatbelt";
13
+ }
14
+ /** The one launch a seatbelt allows: no child process registers a mach port, nothing listens, no GPU composites. */
15
+ export function oneProcessOverPipe(options) {
16
+ const args = options.args ?? [];
17
+ const missing = [SINGLE_PROCESS, CPU_COMPOSITING].filter((flag) => !args.includes(flag));
18
+ return { ...options, pipe: true, args: [...args, ...missing] };
19
+ }
20
+ export function isOneProcessOverPipe(options) {
21
+ return options.pipe === true && (options.args ?? []).includes(SINGLE_PROCESS);
22
+ }
23
+ /** A process sandbox (an agent's macOS seatbelt) refused Chrome its mach service, so no browser starts here. */
24
+ export function sandboxDeniedRemediation(errorMessage) {
25
+ if (!machServiceDenied(errorMessage))
26
+ return undefined;
27
+ return [
28
+ "The browser can't start inside this sandbox: macOS refused Chrome's mach service (bootstrap_check_in: Permission denied).",
29
+ "Rerun this command with escalation, outside the sandbox.",
30
+ ].join("\n");
31
+ }
@@ -61,6 +61,8 @@ export interface AudioMetadata {
61
61
  latestStreamLeadSeconds?: number;
62
62
  audioStreamLeadSeconds?: number;
63
63
  audioStreamCount?: number;
64
+ /** The track read for these facts and for the mix, by its place among the audio streams (decodableAudioStream). */
65
+ audioStream: number;
64
66
  }
65
67
  interface FFProbeStream {
66
68
  codec_type: string;
@@ -8,6 +8,7 @@ import { basename, join } from "path";
8
8
  import { redactTelemetryString } from "@hyperframes/core";
9
9
  import { alphaDecoderFor, pixelFormatHasAlpha, videoStreamHasAlpha, } from "@hyperframes/parsers/media-alpha";
10
10
  import { urlInputArgs } from "./urlInput.js";
11
+ import { decodableAudioStream } from "@hyperframes/parsers/media-streams";
11
12
  import { runFfmpeg } from "./runFfmpeg.js";
12
13
  import { FFPROBE_PATH_ENV, getFfprobeBinary } from "./ffmpegBinaries.js";
13
14
  import { ManagedChildProcess } from "./managedChildProcess.js";
@@ -445,7 +446,7 @@ export async function probeMediaProfile(filePath, options) {
445
446
  }
446
447
  return {
447
448
  hasVideoStream: videoStreams.length > 0,
448
- hasAudioStream: output.streams.some((stream) => stream.codec_type === "audio"),
449
+ hasAudioStream: decodableAudioStream(output.streams) !== null,
449
450
  visualKind: isStillImage ? "still" : hasMovingVideoStream ? "moving" : "none",
450
451
  };
451
452
  }
@@ -790,7 +791,7 @@ export async function extractMediaMetadata(filePath) {
790
791
  height: videoStream.height || stillImage()?.height || 0,
791
792
  fps,
792
793
  videoCodec: videoStream.codec_name || "unknown",
793
- hasAudio: output?.streams.some((s) => s.codec_type === "audio") ?? false,
794
+ hasAudio: decodableAudioStream(output?.streams ?? []) !== null,
794
795
  isVFR,
795
796
  hasAlpha,
796
797
  ...(alphaPlaneDecoded === undefined ? {} : { alphaPlaneDecoded }),
@@ -956,8 +957,10 @@ export async function extractAudioMetadata(filePath, options) {
956
957
  }
957
958
  async function probeAudioMetadata(filePath, signal) {
958
959
  const output = await probeMediaOutput(filePath, signal);
959
- const audioStream = output.streams.find((s) => s.codec_type === "audio");
960
- if (!audioStream)
960
+ const audioStreams = output.streams.filter((s) => s.codec_type === "audio");
961
+ const audioStreamIndex = decodableAudioStream(output.streams);
962
+ const audioStream = audioStreamIndex === null ? undefined : audioStreams[audioStreamIndex];
963
+ if (audioStreamIndex === null || !audioStream)
961
964
  throw new Error("[FFmpeg] No audio stream found");
962
965
  let durationSeconds = output.format.duration ? parseFloat(output.format.duration) : 0;
963
966
  const streamDuration = audioStream.duration ? parseFloat(audioStream.duration) : undefined;
@@ -966,7 +969,7 @@ async function probeAudioMetadata(filePath, signal) {
966
969
  // Container summaries can undercount intact audio, so the decoded last frame wins.
967
970
  // The probe returns null instead of throwing: a throw here drops the audio element.
968
971
  if (sampleRate > 0) {
969
- const decoded = await probeDecodedAudioDuration(filePath, sampleRate, audioStartSeconds(audioStream, output.format), durationSeconds, signal);
972
+ const decoded = await probeDecodedAudioDuration(filePath, audioStreamIndex, sampleRate, audioStartSeconds(audioStream, output.format), durationSeconds, signal);
970
973
  if (decoded !== null) {
971
974
  durationSeconds = reconcileAudioDuration(durationSeconds, decoded, output.format);
972
975
  }
@@ -980,7 +983,8 @@ async function probeAudioMetadata(filePath, signal) {
980
983
  bitrate: output.format.bit_rate ? parseInt(output.format.bit_rate) : undefined,
981
984
  latestStreamLeadSeconds: latestStreamLeadSeconds(output),
982
985
  audioStreamLeadSeconds: streamLeadSeconds(output, audioStream),
983
- audioStreamCount: output.streams.filter((s) => s.codec_type === "audio").length,
986
+ audioStreamCount: audioStreams.length,
987
+ audioStream: audioStreamIndex,
984
988
  };
985
989
  }
986
990
  /** Chrome's media time 0 is the earliest of these; a stream starting later plays that much in. */
@@ -1018,11 +1022,11 @@ function reconcileAudioDuration(containerSeconds, decodedSeconds, format) {
1018
1022
  * True audio duration from the last decoded frame (timestamp + nb_samples - stream start): tail
1019
1023
  * first, then the whole file if a bad seek finds nothing. Null, never a throw, except on abort.
1020
1024
  */
1021
- async function probeDecodedAudioDuration(filePath, sampleRate, startSeconds, containerDurationSeconds, signal) {
1025
+ async function probeDecodedAudioDuration(filePath, audioStream, sampleRate, startSeconds, containerDurationSeconds, signal) {
1022
1026
  const probe = async (readInterval) => {
1023
1027
  const args = [
1024
1028
  "-select_streams",
1025
- "a:0",
1029
+ `a:${audioStream}`,
1026
1030
  "-show_entries",
1027
1031
  "frame=best_effort_timestamp_time,nb_samples",
1028
1032
  "-of",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/engine",
3
- "version": "0.8.208",
3
+ "version": "0.8.209",
4
4
  "description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
5
5
  "repository": {
6
6
  "type": "git",
@@ -54,8 +54,8 @@
54
54
  "linkedom": "^0.18.12",
55
55
  "puppeteer": "^25.8.0",
56
56
  "puppeteer-core": "^25.8.0",
57
- "@hyperframes/core": "^0.8.208",
58
- "@hyperframes/parsers": "^0.8.208"
57
+ "@hyperframes/parsers": "^0.8.209",
58
+ "@hyperframes/core": "^0.8.209"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@types/node": "^25.0.10",
@@ -1,5 +0,0 @@
1
- /** Removes a scratch folder or file the mix is already done with. A refusal (Windows temp folders that deny
2
- * delete) leaves it for the render's own cleanup and never fails the audio it served. */
3
- export declare function removeScratch(path: string): void;
4
- /** Runs `run`; whatever removeScratch left behind inside it (this run's only) goes to `report` once it ends. */
5
- export declare function collectScratchLeftovers<T>(run: () => Promise<T>, report: (left: string[]) => void): Promise<T>;
@@ -1,23 +0,0 @@
1
- import { AsyncLocalStorage } from "node:async_hooks";
2
- import { rmSync } from "node:fs";
3
- const collecting = new AsyncLocalStorage();
4
- /** Removes a scratch folder or file the mix is already done with. A refusal (Windows temp folders that deny
5
- * delete) leaves it for the render's own cleanup and never fails the audio it served. */
6
- export function removeScratch(path) {
7
- try {
8
- rmSync(path, { recursive: true, force: true });
9
- }
10
- catch {
11
- collecting.getStore()?.push(path);
12
- }
13
- }
14
- /** Runs `run`; whatever removeScratch left behind inside it (this run's only) goes to `report` once it ends. */
15
- export async function collectScratchLeftovers(run, report) {
16
- const left = [];
17
- try {
18
- return await collecting.run(left, run);
19
- }
20
- finally {
21
- report(left);
22
- }
23
- }
@@ -1,16 +0,0 @@
1
- interface Options {
2
- platform?: NodeJS.Platform;
3
- /** Copy the bytes when Windows keeps refusing (a TEMP that denies delete). Not atomic: scratch files only. */
4
- copyFallback?: boolean;
5
- }
6
- /** `renameSync` that rides out Windows' transient file locks; other platforms and errors throw at once. */
7
- export declare function renameWithRetry(from: string, to: string, options?: Options & {
8
- rename?: (from: string, to: string) => void;
9
- copy?: (from: string, to: string) => void;
10
- }): void;
11
- /** Async twin of {@link renameWithRetry}. */
12
- export declare function renameWithRetryAsync(from: string, to: string, options?: Options & {
13
- rename?: (from: string, to: string) => Promise<void>;
14
- copy?: (from: string, to: string) => Promise<void>;
15
- }): Promise<void>;
16
- export {};
@@ -1,81 +0,0 @@
1
- import { copyFileSync, renameSync } from "node:fs";
2
- import { copyFile, rename } from "node:fs/promises";
3
- import { removeScratch } from "./removeScratch.js";
4
- // Windows reports EPERM/EACCES/EBUSY while antivirus or a just-exited process still holds the
5
- // file; the hold clears within milliseconds. Total wait before giving up: about 1.3 s.
6
- const RETRYABLE = new Set(["EPERM", "EACCES", "EBUSY"]);
7
- const BACKOFF_MS = [10, 20, 40, 80, 160, 320, 640];
8
- function isRetryable(error, platform) {
9
- if (platform !== "win32")
10
- return false;
11
- return RETRYABLE.has(error?.code ?? "");
12
- }
13
- /** What a refused rename does next: wait and try again, fall back to a copy, or give up with the error. */
14
- function nextStep(error, attempt, options, platform) {
15
- if (!isRetryable(error, platform))
16
- return "throw";
17
- if (attempt < BACKOFF_MS.length)
18
- return "wait";
19
- return options.copyFallback ? "copy" : "throw";
20
- }
21
- function sleepSync(ms) {
22
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
23
- }
24
- /** `renameSync` that rides out Windows' transient file locks; other platforms and errors throw at once. */
25
- export function renameWithRetry(from, to, options = {}) {
26
- const doRename = options.rename ?? renameSync;
27
- const doCopy = options.copy ?? copyFileSync;
28
- const platform = options.platform ?? process.platform;
29
- for (let attempt = 0;; attempt++) {
30
- try {
31
- doRename(from, to);
32
- return;
33
- }
34
- catch (error) {
35
- const step = nextStep(error, attempt, options, platform);
36
- if (step === "throw")
37
- throw error;
38
- if (step === "wait") {
39
- sleepSync(BACKOFF_MS[attempt]);
40
- continue;
41
- }
42
- try {
43
- doCopy(from, to);
44
- }
45
- catch {
46
- throw error;
47
- }
48
- removeScratch(from);
49
- return;
50
- }
51
- }
52
- }
53
- /** Async twin of {@link renameWithRetry}. */
54
- export async function renameWithRetryAsync(from, to, options = {}) {
55
- const doRename = options.rename ?? rename;
56
- const doCopy = options.copy ?? copyFile;
57
- const platform = options.platform ?? process.platform;
58
- for (let attempt = 0;; attempt++) {
59
- try {
60
- await doRename(from, to);
61
- return;
62
- }
63
- catch (error) {
64
- const step = nextStep(error, attempt, options, platform);
65
- if (step === "throw")
66
- throw error;
67
- if (step === "wait") {
68
- await new Promise((resolve) => setTimeout(resolve, BACKOFF_MS[attempt]));
69
- continue;
70
- }
71
- try {
72
- await doCopy(from, to);
73
- }
74
- catch {
75
- throw error;
76
- }
77
- removeScratch(from);
78
- return;
79
- }
80
- }
81
- }