@hyperframes/producer 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/index.js CHANGED
@@ -1508,7 +1508,7 @@ async function defaultArtifactDurationProbe(path) {
1508
1508
  };
1509
1509
  }
1510
1510
  function buildArtifactExpectation(input) {
1511
- if (input.outputFormat === "png-sequence" || input.outputFormat === "gif") return void 0;
1511
+ if (input.outputFormat === "gif") return void 0;
1512
1512
  if (!Number.isFinite(input.durationSeconds) || input.durationSeconds <= 0) return void 0;
1513
1513
  const expectation = {
1514
1514
  expectedDurationSeconds: input.durationSeconds,
@@ -1573,12 +1573,12 @@ function assertDurationWithinTolerance(expectedSeconds, probedSeconds, tolerance
1573
1573
  `Render artifact is truncated: expected ${expectedSeconds.toFixed(3)}s, probed ${probedSeconds.toFixed(3)}s (deficit ${deficit.toFixed(3)}s exceeds tolerance ${toleranceSeconds.toFixed(3)}s). Artifact: ${stagingPath}`
1574
1574
  );
1575
1575
  }
1576
- function assertFrameCountWithinTolerance(expectedFrames, probedFrames, stagingPath) {
1576
+ function assertFrameCountWithinTolerance(expectedFrames, probedFrames, stagingPath, toleranceFrames = 1) {
1577
1577
  if (probedFrames === void 0 || !Number.isFinite(probedFrames) || probedFrames <= 0) {
1578
1578
  return;
1579
1579
  }
1580
1580
  const shortfall = expectedFrames - probedFrames;
1581
- if (shortfall <= 1) return;
1581
+ if (shortfall <= toleranceFrames) return;
1582
1582
  throw new Error(
1583
1583
  `Render artifact is truncated: expected ${expectedFrames} frames, probed ${probedFrames} frames (shortfall ${shortfall} frames). Artifact: ${stagingPath}`
1584
1584
  );
@@ -1615,8 +1615,8 @@ var ArtifactTransaction = class {
1615
1615
  if (expected) await this.assertArtifactDuration(expected);
1616
1616
  return;
1617
1617
  }
1618
- this.assertReadableNonEmptyDirectory();
1619
- if (expected) await this.assertDirectoryExpectation(expected);
1618
+ const files = this.assertReadableNonEmptyDirectory();
1619
+ if (expected) await this.assertDirectoryExpectation(expected, files);
1620
1620
  }
1621
1621
  assertReadableNonEmptyDirectory() {
1622
1622
  let files;
@@ -1631,13 +1631,14 @@ var ArtifactTransaction = class {
1631
1631
  throw new Error(`Render artifact directory is empty: ${this.stagingPath}`);
1632
1632
  }
1633
1633
  for (const file of files) assertReadableNonEmptyFile(file);
1634
+ return files;
1634
1635
  }
1635
1636
  /**
1636
1637
  * The named-entry and duration gates a directory artifact opts into. A
1637
- * directory has no single probe target, so an expectation without
1638
- * `probeTarget` (png-sequence) keeps the historical behavior: structure only.
1638
+ * directory with a probe target checks duration; one without a probe target
1639
+ * checks the exported PNG frame count when the caller supplied it.
1639
1640
  */
1640
- async assertDirectoryExpectation(expected) {
1641
+ async assertDirectoryExpectation(expected, files) {
1641
1642
  for (const required of expected.requiredFiles ?? []) {
1642
1643
  const path = join5(this.stagingPath, required);
1643
1644
  if (!existsSync5(path)) {
@@ -1645,8 +1646,25 @@ var ArtifactTransaction = class {
1645
1646
  }
1646
1647
  assertReadableNonEmptyFile(path);
1647
1648
  }
1648
- if (expected.probeTarget === void 0) return;
1649
- await this.assertArtifactDuration(expected, join5(this.stagingPath, expected.probeTarget));
1649
+ if (expected.probeTarget !== void 0) {
1650
+ await this.assertArtifactDuration(expected, join5(this.stagingPath, expected.probeTarget));
1651
+ return;
1652
+ }
1653
+ if (expected.expectedFrames === void 0) return;
1654
+ const frameCount = files.filter(
1655
+ (file) => dirname4(file) === this.stagingPath && /^frame_\d+\.png$/.test(basename3(file))
1656
+ ).length;
1657
+ if (frameCount === 0) {
1658
+ throw new Error(
1659
+ `Render artifact is truncated: expected ${expected.expectedFrames} frames, probed 0 frames. Artifact: ${this.stagingPath}`
1660
+ );
1661
+ }
1662
+ assertFrameCountWithinTolerance(
1663
+ expected.expectedFrames,
1664
+ frameCount,
1665
+ this.stagingPath,
1666
+ Math.max(1, Math.ceil(expected.expectedFrames * 2e-3))
1667
+ );
1650
1668
  }
1651
1669
  async assertArtifactDuration(expected, probePath = this.stagingPath) {
1652
1670
  const expectedSeconds = expected.expectedDurationSeconds;
@@ -7145,7 +7163,7 @@ async function compileForRender(projectDir, htmlPath, downloadDir, options = {})
7145
7163
  );
7146
7164
  if (analysis.isProblematic) {
7147
7165
  defaultLogger.warn(
7148
- `[Compiler] WARNING: Video "${video.id}" has sparse keyframes (max interval: ${analysis.maxIntervalSeconds}s). This causes seek failures and frame freezing. Re-encode with: ${reencode}`
7166
+ `[Compiler] WARNING: Video "${video.id}" has sparse keyframes (max interval: ${analysis.maxIntervalSeconds}s). Long GOPs can make preview seeking slower; rendering is unaffected. If preview seeking is slow, re-encode with: ${reencode}`
7149
7167
  );
7150
7168
  }
7151
7169
  if (metadata.isVFR) {
@@ -9196,6 +9214,7 @@ async function mixAudioForDelivery(input, duration, pcmSourcePath) {
9196
9214
  audios,
9197
9215
  masterFxChain,
9198
9216
  urlByAbsentLocalPath,
9217
+ audioSidecarUrlByLocalPath,
9199
9218
  abortSignal,
9200
9219
  assertNotAborted,
9201
9220
  log
@@ -9219,6 +9238,7 @@ async function mixAudioForDelivery(input, duration, pcmSourcePath) {
9219
9238
  audioGain,
9220
9239
  masterFxChain,
9221
9240
  urlByAbsentLocalPath,
9241
+ audioSidecarUrlByLocalPath,
9222
9242
  keepEncoderInputAt: pcmSourcePath
9223
9243
  },
9224
9244
  compiledDir
@@ -15901,6 +15921,11 @@ async function compilePlanComposition(projectDir, config, planDir, options) {
15901
15921
  const localPathByUrl = new Map(
15902
15922
  remoteFiles.map(([rel, file]) => [file.url, remoteLocalPath(rel)])
15903
15923
  );
15924
+ const audioSidecarUrlByLocalPath = new Map(
15925
+ remoteFiles.flatMap(
15926
+ ([rel, file]) => file.audioSidecarUrl ? [[remoteLocalPath(rel), file.audioSidecarUrl]] : []
15927
+ )
15928
+ );
15904
15929
  stageStarted = Date.now();
15905
15930
  const compileResult = await runCompileStage({
15906
15931
  urlByAbsentLocalPath,
@@ -15985,6 +16010,7 @@ async function compilePlanComposition(projectDir, config, planDir, options) {
15985
16010
  finalCompiledDir,
15986
16011
  remoteFiles,
15987
16012
  urlByAbsentLocalPath,
16013
+ audioSidecarUrlByLocalPath,
15988
16014
  localPathByUrl,
15989
16015
  compileResult,
15990
16016
  compiled,
@@ -16002,6 +16028,7 @@ function mixPlanAudio(c, compiledDir) {
16002
16028
  workDir: c.workDir,
16003
16029
  compiledDir,
16004
16030
  urlByAbsentLocalPath: c.urlByAbsentLocalPath,
16031
+ audioSidecarUrlByLocalPath: c.audioSidecarUrlByLocalPath,
16005
16032
  totalFrames: c.totalFrames,
16006
16033
  fps: c.job.config.fps,
16007
16034
  ffmpegProcessTimeout: c.cfg.ffmpegProcessTimeout,
@@ -19556,6 +19583,10 @@ async function assembleV2(planV2Dir, chunkPaths, outputPath, options) {
19556
19583
 
19557
19584
  // src/services/distributed/projectHash.ts
19558
19585
  var READ_CHUNK_BYTES = 256 * 1024;
19586
+
19587
+ // src/services/distributed/audioSidecar.ts
19588
+ import { extractAudioMetadata as extractAudioMetadata4, runFfmpeg as runFfmpeg8 } from "@hyperframes/engine";
19589
+ var AUDIO_SIDECAR_MIN_BYTES = 32 * 1024 * 1024;
19559
19590
  export {
19560
19591
  CURRENT_PLAN_PROTOCOL,
19561
19592
  DEFAULT_CONFIG2 as DEFAULT_CONFIG,
@@ -1527,7 +1527,7 @@ async function defaultArtifactDurationProbe(path) {
1527
1527
  };
1528
1528
  }
1529
1529
  function buildArtifactExpectation(input) {
1530
- if (input.outputFormat === "png-sequence" || input.outputFormat === "gif") return void 0;
1530
+ if (input.outputFormat === "gif") return void 0;
1531
1531
  if (!Number.isFinite(input.durationSeconds) || input.durationSeconds <= 0) return void 0;
1532
1532
  const expectation = {
1533
1533
  expectedDurationSeconds: input.durationSeconds,
@@ -1592,12 +1592,12 @@ function assertDurationWithinTolerance(expectedSeconds, probedSeconds, tolerance
1592
1592
  `Render artifact is truncated: expected ${expectedSeconds.toFixed(3)}s, probed ${probedSeconds.toFixed(3)}s (deficit ${deficit.toFixed(3)}s exceeds tolerance ${toleranceSeconds.toFixed(3)}s). Artifact: ${stagingPath}`
1593
1593
  );
1594
1594
  }
1595
- function assertFrameCountWithinTolerance(expectedFrames, probedFrames, stagingPath) {
1595
+ function assertFrameCountWithinTolerance(expectedFrames, probedFrames, stagingPath, toleranceFrames = 1) {
1596
1596
  if (probedFrames === void 0 || !Number.isFinite(probedFrames) || probedFrames <= 0) {
1597
1597
  return;
1598
1598
  }
1599
1599
  const shortfall = expectedFrames - probedFrames;
1600
- if (shortfall <= 1) return;
1600
+ if (shortfall <= toleranceFrames) return;
1601
1601
  throw new Error(
1602
1602
  `Render artifact is truncated: expected ${expectedFrames} frames, probed ${probedFrames} frames (shortfall ${shortfall} frames). Artifact: ${stagingPath}`
1603
1603
  );
@@ -1634,8 +1634,8 @@ var ArtifactTransaction = class {
1634
1634
  if (expected) await this.assertArtifactDuration(expected);
1635
1635
  return;
1636
1636
  }
1637
- this.assertReadableNonEmptyDirectory();
1638
- if (expected) await this.assertDirectoryExpectation(expected);
1637
+ const files = this.assertReadableNonEmptyDirectory();
1638
+ if (expected) await this.assertDirectoryExpectation(expected, files);
1639
1639
  }
1640
1640
  assertReadableNonEmptyDirectory() {
1641
1641
  let files;
@@ -1650,13 +1650,14 @@ var ArtifactTransaction = class {
1650
1650
  throw new Error(`Render artifact directory is empty: ${this.stagingPath}`);
1651
1651
  }
1652
1652
  for (const file of files) assertReadableNonEmptyFile(file);
1653
+ return files;
1653
1654
  }
1654
1655
  /**
1655
1656
  * The named-entry and duration gates a directory artifact opts into. A
1656
- * directory has no single probe target, so an expectation without
1657
- * `probeTarget` (png-sequence) keeps the historical behavior: structure only.
1657
+ * directory with a probe target checks duration; one without a probe target
1658
+ * checks the exported PNG frame count when the caller supplied it.
1658
1659
  */
1659
- async assertDirectoryExpectation(expected) {
1660
+ async assertDirectoryExpectation(expected, files) {
1660
1661
  for (const required of expected.requiredFiles ?? []) {
1661
1662
  const path = join5(this.stagingPath, required);
1662
1663
  if (!existsSync5(path)) {
@@ -1664,8 +1665,25 @@ var ArtifactTransaction = class {
1664
1665
  }
1665
1666
  assertReadableNonEmptyFile(path);
1666
1667
  }
1667
- if (expected.probeTarget === void 0) return;
1668
- await this.assertArtifactDuration(expected, join5(this.stagingPath, expected.probeTarget));
1668
+ if (expected.probeTarget !== void 0) {
1669
+ await this.assertArtifactDuration(expected, join5(this.stagingPath, expected.probeTarget));
1670
+ return;
1671
+ }
1672
+ if (expected.expectedFrames === void 0) return;
1673
+ const frameCount = files.filter(
1674
+ (file) => dirname4(file) === this.stagingPath && /^frame_\d+\.png$/.test(basename3(file))
1675
+ ).length;
1676
+ if (frameCount === 0) {
1677
+ throw new Error(
1678
+ `Render artifact is truncated: expected ${expected.expectedFrames} frames, probed 0 frames. Artifact: ${this.stagingPath}`
1679
+ );
1680
+ }
1681
+ assertFrameCountWithinTolerance(
1682
+ expected.expectedFrames,
1683
+ frameCount,
1684
+ this.stagingPath,
1685
+ Math.max(1, Math.ceil(expected.expectedFrames * 2e-3))
1686
+ );
1669
1687
  }
1670
1688
  async assertArtifactDuration(expected, probePath = this.stagingPath) {
1671
1689
  const expectedSeconds = expected.expectedDurationSeconds;
@@ -7164,7 +7182,7 @@ async function compileForRender(projectDir, htmlPath, downloadDir, options = {})
7164
7182
  );
7165
7183
  if (analysis.isProblematic) {
7166
7184
  defaultLogger.warn(
7167
- `[Compiler] WARNING: Video "${video.id}" has sparse keyframes (max interval: ${analysis.maxIntervalSeconds}s). This causes seek failures and frame freezing. Re-encode with: ${reencode}`
7185
+ `[Compiler] WARNING: Video "${video.id}" has sparse keyframes (max interval: ${analysis.maxIntervalSeconds}s). Long GOPs can make preview seeking slower; rendering is unaffected. If preview seeking is slow, re-encode with: ${reencode}`
7168
7186
  );
7169
7187
  }
7170
7188
  if (metadata.isVFR) {
@@ -9179,6 +9197,7 @@ async function mixAudioForDelivery(input, duration, pcmSourcePath) {
9179
9197
  audios,
9180
9198
  masterFxChain,
9181
9199
  urlByAbsentLocalPath,
9200
+ audioSidecarUrlByLocalPath,
9182
9201
  abortSignal,
9183
9202
  assertNotAborted,
9184
9203
  log
@@ -9202,6 +9221,7 @@ async function mixAudioForDelivery(input, duration, pcmSourcePath) {
9202
9221
  audioGain,
9203
9222
  masterFxChain,
9204
9223
  urlByAbsentLocalPath,
9224
+ audioSidecarUrlByLocalPath,
9205
9225
  keepEncoderInputAt: pcmSourcePath
9206
9226
  },
9207
9227
  compiledDir
@@ -0,0 +1,10 @@
1
+ /**
2
+ * A video's audio copied out of its container, nothing re-encoded, stored beside it. Reading audio from the
3
+ * cloud file moves the whole interleaved file; the sidecar is a few MB.
4
+ */
5
+ /** Smaller files are cheap to read whole. */
6
+ export declare const AUDIO_SIDECAR_MIN_BYTES: number;
7
+ export declare function wantsAudioSidecar(path: string, sizeBytes: number): boolean;
8
+ /** `unavailable`: no ffmpeg on this machine. `none`: no audio, or audio the sidecar would not read back the same. */
9
+ export type AudioSidecarOutcome = "built" | "none" | "unavailable";
10
+ export declare function buildAudioSidecar(inputPath: string, outputPath: string, signal?: AbortSignal): Promise<AudioSidecarOutcome>;
@@ -217,6 +217,8 @@ export interface BuildLocalExecutionPlanOptions {
217
217
  }
218
218
  export interface RemoteProjectFile {
219
219
  readonly url: string;
220
+ /** A file of only this video's audio, when one was stored: the audio mix reads it, not the video. */
221
+ readonly audioSidecarUrl?: string;
220
222
  readonly sha256: string;
221
223
  readonly sizeBytes: number;
222
224
  }
@@ -37,7 +37,7 @@ export interface ArtifactDurationProbeResult {
37
37
  */
38
38
  export type ArtifactDurationProbe = (path: string) => Promise<ArtifactDurationProbeResult>;
39
39
  /**
40
- * Caller-supplied expectation for file-artifact validation. The transaction
40
+ * Caller-supplied expectation for artifact validation. The transaction
41
41
  * still does the readable-non-empty check; this layer adds a duration /
42
42
  * frame-count comparison against the values the pipeline already held.
43
43
  *
@@ -48,15 +48,15 @@ export type ArtifactDurationProbe = (path: string) => Promise<ArtifactDurationPr
48
48
  *
49
49
  * `toleranceSeconds` defaults to a single frame at `fps` (or 20 ms when fps
50
50
  * is unknown) so that a normal container-level last-frame rounding does not
51
- * trip the gate. Multi-frame drops (e.g. the 326-frame / 11s truncation in
52
- * #3395) still fail.
51
+ * trip the gate. Multi-frame drops beyond that tolerance still fail.
53
52
  *
54
53
  * `probeTarget` / `requiredFiles` exist for directory artifacts. A `"file"`
55
54
  * artifact is its own probe target; a directory has no single one, so an HLS
56
55
  * output names its video playlist (`probeTarget: "video.m3u8"`) and the files
57
56
  * that must be present (`requiredFiles: ["master.m3u8"]`). Without
58
- * `probeTarget` the directory branch keeps its historical behavior — readable
59
- * non-empty files only, no duration gate (png-sequence).
57
+ * `probeTarget`, an `expectedFrames` value instead checks the top-level PNG
58
+ * frame files. Directory artifacts without either keep their historical
59
+ * readable-non-empty check.
60
60
  */
61
61
  export interface ArtifactValidationExpectation {
62
62
  expectedDurationSeconds: number;
@@ -75,8 +75,10 @@ export interface ArtifactValidationExpectation {
75
75
  * The per-format expectation the render pipeline hands `validate()`, or
76
76
  * `undefined` when the format has nothing probeable.
77
77
  *
78
- * - `png-sequence` / `gif`: no expectation. A PNG directory has no container
79
- * duration, and the GIF encoder writes `outputPath` itself without a
78
+ * - `png-sequence`: count exported PNG frames without a duration probe.
79
+ * `expectedDurationSeconds` is carried by the shared expectation but is
80
+ * unused for this directory format.
81
+ * - `gif`: no expectation. The GIF encoder writes `outputPath` itself without a
80
82
  * frame-accurate duration the pipeline could compare against.
81
83
  * - `hls`: the directory's `video.m3u8` is the probe target and `master.m3u8`
82
84
  * must exist. `expectedFrames` is passed through but is inert — ffprobe
@@ -104,8 +106,9 @@ export declare function buildArtifactExpectation(input: {
104
106
  * transaction additionally probes the staged artifact's container duration
105
107
  * (and decoded frame count when available) and rejects any artifact that is
106
108
  * significantly shorter than what the pipeline asked for. A directory artifact
107
- * is probed only when `expected.probeTarget` names a file inside it. The
108
- * readable-non-empty check is unchanged; this is a second gate on top.
109
+ * is probed only when `expected.probeTarget` names a file inside it.
110
+ * Without a directory probe target, the expectation instead checks exported
111
+ * PNG frame files. The readable-non-empty check is a separate gate.
109
112
  */
110
113
  export declare class ArtifactTransaction {
111
114
  private readonly kind;
@@ -121,8 +124,8 @@ export declare class ArtifactTransaction {
121
124
  private assertReadableNonEmptyDirectory;
122
125
  /**
123
126
  * The named-entry and duration gates a directory artifact opts into. A
124
- * directory has no single probe target, so an expectation without
125
- * `probeTarget` (png-sequence) keeps the historical behavior: structure only.
127
+ * directory with a probe target checks duration; one without a probe target
128
+ * checks the exported PNG frame count when the caller supplied it.
126
129
  */
127
130
  private assertDirectoryExpectation;
128
131
  private assertArtifactDuration;
@@ -36,6 +36,7 @@ export interface AudioStageInput {
36
36
  audios: CompositionMetadata["audios"];
37
37
  masterFxChain?: string;
38
38
  urlByAbsentLocalPath?: ReadonlyMap<string, string>;
39
+ audioSidecarUrlByLocalPath?: ReadonlyMap<string, string>;
39
40
  abortSignal: AbortSignal | undefined;
40
41
  assertNotAborted: () => void;
41
42
  /** Where a per-track failure's detail goes. Optional so tests need not pass one. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/producer",
3
- "version": "0.8.190",
3
+ "version": "0.8.191",
4
4
  "description": "HTML-to-video rendering engine using Chrome's BeginFrame API",
5
5
  "repository": {
6
6
  "type": "git",
@@ -35,11 +35,11 @@
35
35
  "postcss": "^8.4.0",
36
36
  "puppeteer": "^25.2.1",
37
37
  "puppeteer-core": "^25.2.1",
38
- "@hyperframes/core": "0.8.190",
39
- "@hyperframes/engine": "0.8.190",
40
- "@hyperframes/parsers": "0.8.190",
41
- "@hyperframes/lint": "0.8.190",
42
- "@hyperframes/studio-server": "0.8.190"
38
+ "@hyperframes/lint": "0.8.191",
39
+ "@hyperframes/core": "0.8.191",
40
+ "@hyperframes/parsers": "0.8.191",
41
+ "@hyperframes/studio-server": "0.8.191",
42
+ "@hyperframes/engine": "0.8.191"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/node": "^25.0.10",
@@ -48,7 +48,7 @@
48
48
  "tsx": "^4.21.0",
49
49
  "typescript": "^5.7.2",
50
50
  "vitest": "^4.1.11",
51
- "@hyperframes/aws-lambda": "0.8.190"
51
+ "@hyperframes/aws-lambda": "0.8.191"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=22"