@hyperframes/producer 0.8.185 → 0.8.188
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/README.md +26 -30
- package/dist/distributed.js +1307 -1328
- package/dist/hyperframe.manifest.json +1 -1
- package/dist/hyperframe.runtime.iife.js +70 -69
- package/dist/index.js +1958 -1935
- package/dist/public-server.js +1566 -1523
- package/dist/renderRequest.d.ts +1 -0
- package/dist/server.d.ts +1 -0
- package/dist/services/distributed/assemble.d.ts +1 -1
- package/dist/services/distributed/chunkFrames.d.ts +1 -1
- package/dist/services/distributed/plan.d.ts +2 -1
- package/dist/services/htmlCompiler.d.ts +3 -2
- package/dist/services/render/motionBlurRoute.d.ts +11 -1
- package/dist/services/render/segmentManifest.d.ts +1 -0
- package/dist/services/render/stages/assembleStage.d.ts +1 -0
- package/dist/services/render/stages/audioStage.d.ts +6 -4
- package/dist/services/render/stages/captureStage.d.ts +3 -8
- package/dist/services/render/stages/compileStage.d.ts +2 -0
- package/dist/services/render/stages/freezePlan.d.ts +2 -1
- package/dist/services/renderOrchestrator.d.ts +1 -0
- package/package.json +7 -7
package/dist/public-server.js
CHANGED
|
@@ -7,10 +7,10 @@ import {
|
|
|
7
7
|
statSync as statSync8,
|
|
8
8
|
mkdtempSync as mkdtempSync4,
|
|
9
9
|
writeFileSync as writeFileSync6,
|
|
10
|
-
rmSync as
|
|
10
|
+
rmSync as rmSync11,
|
|
11
11
|
createReadStream as createReadStream3
|
|
12
12
|
} from "node:fs";
|
|
13
|
-
import { resolve as resolve9, dirname as dirname12, join as
|
|
13
|
+
import { resolve as resolve9, dirname as dirname12, join as join29 } from "node:path";
|
|
14
14
|
import { tmpdir as tmpdir3 } from "node:os";
|
|
15
15
|
import { parseArgs } from "node:util";
|
|
16
16
|
import crypto from "node:crypto";
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
mkdirSync as mkdirSync9,
|
|
26
26
|
readFileSync as readFileSync8,
|
|
27
27
|
readdirSync as readdirSync5,
|
|
28
|
-
rmSync as
|
|
28
|
+
rmSync as rmSync10,
|
|
29
29
|
statSync as statSync6,
|
|
30
30
|
writeFileSync as writeFileSync5,
|
|
31
31
|
copyFileSync as copyFileSync4,
|
|
@@ -75,7 +75,7 @@ import {
|
|
|
75
75
|
augmentProtocolTimeoutError,
|
|
76
76
|
augmentPageNavigationTimeoutError
|
|
77
77
|
} from "@hyperframes/engine";
|
|
78
|
-
import { join as
|
|
78
|
+
import { join as join25, dirname as dirname10, resolve as resolve7 } from "path";
|
|
79
79
|
import { totalmem as totalmem2 } from "node:os";
|
|
80
80
|
import { randomUUID } from "crypto";
|
|
81
81
|
import { fileURLToPath as fileURLToPath4 } from "url";
|
|
@@ -4355,6 +4355,7 @@ function computeSegmentPlanHash(input) {
|
|
|
4355
4355
|
input.useGpu ? "gpu" : "cpu",
|
|
4356
4356
|
`${input.outputWidth}x${input.outputHeight}`,
|
|
4357
4357
|
input.motionBlur,
|
|
4358
|
+
input.pageMotionBlur ?? "",
|
|
4358
4359
|
input.captureQuality === void 0 ? "" : String(input.captureQuality)
|
|
4359
4360
|
].join(" ");
|
|
4360
4361
|
return createHash2("sha256").update(ordered).digest("hex").slice(0, 16);
|
|
@@ -6023,7 +6024,10 @@ function stripCompilerMountBootstrap(source) {
|
|
|
6023
6024
|
""
|
|
6024
6025
|
);
|
|
6025
6026
|
}
|
|
6026
|
-
function
|
|
6027
|
+
function runtimeRoot(document2) {
|
|
6028
|
+
return document2.querySelector('[data-composition-id][data-root="true"]') ?? document2.querySelector("[data-composition-id]");
|
|
6029
|
+
}
|
|
6030
|
+
function detectRenderModeHints(html, pageMotionBlur) {
|
|
6027
6031
|
const reasons = [];
|
|
6028
6032
|
const { document: document2 } = parseHTML4(html);
|
|
6029
6033
|
if (document2.querySelector("canvas[layoutsubtree]")) {
|
|
@@ -6031,6 +6035,11 @@ function detectRenderModeHints(html) {
|
|
|
6031
6035
|
code: "htmlInCanvas",
|
|
6032
6036
|
message: "Detected html-in-canvas API (layoutsubtree canvas). Chrome does not support concurrent drawElementImage across multiple workers; render is pinned to a single worker."
|
|
6033
6037
|
});
|
|
6038
|
+
} else if (pageMotionBlur !== "off" && runtimeRoot(document2)?.getAttribute("data-motion-blur") === "on") {
|
|
6039
|
+
reasons.push({
|
|
6040
|
+
code: "captureMotionBlur",
|
|
6041
|
+
message: 'Detected capture motion blur (data-motion-blur="on"), which draws each frame with html-in-canvas; routed through screenshot capture mode.'
|
|
6042
|
+
});
|
|
6034
6043
|
}
|
|
6035
6044
|
if (document2.querySelector("iframe")) {
|
|
6036
6045
|
reasons.push({
|
|
@@ -7062,7 +7071,7 @@ async function compileForRender(projectDir, htmlPath, downloadDir, options = {})
|
|
|
7062
7071
|
/(<(?:video|audio)\b[^>]*?)\s+preload\s*=\s*["']none["']/gi,
|
|
7063
7072
|
"$1"
|
|
7064
7073
|
);
|
|
7065
|
-
const renderModeHints = detectRenderModeHints(sanitizedHtml);
|
|
7074
|
+
const renderModeHints = detectRenderModeHints(sanitizedHtml, options.pageMotionBlur);
|
|
7066
7075
|
const hasShaderTransitions = detectShaderTransitionUsage(sanitizedHtml);
|
|
7067
7076
|
const usesThreeDTransforms = detectThreeDTransformUsage(sanitizedHtml);
|
|
7068
7077
|
const usesMixBlendMode = detectMixBlendModeUsage(sanitizedHtml);
|
|
@@ -7204,7 +7213,9 @@ async function discoverMediaFromBrowser(page) {
|
|
|
7204
7213
|
const tagName = isImage ? "image" : htmlEl.tagName.toLowerCase() === "video" ? "video" : "audio";
|
|
7205
7214
|
const id = htmlEl.getAttribute("data-hf-render-id") || htmlEl.id || (isImage ? autoImageIds.get(htmlEl) : void 0);
|
|
7206
7215
|
if (!id) return;
|
|
7207
|
-
const
|
|
7216
|
+
const ownSrc = htmlEl.getAttribute("src");
|
|
7217
|
+
const blankSrc = ownSrc !== null && ownSrc.trim() === "" && !htmlEl.querySelector("source[src]");
|
|
7218
|
+
const src = blankSrc ? "" : htmlEl.currentSrc || htmlEl.src || ownSrc || "";
|
|
7208
7219
|
const start = parseFloat(htmlEl.getAttribute("data-start") || "0");
|
|
7209
7220
|
const endRaw = htmlEl.getAttribute("data-end");
|
|
7210
7221
|
const durationRaw = htmlEl.getAttribute("data-duration");
|
|
@@ -7547,6 +7558,7 @@ async function runCompileStage(input) {
|
|
|
7547
7558
|
abortSignal,
|
|
7548
7559
|
variables: input.variables,
|
|
7549
7560
|
urlByAbsentLocalPath: input.urlByAbsentLocalPath,
|
|
7561
|
+
pageMotionBlur: input.pageMotionBlur,
|
|
7550
7562
|
animatedGifCacheDir: cfg.extractCacheDir ? join14(cfg.extractCacheDir, "animated-gif") : void 0,
|
|
7551
7563
|
ffmpegProcessTimeout: cfg.ffmpegProcessTimeout
|
|
7552
7564
|
});
|
|
@@ -7843,6 +7855,7 @@ async function runProbeStage(input) {
|
|
|
7843
7855
|
variables: job.config.variables,
|
|
7844
7856
|
deviceScaleFactor,
|
|
7845
7857
|
motionBlur,
|
|
7858
|
+
pageMotionBlur: job.config.pageMotionBlur,
|
|
7846
7859
|
requiresWebGpu: compositionRequiresWebGpu(compiled.html),
|
|
7847
7860
|
subTimelineWaitMemo
|
|
7848
7861
|
};
|
|
@@ -8669,771 +8682,1209 @@ function appendAutoDetectedVideoAudio(composition, extracted) {
|
|
|
8669
8682
|
}
|
|
8670
8683
|
|
|
8671
8684
|
// src/services/render/stages/audioStage.ts
|
|
8672
|
-
import { join as
|
|
8685
|
+
import { join as join18 } from "node:path";
|
|
8686
|
+
import { rmSync as rmSync8 } from "node:fs";
|
|
8673
8687
|
import {
|
|
8674
8688
|
MIXED_AUDIO_FILENAME,
|
|
8675
8689
|
audioFailureDiagnostics,
|
|
8676
8690
|
processCompositionAudio
|
|
8677
8691
|
} from "@hyperframes/engine";
|
|
8678
|
-
|
|
8679
|
-
|
|
8692
|
+
|
|
8693
|
+
// src/services/render/audioPadTrim.ts
|
|
8694
|
+
import { spawn } from "node:child_process";
|
|
8695
|
+
import { mkdtempSync as mkdtempSync3, renameSync as renameSync4, rmSync as rmSync7 } from "node:fs";
|
|
8696
|
+
import { dirname as dirname7, join as join17 } from "node:path";
|
|
8697
|
+
import {
|
|
8698
|
+
DEFAULT_CONFIG,
|
|
8699
|
+
describeFfmpegFailure as describeFfmpegFailure2,
|
|
8700
|
+
encodeAac,
|
|
8701
|
+
extractAudioMetadata as extractAudioMetadata3,
|
|
8702
|
+
getFfprobeBinary,
|
|
8703
|
+
ManagedChildProcess,
|
|
8704
|
+
runFfmpeg as runFfmpeg4,
|
|
8705
|
+
trackChildProcess
|
|
8706
|
+
} from "@hyperframes/engine";
|
|
8707
|
+
import { redactKnownPaths, redactTelemetryString as redactTelemetryString3 } from "@hyperframes/core";
|
|
8708
|
+
|
|
8709
|
+
// src/services/render/audioTruePeak.ts
|
|
8710
|
+
import {
|
|
8711
|
+
aacSegmentCount,
|
|
8712
|
+
describeFfmpegFailure,
|
|
8713
|
+
extractAudioMetadata as extractAudioMetadata2,
|
|
8714
|
+
runFfmpeg as runFfmpeg3
|
|
8715
|
+
} from "@hyperframes/engine";
|
|
8716
|
+
var toDbfs = (token) => token.toLowerCase() === "-inf" ? Number.NEGATIVE_INFINITY : Number(token);
|
|
8717
|
+
function checkedDbfs(value) {
|
|
8718
|
+
if (Number.isNaN(value) || value === Number.POSITIVE_INFINITY) {
|
|
8719
|
+
throw new Error("[audioTruePeak] FFmpeg did not report AAC true peak");
|
|
8720
|
+
}
|
|
8721
|
+
return value;
|
|
8680
8722
|
}
|
|
8681
|
-
async function
|
|
8682
|
-
|
|
8683
|
-
|
|
8684
|
-
|
|
8685
|
-
|
|
8686
|
-
|
|
8687
|
-
|
|
8688
|
-
|
|
8689
|
-
|
|
8690
|
-
|
|
8691
|
-
|
|
8692
|
-
|
|
8693
|
-
|
|
8694
|
-
assertNotAborted,
|
|
8695
|
-
log
|
|
8696
|
-
} = input;
|
|
8697
|
-
const stage3Start = Date.now();
|
|
8698
|
-
const audioOutputPath = join17(workDir, MIXED_AUDIO_FILENAME);
|
|
8699
|
-
let hasAudio = false;
|
|
8700
|
-
let audioError;
|
|
8701
|
-
let audioFailures;
|
|
8702
|
-
if (audios.length > 0) {
|
|
8703
|
-
let audioResult;
|
|
8704
|
-
try {
|
|
8705
|
-
audioResult = await processCompositionAudio(
|
|
8706
|
-
audios,
|
|
8707
|
-
projectDir,
|
|
8708
|
-
join17(workDir, "audio-work"),
|
|
8709
|
-
audioOutputPath,
|
|
8710
|
-
duration,
|
|
8711
|
-
abortSignal,
|
|
8712
|
-
{
|
|
8713
|
-
ffmpegProcessTimeout,
|
|
8714
|
-
audioGain,
|
|
8715
|
-
masterFxChain,
|
|
8716
|
-
urlByAbsentLocalPath,
|
|
8717
|
-
keepEncoderInputAt
|
|
8718
|
-
},
|
|
8719
|
-
compiledDir
|
|
8720
|
-
);
|
|
8721
|
-
} catch (err) {
|
|
8722
|
-
assertNotAborted();
|
|
8723
|
-
const detail = err instanceof Error ? err.message : String(err);
|
|
8724
|
-
return {
|
|
8725
|
-
audioOutputPath,
|
|
8726
|
-
hasAudio: false,
|
|
8727
|
-
audioProcessMs: Date.now() - stage3Start,
|
|
8728
|
-
audioError: detail,
|
|
8729
|
-
// Synthesised rather than left undefined. The warning policy reads
|
|
8730
|
-
// owner, retryability, reason and stage off this list, so reporting the
|
|
8731
|
-
// FATAL failure — the one that took the whole mix down — with an empty
|
|
8732
|
-
// one gave it strictly less classification than a single dropped track
|
|
8733
|
-
// gets, which is the opposite of what this stage exists to do.
|
|
8734
|
-
// "internal" is the honest bucket: the stages enumerate ffmpeg steps
|
|
8735
|
-
// and this is the FX render, which is none of them.
|
|
8736
|
-
audioFailures: [
|
|
8737
|
-
{
|
|
8738
|
-
stage: "internal",
|
|
8739
|
-
reason: "internal",
|
|
8740
|
-
owner: "system",
|
|
8741
|
-
retryable: false,
|
|
8742
|
-
detail: detail.slice(0, 2e3)
|
|
8743
|
-
}
|
|
8744
|
-
]
|
|
8745
|
-
};
|
|
8746
|
-
}
|
|
8747
|
-
assertNotAborted();
|
|
8748
|
-
const interrupted = audioResult.failures?.find(
|
|
8749
|
-
(failure) => failure.reason === "external_interruption"
|
|
8750
|
-
);
|
|
8751
|
-
if (interrupted) {
|
|
8752
|
-
throw encoderFailureError("Audio processing failed", {
|
|
8753
|
-
error: interrupted.detail,
|
|
8754
|
-
failureReason: "external_interruption",
|
|
8755
|
-
audioFailures: audioFailureDiagnostics(triggerFirst(audioResult.failures, interrupted))
|
|
8756
|
-
});
|
|
8757
|
-
}
|
|
8758
|
-
hasAudio = audioResult.success;
|
|
8759
|
-
audioFailures = audioResult.failures;
|
|
8760
|
-
if (!hasAudio) {
|
|
8761
|
-
audioError = audioResult.error ?? "audio mix failed for an unknown reason";
|
|
8762
|
-
for (const failure of audioResult.failures ?? []) {
|
|
8763
|
-
log?.warn("Audio track failed", {
|
|
8764
|
-
elementId: failure.elementId,
|
|
8765
|
-
stage: failure.stage,
|
|
8766
|
-
reason: failure.reason,
|
|
8767
|
-
owner: failure.owner,
|
|
8768
|
-
retryable: failure.retryable,
|
|
8769
|
-
detail: failure.detail
|
|
8770
|
-
});
|
|
8771
|
-
}
|
|
8723
|
+
async function scanTruePeak(audioPath, trim, timeoutMs, signal) {
|
|
8724
|
+
let firstBlock;
|
|
8725
|
+
let laterBlocks = Number.NEGATIVE_INFINITY;
|
|
8726
|
+
let partialLine = "";
|
|
8727
|
+
const readLines = (chunk) => {
|
|
8728
|
+
const lines = (partialLine + chunk).split(/\r|\n/);
|
|
8729
|
+
partialLine = lines.pop() ?? "";
|
|
8730
|
+
for (const line of lines) {
|
|
8731
|
+
const ftpk = /FTPK:([^d]*)dBFS/.exec(line)?.[1];
|
|
8732
|
+
if (!ftpk) continue;
|
|
8733
|
+
const block = Math.max(...ftpk.trim().split(/\s+/).map(toDbfs));
|
|
8734
|
+
if (firstBlock === void 0) firstBlock = block;
|
|
8735
|
+
else laterBlocks = Math.max(laterBlocks, block);
|
|
8772
8736
|
}
|
|
8737
|
+
};
|
|
8738
|
+
const result = await runFfmpeg3(
|
|
8739
|
+
["-i", audioPath, "-map", "0:a:0", "-vn", "-af", `${trim}ebur128=peak=true`, "-f", "null", "-"],
|
|
8740
|
+
{ signal, timeout: timeoutMs, onStderr: readLines }
|
|
8741
|
+
);
|
|
8742
|
+
if (!result.success) {
|
|
8743
|
+
throw new Error(`[audioTruePeak] ${describeFfmpegFailure(result, timeoutMs)}`);
|
|
8773
8744
|
}
|
|
8774
|
-
const
|
|
8745
|
+
const summary = [...result.stderr.matchAll(/^\s*Peak:\s*([+-]?(?:[\d.]+|inf)) dBFS$/gim)];
|
|
8775
8746
|
return {
|
|
8776
|
-
|
|
8777
|
-
|
|
8778
|
-
|
|
8779
|
-
audioError,
|
|
8780
|
-
audioFailures
|
|
8747
|
+
peak: checkedDbfs(toDbfs(summary.at(-1)?.[1] ?? "nan")),
|
|
8748
|
+
firstBlock: checkedDbfs(firstBlock ?? Number.NEGATIVE_INFINITY),
|
|
8749
|
+
laterBlocks: checkedDbfs(laterBlocks)
|
|
8781
8750
|
};
|
|
8782
8751
|
}
|
|
8783
|
-
|
|
8784
|
-
|
|
8785
|
-
|
|
8786
|
-
|
|
8787
|
-
|
|
8788
|
-
|
|
8789
|
-
|
|
8790
|
-
|
|
8791
|
-
|
|
8792
|
-
|
|
8793
|
-
|
|
8794
|
-
|
|
8795
|
-
|
|
8796
|
-
|
|
8797
|
-
|
|
8798
|
-
|
|
8799
|
-
|
|
8800
|
-
|
|
8801
|
-
|
|
8802
|
-
|
|
8752
|
+
async function measureTruePeakDbfs(audioPath, options) {
|
|
8753
|
+
const { timeoutMs, signal } = options;
|
|
8754
|
+
const wholeFile = async () => (await scanTruePeak(audioPath, "", timeoutMs, signal)).peak;
|
|
8755
|
+
const { durationSeconds, sampleRate } = await extractAudioMetadata2(audioPath, { signal });
|
|
8756
|
+
const block = 4800;
|
|
8757
|
+
const count = sampleRate === 48e3 && Number.isFinite(durationSeconds) ? aacSegmentCount(durationSeconds) : 1;
|
|
8758
|
+
if (count === 1) return wholeFile();
|
|
8759
|
+
const cut = (k) => k * Math.floor(durationSeconds * 10 / count) * block;
|
|
8760
|
+
const peers = new AbortController();
|
|
8761
|
+
const segmentSignal = signal ? AbortSignal.any([signal, peers.signal]) : peers.signal;
|
|
8762
|
+
const scans = await Promise.all(
|
|
8763
|
+
Array.from({ length: count }, (_, k) => {
|
|
8764
|
+
const start = k === 0 ? 0 : cut(k) - block;
|
|
8765
|
+
const end = k === count - 1 ? "" : `:end_sample=${cut(k + 1)}`;
|
|
8766
|
+
return scanTruePeak(
|
|
8767
|
+
audioPath,
|
|
8768
|
+
`atrim=start_sample=${start}${end},`,
|
|
8769
|
+
timeoutMs,
|
|
8770
|
+
segmentSignal
|
|
8771
|
+
);
|
|
8772
|
+
})
|
|
8773
|
+
).catch((error) => {
|
|
8774
|
+
peers.abort();
|
|
8775
|
+
throw error;
|
|
8776
|
+
});
|
|
8777
|
+
const last = scans[count - 1];
|
|
8778
|
+
const counted = Math.max(scans[0].peak, ...scans.slice(1).map((scan) => scan.laterBlocks));
|
|
8779
|
+
if (last.peak > last.firstBlock || last.peak <= counted) return Math.max(counted, last.peak);
|
|
8780
|
+
return wholeFile();
|
|
8803
8781
|
}
|
|
8804
|
-
|
|
8805
|
-
|
|
8806
|
-
var
|
|
8807
|
-
|
|
8808
|
-
|
|
8809
|
-
|
|
8810
|
-
|
|
8811
|
-
|
|
8812
|
-
return frameFileExtension(captureOptions.format) === "jpg" ? Math.ceil(
|
|
8813
|
-
rawBytes / ((captureOptions.quality ?? 80) > 80 ? HIGH_QUALITY_JPEG_RAW_RGBA_DIVISOR : JPEG_RAW_RGBA_DIVISOR)
|
|
8814
|
-
) : rawBytes;
|
|
8782
|
+
|
|
8783
|
+
// src/services/render/audioPadTrim.ts
|
|
8784
|
+
var AUDIO_DURATION_TOLERANCE_SECONDS = 1e-3;
|
|
8785
|
+
var AAC_DELIVERY_TRUE_PEAK_DBFS = -1;
|
|
8786
|
+
var AAC_TRUE_PEAK_CORRECTION_HEADROOM_DB = 0.5;
|
|
8787
|
+
var MAX_TRUE_PEAK_CORRECTION_PASSES = 3;
|
|
8788
|
+
function aacTruePeakCorrectionFilter(targetDurationSeconds, attenuationDb) {
|
|
8789
|
+
return `volume=${attenuationDb.toFixed(3)}dB,apad,atrim=0:${formatSeconds(targetDurationSeconds)}`;
|
|
8815
8790
|
}
|
|
8816
|
-
function
|
|
8817
|
-
|
|
8818
|
-
|
|
8819
|
-
|
|
8820
|
-
|
|
8821
|
-
|
|
8791
|
+
function buildPadTrimAudioPlan(audioPath, outputPath, sourceDurationSeconds, targetDurationSeconds) {
|
|
8792
|
+
const delta = targetDurationSeconds - sourceDurationSeconds;
|
|
8793
|
+
const targetSec = formatSeconds(targetDurationSeconds);
|
|
8794
|
+
if (Math.abs(delta) < AUDIO_DURATION_TOLERANCE_SECONDS) {
|
|
8795
|
+
return {
|
|
8796
|
+
operation: "copy",
|
|
8797
|
+
steps: [{ kind: "copy", args: ["-i", audioPath, "-c:a", "copy", "-y", outputPath] }],
|
|
8798
|
+
cleanupPaths: []
|
|
8799
|
+
};
|
|
8822
8800
|
}
|
|
8823
|
-
|
|
8824
|
-
|
|
8825
|
-
|
|
8826
|
-
|
|
8827
|
-
|
|
8828
|
-
|
|
8801
|
+
if (delta > 0) {
|
|
8802
|
+
return {
|
|
8803
|
+
operation: "pad",
|
|
8804
|
+
steps: [{ kind: "normalize", filter: `apad,atrim=0:${targetSec}` }],
|
|
8805
|
+
cleanupPaths: []
|
|
8806
|
+
};
|
|
8829
8807
|
}
|
|
8830
|
-
return {
|
|
8808
|
+
return {
|
|
8809
|
+
operation: "trim",
|
|
8810
|
+
steps: [{ kind: "trim", filter: `atrim=duration=${targetSec},asetpts=PTS-STARTPTS` }],
|
|
8811
|
+
cleanupPaths: []
|
|
8812
|
+
};
|
|
8831
8813
|
}
|
|
8832
|
-
function
|
|
8833
|
-
|
|
8834
|
-
if (workerCount > 1 && oneWorker.enabled) {
|
|
8835
|
-
return `This render stores frames because it captures with ${workerCount} workers. Free up disk space, or re-run this render with --workers 1 to stream frames into the encoder instead. One worker is slower, so keep the default worker count for renders that fit on disk.`;
|
|
8836
|
-
}
|
|
8837
|
-
switch (oneWorker.reason) {
|
|
8838
|
-
case "format_excluded":
|
|
8839
|
-
return `${outputFormat} output stores every frame at any worker count. Free up disk space or render to mp4.`;
|
|
8840
|
-
case "disabled_by_config":
|
|
8841
|
-
return "PRODUCER_ENABLE_STREAMING_ENCODE=false sends every render to disk. Free up disk space or unset it.";
|
|
8842
|
-
case "duration_cap":
|
|
8843
|
-
return "The streaming duration cap (PRODUCER_STREAMING_ENCODE_DURATION_CAP_ENABLED=true) sends renders longer than PRODUCER_STREAMING_ENCODE_MAX_DURATION_SECONDS to disk at any worker count. Free up disk space, unset the cap, or use --low-memory-mode, which streams past it on one worker.";
|
|
8844
|
-
default:
|
|
8845
|
-
return "Free up disk space.";
|
|
8846
|
-
}
|
|
8814
|
+
function formatSeconds(sec) {
|
|
8815
|
+
return sec.toFixed(6);
|
|
8847
8816
|
}
|
|
8848
|
-
function
|
|
8849
|
-
const
|
|
8850
|
-
|
|
8851
|
-
|
|
8852
|
-
);
|
|
8817
|
+
function sanitizeProbeFailure(reason, paths) {
|
|
8818
|
+
const message = reason instanceof Error ? reason.message : String(reason);
|
|
8819
|
+
const keepFfmpegsLastLineUncapped = Number.POSITIVE_INFINITY;
|
|
8820
|
+
return redactTelemetryString3(redactKnownPaths(message, paths), keepFfmpegsLastLineUncapped);
|
|
8853
8821
|
}
|
|
8854
|
-
function
|
|
8855
|
-
const
|
|
8856
|
-
|
|
8857
|
-
|
|
8858
|
-
|
|
8859
|
-
|
|
8860
|
-
);
|
|
8861
|
-
|
|
8862
|
-
|
|
8863
|
-
|
|
8864
|
-
|
|
8865
|
-
|
|
8866
|
-
|
|
8867
|
-
|
|
8822
|
+
async function padOrTrimAudioToVideoFrameCount(input) {
|
|
8823
|
+
const probeVideo = input.probeVideoFrameInfo ?? ((videoPath) => defaultProbeVideoFrameInfo(videoPath, input.signal));
|
|
8824
|
+
const probeAudio = input.probeAudioInfo ?? defaultProbeAudioInfo;
|
|
8825
|
+
const timeoutMs = input.timeoutMs ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
|
|
8826
|
+
const runner = input.runFfmpeg ?? ((args) => defaultRunFfmpeg(args, timeoutMs, input.signal));
|
|
8827
|
+
const encode = input.encodeAac ?? defaultAacEncoder(timeoutMs, input.signal);
|
|
8828
|
+
const probeTruePeak = input.probeAudioTruePeakDbfs ?? (input.runFfmpeg || input.encodeAac ? void 0 : (audioPath, signal) => measureTruePeakDbfs(audioPath, { timeoutMs, signal }));
|
|
8829
|
+
const [videoResult, audioResult] = await Promise.allSettled([
|
|
8830
|
+
probeVideo(input.videoPath),
|
|
8831
|
+
probeAudio(input.audioPath, input.signal)
|
|
8832
|
+
]);
|
|
8833
|
+
const probePaths = [input.videoPath, input.audioPath, input.outputPath];
|
|
8834
|
+
if (videoResult.status === "rejected") {
|
|
8835
|
+
return failResult(
|
|
8836
|
+
input.outputPath,
|
|
8837
|
+
0,
|
|
8838
|
+
audioResult.status === "fulfilled" ? audioResult.value.durationSeconds : 0,
|
|
8839
|
+
`audioPadTrim: failed to probe video: ${sanitizeProbeFailure(
|
|
8840
|
+
videoResult.reason,
|
|
8841
|
+
probePaths
|
|
8842
|
+
)}`
|
|
8843
|
+
);
|
|
8844
|
+
}
|
|
8845
|
+
if (audioResult.status === "rejected") {
|
|
8846
|
+
return failResult(
|
|
8847
|
+
input.outputPath,
|
|
8848
|
+
0,
|
|
8849
|
+
0,
|
|
8850
|
+
`audioPadTrim: failed to probe audio: ${sanitizeProbeFailure(
|
|
8851
|
+
audioResult.reason,
|
|
8852
|
+
probePaths
|
|
8853
|
+
)}`
|
|
8854
|
+
);
|
|
8855
|
+
}
|
|
8856
|
+
const videoInfo = videoResult.value;
|
|
8857
|
+
const audioInfo = audioResult.value;
|
|
8858
|
+
if (!Number.isFinite(videoInfo.frameCount) || videoInfo.frameCount <= 0 || !Number.isFinite(videoInfo.fpsNum) || videoInfo.fpsNum <= 0 || !Number.isFinite(videoInfo.fpsDen) || videoInfo.fpsDen <= 0) {
|
|
8859
|
+
return failResult(
|
|
8860
|
+
input.outputPath,
|
|
8861
|
+
0,
|
|
8862
|
+
audioInfo.durationSeconds,
|
|
8863
|
+
`audioPadTrim: invalid video frame info: ${JSON.stringify(videoInfo)}`
|
|
8864
|
+
);
|
|
8865
|
+
}
|
|
8866
|
+
const targetDurationSeconds = videoInfo.frameCount * videoInfo.fpsDen / videoInfo.fpsNum;
|
|
8867
|
+
const plan = buildPadTrimAudioPlan(
|
|
8868
|
+
input.audioPath,
|
|
8869
|
+
input.outputPath,
|
|
8870
|
+
audioInfo.durationSeconds,
|
|
8871
|
+
targetDurationSeconds
|
|
8868
8872
|
);
|
|
8869
|
-
|
|
8870
|
-
|
|
8871
|
-
|
|
8872
|
-
|
|
8873
|
-
|
|
8874
|
-
|
|
8875
|
-
|
|
8876
|
-
|
|
8877
|
-
|
|
8878
|
-
|
|
8879
|
-
|
|
8880
|
-
|
|
8881
|
-
|
|
8873
|
+
try {
|
|
8874
|
+
for (const step of plan.steps) {
|
|
8875
|
+
const ffmpegResult = step.kind === "copy" ? await runner(step.args) : await encode(input.audioPath, input.outputPath, targetDurationSeconds, step.filter);
|
|
8876
|
+
if (!ffmpegResult.success) {
|
|
8877
|
+
return {
|
|
8878
|
+
success: false,
|
|
8879
|
+
outputPath: input.outputPath,
|
|
8880
|
+
targetDurationSeconds,
|
|
8881
|
+
sourceDurationSeconds: audioInfo.durationSeconds,
|
|
8882
|
+
operation: plan.operation,
|
|
8883
|
+
error: ffmpegResult.error,
|
|
8884
|
+
failureReason: ffmpegResult.failureReason
|
|
8885
|
+
};
|
|
8886
|
+
}
|
|
8882
8887
|
}
|
|
8888
|
+
} catch (err) {
|
|
8889
|
+
return {
|
|
8890
|
+
success: false,
|
|
8891
|
+
outputPath: input.outputPath,
|
|
8892
|
+
targetDurationSeconds,
|
|
8893
|
+
sourceDurationSeconds: audioInfo.durationSeconds,
|
|
8894
|
+
operation: plan.operation,
|
|
8895
|
+
error: `audioPadTrim: failed to materialize ${plan.operation}: ${err instanceof Error ? err.message : String(err)}`
|
|
8896
|
+
};
|
|
8897
|
+
} finally {
|
|
8898
|
+
for (const path of plan.cleanupPaths) rmSync7(path, { force: true });
|
|
8883
8899
|
}
|
|
8884
|
-
|
|
8885
|
-
|
|
8886
|
-
|
|
8887
|
-
|
|
8888
|
-
|
|
8889
|
-
|
|
8890
|
-
|
|
8891
|
-
|
|
8892
|
-
|
|
8893
|
-
|
|
8894
|
-
|
|
8895
|
-
|
|
8896
|
-
|
|
8897
|
-
|
|
8898
|
-
|
|
8899
|
-
|
|
8900
|
+
let loweredDb = 0;
|
|
8901
|
+
if (probeTruePeak) {
|
|
8902
|
+
const correction = await enforceAacTruePeak({
|
|
8903
|
+
audioPath: input.outputPath,
|
|
8904
|
+
targetDurationSeconds,
|
|
8905
|
+
signal: input.signal,
|
|
8906
|
+
probeTruePeak,
|
|
8907
|
+
encode
|
|
8908
|
+
});
|
|
8909
|
+
if (!correction.success) {
|
|
8910
|
+
return {
|
|
8911
|
+
success: false,
|
|
8912
|
+
outputPath: input.outputPath,
|
|
8913
|
+
targetDurationSeconds,
|
|
8914
|
+
sourceDurationSeconds: audioInfo.durationSeconds,
|
|
8915
|
+
operation: plan.operation,
|
|
8916
|
+
error: correction.error,
|
|
8917
|
+
failureReason: correction.failureReason
|
|
8918
|
+
};
|
|
8900
8919
|
}
|
|
8920
|
+
loweredDb = correction.loweredDb ?? 0;
|
|
8921
|
+
}
|
|
8922
|
+
return {
|
|
8923
|
+
success: true,
|
|
8924
|
+
outputPath: input.outputPath,
|
|
8925
|
+
targetDurationSeconds,
|
|
8926
|
+
sourceDurationSeconds: audioInfo.durationSeconds,
|
|
8927
|
+
operation: plan.operation,
|
|
8928
|
+
...loweredDb > 0 ? { audioLoweredDb: loweredDb } : {}
|
|
8901
8929
|
};
|
|
8902
8930
|
}
|
|
8903
|
-
async function
|
|
8904
|
-
const
|
|
8905
|
-
|
|
8906
|
-
|
|
8907
|
-
|
|
8908
|
-
|
|
8909
|
-
|
|
8910
|
-
|
|
8911
|
-
|
|
8912
|
-
|
|
8913
|
-
|
|
8914
|
-
|
|
8915
|
-
|
|
8916
|
-
|
|
8917
|
-
|
|
8918
|
-
|
|
8919
|
-
|
|
8920
|
-
|
|
8921
|
-
|
|
8922
|
-
|
|
8923
|
-
|
|
8924
|
-
|
|
8925
|
-
let captureBeyondViewport = probeSession?.options.captureBeyondViewport;
|
|
8926
|
-
const captureCfg = cfg.forceScreenshot === forceScreenshot ? cfg : { ...cfg, forceScreenshot };
|
|
8927
|
-
if (frameRange !== void 0) {
|
|
8928
|
-
if (!Number.isFinite(frameRange.startFrame) || !Number.isFinite(frameRange.endFrame) || frameRange.startFrame < 0 || frameRange.endFrame <= frameRange.startFrame) {
|
|
8929
|
-
throw new Error(
|
|
8930
|
-
`[captureStage] invalid frameRange: ${JSON.stringify(frameRange)}. Expected non-negative startFrame strictly less than endFrame.`
|
|
8931
|
+
async function constrainAacTruePeak(input) {
|
|
8932
|
+
const timeoutMs = input.timeoutMs ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
|
|
8933
|
+
return enforceAacTruePeak({
|
|
8934
|
+
audioPath: input.audioPath,
|
|
8935
|
+
targetDurationSeconds: input.targetDurationSeconds,
|
|
8936
|
+
signal: input.signal,
|
|
8937
|
+
probeTruePeak: (audioPath, signal) => measureTruePeakDbfs(audioPath, { timeoutMs, signal }),
|
|
8938
|
+
encode: defaultAacEncoder(timeoutMs, input.signal, input.sourcePath)
|
|
8939
|
+
});
|
|
8940
|
+
}
|
|
8941
|
+
async function enforceAacTruePeak(input) {
|
|
8942
|
+
let scratchDir;
|
|
8943
|
+
try {
|
|
8944
|
+
scratchDir = mkdtempSync3(join17(dirname7(input.audioPath), ".true-peak-"));
|
|
8945
|
+
const correctedPath = join17(scratchDir, "audio.m4a");
|
|
8946
|
+
const measurements = [];
|
|
8947
|
+
let attenuationDb = 0;
|
|
8948
|
+
let measuredPath = input.audioPath;
|
|
8949
|
+
for (let pass = 0; pass <= MAX_TRUE_PEAK_CORRECTION_PASSES; pass += 1) {
|
|
8950
|
+
const truePeakDbfs = await input.probeTruePeak(measuredPath, input.signal);
|
|
8951
|
+
measurements.push(
|
|
8952
|
+
`pass ${pass}: ${truePeakDbfs.toFixed(3)} dBFS at ${attenuationDb.toFixed(3)} dB attenuation`
|
|
8931
8953
|
);
|
|
8932
|
-
|
|
8933
|
-
|
|
8934
|
-
|
|
8935
|
-
|
|
8936
|
-
|
|
8954
|
+
if (Number.isNaN(truePeakDbfs) || truePeakDbfs === Number.POSITIVE_INFINITY) {
|
|
8955
|
+
return { success: false, error: "audioPadTrim: FFmpeg reported an invalid true peak" };
|
|
8956
|
+
}
|
|
8957
|
+
if (truePeakDbfs <= AAC_DELIVERY_TRUE_PEAK_DBFS) {
|
|
8958
|
+
if (measuredPath !== correctedPath) return { success: true };
|
|
8959
|
+
renameSync4(correctedPath, input.audioPath);
|
|
8960
|
+
return { success: true, loweredDb: Number((-attenuationDb).toFixed(2)) };
|
|
8961
|
+
}
|
|
8962
|
+
if (pass === MAX_TRUE_PEAK_CORRECTION_PASSES) break;
|
|
8963
|
+
const headroomDb = AAC_TRUE_PEAK_CORRECTION_HEADROOM_DB * 2 ** pass;
|
|
8964
|
+
attenuationDb += AAC_DELIVERY_TRUE_PEAK_DBFS - headroomDb - truePeakDbfs;
|
|
8965
|
+
const result = await input.encode(
|
|
8966
|
+
input.audioPath,
|
|
8967
|
+
correctedPath,
|
|
8968
|
+
input.targetDurationSeconds,
|
|
8969
|
+
aacTruePeakCorrectionFilter(input.targetDurationSeconds, attenuationDb)
|
|
8937
8970
|
);
|
|
8971
|
+
if (!result.success) {
|
|
8972
|
+
return {
|
|
8973
|
+
success: false,
|
|
8974
|
+
error: sanitizeProbeFailure(
|
|
8975
|
+
result.error ?? "audioPadTrim: failed to constrain AAC true peak",
|
|
8976
|
+
[input.audioPath, correctedPath]
|
|
8977
|
+
),
|
|
8978
|
+
failureReason: result.failureReason
|
|
8979
|
+
};
|
|
8980
|
+
}
|
|
8981
|
+
measuredPath = correctedPath;
|
|
8938
8982
|
}
|
|
8983
|
+
return {
|
|
8984
|
+
success: false,
|
|
8985
|
+
error: `audioPadTrim: AAC true peak remained above ${AAC_DELIVERY_TRUE_PEAK_DBFS} dBFS after ${MAX_TRUE_PEAK_CORRECTION_PASSES} correction passes; ${measurements.join("; ")}`
|
|
8986
|
+
};
|
|
8987
|
+
} catch (err) {
|
|
8988
|
+
return {
|
|
8989
|
+
success: false,
|
|
8990
|
+
error: `audioPadTrim: failed to validate AAC true peak: ${sanitizeProbeFailure(
|
|
8991
|
+
err,
|
|
8992
|
+
scratchDir ? [input.audioPath, scratchDir] : [input.audioPath]
|
|
8993
|
+
)}`
|
|
8994
|
+
};
|
|
8995
|
+
} finally {
|
|
8996
|
+
if (scratchDir) rmSync7(scratchDir, { recursive: true, force: true });
|
|
8939
8997
|
}
|
|
8940
|
-
|
|
8941
|
-
|
|
8942
|
-
|
|
8943
|
-
|
|
8944
|
-
|
|
8945
|
-
|
|
8946
|
-
|
|
8947
|
-
|
|
8948
|
-
|
|
8949
|
-
|
|
8950
|
-
|
|
8951
|
-
|
|
8952
|
-
|
|
8953
|
-
|
|
8954
|
-
|
|
8955
|
-
|
|
8956
|
-
|
|
8957
|
-
|
|
8958
|
-
|
|
8959
|
-
|
|
8960
|
-
|
|
8961
|
-
|
|
8962
|
-
|
|
8963
|
-
|
|
8964
|
-
|
|
8965
|
-
|
|
8966
|
-
|
|
8967
|
-
|
|
8968
|
-
|
|
8969
|
-
|
|
8970
|
-
|
|
8971
|
-
|
|
8972
|
-
|
|
8973
|
-
|
|
8974
|
-
|
|
8975
|
-
|
|
8976
|
-
|
|
8977
|
-
|
|
8978
|
-
|
|
8979
|
-
|
|
8980
|
-
|
|
8981
|
-
|
|
8982
|
-
|
|
8983
|
-
|
|
8984
|
-
|
|
8985
|
-
|
|
8986
|
-
|
|
8987
|
-
|
|
8998
|
+
}
|
|
8999
|
+
function failResult(outputPath, target, source, error) {
|
|
9000
|
+
return {
|
|
9001
|
+
success: false,
|
|
9002
|
+
outputPath,
|
|
9003
|
+
targetDurationSeconds: target,
|
|
9004
|
+
sourceDurationSeconds: source,
|
|
9005
|
+
operation: "copy",
|
|
9006
|
+
error
|
|
9007
|
+
};
|
|
9008
|
+
}
|
|
9009
|
+
async function defaultProbeVideoFrameInfo(videoPath, signal) {
|
|
9010
|
+
const fastInfo = await runFfprobeJson(
|
|
9011
|
+
[
|
|
9012
|
+
"-v",
|
|
9013
|
+
"error",
|
|
9014
|
+
"-select_streams",
|
|
9015
|
+
"v:0",
|
|
9016
|
+
"-show_entries",
|
|
9017
|
+
"stream=nb_frames,r_frame_rate",
|
|
9018
|
+
"-of",
|
|
9019
|
+
"json",
|
|
9020
|
+
"--",
|
|
9021
|
+
videoPath
|
|
9022
|
+
],
|
|
9023
|
+
signal
|
|
9024
|
+
);
|
|
9025
|
+
let stream = fastInfo.streams?.[0];
|
|
9026
|
+
const fastCount = Number(stream?.nb_frames);
|
|
9027
|
+
if (stream && Number.isFinite(fastCount) && fastCount > 0) {
|
|
9028
|
+
return { frameCount: fastCount, ...parseFrameRate(stream.r_frame_rate ?? "") };
|
|
9029
|
+
}
|
|
9030
|
+
const slowInfo = await runFfprobeJson(
|
|
9031
|
+
[
|
|
9032
|
+
"-v",
|
|
9033
|
+
"error",
|
|
9034
|
+
"-select_streams",
|
|
9035
|
+
"v:0",
|
|
9036
|
+
"-count_packets",
|
|
9037
|
+
"-show_entries",
|
|
9038
|
+
"stream=nb_read_packets,r_frame_rate",
|
|
9039
|
+
"-of",
|
|
9040
|
+
"json",
|
|
9041
|
+
"--",
|
|
9042
|
+
videoPath
|
|
9043
|
+
],
|
|
9044
|
+
signal
|
|
9045
|
+
);
|
|
9046
|
+
stream = slowInfo.streams?.[0];
|
|
9047
|
+
if (!stream) throw new Error(`ffprobe found no video stream in ${videoPath}`);
|
|
9048
|
+
const slowCount = Number(stream.nb_read_packets);
|
|
9049
|
+
if (!Number.isFinite(slowCount) || slowCount <= 0) {
|
|
9050
|
+
throw new Error(`ffprobe returned no frame count: ${JSON.stringify(stream)}`);
|
|
9051
|
+
}
|
|
9052
|
+
return { frameCount: slowCount, ...parseFrameRate(stream.r_frame_rate ?? "") };
|
|
9053
|
+
}
|
|
9054
|
+
function parseFrameRate(rate) {
|
|
9055
|
+
const [n, d] = rate.split("/");
|
|
9056
|
+
const fpsNum = Number(n);
|
|
9057
|
+
const fpsDen = d === void 0 ? 1 : Number(d);
|
|
9058
|
+
if (!Number.isFinite(fpsNum) || !Number.isFinite(fpsDen) || fpsNum <= 0 || fpsDen <= 0) {
|
|
9059
|
+
throw new Error(`Invalid r_frame_rate: ${JSON.stringify(rate)}`);
|
|
9060
|
+
}
|
|
9061
|
+
return { fpsNum, fpsDen };
|
|
9062
|
+
}
|
|
9063
|
+
async function defaultProbeAudioInfo(audioPath, signal) {
|
|
9064
|
+
const metadata = await extractAudioMetadata3(audioPath, { signal });
|
|
9065
|
+
return {
|
|
9066
|
+
durationSeconds: metadata.durationSeconds,
|
|
9067
|
+
sampleRate: metadata.sampleRate,
|
|
9068
|
+
channels: metadata.channels,
|
|
9069
|
+
audioCodec: metadata.audioCodec
|
|
9070
|
+
};
|
|
9071
|
+
}
|
|
9072
|
+
async function defaultRunFfmpeg(args, timeoutMs, signal) {
|
|
9073
|
+
return toStepResult(await runFfmpeg4(args, { signal, timeout: timeoutMs }), timeoutMs);
|
|
9074
|
+
}
|
|
9075
|
+
function defaultAacEncoder(timeoutMs, signal, pcmSourcePath) {
|
|
9076
|
+
return async (inputPath, outputPath, durationSeconds, filter) => {
|
|
9077
|
+
const options = pcmSourcePath ? { signal, timeout: timeoutMs, filter } : { signal, timeout: timeoutMs, filter, segments: 1 };
|
|
9078
|
+
return toStepResult(
|
|
9079
|
+
await encodeAac(pcmSourcePath ?? inputPath, outputPath, durationSeconds, options),
|
|
9080
|
+
timeoutMs
|
|
9081
|
+
);
|
|
9082
|
+
};
|
|
9083
|
+
}
|
|
9084
|
+
function toStepResult(result, timeoutMs) {
|
|
9085
|
+
if (result.success) return { success: true };
|
|
9086
|
+
return {
|
|
9087
|
+
success: false,
|
|
9088
|
+
error: `[audioPadTrim] ${describeFfmpegFailure2(result, timeoutMs)}`,
|
|
9089
|
+
failureReason: result.failureReason
|
|
9090
|
+
};
|
|
9091
|
+
}
|
|
9092
|
+
async function runFfprobeJson(args, signal) {
|
|
9093
|
+
if (!args.includes("--")) {
|
|
9094
|
+
throw new Error('[audioPadTrim] ffprobe args must terminate options with "--".');
|
|
9095
|
+
}
|
|
9096
|
+
const proc = spawn(getFfprobeBinary(), args, {
|
|
9097
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
9098
|
+
windowsHide: true
|
|
9099
|
+
});
|
|
9100
|
+
trackChildProcess(proc);
|
|
9101
|
+
let stdout = "";
|
|
9102
|
+
proc.stdout.on("data", (data) => {
|
|
9103
|
+
stdout += data.toString();
|
|
9104
|
+
});
|
|
9105
|
+
const managed = new ManagedChildProcess(proc, {
|
|
9106
|
+
signal,
|
|
9107
|
+
deadlineAtMs: Date.now() + 3e4
|
|
9108
|
+
});
|
|
9109
|
+
const outcome = await managed.wait();
|
|
9110
|
+
if (outcome.reason === "spawn_error") {
|
|
9111
|
+
if (outcome.error?.code === "ENOENT") {
|
|
9112
|
+
throw new Error("[audioPadTrim] ffprobe not found. Please install FFmpeg.");
|
|
8988
9113
|
}
|
|
8989
|
-
|
|
8990
|
-
|
|
8991
|
-
|
|
8992
|
-
|
|
8993
|
-
|
|
9114
|
+
throw outcome.error ?? new Error(outcome.stderr);
|
|
9115
|
+
}
|
|
9116
|
+
if (outcome.reason !== "exit" || outcome.exitCode !== 0) {
|
|
9117
|
+
const probed = args[args.length - 1];
|
|
9118
|
+
const scrubbed = redactKnownPaths(outcome.stderr, probed === void 0 ? [] : [probed]);
|
|
9119
|
+
throw new Error(`ffprobe ${outcome.reason}: ${redactTelemetryString3(scrubbed, 2e3)}`);
|
|
9120
|
+
}
|
|
9121
|
+
try {
|
|
9122
|
+
return JSON.parse(stdout);
|
|
9123
|
+
} catch (err) {
|
|
9124
|
+
throw new Error(`Failed to parse ffprobe output: ${err.message}`);
|
|
9125
|
+
}
|
|
9126
|
+
}
|
|
9127
|
+
|
|
9128
|
+
// src/services/render/stages/audioStage.ts
|
|
9129
|
+
function assertAudioNotInterrupted(failures) {
|
|
9130
|
+
const interrupted = failures?.find((failure) => failure.reason === "external_interruption");
|
|
9131
|
+
if (!interrupted) return;
|
|
9132
|
+
throw encoderFailureError("Audio processing failed", {
|
|
9133
|
+
error: interrupted.detail,
|
|
9134
|
+
failureReason: "external_interruption",
|
|
9135
|
+
audioFailures: audioFailureDiagnostics([
|
|
9136
|
+
interrupted,
|
|
9137
|
+
...(failures ?? []).filter((failure) => failure !== interrupted)
|
|
9138
|
+
])
|
|
9139
|
+
});
|
|
9140
|
+
}
|
|
9141
|
+
async function runAudioStage(input) {
|
|
9142
|
+
const stageStart = Date.now();
|
|
9143
|
+
const pcmSourcePath = join18(input.workDir, "audio-source.wav");
|
|
9144
|
+
const duration = input.totalFrames * input.fps.den / input.fps.num;
|
|
9145
|
+
try {
|
|
9146
|
+
const result = await mixAudioForDelivery(input, duration, pcmSourcePath);
|
|
9147
|
+
let audioLoweredDb;
|
|
9148
|
+
if (result.hasAudio) {
|
|
9149
|
+
const peak = await constrainAacTruePeak({
|
|
9150
|
+
audioPath: result.audioOutputPath,
|
|
9151
|
+
sourcePath: pcmSourcePath,
|
|
9152
|
+
targetDurationSeconds: duration,
|
|
9153
|
+
signal: input.abortSignal,
|
|
9154
|
+
timeoutMs: input.ffmpegProcessTimeout
|
|
9155
|
+
});
|
|
9156
|
+
if (!peak.success) throw encoderFailureError("Audio true-peak check failed", peak);
|
|
9157
|
+
audioLoweredDb = peak.loweredDb;
|
|
8994
9158
|
}
|
|
8995
|
-
|
|
8996
|
-
|
|
8997
|
-
|
|
8998
|
-
captureOptions,
|
|
8999
|
-
captureCfg,
|
|
9000
|
-
checkDiskProjection
|
|
9001
|
-
);
|
|
9002
|
-
probeSession = null;
|
|
9003
|
-
lastBrowserConsole = sequential.lastBrowserConsole;
|
|
9004
|
-
captureBeyondViewport = sequential.captureBeyondViewport;
|
|
9159
|
+
return { ...result, audioLoweredDb, audioProcessMs: Date.now() - stageStart };
|
|
9160
|
+
} finally {
|
|
9161
|
+
rmSync8(pcmSourcePath, { force: true });
|
|
9005
9162
|
}
|
|
9006
|
-
return { workerCount, probeSession, lastBrowserConsole, captureBeyondViewport };
|
|
9007
9163
|
}
|
|
9008
|
-
async function
|
|
9164
|
+
async function mixAudioForDelivery(input, duration, pcmSourcePath) {
|
|
9009
9165
|
const {
|
|
9010
|
-
|
|
9011
|
-
|
|
9012
|
-
|
|
9013
|
-
|
|
9014
|
-
|
|
9166
|
+
projectDir,
|
|
9167
|
+
workDir,
|
|
9168
|
+
compiledDir,
|
|
9169
|
+
ffmpegProcessTimeout,
|
|
9170
|
+
audioGain,
|
|
9171
|
+
audios,
|
|
9172
|
+
masterFxChain,
|
|
9173
|
+
urlByAbsentLocalPath,
|
|
9015
9174
|
abortSignal,
|
|
9016
9175
|
assertNotAborted,
|
|
9017
|
-
|
|
9176
|
+
log
|
|
9018
9177
|
} = input;
|
|
9019
|
-
|
|
9020
|
-
let
|
|
9021
|
-
let
|
|
9022
|
-
|
|
9023
|
-
|
|
9024
|
-
|
|
9025
|
-
let transientRetriesUsed = 0;
|
|
9026
|
-
let resumeFrom = 0;
|
|
9027
|
-
while (true) {
|
|
9028
|
-
const videoInjector = createRenderVideoFrameInjector();
|
|
9029
|
-
const session = probeSession ?? await createCaptureSession5(
|
|
9030
|
-
fileServer.url,
|
|
9031
|
-
framesDir,
|
|
9032
|
-
captureOptions,
|
|
9033
|
-
videoInjector,
|
|
9034
|
-
captureCfg
|
|
9035
|
-
);
|
|
9036
|
-
captureBeyondViewport = session.options.captureBeyondViewport;
|
|
9178
|
+
const audioOutputPath = join18(workDir, MIXED_AUDIO_FILENAME);
|
|
9179
|
+
let hasAudio = false;
|
|
9180
|
+
let audioError;
|
|
9181
|
+
let audioFailures;
|
|
9182
|
+
if (audios.length > 0) {
|
|
9183
|
+
let audioResult;
|
|
9037
9184
|
try {
|
|
9038
|
-
|
|
9039
|
-
|
|
9040
|
-
|
|
9041
|
-
|
|
9042
|
-
|
|
9043
|
-
|
|
9044
|
-
lastBrowserConsole = session.browserConsoleBuffer;
|
|
9045
|
-
await captureSessionFrames(session, {
|
|
9046
|
-
input,
|
|
9047
|
-
rangeStart,
|
|
9048
|
-
rangeEnd,
|
|
9049
|
-
resumeFrom,
|
|
9050
|
-
checkDiskProjection
|
|
9051
|
-
});
|
|
9052
|
-
break;
|
|
9053
|
-
} catch (error) {
|
|
9054
|
-
lastBrowserConsole = session.browserConsoleBuffer;
|
|
9055
|
-
const resume = planTransientResume(error, {
|
|
9185
|
+
audioResult = await processCompositionAudio(
|
|
9186
|
+
audios,
|
|
9187
|
+
projectDir,
|
|
9188
|
+
join18(workDir, "audio-work"),
|
|
9189
|
+
audioOutputPath,
|
|
9190
|
+
duration,
|
|
9056
9191
|
abortSignal,
|
|
9057
|
-
|
|
9058
|
-
|
|
9059
|
-
|
|
9060
|
-
|
|
9061
|
-
|
|
9062
|
-
|
|
9063
|
-
|
|
9064
|
-
|
|
9065
|
-
|
|
9066
|
-
|
|
9067
|
-
|
|
9068
|
-
|
|
9069
|
-
|
|
9070
|
-
|
|
9071
|
-
|
|
9072
|
-
|
|
9073
|
-
|
|
9074
|
-
|
|
9075
|
-
|
|
9076
|
-
|
|
9077
|
-
|
|
9078
|
-
|
|
9079
|
-
|
|
9192
|
+
{
|
|
9193
|
+
ffmpegProcessTimeout,
|
|
9194
|
+
audioGain,
|
|
9195
|
+
masterFxChain,
|
|
9196
|
+
urlByAbsentLocalPath,
|
|
9197
|
+
keepEncoderInputAt: pcmSourcePath
|
|
9198
|
+
},
|
|
9199
|
+
compiledDir
|
|
9200
|
+
);
|
|
9201
|
+
} catch (err) {
|
|
9202
|
+
assertNotAborted();
|
|
9203
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
9204
|
+
return {
|
|
9205
|
+
audioOutputPath,
|
|
9206
|
+
hasAudio: false,
|
|
9207
|
+
audioError: detail,
|
|
9208
|
+
// Synthesised rather than left undefined. The warning policy reads
|
|
9209
|
+
// owner, retryability, reason and stage off this list, so reporting the
|
|
9210
|
+
// FATAL failure — the one that took the whole mix down — with an empty
|
|
9211
|
+
// one gave it strictly less classification than a single dropped track
|
|
9212
|
+
// gets, which is the opposite of what this stage exists to do.
|
|
9213
|
+
// "internal" is the honest bucket: the stages enumerate ffmpeg steps
|
|
9214
|
+
// and this is the FX render, which is none of them.
|
|
9215
|
+
audioFailures: [
|
|
9216
|
+
{
|
|
9217
|
+
stage: "internal",
|
|
9218
|
+
reason: "internal",
|
|
9219
|
+
owner: "system",
|
|
9220
|
+
retryable: false,
|
|
9221
|
+
detail: detail.slice(0, 2e3)
|
|
9222
|
+
}
|
|
9223
|
+
]
|
|
9224
|
+
};
|
|
9225
|
+
}
|
|
9226
|
+
assertNotAborted();
|
|
9227
|
+
assertAudioNotInterrupted(audioResult.failures);
|
|
9228
|
+
hasAudio = audioResult.success;
|
|
9229
|
+
audioFailures = audioResult.failures;
|
|
9230
|
+
if (!hasAudio) {
|
|
9231
|
+
audioError = audioResult.error ?? "audio mix failed for an unknown reason";
|
|
9232
|
+
for (const failure of audioResult.failures ?? []) {
|
|
9233
|
+
log?.warn("Audio track failed", {
|
|
9234
|
+
elementId: failure.elementId,
|
|
9235
|
+
stage: failure.stage,
|
|
9236
|
+
reason: failure.reason,
|
|
9237
|
+
owner: failure.owner,
|
|
9238
|
+
retryable: failure.retryable,
|
|
9239
|
+
detail: failure.detail
|
|
9240
|
+
});
|
|
9241
|
+
}
|
|
9080
9242
|
}
|
|
9081
9243
|
}
|
|
9082
|
-
return {
|
|
9244
|
+
return {
|
|
9245
|
+
audioOutputPath,
|
|
9246
|
+
hasAudio,
|
|
9247
|
+
audioError,
|
|
9248
|
+
audioFailures
|
|
9249
|
+
};
|
|
9083
9250
|
}
|
|
9084
|
-
|
|
9085
|
-
|
|
9251
|
+
|
|
9252
|
+
// src/services/render/stages/captureStage.ts
|
|
9253
|
+
import { existsSync as existsSync12, readdirSync as readdirSync3, statfsSync as statfsSync2 } from "node:fs";
|
|
9254
|
+
import { join as join19 } from "node:path";
|
|
9255
|
+
import {
|
|
9256
|
+
classifyCaptureFailure as classifyCaptureFailure2,
|
|
9257
|
+
frameFileExtension,
|
|
9258
|
+
captureFrame,
|
|
9259
|
+
captureFrameToBufferPipelined as captureFrameToBufferPipelined2,
|
|
9260
|
+
verifyDiskDrawElementSamples,
|
|
9261
|
+
writeCapturedFrame,
|
|
9262
|
+
closeCaptureSession as closeCaptureSession5,
|
|
9263
|
+
completeDeferredDrawElementInit as completeDeferredDrawElementInit3,
|
|
9264
|
+
createCaptureSession as createCaptureSession5,
|
|
9265
|
+
getCapturePerfSummary as getCapturePerfSummary3,
|
|
9266
|
+
initializeSession as initializeSession5,
|
|
9267
|
+
prepareCaptureSessionForReuse as prepareCaptureSessionForReuse3
|
|
9268
|
+
} from "@hyperframes/engine";
|
|
9269
|
+
function shouldAllowAdaptiveCaptureRetry(workerCount, _explicitlyConfigured) {
|
|
9270
|
+
return workerCount > 1;
|
|
9086
9271
|
}
|
|
9087
|
-
|
|
9088
|
-
|
|
9089
|
-
|
|
9090
|
-
|
|
9091
|
-
|
|
9092
|
-
|
|
9272
|
+
var JPEG_RAW_RGBA_DIVISOR = 8;
|
|
9273
|
+
var HIGH_QUALITY_JPEG_RAW_RGBA_DIVISOR = 4;
|
|
9274
|
+
var DISK_PROJECTION_SAMPLE_FRAMES = 10;
|
|
9275
|
+
function estimateDiskCaptureBytes(totalFrames, captureOptions) {
|
|
9276
|
+
const scale = captureOptions.deviceScaleFactor ?? 1;
|
|
9277
|
+
const outputWidth = Math.ceil(captureOptions.width * scale);
|
|
9278
|
+
const outputHeight = Math.ceil(captureOptions.height * scale);
|
|
9279
|
+
const rawBytes = Math.ceil(totalFrames) * outputWidth * outputHeight * 4;
|
|
9280
|
+
return frameFileExtension(captureOptions.format) === "jpg" ? Math.ceil(
|
|
9281
|
+
rawBytes / ((captureOptions.quality ?? 80) > 80 ? HIGH_QUALITY_JPEG_RAW_RGBA_DIVISOR : JPEG_RAW_RGBA_DIVISOR)
|
|
9282
|
+
) : rawBytes;
|
|
9093
9283
|
}
|
|
9094
|
-
|
|
9095
|
-
|
|
9096
|
-
|
|
9097
|
-
return;
|
|
9098
|
-
}
|
|
9099
|
-
|
|
9100
|
-
await completeDeferredDrawElementInit3(session);
|
|
9284
|
+
function freeDiskBytesAt(path) {
|
|
9285
|
+
try {
|
|
9286
|
+
const stat = statfsSync2(path);
|
|
9287
|
+
return stat.bavail * stat.bsize;
|
|
9288
|
+
} catch {
|
|
9289
|
+
return null;
|
|
9101
9290
|
}
|
|
9102
9291
|
}
|
|
9103
|
-
|
|
9104
|
-
const
|
|
9105
|
-
const
|
|
9106
|
-
|
|
9107
|
-
|
|
9108
|
-
checkDiskProjection(fileIndex + 1);
|
|
9109
|
-
reportFrameProgress(
|
|
9110
|
-
job,
|
|
9111
|
-
`Capturing frame ${fileIndex + 1}/${rangeFrames}`,
|
|
9112
|
-
Math.round(25 + (fileIndex + 1) / rangeFrames * 45),
|
|
9113
|
-
onProgress,
|
|
9114
|
-
fileIndex + 1,
|
|
9115
|
-
rangeFrames
|
|
9116
|
-
);
|
|
9117
|
-
};
|
|
9118
|
-
if (session.workerEncodeEnabled) {
|
|
9119
|
-
let prev = null;
|
|
9120
|
-
const drainPrev = async () => {
|
|
9121
|
-
if (!prev) return;
|
|
9122
|
-
assertNotAborted();
|
|
9123
|
-
const buf = await prev.encodeResult;
|
|
9124
|
-
writeCapturedFrame(session, prev.fileIndex, buf);
|
|
9125
|
-
reportFrame(prev.fileIndex);
|
|
9126
|
-
};
|
|
9127
|
-
for (let i = resumeFrom; i < rangeFrames; i++) {
|
|
9128
|
-
assertNotAborted();
|
|
9129
|
-
const absoluteIdx = rangeStart + i;
|
|
9130
|
-
const time = absoluteIdx * job.config.fps.den / job.config.fps.num;
|
|
9131
|
-
const { encodeResult } = await captureFrameToBufferPipelined2(session, i, time);
|
|
9132
|
-
await drainPrev();
|
|
9133
|
-
prev = { fileIndex: i, encodeResult };
|
|
9134
|
-
}
|
|
9135
|
-
await drainPrev();
|
|
9136
|
-
} else {
|
|
9137
|
-
for (let i = resumeFrom; i < rangeFrames; i++) {
|
|
9138
|
-
assertNotAborted();
|
|
9139
|
-
const absoluteIdx = rangeStart + i;
|
|
9140
|
-
const time = absoluteIdx * job.config.fps.den / job.config.fps.num;
|
|
9141
|
-
await captureFrame(session, i, time);
|
|
9142
|
-
reportFrame(i);
|
|
9143
|
-
}
|
|
9292
|
+
function inspectDiskCaptureHeadroom(framesDir, totalFrames, captureOptions, freeDiskBytes = freeDiskBytesAt) {
|
|
9293
|
+
const freeBytes = freeDiskBytes(framesDir);
|
|
9294
|
+
const estimatedBytes = estimateDiskCaptureBytes(totalFrames, captureOptions);
|
|
9295
|
+
if (freeBytes === null || estimatedBytes <= freeBytes * 0.9) {
|
|
9296
|
+
return { available: true, estimatedBytes, freeBytes };
|
|
9144
9297
|
}
|
|
9145
|
-
|
|
9146
|
-
session,
|
|
9147
|
-
{
|
|
9148
|
-
workerId: 0,
|
|
9149
|
-
startFrame: rangeStart + resumeFrom,
|
|
9150
|
-
endFrame: rangeEnd,
|
|
9151
|
-
outputDir: framesDir,
|
|
9152
|
-
outputFrameOffset: rangeStart
|
|
9153
|
-
},
|
|
9154
|
-
false
|
|
9155
|
-
);
|
|
9156
|
-
dedupPerfs.push(getCapturePerfSummary3(session));
|
|
9298
|
+
return { available: false, estimatedBytes, freeBytes };
|
|
9157
9299
|
}
|
|
9158
|
-
|
|
9159
|
-
|
|
9160
|
-
|
|
9161
|
-
|
|
9162
|
-
import {
|
|
9163
|
-
closeCaptureSession as closeCaptureSession7,
|
|
9164
|
-
cloneCaptureWarnings as cloneCaptureWarnings2,
|
|
9165
|
-
createCaptureSession as createCaptureSession7,
|
|
9166
|
-
initTransparentBackground as initTransparentBackground2,
|
|
9167
|
-
initializeSession as initializeSession7,
|
|
9168
|
-
spawnStreamingEncoder as spawnStreamingEncoder3
|
|
9169
|
-
} from "@hyperframes/engine";
|
|
9170
|
-
import { fpsToNumber as fpsToNumber8 } from "@hyperframes/core";
|
|
9171
|
-
|
|
9172
|
-
// src/services/hdrImageTransferCache.ts
|
|
9173
|
-
import { convertTransfer as convertTransfer2 } from "@hyperframes/engine";
|
|
9174
|
-
var DEFAULT_MAX_BYTES = 200 * 1024 * 1024;
|
|
9175
|
-
function createHdrImageTransferCache(options = {}) {
|
|
9176
|
-
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
9177
|
-
if (!Number.isInteger(maxBytes) || maxBytes < 0) {
|
|
9178
|
-
throw new Error(
|
|
9179
|
-
`createHdrImageTransferCache: maxBytes must be a non-negative integer, got ${String(maxBytes)}`
|
|
9180
|
-
);
|
|
9300
|
+
function diskCaptureRemedy(cfg, outputFormat, durationSeconds, workerCount) {
|
|
9301
|
+
const oneWorker = explainStreamingEncodeGate(cfg, outputFormat, 1, durationSeconds);
|
|
9302
|
+
if (workerCount > 1 && oneWorker.enabled) {
|
|
9303
|
+
return `This render stores frames because it captures with ${workerCount} workers. Free up disk space, or re-run this render with --workers 1 to stream frames into the encoder instead. One worker is slower, so keep the default worker count for renders that fit on disk.`;
|
|
9181
9304
|
}
|
|
9182
|
-
|
|
9183
|
-
|
|
9184
|
-
|
|
9185
|
-
|
|
9305
|
+
switch (oneWorker.reason) {
|
|
9306
|
+
case "format_excluded":
|
|
9307
|
+
return `${outputFormat} output stores every frame at any worker count. Free up disk space or render to mp4.`;
|
|
9308
|
+
case "disabled_by_config":
|
|
9309
|
+
return "PRODUCER_ENABLE_STREAMING_ENCODE=false sends every render to disk. Free up disk space or unset it.";
|
|
9310
|
+
case "duration_cap":
|
|
9311
|
+
return "The streaming duration cap (PRODUCER_STREAMING_ENCODE_DURATION_CAP_ENABLED=true) sends renders longer than PRODUCER_STREAMING_ENCODE_MAX_DURATION_SECONDS to disk at any worker count. Free up disk space, unset the cap, or use --low-memory-mode, which streams past it on one worker.";
|
|
9312
|
+
default:
|
|
9313
|
+
return "Free up disk space.";
|
|
9186
9314
|
}
|
|
9187
|
-
|
|
9188
|
-
|
|
9189
|
-
|
|
9190
|
-
|
|
9191
|
-
|
|
9192
|
-
|
|
9193
|
-
|
|
9315
|
+
}
|
|
9316
|
+
function diskCaptureShortfallError(needBytes, freeBytes, framesDir, basis, remedy) {
|
|
9317
|
+
const basisNote = basis === "measured" ? ` (measured from the first ${DISK_PROJECTION_SAMPLE_FRAMES} frames)` : "";
|
|
9318
|
+
return new Error(
|
|
9319
|
+
`Disk capture may need ~${(needBytes / 1e6).toFixed(1)} MB of temporary frame storage${basisNote}, but only ${(freeBytes / 1e6).toFixed(1)} MB is free at ${framesDir}. Disk capture stores every frame as an image file (JPEG, or PNG for alpha). ${remedy}`
|
|
9320
|
+
);
|
|
9321
|
+
}
|
|
9322
|
+
function measureCaptureFrameBytes(workDir, framesDir) {
|
|
9323
|
+
let total = sampleDirectoryBytes(framesDir);
|
|
9324
|
+
if (!existsSync12(workDir)) return total;
|
|
9325
|
+
for (const attempt of readdirSync3(workDir)) {
|
|
9326
|
+
if (/^retry-\d+-batch-\d+-worker-\d+$/.test(attempt)) {
|
|
9327
|
+
total += sampleDirectoryBytes(join19(workDir, attempt));
|
|
9328
|
+
continue;
|
|
9329
|
+
}
|
|
9330
|
+
if (!/^capture-attempt-\d+$/.test(attempt)) continue;
|
|
9331
|
+
const attemptDir = join19(workDir, attempt);
|
|
9332
|
+
for (const name of readdirSync3(attemptDir)) {
|
|
9333
|
+
if (/^worker-\d+$/.test(name)) total += sampleDirectoryBytes(join19(attemptDir, name));
|
|
9194
9334
|
}
|
|
9195
9335
|
}
|
|
9196
|
-
return
|
|
9197
|
-
|
|
9198
|
-
|
|
9199
|
-
|
|
9200
|
-
|
|
9201
|
-
|
|
9202
|
-
|
|
9203
|
-
|
|
9204
|
-
|
|
9205
|
-
|
|
9206
|
-
|
|
9207
|
-
|
|
9208
|
-
|
|
9209
|
-
|
|
9210
|
-
|
|
9211
|
-
|
|
9212
|
-
}
|
|
9213
|
-
const converted = Buffer.from(source);
|
|
9214
|
-
convertTransfer2(converted, sourceTransfer, targetTransfer);
|
|
9215
|
-
if (converted.byteLength > maxBytes) {
|
|
9216
|
-
return converted;
|
|
9217
|
-
}
|
|
9218
|
-
evictUntilRoom(converted.byteLength);
|
|
9219
|
-
entries.set(key, converted);
|
|
9220
|
-
totalBytes += converted.byteLength;
|
|
9221
|
-
return converted;
|
|
9222
|
-
},
|
|
9223
|
-
size() {
|
|
9224
|
-
return entries.size;
|
|
9225
|
-
},
|
|
9226
|
-
bytesUsed() {
|
|
9227
|
-
return totalBytes;
|
|
9336
|
+
return total;
|
|
9337
|
+
}
|
|
9338
|
+
function createDiskCaptureProjection(input) {
|
|
9339
|
+
const { framesDir, totalFrames, measureBytes, remedy, freeDiskBytes = freeDiskBytesAt } = input;
|
|
9340
|
+
let checked = false;
|
|
9341
|
+
return (capturedFrames) => {
|
|
9342
|
+
if (checked || capturedFrames < DISK_PROJECTION_SAMPLE_FRAMES) return;
|
|
9343
|
+
const measured = measureBytes();
|
|
9344
|
+
if (measured <= 0) return;
|
|
9345
|
+
checked = true;
|
|
9346
|
+
const remaining = totalFrames - capturedFrames;
|
|
9347
|
+
const freeBytes = freeDiskBytes(framesDir);
|
|
9348
|
+
if (remaining <= 0 || freeBytes === null) return;
|
|
9349
|
+
const needBytes = Math.ceil(measured / capturedFrames * remaining);
|
|
9350
|
+
if (needBytes > freeBytes * 0.9) {
|
|
9351
|
+
throw diskCaptureShortfallError(needBytes, freeBytes, framesDir, "measured", remedy);
|
|
9228
9352
|
}
|
|
9229
9353
|
};
|
|
9230
9354
|
}
|
|
9231
|
-
|
|
9232
|
-
// src/services/render/stages/captureHdrSequentialLoop.ts
|
|
9233
|
-
import { join as join19 } from "node:path";
|
|
9234
|
-
import {
|
|
9235
|
-
TRANSITIONS,
|
|
9236
|
-
crossfade
|
|
9237
|
-
} from "@hyperframes/engine";
|
|
9238
|
-
async function runSequentialLayeredFrameLoop(input) {
|
|
9355
|
+
async function runCaptureStage(input) {
|
|
9239
9356
|
const {
|
|
9357
|
+
fileServer,
|
|
9358
|
+
workDir,
|
|
9359
|
+
framesDir,
|
|
9240
9360
|
job,
|
|
9241
|
-
log,
|
|
9242
|
-
width,
|
|
9243
|
-
height,
|
|
9244
9361
|
totalFrames,
|
|
9245
|
-
|
|
9246
|
-
|
|
9247
|
-
|
|
9248
|
-
|
|
9249
|
-
|
|
9250
|
-
|
|
9251
|
-
|
|
9252
|
-
|
|
9253
|
-
|
|
9254
|
-
|
|
9255
|
-
hdrVideoEndTimes,
|
|
9256
|
-
cleanedUpVideos,
|
|
9257
|
-
hdrVideoFrameSources,
|
|
9258
|
-
debugDumpEnabled,
|
|
9259
|
-
debugDumpDir,
|
|
9260
|
-
assertNotAborted,
|
|
9261
|
-
onProgress
|
|
9362
|
+
cfg,
|
|
9363
|
+
plan,
|
|
9364
|
+
log,
|
|
9365
|
+
captureAttempts,
|
|
9366
|
+
buildCaptureOptions,
|
|
9367
|
+
createRenderVideoFrameInjector,
|
|
9368
|
+
abortSignal,
|
|
9369
|
+
onProgress,
|
|
9370
|
+
frameRange,
|
|
9371
|
+
dedupPerfs
|
|
9262
9372
|
} = input;
|
|
9263
|
-
|
|
9264
|
-
|
|
9265
|
-
const
|
|
9266
|
-
|
|
9267
|
-
|
|
9268
|
-
|
|
9269
|
-
|
|
9270
|
-
|
|
9271
|
-
|
|
9272
|
-
|
|
9273
|
-
assertNotAborted();
|
|
9274
|
-
const time = i * job.config.fps.den / job.config.fps.num;
|
|
9275
|
-
if (hdrPerf) hdrPerf.frames += 1;
|
|
9276
|
-
const stackingInfo = await seekInjectAndQueryStacking(
|
|
9277
|
-
domSession.page,
|
|
9278
|
-
time,
|
|
9279
|
-
beforeCaptureHook,
|
|
9280
|
-
nativeHdrIds,
|
|
9281
|
-
hdrPerf,
|
|
9282
|
-
"frameSeekMs",
|
|
9283
|
-
"frameInjectMs",
|
|
9284
|
-
"stackingQueryMs"
|
|
9285
|
-
);
|
|
9286
|
-
const activeTransition = transitionRanges.find((t) => i >= t.startFrame && i <= t.endFrame);
|
|
9287
|
-
if (i % 30 === 0 && (log.isLevelEnabled?.("debug") ?? true)) {
|
|
9288
|
-
const hdrEl = stackingInfo.find((e) => e.isHdr);
|
|
9289
|
-
log.debug("[Render] HDR layer composite frame", {
|
|
9290
|
-
frame: i,
|
|
9291
|
-
time: time.toFixed(2),
|
|
9292
|
-
hdrElement: hdrEl ? { z: hdrEl.zIndex, visible: hdrEl.visible, width: hdrEl.width } : null,
|
|
9293
|
-
stackingCount: stackingInfo.length,
|
|
9294
|
-
activeTransition: activeTransition?.shader
|
|
9295
|
-
});
|
|
9296
|
-
}
|
|
9297
|
-
if (activeTransition && transitionBuffers) {
|
|
9298
|
-
if (hdrPerf) hdrPerf.transitionFrames += 1;
|
|
9299
|
-
const transitionTimingStart = Date.now();
|
|
9300
|
-
const progress = activeTransition.endFrame === activeTransition.startFrame ? 1 : (i - activeTransition.startFrame) / (activeTransition.endFrame - activeTransition.startFrame);
|
|
9301
|
-
const sceneAIds = new Set(sceneElements[activeTransition.fromScene] ?? []);
|
|
9302
|
-
const sceneBIds = new Set(sceneElements[activeTransition.toScene] ?? []);
|
|
9303
|
-
timeHdrPhase(hdrPerf, "canvasClearMs", () => {
|
|
9304
|
-
transitionBuffers.bufferA.fill(0);
|
|
9305
|
-
transitionBuffers.bufferB.fill(0);
|
|
9306
|
-
});
|
|
9307
|
-
const sceneCaptures = [
|
|
9308
|
-
[transitionBuffers.bufferA, sceneAIds],
|
|
9309
|
-
[transitionBuffers.bufferB, sceneBIds]
|
|
9310
|
-
];
|
|
9311
|
-
for (const [sceneBuf, sceneIds] of sceneCaptures) {
|
|
9312
|
-
assertNotAborted();
|
|
9313
|
-
await captureSceneIntoBuffer({
|
|
9314
|
-
session: domSession,
|
|
9315
|
-
sceneBuf,
|
|
9316
|
-
sceneIds,
|
|
9317
|
-
stackingInfo,
|
|
9318
|
-
time,
|
|
9319
|
-
width,
|
|
9320
|
-
height,
|
|
9321
|
-
nativeHdrIds,
|
|
9322
|
-
nativeHdrImageIds,
|
|
9323
|
-
beforeCaptureHook,
|
|
9324
|
-
hdrCompositeCtx,
|
|
9325
|
-
compositeTransfer,
|
|
9326
|
-
hdrTargetTransfer,
|
|
9327
|
-
hdrPerf,
|
|
9328
|
-
log,
|
|
9329
|
-
frameIdx: i
|
|
9330
|
-
});
|
|
9331
|
-
}
|
|
9332
|
-
const shaderName = activeTransition.shader;
|
|
9333
|
-
const transitionFn = shaderName ? TRANSITIONS[shaderName] ?? crossfade : crossfade;
|
|
9334
|
-
transitionFn(
|
|
9335
|
-
transitionBuffers.bufferA,
|
|
9336
|
-
transitionBuffers.bufferB,
|
|
9337
|
-
transitionBuffers.output,
|
|
9338
|
-
width,
|
|
9339
|
-
height,
|
|
9340
|
-
progress
|
|
9341
|
-
);
|
|
9342
|
-
addHdrTiming(hdrPerf, "transitionCompositeMs", transitionTimingStart);
|
|
9343
|
-
await timeHdrPhaseAsync(
|
|
9344
|
-
hdrPerf,
|
|
9345
|
-
"encoderWriteMs",
|
|
9346
|
-
async () => ensureFrameWritten(await hdrEncoder.writeFrame(transitionBuffers.output), i, hdrEncoder)
|
|
9347
|
-
);
|
|
9348
|
-
} else {
|
|
9349
|
-
if (hdrPerf) hdrPerf.normalFrames += 1;
|
|
9350
|
-
timeHdrPhase(hdrPerf, "canvasClearMs", () => normalCanvas.fill(0));
|
|
9351
|
-
await timeHdrPhaseAsync(
|
|
9352
|
-
hdrPerf,
|
|
9353
|
-
"normalCompositeMs",
|
|
9354
|
-
() => compositeHdrFrame(hdrCompositeCtx, normalCanvas, time, stackingInfo, void 0, i)
|
|
9373
|
+
let { probeSession } = input;
|
|
9374
|
+
let { workerCount } = plan;
|
|
9375
|
+
const { forceScreenshot } = plan;
|
|
9376
|
+
let lastBrowserConsole = [];
|
|
9377
|
+
let captureBeyondViewport = probeSession?.options.captureBeyondViewport;
|
|
9378
|
+
const captureCfg = cfg.forceScreenshot === forceScreenshot ? cfg : { ...cfg, forceScreenshot };
|
|
9379
|
+
if (frameRange !== void 0) {
|
|
9380
|
+
if (!Number.isFinite(frameRange.startFrame) || !Number.isFinite(frameRange.endFrame) || frameRange.startFrame < 0 || frameRange.endFrame <= frameRange.startFrame) {
|
|
9381
|
+
throw new Error(
|
|
9382
|
+
`[captureStage] invalid frameRange: ${JSON.stringify(frameRange)}. Expected non-negative startFrame strictly less than endFrame.`
|
|
9355
9383
|
);
|
|
9356
|
-
|
|
9357
|
-
|
|
9358
|
-
|
|
9359
|
-
|
|
9360
|
-
)
|
|
9361
|
-
}
|
|
9362
|
-
await timeHdrPhaseAsync(
|
|
9363
|
-
hdrPerf,
|
|
9364
|
-
"encoderWriteMs",
|
|
9365
|
-
async () => ensureFrameWritten(await hdrEncoder.writeFrame(normalCanvas), i, hdrEncoder)
|
|
9384
|
+
}
|
|
9385
|
+
const rangeFrames = frameRange.endFrame - frameRange.startFrame;
|
|
9386
|
+
if (rangeFrames !== totalFrames) {
|
|
9387
|
+
throw new Error(
|
|
9388
|
+
`[captureStage] frameRange size (${rangeFrames}) must equal totalFrames (${totalFrames}). Received frameRange=${JSON.stringify(frameRange)}.`
|
|
9366
9389
|
);
|
|
9367
9390
|
}
|
|
9368
|
-
|
|
9369
|
-
|
|
9370
|
-
|
|
9371
|
-
|
|
9372
|
-
|
|
9373
|
-
|
|
9374
|
-
|
|
9391
|
+
}
|
|
9392
|
+
const captureOptions = buildCaptureOptions();
|
|
9393
|
+
const remedy = diskCaptureRemedy(cfg, job.config.format ?? "mp4", job.duration ?? 0, workerCount);
|
|
9394
|
+
const checkDiskProjection = createDiskCaptureProjection({
|
|
9395
|
+
framesDir,
|
|
9396
|
+
totalFrames,
|
|
9397
|
+
measureBytes: () => measureCaptureFrameBytes(workDir, framesDir),
|
|
9398
|
+
remedy
|
|
9399
|
+
});
|
|
9400
|
+
if (workerCount > 1) {
|
|
9401
|
+
const attempts = await executeDiskCaptureWithAdaptiveRetry({
|
|
9402
|
+
serverUrl: fileServer.url,
|
|
9403
|
+
workDir,
|
|
9404
|
+
framesDir,
|
|
9405
|
+
totalFrames,
|
|
9406
|
+
initialWorkerCount: workerCount,
|
|
9407
|
+
allowRetry: shouldAllowAdaptiveCaptureRetry(workerCount, job.config.workers !== void 0),
|
|
9408
|
+
frameExt: frameFileExtension(captureOptions.format),
|
|
9409
|
+
captureOptions,
|
|
9410
|
+
createBeforeCaptureHook: createRenderVideoFrameInjector,
|
|
9411
|
+
abortSignal,
|
|
9412
|
+
frameRangeStart: frameRange?.startFrame,
|
|
9413
|
+
dedupPerfs,
|
|
9414
|
+
onProgress: (progress) => {
|
|
9415
|
+
if (progress.latestWorkerPhase) {
|
|
9416
|
+
if (progress.capturedFrames === 0) reportWorkerStartup(job, progress, onProgress);
|
|
9417
|
+
return;
|
|
9418
|
+
}
|
|
9419
|
+
if (progress.landedFrame) input.onFrameLanded?.(progress.landedFrame);
|
|
9420
|
+
job.framesRendered = progress.capturedFrames;
|
|
9421
|
+
checkDiskProjection(progress.capturedFrames);
|
|
9422
|
+
const frameProgress = progress.capturedFrames / progress.totalFrames;
|
|
9423
|
+
reportFrameProgress(
|
|
9424
|
+
job,
|
|
9425
|
+
`Capturing frame ${progress.capturedFrames}/${progress.totalFrames} (${progress.activeWorkers} workers)`,
|
|
9426
|
+
Math.round(25 + frameProgress * 45),
|
|
9427
|
+
onProgress,
|
|
9428
|
+
progress.capturedFrames,
|
|
9429
|
+
progress.totalFrames
|
|
9430
|
+
);
|
|
9431
|
+
},
|
|
9432
|
+
cfg: captureCfg,
|
|
9375
9433
|
log
|
|
9376
9434
|
});
|
|
9377
|
-
|
|
9378
|
-
|
|
9379
|
-
|
|
9380
|
-
|
|
9381
|
-
|
|
9382
|
-
|
|
9383
|
-
|
|
9384
|
-
|
|
9435
|
+
captureAttempts.push(...attempts);
|
|
9436
|
+
const lastAttempt = attempts[attempts.length - 1];
|
|
9437
|
+
if (lastAttempt) {
|
|
9438
|
+
workerCount = lastAttempt.workers;
|
|
9439
|
+
}
|
|
9440
|
+
if (probeSession) {
|
|
9441
|
+
captureBeyondViewport = probeSession.options.captureBeyondViewport;
|
|
9442
|
+
lastBrowserConsole = probeSession.browserConsoleBuffer;
|
|
9443
|
+
await closeCaptureSession5(probeSession);
|
|
9444
|
+
probeSession = null;
|
|
9445
|
+
}
|
|
9446
|
+
} else {
|
|
9447
|
+
const sequential = await runSequentialDiskCapture(
|
|
9448
|
+
input,
|
|
9449
|
+
captureOptions,
|
|
9450
|
+
captureCfg,
|
|
9451
|
+
checkDiskProjection
|
|
9385
9452
|
);
|
|
9453
|
+
probeSession = null;
|
|
9454
|
+
lastBrowserConsole = sequential.lastBrowserConsole;
|
|
9455
|
+
captureBeyondViewport = sequential.captureBeyondViewport;
|
|
9456
|
+
}
|
|
9457
|
+
return { workerCount, probeSession, lastBrowserConsole, captureBeyondViewport };
|
|
9458
|
+
}
|
|
9459
|
+
async function runSequentialDiskCapture(input, captureOptions, captureCfg, checkDiskProjection) {
|
|
9460
|
+
const {
|
|
9461
|
+
fileServer,
|
|
9462
|
+
framesDir,
|
|
9463
|
+
totalFrames,
|
|
9464
|
+
log,
|
|
9465
|
+
createRenderVideoFrameInjector,
|
|
9466
|
+
abortSignal,
|
|
9467
|
+
assertNotAborted,
|
|
9468
|
+
frameRange
|
|
9469
|
+
} = input;
|
|
9470
|
+
let { probeSession } = input;
|
|
9471
|
+
let lastBrowserConsole = [];
|
|
9472
|
+
let captureBeyondViewport;
|
|
9473
|
+
const { rangeStart, rangeEnd } = resolveCaptureRange(frameRange, totalFrames);
|
|
9474
|
+
const rangeFrames = rangeEnd - rangeStart;
|
|
9475
|
+
const frameExt = frameFileExtension(captureOptions.format);
|
|
9476
|
+
let transientRetriesUsed = 0;
|
|
9477
|
+
let resumeFrom = 0;
|
|
9478
|
+
while (true) {
|
|
9479
|
+
const videoInjector = createRenderVideoFrameInjector();
|
|
9480
|
+
const session = probeSession ?? await createCaptureSession5(
|
|
9481
|
+
fileServer.url,
|
|
9482
|
+
framesDir,
|
|
9483
|
+
captureOptions,
|
|
9484
|
+
videoInjector,
|
|
9485
|
+
captureCfg
|
|
9486
|
+
);
|
|
9487
|
+
captureBeyondViewport = session.options.captureBeyondViewport;
|
|
9488
|
+
try {
|
|
9489
|
+
if (probeSession) {
|
|
9490
|
+
prepareCaptureSessionForReuse3(session, framesDir, videoInjector);
|
|
9491
|
+
probeSession = null;
|
|
9492
|
+
}
|
|
9493
|
+
await ensureSessionReady(session);
|
|
9494
|
+
assertNotAborted();
|
|
9495
|
+
lastBrowserConsole = session.browserConsoleBuffer;
|
|
9496
|
+
await captureSessionFrames(session, {
|
|
9497
|
+
input,
|
|
9498
|
+
rangeStart,
|
|
9499
|
+
rangeEnd,
|
|
9500
|
+
resumeFrom,
|
|
9501
|
+
checkDiskProjection
|
|
9502
|
+
});
|
|
9503
|
+
break;
|
|
9504
|
+
} catch (error) {
|
|
9505
|
+
lastBrowserConsole = session.browserConsoleBuffer;
|
|
9506
|
+
const resume = planTransientResume(error, {
|
|
9507
|
+
abortSignal,
|
|
9508
|
+
rangeFrames,
|
|
9509
|
+
framesDir,
|
|
9510
|
+
frameExt,
|
|
9511
|
+
retriesUsed: transientRetriesUsed
|
|
9512
|
+
});
|
|
9513
|
+
if (resume !== null) {
|
|
9514
|
+
transientRetriesUsed++;
|
|
9515
|
+
resumeFrom = resume;
|
|
9516
|
+
log.warn(
|
|
9517
|
+
"[Render] Transient browser failure during sequential capture; retrying once with a fresh session.",
|
|
9518
|
+
{
|
|
9519
|
+
resumeFrom,
|
|
9520
|
+
rangeFrames,
|
|
9521
|
+
transientRetriesUsed,
|
|
9522
|
+
error: error instanceof Error ? error.message : String(error)
|
|
9523
|
+
}
|
|
9524
|
+
);
|
|
9525
|
+
continue;
|
|
9526
|
+
}
|
|
9527
|
+
throw wrapCaptureStageError(error, lastBrowserConsole);
|
|
9528
|
+
} finally {
|
|
9529
|
+
lastBrowserConsole = session.browserConsoleBuffer;
|
|
9530
|
+
await closeCaptureSession5(session);
|
|
9531
|
+
}
|
|
9532
|
+
}
|
|
9533
|
+
return { lastBrowserConsole, captureBeyondViewport };
|
|
9534
|
+
}
|
|
9535
|
+
function resolveCaptureRange(frameRange, totalFrames) {
|
|
9536
|
+
return { rangeStart: frameRange?.startFrame ?? 0, rangeEnd: frameRange?.endFrame ?? totalFrames };
|
|
9537
|
+
}
|
|
9538
|
+
function planTransientResume(error, ctx) {
|
|
9539
|
+
const failure = classifyCaptureFailure2(error, { signal: ctx.abortSignal });
|
|
9540
|
+
const missing = findMissingFrameRanges(ctx.rangeFrames, ctx.framesDir, ctx.frameExt);
|
|
9541
|
+
const [firstMissing] = missing;
|
|
9542
|
+
if (!firstMissing) return null;
|
|
9543
|
+
return isTransientCaptureRetryEligible(failure.kind, missing, ctx.retriesUsed) ? firstMissing.startFrame : null;
|
|
9544
|
+
}
|
|
9545
|
+
async function ensureSessionReady(session) {
|
|
9546
|
+
if (!session.isInitialized) {
|
|
9547
|
+
await initializeSession5(session);
|
|
9548
|
+
return;
|
|
9549
|
+
}
|
|
9550
|
+
if (process.env.PRODUCER_EXPERIMENTAL_FAST_CAPTURE === "true") {
|
|
9551
|
+
await completeDeferredDrawElementInit3(session);
|
|
9552
|
+
}
|
|
9553
|
+
}
|
|
9554
|
+
async function captureSessionFrames(session, { input, rangeStart, rangeEnd, resumeFrom, checkDiskProjection }) {
|
|
9555
|
+
const { job, framesDir, dedupPerfs, assertNotAborted, onProgress } = input;
|
|
9556
|
+
const rangeFrames = rangeEnd - rangeStart;
|
|
9557
|
+
const reportFrame = (fileIndex) => {
|
|
9558
|
+
job.framesRendered = fileIndex + 1;
|
|
9559
|
+
checkDiskProjection(fileIndex + 1);
|
|
9560
|
+
reportFrameProgress(
|
|
9561
|
+
job,
|
|
9562
|
+
`Capturing frame ${fileIndex + 1}/${rangeFrames}`,
|
|
9563
|
+
Math.round(25 + (fileIndex + 1) / rangeFrames * 45),
|
|
9564
|
+
onProgress,
|
|
9565
|
+
fileIndex + 1,
|
|
9566
|
+
rangeFrames
|
|
9567
|
+
);
|
|
9568
|
+
};
|
|
9569
|
+
if (session.workerEncodeEnabled) {
|
|
9570
|
+
let prev = null;
|
|
9571
|
+
const drainPrev = async () => {
|
|
9572
|
+
if (!prev) return;
|
|
9573
|
+
assertNotAborted();
|
|
9574
|
+
const buf = await prev.encodeResult;
|
|
9575
|
+
writeCapturedFrame(session, prev.fileIndex, buf);
|
|
9576
|
+
reportFrame(prev.fileIndex);
|
|
9577
|
+
};
|
|
9578
|
+
for (let i = resumeFrom; i < rangeFrames; i++) {
|
|
9579
|
+
assertNotAborted();
|
|
9580
|
+
const absoluteIdx = rangeStart + i;
|
|
9581
|
+
const time = absoluteIdx * job.config.fps.den / job.config.fps.num;
|
|
9582
|
+
const { encodeResult } = await captureFrameToBufferPipelined2(session, i, time);
|
|
9583
|
+
await drainPrev();
|
|
9584
|
+
prev = { fileIndex: i, encodeResult };
|
|
9585
|
+
}
|
|
9586
|
+
await drainPrev();
|
|
9587
|
+
} else {
|
|
9588
|
+
for (let i = resumeFrom; i < rangeFrames; i++) {
|
|
9589
|
+
assertNotAborted();
|
|
9590
|
+
const absoluteIdx = rangeStart + i;
|
|
9591
|
+
const time = absoluteIdx * job.config.fps.den / job.config.fps.num;
|
|
9592
|
+
await captureFrame(session, i, time);
|
|
9593
|
+
reportFrame(i);
|
|
9594
|
+
}
|
|
9386
9595
|
}
|
|
9596
|
+
await verifyDiskDrawElementSamples(
|
|
9597
|
+
session,
|
|
9598
|
+
{
|
|
9599
|
+
workerId: 0,
|
|
9600
|
+
startFrame: rangeStart + resumeFrom,
|
|
9601
|
+
endFrame: rangeEnd,
|
|
9602
|
+
outputDir: framesDir,
|
|
9603
|
+
outputFrameOffset: rangeStart
|
|
9604
|
+
},
|
|
9605
|
+
false
|
|
9606
|
+
);
|
|
9607
|
+
dedupPerfs.push(getCapturePerfSummary3(session));
|
|
9387
9608
|
}
|
|
9388
9609
|
|
|
9389
|
-
// src/services/render/stages/
|
|
9390
|
-
import {
|
|
9610
|
+
// src/services/render/stages/captureHdrStage.ts
|
|
9611
|
+
import { existsSync as existsSync14, mkdirSync as mkdirSync7 } from "node:fs";
|
|
9612
|
+
import { join as join23 } from "node:path";
|
|
9391
9613
|
import {
|
|
9392
|
-
|
|
9393
|
-
|
|
9394
|
-
createCaptureSession as
|
|
9395
|
-
|
|
9396
|
-
|
|
9397
|
-
|
|
9398
|
-
initializeSession as initializeSession6
|
|
9614
|
+
closeCaptureSession as closeCaptureSession7,
|
|
9615
|
+
cloneCaptureWarnings as cloneCaptureWarnings2,
|
|
9616
|
+
createCaptureSession as createCaptureSession7,
|
|
9617
|
+
initTransparentBackground as initTransparentBackground2,
|
|
9618
|
+
initializeSession as initializeSession7,
|
|
9619
|
+
spawnStreamingEncoder as spawnStreamingEncoder3
|
|
9399
9620
|
} from "@hyperframes/engine";
|
|
9621
|
+
import { fpsToNumber as fpsToNumber8 } from "@hyperframes/core";
|
|
9400
9622
|
|
|
9401
|
-
// src/services/
|
|
9402
|
-
import {
|
|
9403
|
-
|
|
9404
|
-
|
|
9405
|
-
|
|
9406
|
-
|
|
9407
|
-
|
|
9408
|
-
|
|
9409
|
-
|
|
9410
|
-
return { path: explicit, isTs: explicit.endsWith(".ts") };
|
|
9623
|
+
// src/services/hdrImageTransferCache.ts
|
|
9624
|
+
import { convertTransfer as convertTransfer2 } from "@hyperframes/engine";
|
|
9625
|
+
var DEFAULT_MAX_BYTES = 200 * 1024 * 1024;
|
|
9626
|
+
function createHdrImageTransferCache(options = {}) {
|
|
9627
|
+
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
9628
|
+
if (!Number.isInteger(maxBytes) || maxBytes < 0) {
|
|
9629
|
+
throw new Error(
|
|
9630
|
+
`createHdrImageTransferCache: maxBytes must be a non-negative integer, got ${String(maxBytes)}`
|
|
9631
|
+
);
|
|
9411
9632
|
}
|
|
9412
|
-
const
|
|
9413
|
-
|
|
9414
|
-
|
|
9415
|
-
return {
|
|
9633
|
+
const entries = /* @__PURE__ */ new Map();
|
|
9634
|
+
let totalBytes = 0;
|
|
9635
|
+
function makeKey(imageId, targetTransfer) {
|
|
9636
|
+
return `${imageId}|${targetTransfer}`;
|
|
9416
9637
|
}
|
|
9417
|
-
|
|
9418
|
-
|
|
9419
|
-
|
|
9420
|
-
|
|
9421
|
-
|
|
9422
|
-
|
|
9423
|
-
|
|
9424
|
-
|
|
9425
|
-
if (!entryIsTs) return inherited;
|
|
9426
|
-
const hasLoader = inherited.some(
|
|
9427
|
-
(a) => a.includes("tsx/esm") || a.includes("ts-node/esm") || a.includes("--import")
|
|
9428
|
-
);
|
|
9429
|
-
if (hasLoader) return inherited;
|
|
9430
|
-
try {
|
|
9431
|
-
const require2 = createRequire(import.meta.url);
|
|
9432
|
-
const tsxEsm = require2.resolve("tsx/esm");
|
|
9433
|
-
inherited.push("--import", pathToFileURL(tsxEsm).href);
|
|
9434
|
-
} catch {
|
|
9638
|
+
function evictUntilRoom(needed) {
|
|
9639
|
+
while (totalBytes + needed > maxBytes && entries.size > 0) {
|
|
9640
|
+
const lruKey = entries.keys().next().value;
|
|
9641
|
+
if (lruKey === void 0) break;
|
|
9642
|
+
const evicted = entries.get(lruKey);
|
|
9643
|
+
if (evicted) totalBytes -= evicted.byteLength;
|
|
9644
|
+
entries.delete(lruKey);
|
|
9645
|
+
}
|
|
9435
9646
|
}
|
|
9436
|
-
return
|
|
9647
|
+
return {
|
|
9648
|
+
getConverted(imageId, sourceTransfer, targetTransfer, source) {
|
|
9649
|
+
if (sourceTransfer === targetTransfer) {
|
|
9650
|
+
return source;
|
|
9651
|
+
}
|
|
9652
|
+
if (maxBytes === 0) {
|
|
9653
|
+
const fresh = Buffer.from(source);
|
|
9654
|
+
convertTransfer2(fresh, sourceTransfer, targetTransfer);
|
|
9655
|
+
return fresh;
|
|
9656
|
+
}
|
|
9657
|
+
const key = makeKey(imageId, targetTransfer);
|
|
9658
|
+
const existing = entries.get(key);
|
|
9659
|
+
if (existing) {
|
|
9660
|
+
entries.delete(key);
|
|
9661
|
+
entries.set(key, existing);
|
|
9662
|
+
return existing;
|
|
9663
|
+
}
|
|
9664
|
+
const converted = Buffer.from(source);
|
|
9665
|
+
convertTransfer2(converted, sourceTransfer, targetTransfer);
|
|
9666
|
+
if (converted.byteLength > maxBytes) {
|
|
9667
|
+
return converted;
|
|
9668
|
+
}
|
|
9669
|
+
evictUntilRoom(converted.byteLength);
|
|
9670
|
+
entries.set(key, converted);
|
|
9671
|
+
totalBytes += converted.byteLength;
|
|
9672
|
+
return converted;
|
|
9673
|
+
},
|
|
9674
|
+
size() {
|
|
9675
|
+
return entries.size;
|
|
9676
|
+
},
|
|
9677
|
+
bytesUsed() {
|
|
9678
|
+
return totalBytes;
|
|
9679
|
+
}
|
|
9680
|
+
};
|
|
9681
|
+
}
|
|
9682
|
+
|
|
9683
|
+
// src/services/render/stages/captureHdrSequentialLoop.ts
|
|
9684
|
+
import { join as join20 } from "node:path";
|
|
9685
|
+
import {
|
|
9686
|
+
TRANSITIONS,
|
|
9687
|
+
crossfade
|
|
9688
|
+
} from "@hyperframes/engine";
|
|
9689
|
+
async function runSequentialLayeredFrameLoop(input) {
|
|
9690
|
+
const {
|
|
9691
|
+
job,
|
|
9692
|
+
log,
|
|
9693
|
+
width,
|
|
9694
|
+
height,
|
|
9695
|
+
totalFrames,
|
|
9696
|
+
nativeHdrIds,
|
|
9697
|
+
nativeHdrImageIds,
|
|
9698
|
+
hdrCompositeCtx,
|
|
9699
|
+
hdrPerf,
|
|
9700
|
+
hdrEncoder,
|
|
9701
|
+
domSession,
|
|
9702
|
+
transitionRanges,
|
|
9703
|
+
sceneElements,
|
|
9704
|
+
compositeTransfer,
|
|
9705
|
+
hdrTargetTransfer,
|
|
9706
|
+
hdrVideoEndTimes,
|
|
9707
|
+
cleanedUpVideos,
|
|
9708
|
+
hdrVideoFrameSources,
|
|
9709
|
+
debugDumpEnabled,
|
|
9710
|
+
debugDumpDir,
|
|
9711
|
+
assertNotAborted,
|
|
9712
|
+
onProgress
|
|
9713
|
+
} = input;
|
|
9714
|
+
const beforeCaptureHook = domSession.onBeforeCapture;
|
|
9715
|
+
const bufSize = width * height * 6;
|
|
9716
|
+
const hasTransitions = transitionRanges.length > 0;
|
|
9717
|
+
const transitionBuffers = hasTransitions ? {
|
|
9718
|
+
bufferA: Buffer.alloc(bufSize),
|
|
9719
|
+
bufferB: Buffer.alloc(bufSize),
|
|
9720
|
+
output: Buffer.alloc(bufSize)
|
|
9721
|
+
} : null;
|
|
9722
|
+
const normalCanvas = Buffer.alloc(bufSize);
|
|
9723
|
+
for (let i = 0; i < totalFrames; i++) {
|
|
9724
|
+
assertNotAborted();
|
|
9725
|
+
const time = i * job.config.fps.den / job.config.fps.num;
|
|
9726
|
+
if (hdrPerf) hdrPerf.frames += 1;
|
|
9727
|
+
const stackingInfo = await seekInjectAndQueryStacking(
|
|
9728
|
+
domSession.page,
|
|
9729
|
+
time,
|
|
9730
|
+
beforeCaptureHook,
|
|
9731
|
+
nativeHdrIds,
|
|
9732
|
+
hdrPerf,
|
|
9733
|
+
"frameSeekMs",
|
|
9734
|
+
"frameInjectMs",
|
|
9735
|
+
"stackingQueryMs"
|
|
9736
|
+
);
|
|
9737
|
+
const activeTransition = transitionRanges.find((t) => i >= t.startFrame && i <= t.endFrame);
|
|
9738
|
+
if (i % 30 === 0 && (log.isLevelEnabled?.("debug") ?? true)) {
|
|
9739
|
+
const hdrEl = stackingInfo.find((e) => e.isHdr);
|
|
9740
|
+
log.debug("[Render] HDR layer composite frame", {
|
|
9741
|
+
frame: i,
|
|
9742
|
+
time: time.toFixed(2),
|
|
9743
|
+
hdrElement: hdrEl ? { z: hdrEl.zIndex, visible: hdrEl.visible, width: hdrEl.width } : null,
|
|
9744
|
+
stackingCount: stackingInfo.length,
|
|
9745
|
+
activeTransition: activeTransition?.shader
|
|
9746
|
+
});
|
|
9747
|
+
}
|
|
9748
|
+
if (activeTransition && transitionBuffers) {
|
|
9749
|
+
if (hdrPerf) hdrPerf.transitionFrames += 1;
|
|
9750
|
+
const transitionTimingStart = Date.now();
|
|
9751
|
+
const progress = activeTransition.endFrame === activeTransition.startFrame ? 1 : (i - activeTransition.startFrame) / (activeTransition.endFrame - activeTransition.startFrame);
|
|
9752
|
+
const sceneAIds = new Set(sceneElements[activeTransition.fromScene] ?? []);
|
|
9753
|
+
const sceneBIds = new Set(sceneElements[activeTransition.toScene] ?? []);
|
|
9754
|
+
timeHdrPhase(hdrPerf, "canvasClearMs", () => {
|
|
9755
|
+
transitionBuffers.bufferA.fill(0);
|
|
9756
|
+
transitionBuffers.bufferB.fill(0);
|
|
9757
|
+
});
|
|
9758
|
+
const sceneCaptures = [
|
|
9759
|
+
[transitionBuffers.bufferA, sceneAIds],
|
|
9760
|
+
[transitionBuffers.bufferB, sceneBIds]
|
|
9761
|
+
];
|
|
9762
|
+
for (const [sceneBuf, sceneIds] of sceneCaptures) {
|
|
9763
|
+
assertNotAborted();
|
|
9764
|
+
await captureSceneIntoBuffer({
|
|
9765
|
+
session: domSession,
|
|
9766
|
+
sceneBuf,
|
|
9767
|
+
sceneIds,
|
|
9768
|
+
stackingInfo,
|
|
9769
|
+
time,
|
|
9770
|
+
width,
|
|
9771
|
+
height,
|
|
9772
|
+
nativeHdrIds,
|
|
9773
|
+
nativeHdrImageIds,
|
|
9774
|
+
beforeCaptureHook,
|
|
9775
|
+
hdrCompositeCtx,
|
|
9776
|
+
compositeTransfer,
|
|
9777
|
+
hdrTargetTransfer,
|
|
9778
|
+
hdrPerf,
|
|
9779
|
+
log,
|
|
9780
|
+
frameIdx: i
|
|
9781
|
+
});
|
|
9782
|
+
}
|
|
9783
|
+
const shaderName = activeTransition.shader;
|
|
9784
|
+
const transitionFn = shaderName ? TRANSITIONS[shaderName] ?? crossfade : crossfade;
|
|
9785
|
+
transitionFn(
|
|
9786
|
+
transitionBuffers.bufferA,
|
|
9787
|
+
transitionBuffers.bufferB,
|
|
9788
|
+
transitionBuffers.output,
|
|
9789
|
+
width,
|
|
9790
|
+
height,
|
|
9791
|
+
progress
|
|
9792
|
+
);
|
|
9793
|
+
addHdrTiming(hdrPerf, "transitionCompositeMs", transitionTimingStart);
|
|
9794
|
+
await timeHdrPhaseAsync(
|
|
9795
|
+
hdrPerf,
|
|
9796
|
+
"encoderWriteMs",
|
|
9797
|
+
async () => ensureFrameWritten(await hdrEncoder.writeFrame(transitionBuffers.output), i, hdrEncoder)
|
|
9798
|
+
);
|
|
9799
|
+
} else {
|
|
9800
|
+
if (hdrPerf) hdrPerf.normalFrames += 1;
|
|
9801
|
+
timeHdrPhase(hdrPerf, "canvasClearMs", () => normalCanvas.fill(0));
|
|
9802
|
+
await timeHdrPhaseAsync(
|
|
9803
|
+
hdrPerf,
|
|
9804
|
+
"normalCompositeMs",
|
|
9805
|
+
() => compositeHdrFrame(hdrCompositeCtx, normalCanvas, time, stackingInfo, void 0, i)
|
|
9806
|
+
);
|
|
9807
|
+
if (debugDumpEnabled && debugDumpDir && i % 30 === 0) {
|
|
9808
|
+
writeFileExclusiveSync(
|
|
9809
|
+
join20(debugDumpDir, `frame_${String(i).padStart(4, "0")}_final_rgb48le.bin`),
|
|
9810
|
+
normalCanvas
|
|
9811
|
+
);
|
|
9812
|
+
}
|
|
9813
|
+
await timeHdrPhaseAsync(
|
|
9814
|
+
hdrPerf,
|
|
9815
|
+
"encoderWriteMs",
|
|
9816
|
+
async () => ensureFrameWritten(await hdrEncoder.writeFrame(normalCanvas), i, hdrEncoder)
|
|
9817
|
+
);
|
|
9818
|
+
}
|
|
9819
|
+
cleanupEndedHdrVideos({
|
|
9820
|
+
time,
|
|
9821
|
+
activeTransition,
|
|
9822
|
+
hdrVideoEndTimes,
|
|
9823
|
+
cleanedUpVideos,
|
|
9824
|
+
hdrVideoFrameSources,
|
|
9825
|
+
sceneElements,
|
|
9826
|
+
log
|
|
9827
|
+
});
|
|
9828
|
+
job.framesRendered = i + 1;
|
|
9829
|
+
reportFrameProgress(
|
|
9830
|
+
job,
|
|
9831
|
+
`Layered composite frame ${i + 1}/${job.totalFrames}`,
|
|
9832
|
+
Math.round(25 + (i + 1) / totalFrames * 55),
|
|
9833
|
+
onProgress,
|
|
9834
|
+
i + 1,
|
|
9835
|
+
totalFrames
|
|
9836
|
+
);
|
|
9837
|
+
}
|
|
9838
|
+
}
|
|
9839
|
+
|
|
9840
|
+
// src/services/render/stages/captureHdrHybridLoop.ts
|
|
9841
|
+
import { join as join22 } from "node:path";
|
|
9842
|
+
import {
|
|
9843
|
+
TRANSITIONS as TRANSITIONS2,
|
|
9844
|
+
closeCaptureSession as closeCaptureSession6,
|
|
9845
|
+
createCaptureSession as createCaptureSession6,
|
|
9846
|
+
createFrameReorderBuffer as createFrameReorderBuffer2,
|
|
9847
|
+
crossfade as crossfade2,
|
|
9848
|
+
initTransparentBackground,
|
|
9849
|
+
initializeSession as initializeSession6
|
|
9850
|
+
} from "@hyperframes/engine";
|
|
9851
|
+
|
|
9852
|
+
// src/services/shaderTransitionWorkerPool.ts
|
|
9853
|
+
import { Worker } from "node:worker_threads";
|
|
9854
|
+
import { fileURLToPath as fileURLToPath3, pathToFileURL } from "node:url";
|
|
9855
|
+
import { dirname as dirname8, join as join21 } from "node:path";
|
|
9856
|
+
import { createRequire } from "node:module";
|
|
9857
|
+
import { existsSync as existsSync13 } from "node:fs";
|
|
9858
|
+
import { cpus as cpus2 } from "node:os";
|
|
9859
|
+
function resolveWorkerEntry(explicit) {
|
|
9860
|
+
if (explicit && explicit.length > 0) {
|
|
9861
|
+
return { path: explicit, isTs: explicit.endsWith(".ts") };
|
|
9862
|
+
}
|
|
9863
|
+
const override = process.env.HF_SHADER_WORKER_ENTRY;
|
|
9864
|
+
if (override && override.length > 0) {
|
|
9865
|
+
const isTs = override.endsWith(".ts");
|
|
9866
|
+
return { path: override, isTs };
|
|
9867
|
+
}
|
|
9868
|
+
const moduleDir = dirname8(fileURLToPath3(import.meta.url));
|
|
9869
|
+
const jsPath = join21(moduleDir, "shaderTransitionWorker.js");
|
|
9870
|
+
if (existsSync13(jsPath)) return { path: jsPath, isTs: false };
|
|
9871
|
+
const tsPath = join21(moduleDir, "shaderTransitionWorker.ts");
|
|
9872
|
+
return { path: tsPath, isTs: true };
|
|
9873
|
+
}
|
|
9874
|
+
function buildExecArgv(entryIsTs) {
|
|
9875
|
+
const inherited = [...process.execArgv];
|
|
9876
|
+
if (!entryIsTs) return inherited;
|
|
9877
|
+
const hasLoader = inherited.some(
|
|
9878
|
+
(a) => a.includes("tsx/esm") || a.includes("ts-node/esm") || a.includes("--import")
|
|
9879
|
+
);
|
|
9880
|
+
if (hasLoader) return inherited;
|
|
9881
|
+
try {
|
|
9882
|
+
const require2 = createRequire(import.meta.url);
|
|
9883
|
+
const tsxEsm = require2.resolve("tsx/esm");
|
|
9884
|
+
inherited.push("--import", pathToFileURL(tsxEsm).href);
|
|
9885
|
+
} catch {
|
|
9886
|
+
}
|
|
9887
|
+
return inherited;
|
|
9437
9888
|
}
|
|
9438
9889
|
async function createShaderTransitionWorkerPool(opts) {
|
|
9439
9890
|
const cpuCount = Math.max(1, cpus2().length);
|
|
@@ -9785,7 +10236,7 @@ async function runHybridLayeredFrameLoop(input) {
|
|
|
9785
10236
|
);
|
|
9786
10237
|
if (debugDumpEnabled && debugDumpDir && i % 30 === 0) {
|
|
9787
10238
|
writeFileExclusiveSync(
|
|
9788
|
-
|
|
10239
|
+
join22(debugDumpDir, `frame_${String(i).padStart(4, "0")}_final_rgb48le.bin`),
|
|
9789
10240
|
canvas
|
|
9790
10241
|
);
|
|
9791
10242
|
}
|
|
@@ -9971,7 +10422,7 @@ async function runCaptureHdrStage(input) {
|
|
|
9971
10422
|
if (hdrVideoFrameSources.has(v.id)) hdrVideoEndTimes.set(v.id, v.end);
|
|
9972
10423
|
}
|
|
9973
10424
|
const debugDumpEnabled = process.env.KEEP_TEMP === "1";
|
|
9974
|
-
const debugDumpDir = debugDumpEnabled ?
|
|
10425
|
+
const debugDumpDir = debugDumpEnabled ? join23(framesDir, "debug-composite") : null;
|
|
9975
10426
|
if (debugDumpDir && !existsSync14(debugDumpDir)) {
|
|
9976
10427
|
mkdirSync7(debugDumpDir, { recursive: true });
|
|
9977
10428
|
}
|
|
@@ -10101,832 +10552,405 @@ async function runCaptureHdrStage(input) {
|
|
|
10101
10552
|
});
|
|
10102
10553
|
}
|
|
10103
10554
|
}
|
|
10104
|
-
if (!domSessionClosed) {
|
|
10105
|
-
await closeCaptureSession7(domSession).catch((err) => {
|
|
10106
|
-
log.warn("closeCaptureSession defensive close failed", {
|
|
10107
|
-
err: err instanceof Error ? err.message : String(err)
|
|
10108
|
-
});
|
|
10109
|
-
});
|
|
10110
|
-
}
|
|
10111
|
-
for (const frameSource of hdrVideoFrameSources.values()) {
|
|
10112
|
-
cleanupHdrVideoFrameSource(frameSource, log);
|
|
10113
|
-
}
|
|
10114
|
-
hdrVideoFrameSources.clear();
|
|
10115
|
-
releaseHdrExtractionReservation?.();
|
|
10116
|
-
releaseHdrExtractionReservation = null;
|
|
10117
|
-
}
|
|
10118
|
-
return {
|
|
10119
|
-
lastBrowserConsole,
|
|
10120
|
-
hdrPerf,
|
|
10121
|
-
captureDurationMs,
|
|
10122
|
-
encodeMs,
|
|
10123
|
-
warnings: cloneCaptureWarnings2(domSession.warnings)
|
|
10124
|
-
};
|
|
10125
|
-
}
|
|
10126
|
-
|
|
10127
|
-
// src/services/render/stages/encodeStage.ts
|
|
10128
|
-
import {
|
|
10129
|
-
copyFileSync as copyFileSync3,
|
|
10130
|
-
existsSync as existsSync15,
|
|
10131
|
-
mkdirSync as mkdirSync8,
|
|
10132
|
-
readdirSync as readdirSync4,
|
|
10133
|
-
readFileSync as readFileSync7,
|
|
10134
|
-
rmSync as rmSync7,
|
|
10135
|
-
statSync as statSync5,
|
|
10136
|
-
writeFileSync as writeFileSync4
|
|
10137
|
-
} from "node:fs";
|
|
10138
|
-
import { readFile as readFile2 } from "node:fs/promises";
|
|
10139
|
-
import { dirname as dirname8, join as join24 } from "node:path";
|
|
10140
|
-
import {
|
|
10141
|
-
encodeFramesChunkedConcat,
|
|
10142
|
-
encodeFramesFromDir,
|
|
10143
|
-
formatFfmpegError,
|
|
10144
|
-
MIXED_AUDIO_FILENAME as MIXED_AUDIO_FILENAME2,
|
|
10145
|
-
resolveConfig,
|
|
10146
|
-
runFfmpeg as runFfmpeg3,
|
|
10147
|
-
spawnStreamingEncoder as spawnStreamingEncoder4
|
|
10148
|
-
} from "@hyperframes/engine";
|
|
10149
|
-
import {
|
|
10150
|
-
clearGifFramesBeforeNext,
|
|
10151
|
-
gifClearsAfterLeavingFrameInPlace
|
|
10152
|
-
} from "@hyperframes/core";
|
|
10153
|
-
|
|
10154
|
-
// src/services/render/stages/gifEncodeArgs.ts
|
|
10155
|
-
import { join as join23 } from "node:path";
|
|
10156
|
-
function fpsToFfmpegArg2(fps) {
|
|
10157
|
-
return fps.den === 1 ? String(fps.num) : `${fps.num}/${fps.den}`;
|
|
10158
|
-
}
|
|
10159
|
-
var KEEP_FILTER_GRAPH_WHEN_FRAMES_DROP_ALPHA = ["-reinit_filter", "0"];
|
|
10160
|
-
var WRITE_EVERY_FRAME_WHOLE = ["-gifflags", "0"];
|
|
10161
|
-
var FAIL_ON_UNREADABLE_FRAME_INSTEAD_OF_ENDING_THERE = ["-xerror"];
|
|
10162
|
-
function framesInput(input, fpsArg) {
|
|
10163
|
-
return [
|
|
10164
|
-
...FAIL_ON_UNREADABLE_FRAME_INSTEAD_OF_ENDING_THERE,
|
|
10165
|
-
"-framerate",
|
|
10166
|
-
fpsArg,
|
|
10167
|
-
...KEEP_FILTER_GRAPH_WHEN_FRAMES_DROP_ALPHA,
|
|
10168
|
-
"-i",
|
|
10169
|
-
join23(input.framesDir, input.framePattern)
|
|
10170
|
-
];
|
|
10171
|
-
}
|
|
10172
|
-
function buildGifPalettegenArgs(input) {
|
|
10173
|
-
const fpsArg = fpsToFfmpegArg2(input.fps);
|
|
10174
|
-
const transparency = input.preserveAlpha ? ":reserve_transparent=1" : "";
|
|
10175
|
-
return [
|
|
10176
|
-
"-y",
|
|
10177
|
-
...framesInput(input, fpsArg),
|
|
10178
|
-
"-vf",
|
|
10179
|
-
`fps=${fpsArg},palettegen=stats_mode=diff${transparency}`,
|
|
10180
|
-
input.palettePath
|
|
10181
|
-
];
|
|
10182
|
-
}
|
|
10183
|
-
function buildGifPaletteuseArgs(input) {
|
|
10184
|
-
const fpsArg = fpsToFfmpegArg2(input.fps);
|
|
10185
|
-
const transparency = input.preserveAlpha ? ":alpha_threshold=128" : "";
|
|
10186
|
-
return [
|
|
10187
|
-
"-y",
|
|
10188
|
-
...framesInput(input, fpsArg),
|
|
10189
|
-
"-i",
|
|
10190
|
-
input.palettePath,
|
|
10191
|
-
"-lavfi",
|
|
10192
|
-
`fps=${fpsArg} [x]; [x][1:v] paletteuse=dither=sierra2_4a${transparency}`,
|
|
10193
|
-
"-loop",
|
|
10194
|
-
String(input.loop),
|
|
10195
|
-
...input.wholeFrames ? WRITE_EVERY_FRAME_WHOLE : [],
|
|
10196
|
-
input.outputPath
|
|
10197
|
-
];
|
|
10198
|
-
}
|
|
10199
|
-
|
|
10200
|
-
// src/services/render/stages/encodeStage.ts
|
|
10201
|
-
import { frameFileExtension as frameFileExtension2 } from "@hyperframes/engine";
|
|
10202
|
-
function resolveGifLoop(loop) {
|
|
10203
|
-
const resolved = loop ?? 0;
|
|
10204
|
-
if (!Number.isInteger(resolved) || resolved < 0 || resolved > 65535) {
|
|
10205
|
-
throw new Error(`[Render] gifLoop must be an integer between 0 and 65535 (got ${resolved})`);
|
|
10206
|
-
}
|
|
10207
|
-
return resolved;
|
|
10208
|
-
}
|
|
10209
|
-
function gifEncodeFailure(startTime, outputPath, error, failureReason) {
|
|
10210
|
-
return {
|
|
10211
|
-
success: false,
|
|
10212
|
-
outputPath,
|
|
10213
|
-
durationMs: Date.now() - startTime,
|
|
10214
|
-
framesEncoded: 0,
|
|
10215
|
-
fileSize: 0,
|
|
10216
|
-
error,
|
|
10217
|
-
failureReason
|
|
10218
|
-
};
|
|
10219
|
-
}
|
|
10220
|
-
async function reencodeWithClearedWholeFrames(argsInput, run, startTime) {
|
|
10221
|
-
const { outputPath } = argsInput;
|
|
10222
|
-
const result = await runFfmpeg3(buildGifPaletteuseArgs({ ...argsInput, wholeFrames: true }), run);
|
|
10223
|
-
if (!result.success) {
|
|
10224
|
-
const error = formatFfmpegError(result.exitCode, result.stderr);
|
|
10225
|
-
return gifEncodeFailure(startTime, outputPath, error, result.failureReason);
|
|
10226
|
-
}
|
|
10227
|
-
const gif = readFileSync7(outputPath);
|
|
10228
|
-
if (!clearGifFramesBeforeNext(gif)) {
|
|
10229
|
-
const error = "[FFmpeg] GIF output could not be parsed to clear its frames";
|
|
10230
|
-
return gifEncodeFailure(startTime, outputPath, error);
|
|
10231
|
-
}
|
|
10232
|
-
writeFileSync4(outputPath, gif);
|
|
10233
|
-
return null;
|
|
10234
|
-
}
|
|
10235
|
-
async function encodeGifFromDir(framesDir, framePattern, outputPath, input) {
|
|
10236
|
-
const startTime = Date.now();
|
|
10237
|
-
const files = readdirSync4(framesDir).filter((file) => file.match(/\.(jpg|jpeg|png)$/i));
|
|
10238
|
-
const frameCount = files.length;
|
|
10239
|
-
if (frameCount === 0) {
|
|
10240
|
-
return gifEncodeFailure(startTime, outputPath, "[FFmpeg] No frame files found in directory");
|
|
10241
|
-
}
|
|
10242
|
-
const argsInput = {
|
|
10243
|
-
framesDir,
|
|
10244
|
-
framePattern,
|
|
10245
|
-
palettePath: input.palettePath,
|
|
10246
|
-
outputPath,
|
|
10247
|
-
fps: input.fps,
|
|
10248
|
-
loop: input.loop,
|
|
10249
|
-
preserveAlpha: input.preserveAlpha
|
|
10250
|
-
};
|
|
10251
|
-
try {
|
|
10252
|
-
const run = { signal: input.signal, timeout: input.timeout };
|
|
10253
|
-
for (const args of [buildGifPalettegenArgs(argsInput), buildGifPaletteuseArgs(argsInput)]) {
|
|
10254
|
-
const result = await runFfmpeg3(args, run);
|
|
10255
|
-
if (!result.success) {
|
|
10256
|
-
const error = formatFfmpegError(result.exitCode, result.stderr);
|
|
10257
|
-
return gifEncodeFailure(startTime, outputPath, error, result.failureReason);
|
|
10258
|
-
}
|
|
10259
|
-
}
|
|
10260
|
-
if (input.preserveAlpha && gifClearsAfterLeavingFrameInPlace(readFileSync7(outputPath)) !== false) {
|
|
10261
|
-
const failure = await reencodeWithClearedWholeFrames(argsInput, run, startTime);
|
|
10262
|
-
if (failure) return failure;
|
|
10263
|
-
}
|
|
10264
|
-
const fileSize = existsSync15(outputPath) ? statSync5(outputPath).size : 0;
|
|
10265
|
-
return {
|
|
10266
|
-
success: true,
|
|
10267
|
-
outputPath,
|
|
10268
|
-
durationMs: Date.now() - startTime,
|
|
10269
|
-
framesEncoded: frameCount,
|
|
10270
|
-
fileSize
|
|
10271
|
-
};
|
|
10272
|
-
} finally {
|
|
10273
|
-
rmSync7(input.palettePath, { force: true });
|
|
10274
|
-
}
|
|
10275
|
-
}
|
|
10276
|
-
function startEncodeProgress(job, stage, onProgress) {
|
|
10277
|
-
const total = job.totalFrames ?? 0;
|
|
10278
|
-
updateJobStatus(job, "encoding", stage, 75, onProgress, {
|
|
10279
|
-
code: "encode",
|
|
10280
|
-
...total > 0 && { done: 0, total }
|
|
10281
|
-
});
|
|
10282
|
-
return (frames = total) => reportEncodeProgress(job, frames, total, onProgress, 75);
|
|
10283
|
-
}
|
|
10284
|
-
function resolveEngineConfig(input) {
|
|
10285
|
-
return input.engineConfig ?? input.job.config.producerConfig ?? resolveConfig();
|
|
10286
|
-
}
|
|
10287
|
-
function videoEncoderOptions(input, engineCfg) {
|
|
10288
|
-
const { job, preset } = input;
|
|
10289
|
-
return {
|
|
10290
|
-
fps: job.config.fps,
|
|
10291
|
-
width: input.width,
|
|
10292
|
-
height: input.height,
|
|
10293
|
-
codec: preset.codec,
|
|
10294
|
-
preset: preset.preset,
|
|
10295
|
-
quality: input.effectiveQuality,
|
|
10296
|
-
bitrate: input.effectiveBitrate,
|
|
10297
|
-
pixelFormat: preset.pixelFormat,
|
|
10298
|
-
vp9CpuUsed: engineCfg.vp9CpuUsed,
|
|
10299
|
-
useGpu: job.config.useGpu,
|
|
10300
|
-
hdr: preset.hdr,
|
|
10301
|
-
// Distributed chunk renders pass these so the encoder writes closed-GOP
|
|
10302
|
-
// keyframes that survive `-f concat -c copy` at assemble time. In-process
|
|
10303
|
-
// renders leave both undefined → preserves the existing open-GOP output.
|
|
10304
|
-
lockGopForChunkConcat: input.lockGopForChunkConcat === true,
|
|
10305
|
-
gopSize: input.gopSize
|
|
10306
|
-
};
|
|
10307
|
-
}
|
|
10308
|
-
function canEncodeWhileCapturing(input, workerCount, cpuCount) {
|
|
10309
|
-
return workerCount > 1 && workerCount < cpuCount && !input.isPngSequence && !input.isGif && !input.enableChunkedEncode && input.preset.codec === "h264" && !input.preset.hdr && input.job.config.useGpu !== true && input.captureImageFormat === "jpeg" && input.lockGopForChunkConcat !== true;
|
|
10310
|
-
}
|
|
10311
|
-
async function readLandedFrame(workerPath, mergedPath) {
|
|
10312
|
-
if (workerPath !== void 0) {
|
|
10313
|
-
try {
|
|
10314
|
-
return await readFile2(workerPath);
|
|
10315
|
-
} catch (error) {
|
|
10316
|
-
if (error.code !== "ENOENT") throw error;
|
|
10317
|
-
}
|
|
10318
|
-
}
|
|
10319
|
-
return readFile2(mergedPath);
|
|
10320
|
-
}
|
|
10321
|
-
function encodeWhileCapturing(input, totalFrames) {
|
|
10322
|
-
const engineCfg = resolveEngineConfig(input);
|
|
10323
|
-
const stop = new AbortController();
|
|
10324
|
-
const signal = input.abortSignal ? AbortSignal.any([input.abortSignal, stop.signal]) : stop.signal;
|
|
10325
|
-
const landed = /* @__PURE__ */ new Map();
|
|
10326
|
-
let captureDone = false;
|
|
10327
|
-
let framesFed = 0;
|
|
10328
|
-
let wake = null;
|
|
10329
|
-
const nudge = () => {
|
|
10330
|
-
wake?.();
|
|
10331
|
-
wake = null;
|
|
10332
|
-
};
|
|
10333
|
-
signal.addEventListener("abort", nudge, { once: true });
|
|
10334
|
-
let reportFed = null;
|
|
10335
|
-
const encoder = spawnStreamingEncoder4(
|
|
10336
|
-
input.videoOnlyPath,
|
|
10337
|
-
{ ...videoEncoderOptions(input, engineCfg), imageFormat: "jpeg" },
|
|
10338
|
-
signal,
|
|
10339
|
-
engineCfg
|
|
10340
|
-
);
|
|
10341
|
-
const fed = (async () => {
|
|
10342
|
-
const stream = await encoder;
|
|
10343
|
-
for (let next = 0; next < totalFrames; next++) {
|
|
10344
|
-
while (!landed.has(next) && !captureDone && !signal.aborted) {
|
|
10345
|
-
await new Promise((resolve10) => wake = resolve10);
|
|
10346
|
-
}
|
|
10347
|
-
if (signal.aborted) return;
|
|
10348
|
-
const mergedPath = join24(input.framesDir, formatCaptureFrameName(next, "jpg"));
|
|
10349
|
-
const frame = await readLandedFrame(landed.get(next), mergedPath);
|
|
10350
|
-
landed.delete(next);
|
|
10351
|
-
if (!await stream.writeFrame(frame)) {
|
|
10352
|
-
throw new Error(stream.getExitError() ?? "encoder exited before the last frame");
|
|
10353
|
-
}
|
|
10354
|
-
framesFed++;
|
|
10355
|
-
reportFed?.(framesFed);
|
|
10356
|
-
}
|
|
10357
|
-
})().then(
|
|
10358
|
-
() => void 0,
|
|
10359
|
-
(error) => error ?? new Error("encode failed")
|
|
10360
|
-
);
|
|
10361
|
-
const closeEncoder = async () => (await encoder.catch(() => null))?.close();
|
|
10362
|
-
return {
|
|
10363
|
-
onFrameLanded: (frame) => {
|
|
10364
|
-
landed.set(frame.index, frame.path);
|
|
10365
|
-
nudge();
|
|
10366
|
-
},
|
|
10367
|
-
finish: async () => {
|
|
10368
|
-
const start = Date.now();
|
|
10369
|
-
captureDone = true;
|
|
10370
|
-
input.log.info("[Render] Encoded frames while capturing", {
|
|
10371
|
-
framesFed,
|
|
10372
|
-
totalFrames
|
|
10373
|
-
});
|
|
10374
|
-
const encoded = startEncodeProgress(input.job, "Encoding video", input.onProgress);
|
|
10375
|
-
reportFed = encoded;
|
|
10376
|
-
nudge();
|
|
10377
|
-
const failure = await fed;
|
|
10378
|
-
const result = await closeEncoder();
|
|
10379
|
-
input.assertNotAborted();
|
|
10380
|
-
if (failure === void 0 && result?.success) {
|
|
10381
|
-
encoded();
|
|
10382
|
-
return { encodeMs: Date.now() - start };
|
|
10383
|
-
}
|
|
10384
|
-
input.log.warn(
|
|
10385
|
-
"[Render] Encoding while capturing failed; encoding the frames after capture",
|
|
10386
|
-
{
|
|
10387
|
-
error: String(failure ?? result?.error)
|
|
10388
|
-
}
|
|
10389
|
-
);
|
|
10390
|
-
return runEncodeStage(input);
|
|
10391
|
-
},
|
|
10392
|
-
abort: async () => {
|
|
10393
|
-
stop.abort();
|
|
10394
|
-
await fed;
|
|
10395
|
-
await closeEncoder();
|
|
10396
|
-
},
|
|
10397
|
-
get framesFed() {
|
|
10398
|
-
return framesFed;
|
|
10399
|
-
}
|
|
10400
|
-
};
|
|
10401
|
-
}
|
|
10402
|
-
async function runEncodeStage(input) {
|
|
10403
|
-
const {
|
|
10404
|
-
job,
|
|
10405
|
-
log,
|
|
10406
|
-
outputPath,
|
|
10407
|
-
framesDir,
|
|
10408
|
-
videoOnlyPath,
|
|
10409
|
-
needsAlpha,
|
|
10410
|
-
hasAudio,
|
|
10411
|
-
audioOutputPath,
|
|
10412
|
-
isPngSequence,
|
|
10413
|
-
isGif,
|
|
10414
|
-
enableChunkedEncode,
|
|
10415
|
-
chunkedEncodeSize,
|
|
10416
|
-
abortSignal,
|
|
10417
|
-
assertNotAborted,
|
|
10418
|
-
onProgress
|
|
10419
|
-
} = input;
|
|
10420
|
-
const stage5Start = Date.now();
|
|
10421
|
-
if (isPngSequence) {
|
|
10422
|
-
const encoded = startEncodeProgress(job, "Writing PNG sequence", onProgress);
|
|
10423
|
-
if (!existsSync15(outputPath)) mkdirSync8(outputPath, { recursive: true });
|
|
10424
|
-
const captured = readdirSync4(framesDir).filter((name) => name.endsWith(".png")).sort();
|
|
10425
|
-
if (captured.length === 0) {
|
|
10426
|
-
throw new Error(
|
|
10427
|
-
`[Render] png-sequence output requested but no PNGs were captured to ${framesDir}`
|
|
10428
|
-
);
|
|
10429
|
-
}
|
|
10430
|
-
captured.forEach((name, i) => {
|
|
10431
|
-
const dst = join24(outputPath, formatExportFrameName(i, "png"));
|
|
10432
|
-
copyFileSync3(join24(framesDir, name), dst);
|
|
10433
|
-
});
|
|
10434
|
-
if (hasAudio && audioOutputPath && existsSync15(audioOutputPath)) {
|
|
10435
|
-
copyFileSync3(audioOutputPath, join24(outputPath, MIXED_AUDIO_FILENAME2));
|
|
10436
|
-
log.info(
|
|
10437
|
-
`[Render] png-sequence: ${MIXED_AUDIO_FILENAME2} sidecar written to ${outputPath}/${MIXED_AUDIO_FILENAME2}`
|
|
10438
|
-
);
|
|
10439
|
-
}
|
|
10440
|
-
encoded();
|
|
10441
|
-
return { encodeMs: Date.now() - stage5Start };
|
|
10442
|
-
}
|
|
10443
|
-
const engineCfg = resolveEngineConfig(input);
|
|
10444
|
-
if (isGif) {
|
|
10445
|
-
const encoded = startEncodeProgress(job, "Encoding GIF", onProgress);
|
|
10446
|
-
if (hasAudio) {
|
|
10447
|
-
log.warn("[Render] GIF output does not support audio; audio tracks will be ignored.");
|
|
10448
|
-
}
|
|
10449
|
-
const frameExt2 = frameFileExtension2(input.captureImageFormat);
|
|
10450
|
-
const framePattern2 = `frame_%06d.${frameExt2}`;
|
|
10451
|
-
const loop = resolveGifLoop(job.config.gifLoop);
|
|
10452
|
-
const encodeResult2 = await encodeGifFromDir(framesDir, framePattern2, outputPath, {
|
|
10453
|
-
fps: job.config.fps,
|
|
10454
|
-
loop,
|
|
10455
|
-
palettePath: join24(dirname8(videoOnlyPath), "gif-palette.png"),
|
|
10456
|
-
preserveAlpha: needsAlpha,
|
|
10457
|
-
signal: abortSignal,
|
|
10458
|
-
timeout: engineCfg.ffmpegEncodeTimeout
|
|
10459
|
-
});
|
|
10460
|
-
assertNotAborted();
|
|
10461
|
-
if (!encodeResult2.success) {
|
|
10462
|
-
throw encoderFailureError("Encoding failed", encodeResult2);
|
|
10555
|
+
if (!domSessionClosed) {
|
|
10556
|
+
await closeCaptureSession7(domSession).catch((err) => {
|
|
10557
|
+
log.warn("closeCaptureSession defensive close failed", {
|
|
10558
|
+
err: err instanceof Error ? err.message : String(err)
|
|
10559
|
+
});
|
|
10560
|
+
});
|
|
10463
10561
|
}
|
|
10464
|
-
|
|
10465
|
-
|
|
10466
|
-
|
|
10467
|
-
|
|
10468
|
-
|
|
10469
|
-
|
|
10470
|
-
const videoEngineCfg = scaledEncodeTimeout > engineCfg.ffmpegEncodeTimeout ? { ...engineCfg, ffmpegEncodeTimeout: scaledEncodeTimeout } : engineCfg;
|
|
10471
|
-
const frameExt = frameFileExtension2(input.captureImageFormat);
|
|
10472
|
-
const framePattern = `frame_%06d.${frameExt}`;
|
|
10473
|
-
const encoderOpts = videoEncoderOptions(input, engineCfg);
|
|
10474
|
-
const encodeResult = enableChunkedEncode ? await encodeFramesChunkedConcat(
|
|
10475
|
-
framesDir,
|
|
10476
|
-
framePattern,
|
|
10477
|
-
videoOnlyPath,
|
|
10478
|
-
encoderOpts,
|
|
10479
|
-
chunkedEncodeSize,
|
|
10480
|
-
abortSignal,
|
|
10481
|
-
videoEngineCfg,
|
|
10482
|
-
onFramesEncoded
|
|
10483
|
-
) : await encodeFramesFromDir(
|
|
10484
|
-
framesDir,
|
|
10485
|
-
framePattern,
|
|
10486
|
-
videoOnlyPath,
|
|
10487
|
-
encoderOpts,
|
|
10488
|
-
abortSignal,
|
|
10489
|
-
videoEngineCfg,
|
|
10490
|
-
onFramesEncoded
|
|
10491
|
-
);
|
|
10492
|
-
assertNotAborted();
|
|
10493
|
-
if (!encodeResult.success) {
|
|
10494
|
-
throw encoderFailureError("Encoding failed", encodeResult);
|
|
10562
|
+
for (const frameSource of hdrVideoFrameSources.values()) {
|
|
10563
|
+
cleanupHdrVideoFrameSource(frameSource, log);
|
|
10564
|
+
}
|
|
10565
|
+
hdrVideoFrameSources.clear();
|
|
10566
|
+
releaseHdrExtractionReservation?.();
|
|
10567
|
+
releaseHdrExtractionReservation = null;
|
|
10495
10568
|
}
|
|
10496
|
-
|
|
10497
|
-
|
|
10569
|
+
return {
|
|
10570
|
+
lastBrowserConsole,
|
|
10571
|
+
hdrPerf,
|
|
10572
|
+
captureDurationMs,
|
|
10573
|
+
encodeMs,
|
|
10574
|
+
warnings: cloneCaptureWarnings2(domSession.warnings)
|
|
10575
|
+
};
|
|
10498
10576
|
}
|
|
10499
10577
|
|
|
10500
|
-
// src/services/render/stages/
|
|
10501
|
-
import { applyFaststart, muxVideoWithAudio, packageHls } from "@hyperframes/engine";
|
|
10502
|
-
import { extname as extname3 } from "node:path";
|
|
10503
|
-
|
|
10504
|
-
// src/services/render/audioPadTrim.ts
|
|
10505
|
-
import { spawn } from "node:child_process";
|
|
10506
|
-
import { mkdtempSync as mkdtempSync3, renameSync as renameSync4, rmSync as rmSync8 } from "node:fs";
|
|
10507
|
-
import { dirname as dirname9, join as join25 } from "node:path";
|
|
10578
|
+
// src/services/render/stages/encodeStage.ts
|
|
10508
10579
|
import {
|
|
10509
|
-
|
|
10510
|
-
|
|
10511
|
-
|
|
10512
|
-
|
|
10513
|
-
|
|
10514
|
-
|
|
10580
|
+
copyFileSync as copyFileSync3,
|
|
10581
|
+
existsSync as existsSync15,
|
|
10582
|
+
mkdirSync as mkdirSync8,
|
|
10583
|
+
readdirSync as readdirSync4,
|
|
10584
|
+
readFileSync as readFileSync7,
|
|
10585
|
+
rmSync as rmSync9,
|
|
10586
|
+
statSync as statSync5,
|
|
10587
|
+
writeFileSync as writeFileSync4
|
|
10588
|
+
} from "node:fs";
|
|
10589
|
+
import { readFile as readFile2 } from "node:fs/promises";
|
|
10590
|
+
import { dirname as dirname9, join as join24 } from "node:path";
|
|
10591
|
+
import {
|
|
10592
|
+
encodeFramesChunkedConcat,
|
|
10593
|
+
encodeFramesFromDir,
|
|
10594
|
+
formatFfmpegError,
|
|
10595
|
+
MIXED_AUDIO_FILENAME as MIXED_AUDIO_FILENAME2,
|
|
10596
|
+
resolveConfig,
|
|
10515
10597
|
runFfmpeg as runFfmpeg5,
|
|
10516
|
-
|
|
10598
|
+
spawnStreamingEncoder as spawnStreamingEncoder4
|
|
10517
10599
|
} from "@hyperframes/engine";
|
|
10518
|
-
import { redactKnownPaths, redactTelemetryString as redactTelemetryString3 } from "@hyperframes/core";
|
|
10519
|
-
|
|
10520
|
-
// src/services/render/audioTruePeak.ts
|
|
10521
10600
|
import {
|
|
10522
|
-
|
|
10523
|
-
|
|
10524
|
-
|
|
10525
|
-
runFfmpeg as runFfmpeg4
|
|
10526
|
-
} from "@hyperframes/engine";
|
|
10527
|
-
var toDbfs = (token) => token.toLowerCase() === "-inf" ? Number.NEGATIVE_INFINITY : Number(token);
|
|
10528
|
-
function checkedDbfs(value) {
|
|
10529
|
-
if (Number.isNaN(value) || value === Number.POSITIVE_INFINITY) {
|
|
10530
|
-
throw new Error("[audioTruePeak] FFmpeg did not report AAC true peak");
|
|
10531
|
-
}
|
|
10532
|
-
return value;
|
|
10533
|
-
}
|
|
10534
|
-
async function scanTruePeak(audioPath, trim, timeoutMs, signal) {
|
|
10535
|
-
let firstBlock;
|
|
10536
|
-
let laterBlocks = Number.NEGATIVE_INFINITY;
|
|
10537
|
-
let partialLine = "";
|
|
10538
|
-
const readLines = (chunk) => {
|
|
10539
|
-
const lines = (partialLine + chunk).split(/\r|\n/);
|
|
10540
|
-
partialLine = lines.pop() ?? "";
|
|
10541
|
-
for (const line of lines) {
|
|
10542
|
-
const ftpk = /FTPK:([^d]*)dBFS/.exec(line)?.[1];
|
|
10543
|
-
if (!ftpk) continue;
|
|
10544
|
-
const block = Math.max(...ftpk.trim().split(/\s+/).map(toDbfs));
|
|
10545
|
-
if (firstBlock === void 0) firstBlock = block;
|
|
10546
|
-
else laterBlocks = Math.max(laterBlocks, block);
|
|
10547
|
-
}
|
|
10548
|
-
};
|
|
10549
|
-
const result = await runFfmpeg4(
|
|
10550
|
-
["-i", audioPath, "-map", "0:a:0", "-vn", "-af", `${trim}ebur128=peak=true`, "-f", "null", "-"],
|
|
10551
|
-
{ signal, timeout: timeoutMs, onStderr: readLines }
|
|
10552
|
-
);
|
|
10553
|
-
if (!result.success) {
|
|
10554
|
-
throw new Error(`[audioTruePeak] ${describeFfmpegFailure(result, timeoutMs)}`);
|
|
10555
|
-
}
|
|
10556
|
-
const summary = [...result.stderr.matchAll(/^\s*Peak:\s*([+-]?(?:[\d.]+|inf)) dBFS$/gim)];
|
|
10557
|
-
return {
|
|
10558
|
-
peak: checkedDbfs(toDbfs(summary.at(-1)?.[1] ?? "nan")),
|
|
10559
|
-
firstBlock: checkedDbfs(firstBlock ?? Number.NEGATIVE_INFINITY),
|
|
10560
|
-
laterBlocks: checkedDbfs(laterBlocks)
|
|
10561
|
-
};
|
|
10562
|
-
}
|
|
10563
|
-
async function measureTruePeakDbfs(audioPath, options) {
|
|
10564
|
-
const { timeoutMs, signal } = options;
|
|
10565
|
-
const wholeFile = async () => (await scanTruePeak(audioPath, "", timeoutMs, signal)).peak;
|
|
10566
|
-
const { durationSeconds, sampleRate } = await extractAudioMetadata2(audioPath, { signal });
|
|
10567
|
-
const block = 4800;
|
|
10568
|
-
const count = sampleRate === 48e3 && Number.isFinite(durationSeconds) ? aacSegmentCount(durationSeconds) : 1;
|
|
10569
|
-
if (count === 1) return wholeFile();
|
|
10570
|
-
const cut = (k) => k * Math.floor(durationSeconds * 10 / count) * block;
|
|
10571
|
-
const peers = new AbortController();
|
|
10572
|
-
const segmentSignal = signal ? AbortSignal.any([signal, peers.signal]) : peers.signal;
|
|
10573
|
-
const scans = await Promise.all(
|
|
10574
|
-
Array.from({ length: count }, (_, k) => {
|
|
10575
|
-
const start = k === 0 ? 0 : cut(k) - block;
|
|
10576
|
-
const end = k === count - 1 ? "" : `:end_sample=${cut(k + 1)}`;
|
|
10577
|
-
return scanTruePeak(
|
|
10578
|
-
audioPath,
|
|
10579
|
-
`atrim=start_sample=${start}${end},`,
|
|
10580
|
-
timeoutMs,
|
|
10581
|
-
segmentSignal
|
|
10582
|
-
);
|
|
10583
|
-
})
|
|
10584
|
-
).catch((error) => {
|
|
10585
|
-
peers.abort();
|
|
10586
|
-
throw error;
|
|
10587
|
-
});
|
|
10588
|
-
const last = scans[count - 1];
|
|
10589
|
-
const counted = Math.max(scans[0].peak, ...scans.slice(1).map((scan) => scan.laterBlocks));
|
|
10590
|
-
if (last.peak > last.firstBlock || last.peak <= counted) return Math.max(counted, last.peak);
|
|
10591
|
-
return wholeFile();
|
|
10592
|
-
}
|
|
10601
|
+
clearGifFramesBeforeNext,
|
|
10602
|
+
gifClearsAfterLeavingFrameInPlace
|
|
10603
|
+
} from "@hyperframes/core";
|
|
10593
10604
|
|
|
10594
|
-
// src/services/render/
|
|
10595
|
-
|
|
10596
|
-
|
|
10597
|
-
|
|
10598
|
-
var MAX_TRUE_PEAK_CORRECTION_PASSES = 3;
|
|
10599
|
-
function aacTruePeakCorrectionFilter(targetDurationSeconds, attenuationDb) {
|
|
10600
|
-
return `volume=${attenuationDb.toFixed(3)}dB,apad,atrim=0:${formatSeconds(targetDurationSeconds)}`;
|
|
10601
|
-
}
|
|
10602
|
-
function buildPadTrimAudioPlan(audioPath, outputPath, sourceDurationSeconds, targetDurationSeconds) {
|
|
10603
|
-
const delta = targetDurationSeconds - sourceDurationSeconds;
|
|
10604
|
-
const targetSec = formatSeconds(targetDurationSeconds);
|
|
10605
|
-
if (Math.abs(delta) < AUDIO_DURATION_TOLERANCE_SECONDS) {
|
|
10606
|
-
return {
|
|
10607
|
-
operation: "copy",
|
|
10608
|
-
steps: [{ kind: "copy", args: ["-i", audioPath, "-c:a", "copy", "-y", outputPath] }],
|
|
10609
|
-
cleanupPaths: []
|
|
10610
|
-
};
|
|
10611
|
-
}
|
|
10612
|
-
if (delta > 0) {
|
|
10613
|
-
return {
|
|
10614
|
-
operation: "pad",
|
|
10615
|
-
steps: [{ kind: "normalize", filter: `apad,atrim=0:${targetSec}` }],
|
|
10616
|
-
cleanupPaths: []
|
|
10617
|
-
};
|
|
10618
|
-
}
|
|
10619
|
-
return {
|
|
10620
|
-
operation: "trim",
|
|
10621
|
-
steps: [{ kind: "trim", filter: `atrim=duration=${targetSec},asetpts=PTS-STARTPTS` }],
|
|
10622
|
-
cleanupPaths: []
|
|
10623
|
-
};
|
|
10605
|
+
// src/services/render/stages/gifEncodeArgs.ts
|
|
10606
|
+
import { frameSequencePath } from "@hyperframes/engine";
|
|
10607
|
+
function fpsToFfmpegArg2(fps) {
|
|
10608
|
+
return fps.den === 1 ? String(fps.num) : `${fps.num}/${fps.den}`;
|
|
10624
10609
|
}
|
|
10625
|
-
|
|
10626
|
-
|
|
10610
|
+
var KEEP_FILTER_GRAPH_WHEN_FRAMES_DROP_ALPHA = ["-reinit_filter", "0"];
|
|
10611
|
+
var WRITE_EVERY_FRAME_WHOLE = ["-gifflags", "0"];
|
|
10612
|
+
var FAIL_ON_UNREADABLE_FRAME_INSTEAD_OF_ENDING_THERE = ["-xerror"];
|
|
10613
|
+
function framesInput(input, fpsArg) {
|
|
10614
|
+
return [
|
|
10615
|
+
...FAIL_ON_UNREADABLE_FRAME_INSTEAD_OF_ENDING_THERE,
|
|
10616
|
+
"-framerate",
|
|
10617
|
+
fpsArg,
|
|
10618
|
+
...KEEP_FILTER_GRAPH_WHEN_FRAMES_DROP_ALPHA,
|
|
10619
|
+
"-i",
|
|
10620
|
+
frameSequencePath(input.framesDir, input.framePattern)
|
|
10621
|
+
];
|
|
10627
10622
|
}
|
|
10628
|
-
function
|
|
10629
|
-
const
|
|
10630
|
-
const
|
|
10631
|
-
return
|
|
10623
|
+
function buildGifPalettegenArgs(input) {
|
|
10624
|
+
const fpsArg = fpsToFfmpegArg2(input.fps);
|
|
10625
|
+
const transparency = input.preserveAlpha ? ":reserve_transparent=1" : "";
|
|
10626
|
+
return [
|
|
10627
|
+
"-y",
|
|
10628
|
+
...framesInput(input, fpsArg),
|
|
10629
|
+
"-vf",
|
|
10630
|
+
`fps=${fpsArg},palettegen=stats_mode=diff${transparency}`,
|
|
10631
|
+
input.palettePath
|
|
10632
|
+
];
|
|
10632
10633
|
}
|
|
10633
|
-
|
|
10634
|
-
const
|
|
10635
|
-
const
|
|
10636
|
-
|
|
10637
|
-
|
|
10638
|
-
|
|
10639
|
-
|
|
10640
|
-
|
|
10641
|
-
|
|
10642
|
-
|
|
10643
|
-
|
|
10644
|
-
|
|
10645
|
-
|
|
10646
|
-
|
|
10647
|
-
|
|
10648
|
-
|
|
10649
|
-
|
|
10650
|
-
|
|
10651
|
-
|
|
10652
|
-
|
|
10653
|
-
|
|
10654
|
-
|
|
10655
|
-
|
|
10656
|
-
if (audioResult.status === "rejected") {
|
|
10657
|
-
return failResult(
|
|
10658
|
-
input.outputPath,
|
|
10659
|
-
0,
|
|
10660
|
-
0,
|
|
10661
|
-
`audioPadTrim: failed to probe audio: ${sanitizeProbeFailure(
|
|
10662
|
-
audioResult.reason,
|
|
10663
|
-
probePaths
|
|
10664
|
-
)}`
|
|
10665
|
-
);
|
|
10634
|
+
function buildGifPaletteuseArgs(input) {
|
|
10635
|
+
const fpsArg = fpsToFfmpegArg2(input.fps);
|
|
10636
|
+
const transparency = input.preserveAlpha ? ":alpha_threshold=128" : "";
|
|
10637
|
+
return [
|
|
10638
|
+
"-y",
|
|
10639
|
+
...framesInput(input, fpsArg),
|
|
10640
|
+
"-i",
|
|
10641
|
+
input.palettePath,
|
|
10642
|
+
"-lavfi",
|
|
10643
|
+
`fps=${fpsArg} [x]; [x][1:v] paletteuse=dither=sierra2_4a${transparency}`,
|
|
10644
|
+
"-loop",
|
|
10645
|
+
String(input.loop),
|
|
10646
|
+
...input.wholeFrames ? WRITE_EVERY_FRAME_WHOLE : [],
|
|
10647
|
+
input.outputPath
|
|
10648
|
+
];
|
|
10649
|
+
}
|
|
10650
|
+
|
|
10651
|
+
// src/services/render/stages/encodeStage.ts
|
|
10652
|
+
import { frameFileExtension as frameFileExtension2 } from "@hyperframes/engine";
|
|
10653
|
+
function resolveGifLoop(loop) {
|
|
10654
|
+
const resolved = loop ?? 0;
|
|
10655
|
+
if (!Number.isInteger(resolved) || resolved < 0 || resolved > 65535) {
|
|
10656
|
+
throw new Error(`[Render] gifLoop must be an integer between 0 and 65535 (got ${resolved})`);
|
|
10666
10657
|
}
|
|
10667
|
-
|
|
10668
|
-
|
|
10669
|
-
|
|
10670
|
-
|
|
10671
|
-
|
|
10672
|
-
|
|
10673
|
-
|
|
10674
|
-
|
|
10675
|
-
|
|
10658
|
+
return resolved;
|
|
10659
|
+
}
|
|
10660
|
+
function gifEncodeFailure(startTime, outputPath, error, failureReason) {
|
|
10661
|
+
return {
|
|
10662
|
+
success: false,
|
|
10663
|
+
outputPath,
|
|
10664
|
+
durationMs: Date.now() - startTime,
|
|
10665
|
+
framesEncoded: 0,
|
|
10666
|
+
fileSize: 0,
|
|
10667
|
+
error,
|
|
10668
|
+
failureReason
|
|
10669
|
+
};
|
|
10670
|
+
}
|
|
10671
|
+
async function reencodeWithClearedWholeFrames(argsInput, run, startTime) {
|
|
10672
|
+
const { outputPath } = argsInput;
|
|
10673
|
+
const result = await runFfmpeg5(buildGifPaletteuseArgs({ ...argsInput, wholeFrames: true }), run);
|
|
10674
|
+
if (!result.success) {
|
|
10675
|
+
const error = formatFfmpegError(result.exitCode, result.stderr);
|
|
10676
|
+
return gifEncodeFailure(startTime, outputPath, error, result.failureReason);
|
|
10676
10677
|
}
|
|
10677
|
-
const
|
|
10678
|
-
|
|
10679
|
-
|
|
10680
|
-
|
|
10681
|
-
audioInfo.durationSeconds,
|
|
10682
|
-
targetDurationSeconds
|
|
10683
|
-
);
|
|
10684
|
-
try {
|
|
10685
|
-
for (const step of plan.steps) {
|
|
10686
|
-
const ffmpegResult = step.kind === "copy" ? await runner(step.args) : await encode(input.audioPath, input.outputPath, targetDurationSeconds, step.filter);
|
|
10687
|
-
if (!ffmpegResult.success) {
|
|
10688
|
-
return {
|
|
10689
|
-
success: false,
|
|
10690
|
-
outputPath: input.outputPath,
|
|
10691
|
-
targetDurationSeconds,
|
|
10692
|
-
sourceDurationSeconds: audioInfo.durationSeconds,
|
|
10693
|
-
operation: plan.operation,
|
|
10694
|
-
error: ffmpegResult.error,
|
|
10695
|
-
failureReason: ffmpegResult.failureReason
|
|
10696
|
-
};
|
|
10697
|
-
}
|
|
10698
|
-
}
|
|
10699
|
-
} catch (err) {
|
|
10700
|
-
return {
|
|
10701
|
-
success: false,
|
|
10702
|
-
outputPath: input.outputPath,
|
|
10703
|
-
targetDurationSeconds,
|
|
10704
|
-
sourceDurationSeconds: audioInfo.durationSeconds,
|
|
10705
|
-
operation: plan.operation,
|
|
10706
|
-
error: `audioPadTrim: failed to materialize ${plan.operation}: ${err instanceof Error ? err.message : String(err)}`
|
|
10707
|
-
};
|
|
10708
|
-
} finally {
|
|
10709
|
-
for (const path of plan.cleanupPaths) rmSync8(path, { force: true });
|
|
10678
|
+
const gif = readFileSync7(outputPath);
|
|
10679
|
+
if (!clearGifFramesBeforeNext(gif)) {
|
|
10680
|
+
const error = "[FFmpeg] GIF output could not be parsed to clear its frames";
|
|
10681
|
+
return gifEncodeFailure(startTime, outputPath, error);
|
|
10710
10682
|
}
|
|
10711
|
-
|
|
10712
|
-
|
|
10713
|
-
|
|
10714
|
-
|
|
10715
|
-
|
|
10716
|
-
|
|
10717
|
-
|
|
10718
|
-
|
|
10719
|
-
|
|
10720
|
-
if (!correction.success) {
|
|
10721
|
-
return {
|
|
10722
|
-
success: false,
|
|
10723
|
-
outputPath: input.outputPath,
|
|
10724
|
-
targetDurationSeconds,
|
|
10725
|
-
sourceDurationSeconds: audioInfo.durationSeconds,
|
|
10726
|
-
operation: plan.operation,
|
|
10727
|
-
error: correction.error,
|
|
10728
|
-
failureReason: correction.failureReason
|
|
10729
|
-
};
|
|
10730
|
-
}
|
|
10731
|
-
loweredDb = correction.loweredDb ?? 0;
|
|
10683
|
+
writeFileSync4(outputPath, gif);
|
|
10684
|
+
return null;
|
|
10685
|
+
}
|
|
10686
|
+
async function encodeGifFromDir(framesDir, framePattern, outputPath, input) {
|
|
10687
|
+
const startTime = Date.now();
|
|
10688
|
+
const files = readdirSync4(framesDir).filter((file) => file.match(/\.(jpg|jpeg|png)$/i));
|
|
10689
|
+
const frameCount = files.length;
|
|
10690
|
+
if (frameCount === 0) {
|
|
10691
|
+
return gifEncodeFailure(startTime, outputPath, "[FFmpeg] No frame files found in directory");
|
|
10732
10692
|
}
|
|
10733
|
-
|
|
10734
|
-
|
|
10735
|
-
|
|
10736
|
-
|
|
10737
|
-
|
|
10738
|
-
|
|
10739
|
-
|
|
10693
|
+
const argsInput = {
|
|
10694
|
+
framesDir,
|
|
10695
|
+
framePattern,
|
|
10696
|
+
palettePath: input.palettePath,
|
|
10697
|
+
outputPath,
|
|
10698
|
+
fps: input.fps,
|
|
10699
|
+
loop: input.loop,
|
|
10700
|
+
preserveAlpha: input.preserveAlpha
|
|
10740
10701
|
};
|
|
10741
|
-
}
|
|
10742
|
-
async function enforceAacTruePeak(input) {
|
|
10743
|
-
let scratchDir;
|
|
10744
10702
|
try {
|
|
10745
|
-
|
|
10746
|
-
const
|
|
10747
|
-
|
|
10748
|
-
let attenuationDb = 0;
|
|
10749
|
-
let measuredPath = input.audioPath;
|
|
10750
|
-
for (let pass = 0; pass <= MAX_TRUE_PEAK_CORRECTION_PASSES; pass += 1) {
|
|
10751
|
-
const truePeakDbfs = await input.probeTruePeak(measuredPath, input.signal);
|
|
10752
|
-
measurements.push(
|
|
10753
|
-
`pass ${pass}: ${truePeakDbfs.toFixed(3)} dBFS at ${attenuationDb.toFixed(3)} dB attenuation`
|
|
10754
|
-
);
|
|
10755
|
-
if (Number.isNaN(truePeakDbfs) || truePeakDbfs === Number.POSITIVE_INFINITY) {
|
|
10756
|
-
return { success: false, error: "audioPadTrim: FFmpeg reported an invalid true peak" };
|
|
10757
|
-
}
|
|
10758
|
-
if (truePeakDbfs <= AAC_DELIVERY_TRUE_PEAK_DBFS) {
|
|
10759
|
-
if (measuredPath !== correctedPath) return { success: true };
|
|
10760
|
-
renameSync4(correctedPath, input.audioPath);
|
|
10761
|
-
return { success: true, loweredDb: Number((-attenuationDb).toFixed(2)) };
|
|
10762
|
-
}
|
|
10763
|
-
if (pass === MAX_TRUE_PEAK_CORRECTION_PASSES) break;
|
|
10764
|
-
const headroomDb = AAC_TRUE_PEAK_CORRECTION_HEADROOM_DB * 2 ** pass;
|
|
10765
|
-
attenuationDb += AAC_DELIVERY_TRUE_PEAK_DBFS - headroomDb - truePeakDbfs;
|
|
10766
|
-
const result = await input.encode(
|
|
10767
|
-
input.audioPath,
|
|
10768
|
-
correctedPath,
|
|
10769
|
-
input.targetDurationSeconds,
|
|
10770
|
-
aacTruePeakCorrectionFilter(input.targetDurationSeconds, attenuationDb)
|
|
10771
|
-
);
|
|
10703
|
+
const run = { signal: input.signal, timeout: input.timeout };
|
|
10704
|
+
for (const args of [buildGifPalettegenArgs(argsInput), buildGifPaletteuseArgs(argsInput)]) {
|
|
10705
|
+
const result = await runFfmpeg5(args, run);
|
|
10772
10706
|
if (!result.success) {
|
|
10773
|
-
|
|
10774
|
-
|
|
10775
|
-
error: sanitizeProbeFailure(
|
|
10776
|
-
result.error ?? "audioPadTrim: failed to constrain AAC true peak",
|
|
10777
|
-
[input.audioPath, correctedPath]
|
|
10778
|
-
),
|
|
10779
|
-
failureReason: result.failureReason
|
|
10780
|
-
};
|
|
10707
|
+
const error = formatFfmpegError(result.exitCode, result.stderr);
|
|
10708
|
+
return gifEncodeFailure(startTime, outputPath, error, result.failureReason);
|
|
10781
10709
|
}
|
|
10782
|
-
measuredPath = correctedPath;
|
|
10783
10710
|
}
|
|
10711
|
+
if (input.preserveAlpha && gifClearsAfterLeavingFrameInPlace(readFileSync7(outputPath)) !== false) {
|
|
10712
|
+
const failure = await reencodeWithClearedWholeFrames(argsInput, run, startTime);
|
|
10713
|
+
if (failure) return failure;
|
|
10714
|
+
}
|
|
10715
|
+
const fileSize = existsSync15(outputPath) ? statSync5(outputPath).size : 0;
|
|
10784
10716
|
return {
|
|
10785
|
-
success:
|
|
10786
|
-
|
|
10787
|
-
|
|
10788
|
-
|
|
10789
|
-
|
|
10790
|
-
success: false,
|
|
10791
|
-
error: `audioPadTrim: failed to validate AAC true peak: ${sanitizeProbeFailure(
|
|
10792
|
-
err,
|
|
10793
|
-
scratchDir ? [input.audioPath, scratchDir] : [input.audioPath]
|
|
10794
|
-
)}`
|
|
10717
|
+
success: true,
|
|
10718
|
+
outputPath,
|
|
10719
|
+
durationMs: Date.now() - startTime,
|
|
10720
|
+
framesEncoded: frameCount,
|
|
10721
|
+
fileSize
|
|
10795
10722
|
};
|
|
10796
10723
|
} finally {
|
|
10797
|
-
|
|
10724
|
+
rmSync9(input.palettePath, { force: true });
|
|
10798
10725
|
}
|
|
10799
10726
|
}
|
|
10800
|
-
function
|
|
10801
|
-
|
|
10802
|
-
|
|
10803
|
-
|
|
10804
|
-
|
|
10805
|
-
|
|
10806
|
-
|
|
10807
|
-
error
|
|
10808
|
-
};
|
|
10809
|
-
}
|
|
10810
|
-
async function defaultProbeVideoFrameInfo(videoPath, signal) {
|
|
10811
|
-
const fastInfo = await runFfprobeJson(
|
|
10812
|
-
[
|
|
10813
|
-
"-v",
|
|
10814
|
-
"error",
|
|
10815
|
-
"-select_streams",
|
|
10816
|
-
"v:0",
|
|
10817
|
-
"-show_entries",
|
|
10818
|
-
"stream=nb_frames,r_frame_rate",
|
|
10819
|
-
"-of",
|
|
10820
|
-
"json",
|
|
10821
|
-
"--",
|
|
10822
|
-
videoPath
|
|
10823
|
-
],
|
|
10824
|
-
signal
|
|
10825
|
-
);
|
|
10826
|
-
let stream = fastInfo.streams?.[0];
|
|
10827
|
-
const fastCount = Number(stream?.nb_frames);
|
|
10828
|
-
if (stream && Number.isFinite(fastCount) && fastCount > 0) {
|
|
10829
|
-
return { frameCount: fastCount, ...parseFrameRate(stream.r_frame_rate ?? "") };
|
|
10830
|
-
}
|
|
10831
|
-
const slowInfo = await runFfprobeJson(
|
|
10832
|
-
[
|
|
10833
|
-
"-v",
|
|
10834
|
-
"error",
|
|
10835
|
-
"-select_streams",
|
|
10836
|
-
"v:0",
|
|
10837
|
-
"-count_packets",
|
|
10838
|
-
"-show_entries",
|
|
10839
|
-
"stream=nb_read_packets,r_frame_rate",
|
|
10840
|
-
"-of",
|
|
10841
|
-
"json",
|
|
10842
|
-
"--",
|
|
10843
|
-
videoPath
|
|
10844
|
-
],
|
|
10845
|
-
signal
|
|
10846
|
-
);
|
|
10847
|
-
stream = slowInfo.streams?.[0];
|
|
10848
|
-
if (!stream) throw new Error(`ffprobe found no video stream in ${videoPath}`);
|
|
10849
|
-
const slowCount = Number(stream.nb_read_packets);
|
|
10850
|
-
if (!Number.isFinite(slowCount) || slowCount <= 0) {
|
|
10851
|
-
throw new Error(`ffprobe returned no frame count: ${JSON.stringify(stream)}`);
|
|
10852
|
-
}
|
|
10853
|
-
return { frameCount: slowCount, ...parseFrameRate(stream.r_frame_rate ?? "") };
|
|
10727
|
+
function startEncodeProgress(job, stage, onProgress) {
|
|
10728
|
+
const total = job.totalFrames ?? 0;
|
|
10729
|
+
updateJobStatus(job, "encoding", stage, 75, onProgress, {
|
|
10730
|
+
code: "encode",
|
|
10731
|
+
...total > 0 && { done: 0, total }
|
|
10732
|
+
});
|
|
10733
|
+
return (frames = total) => reportEncodeProgress(job, frames, total, onProgress, 75);
|
|
10854
10734
|
}
|
|
10855
|
-
function
|
|
10856
|
-
|
|
10857
|
-
const fpsNum = Number(n);
|
|
10858
|
-
const fpsDen = d === void 0 ? 1 : Number(d);
|
|
10859
|
-
if (!Number.isFinite(fpsNum) || !Number.isFinite(fpsDen) || fpsNum <= 0 || fpsDen <= 0) {
|
|
10860
|
-
throw new Error(`Invalid r_frame_rate: ${JSON.stringify(rate)}`);
|
|
10861
|
-
}
|
|
10862
|
-
return { fpsNum, fpsDen };
|
|
10735
|
+
function resolveEngineConfig(input) {
|
|
10736
|
+
return input.engineConfig ?? input.job.config.producerConfig ?? resolveConfig();
|
|
10863
10737
|
}
|
|
10864
|
-
|
|
10865
|
-
const
|
|
10738
|
+
function videoEncoderOptions(input, engineCfg) {
|
|
10739
|
+
const { job, preset } = input;
|
|
10866
10740
|
return {
|
|
10867
|
-
|
|
10868
|
-
|
|
10869
|
-
|
|
10870
|
-
|
|
10741
|
+
fps: job.config.fps,
|
|
10742
|
+
width: input.width,
|
|
10743
|
+
height: input.height,
|
|
10744
|
+
codec: preset.codec,
|
|
10745
|
+
preset: preset.preset,
|
|
10746
|
+
quality: input.effectiveQuality,
|
|
10747
|
+
bitrate: input.effectiveBitrate,
|
|
10748
|
+
pixelFormat: preset.pixelFormat,
|
|
10749
|
+
vp9CpuUsed: engineCfg.vp9CpuUsed,
|
|
10750
|
+
useGpu: job.config.useGpu,
|
|
10751
|
+
hdr: preset.hdr,
|
|
10752
|
+
// Distributed chunk renders pass these so the encoder writes closed-GOP
|
|
10753
|
+
// keyframes that survive `-f concat -c copy` at assemble time. In-process
|
|
10754
|
+
// renders leave both undefined → preserves the existing open-GOP output.
|
|
10755
|
+
lockGopForChunkConcat: input.lockGopForChunkConcat === true,
|
|
10756
|
+
gopSize: input.gopSize
|
|
10871
10757
|
};
|
|
10872
10758
|
}
|
|
10873
|
-
|
|
10874
|
-
return
|
|
10759
|
+
function canEncodeWhileCapturing(input, workerCount, cpuCount) {
|
|
10760
|
+
return workerCount > 1 && workerCount < cpuCount && !input.isPngSequence && !input.isGif && !input.enableChunkedEncode && input.preset.codec === "h264" && !input.preset.hdr && input.job.config.useGpu !== true && input.captureImageFormat === "jpeg" && input.lockGopForChunkConcat !== true;
|
|
10875
10761
|
}
|
|
10876
|
-
function
|
|
10877
|
-
|
|
10878
|
-
|
|
10879
|
-
|
|
10880
|
-
|
|
10881
|
-
|
|
10882
|
-
|
|
10883
|
-
}
|
|
10762
|
+
async function readLandedFrame(workerPath, mergedPath) {
|
|
10763
|
+
if (workerPath !== void 0) {
|
|
10764
|
+
try {
|
|
10765
|
+
return await readFile2(workerPath);
|
|
10766
|
+
} catch (error) {
|
|
10767
|
+
if (error.code !== "ENOENT") throw error;
|
|
10768
|
+
}
|
|
10769
|
+
}
|
|
10770
|
+
return readFile2(mergedPath);
|
|
10884
10771
|
}
|
|
10885
|
-
function
|
|
10886
|
-
|
|
10772
|
+
function encodeWhileCapturing(input, totalFrames) {
|
|
10773
|
+
const engineCfg = resolveEngineConfig(input);
|
|
10774
|
+
const stop = new AbortController();
|
|
10775
|
+
const signal = input.abortSignal ? AbortSignal.any([input.abortSignal, stop.signal]) : stop.signal;
|
|
10776
|
+
const landed = /* @__PURE__ */ new Map();
|
|
10777
|
+
let captureDone = false;
|
|
10778
|
+
let framesFed = 0;
|
|
10779
|
+
let wake = null;
|
|
10780
|
+
const nudge = () => {
|
|
10781
|
+
wake?.();
|
|
10782
|
+
wake = null;
|
|
10783
|
+
};
|
|
10784
|
+
signal.addEventListener("abort", nudge, { once: true });
|
|
10785
|
+
let reportFed = null;
|
|
10786
|
+
const encoder = spawnStreamingEncoder4(
|
|
10787
|
+
input.videoOnlyPath,
|
|
10788
|
+
{ ...videoEncoderOptions(input, engineCfg), imageFormat: "jpeg" },
|
|
10789
|
+
signal,
|
|
10790
|
+
engineCfg
|
|
10791
|
+
);
|
|
10792
|
+
const fed = (async () => {
|
|
10793
|
+
const stream = await encoder;
|
|
10794
|
+
for (let next = 0; next < totalFrames; next++) {
|
|
10795
|
+
while (!landed.has(next) && !captureDone && !signal.aborted) {
|
|
10796
|
+
await new Promise((resolve10) => wake = resolve10);
|
|
10797
|
+
}
|
|
10798
|
+
if (signal.aborted) return;
|
|
10799
|
+
const mergedPath = join24(input.framesDir, formatCaptureFrameName(next, "jpg"));
|
|
10800
|
+
const frame = await readLandedFrame(landed.get(next), mergedPath);
|
|
10801
|
+
landed.delete(next);
|
|
10802
|
+
if (!await stream.writeFrame(frame)) {
|
|
10803
|
+
throw new Error(stream.getExitError() ?? "encoder exited before the last frame");
|
|
10804
|
+
}
|
|
10805
|
+
framesFed++;
|
|
10806
|
+
reportFed?.(framesFed);
|
|
10807
|
+
}
|
|
10808
|
+
})().then(
|
|
10809
|
+
() => void 0,
|
|
10810
|
+
(error) => error ?? new Error("encode failed")
|
|
10811
|
+
);
|
|
10812
|
+
const closeEncoder = async () => (await encoder.catch(() => null))?.close();
|
|
10887
10813
|
return {
|
|
10888
|
-
|
|
10889
|
-
|
|
10890
|
-
|
|
10814
|
+
onFrameLanded: (frame) => {
|
|
10815
|
+
landed.set(frame.index, frame.path);
|
|
10816
|
+
nudge();
|
|
10817
|
+
},
|
|
10818
|
+
finish: async () => {
|
|
10819
|
+
const start = Date.now();
|
|
10820
|
+
captureDone = true;
|
|
10821
|
+
input.log.info("[Render] Encoded frames while capturing", {
|
|
10822
|
+
framesFed,
|
|
10823
|
+
totalFrames
|
|
10824
|
+
});
|
|
10825
|
+
const encoded = startEncodeProgress(input.job, "Encoding video", input.onProgress);
|
|
10826
|
+
reportFed = encoded;
|
|
10827
|
+
nudge();
|
|
10828
|
+
const failure = await fed;
|
|
10829
|
+
const result = await closeEncoder();
|
|
10830
|
+
input.assertNotAborted();
|
|
10831
|
+
if (failure === void 0 && result?.success) {
|
|
10832
|
+
encoded();
|
|
10833
|
+
return { encodeMs: Date.now() - start };
|
|
10834
|
+
}
|
|
10835
|
+
input.log.warn(
|
|
10836
|
+
"[Render] Encoding while capturing failed; encoding the frames after capture",
|
|
10837
|
+
{
|
|
10838
|
+
error: String(failure ?? result?.error)
|
|
10839
|
+
}
|
|
10840
|
+
);
|
|
10841
|
+
return runEncodeStage(input);
|
|
10842
|
+
},
|
|
10843
|
+
abort: async () => {
|
|
10844
|
+
stop.abort();
|
|
10845
|
+
await fed;
|
|
10846
|
+
await closeEncoder();
|
|
10847
|
+
},
|
|
10848
|
+
get framesFed() {
|
|
10849
|
+
return framesFed;
|
|
10850
|
+
}
|
|
10891
10851
|
};
|
|
10892
10852
|
}
|
|
10893
|
-
async function
|
|
10894
|
-
|
|
10895
|
-
|
|
10896
|
-
|
|
10897
|
-
|
|
10898
|
-
|
|
10899
|
-
|
|
10900
|
-
|
|
10901
|
-
|
|
10902
|
-
|
|
10903
|
-
|
|
10904
|
-
|
|
10905
|
-
|
|
10906
|
-
|
|
10907
|
-
|
|
10908
|
-
|
|
10909
|
-
|
|
10910
|
-
|
|
10911
|
-
|
|
10912
|
-
|
|
10913
|
-
|
|
10853
|
+
async function runEncodeStage(input) {
|
|
10854
|
+
const {
|
|
10855
|
+
job,
|
|
10856
|
+
log,
|
|
10857
|
+
outputPath,
|
|
10858
|
+
framesDir,
|
|
10859
|
+
videoOnlyPath,
|
|
10860
|
+
needsAlpha,
|
|
10861
|
+
hasAudio,
|
|
10862
|
+
audioOutputPath,
|
|
10863
|
+
isPngSequence,
|
|
10864
|
+
isGif,
|
|
10865
|
+
enableChunkedEncode,
|
|
10866
|
+
chunkedEncodeSize,
|
|
10867
|
+
abortSignal,
|
|
10868
|
+
assertNotAborted,
|
|
10869
|
+
onProgress
|
|
10870
|
+
} = input;
|
|
10871
|
+
const stage5Start = Date.now();
|
|
10872
|
+
if (isPngSequence) {
|
|
10873
|
+
const encoded = startEncodeProgress(job, "Writing PNG sequence", onProgress);
|
|
10874
|
+
if (!existsSync15(outputPath)) mkdirSync8(outputPath, { recursive: true });
|
|
10875
|
+
const captured = readdirSync4(framesDir).filter((name) => name.endsWith(".png")).sort();
|
|
10876
|
+
if (captured.length === 0) {
|
|
10877
|
+
throw new Error(
|
|
10878
|
+
`[Render] png-sequence output requested but no PNGs were captured to ${framesDir}`
|
|
10879
|
+
);
|
|
10914
10880
|
}
|
|
10915
|
-
|
|
10881
|
+
captured.forEach((name, i) => {
|
|
10882
|
+
const dst = join24(outputPath, formatExportFrameName(i, "png"));
|
|
10883
|
+
copyFileSync3(join24(framesDir, name), dst);
|
|
10884
|
+
});
|
|
10885
|
+
if (hasAudio && audioOutputPath && existsSync15(audioOutputPath)) {
|
|
10886
|
+
copyFileSync3(audioOutputPath, join24(outputPath, MIXED_AUDIO_FILENAME2));
|
|
10887
|
+
log.info(
|
|
10888
|
+
`[Render] png-sequence: ${MIXED_AUDIO_FILENAME2} sidecar written to ${outputPath}/${MIXED_AUDIO_FILENAME2}`
|
|
10889
|
+
);
|
|
10890
|
+
}
|
|
10891
|
+
encoded();
|
|
10892
|
+
return { encodeMs: Date.now() - stage5Start };
|
|
10916
10893
|
}
|
|
10917
|
-
|
|
10918
|
-
|
|
10919
|
-
const
|
|
10920
|
-
|
|
10894
|
+
const engineCfg = resolveEngineConfig(input);
|
|
10895
|
+
if (isGif) {
|
|
10896
|
+
const encoded = startEncodeProgress(job, "Encoding GIF", onProgress);
|
|
10897
|
+
if (hasAudio) {
|
|
10898
|
+
log.warn("[Render] GIF output does not support audio; audio tracks will be ignored.");
|
|
10899
|
+
}
|
|
10900
|
+
const frameExt2 = frameFileExtension2(input.captureImageFormat);
|
|
10901
|
+
const framePattern2 = `frame_%06d.${frameExt2}`;
|
|
10902
|
+
const loop = resolveGifLoop(job.config.gifLoop);
|
|
10903
|
+
const encodeResult2 = await encodeGifFromDir(framesDir, framePattern2, outputPath, {
|
|
10904
|
+
fps: job.config.fps,
|
|
10905
|
+
loop,
|
|
10906
|
+
palettePath: join24(dirname9(videoOnlyPath), "gif-palette.png"),
|
|
10907
|
+
preserveAlpha: needsAlpha,
|
|
10908
|
+
signal: abortSignal,
|
|
10909
|
+
timeout: engineCfg.ffmpegEncodeTimeout
|
|
10910
|
+
});
|
|
10911
|
+
assertNotAborted();
|
|
10912
|
+
if (!encodeResult2.success) {
|
|
10913
|
+
throw encoderFailureError("Encoding failed", encodeResult2);
|
|
10914
|
+
}
|
|
10915
|
+
encoded();
|
|
10916
|
+
return { encodeMs: Date.now() - stage5Start };
|
|
10921
10917
|
}
|
|
10922
|
-
|
|
10923
|
-
|
|
10924
|
-
|
|
10925
|
-
|
|
10918
|
+
const onFramesEncoded = startEncodeProgress(job, "Encoding video", onProgress);
|
|
10919
|
+
const baseScaledEncodeTimeout = Math.ceil((job.duration ?? 0) * 4e3);
|
|
10920
|
+
const scaledEncodeTimeout = baseScaledEncodeTimeout * (job.config.quality === "high" ? 6 : 1);
|
|
10921
|
+
const videoEngineCfg = scaledEncodeTimeout > engineCfg.ffmpegEncodeTimeout ? { ...engineCfg, ffmpegEncodeTimeout: scaledEncodeTimeout } : engineCfg;
|
|
10922
|
+
const frameExt = frameFileExtension2(input.captureImageFormat);
|
|
10923
|
+
const framePattern = `frame_%06d.${frameExt}`;
|
|
10924
|
+
const encoderOpts = videoEncoderOptions(input, engineCfg);
|
|
10925
|
+
const encodeResult = enableChunkedEncode ? await encodeFramesChunkedConcat(
|
|
10926
|
+
framesDir,
|
|
10927
|
+
framePattern,
|
|
10928
|
+
videoOnlyPath,
|
|
10929
|
+
encoderOpts,
|
|
10930
|
+
chunkedEncodeSize,
|
|
10931
|
+
abortSignal,
|
|
10932
|
+
videoEngineCfg,
|
|
10933
|
+
onFramesEncoded
|
|
10934
|
+
) : await encodeFramesFromDir(
|
|
10935
|
+
framesDir,
|
|
10936
|
+
framePattern,
|
|
10937
|
+
videoOnlyPath,
|
|
10938
|
+
encoderOpts,
|
|
10939
|
+
abortSignal,
|
|
10940
|
+
videoEngineCfg,
|
|
10941
|
+
onFramesEncoded
|
|
10942
|
+
);
|
|
10943
|
+
assertNotAborted();
|
|
10944
|
+
if (!encodeResult.success) {
|
|
10945
|
+
throw encoderFailureError("Encoding failed", encodeResult);
|
|
10926
10946
|
}
|
|
10947
|
+
onFramesEncoded();
|
|
10948
|
+
return { encodeMs: Date.now() - stage5Start };
|
|
10927
10949
|
}
|
|
10928
10950
|
|
|
10929
10951
|
// src/services/render/stages/assembleStage.ts
|
|
10952
|
+
import { applyFaststart, muxVideoWithAudio, packageHls } from "@hyperframes/engine";
|
|
10953
|
+
import { extname as extname3 } from "node:path";
|
|
10930
10954
|
function recordLimiterAttenuation(job, audioLoweredDb) {
|
|
10931
10955
|
if (audioLoweredDb === void 0) return;
|
|
10932
10956
|
job.audioLoweredDb = audioLoweredDb;
|
|
@@ -11057,6 +11081,10 @@ function assertMotionBlurSupported(motionBlur, route) {
|
|
|
11057
11081
|
`[MotionBlur] sub-frame motion blur is not supported on the "${route}" capture route: ${reason}`
|
|
11058
11082
|
);
|
|
11059
11083
|
}
|
|
11084
|
+
function pageMotionBlurForRoute(route) {
|
|
11085
|
+
if (route.hasHdrContent) return "hdr-render";
|
|
11086
|
+
return route.useLayeredComposite ? "layered-composite" : void 0;
|
|
11087
|
+
}
|
|
11060
11088
|
|
|
11061
11089
|
// src/services/render/outputScaleRoute.ts
|
|
11062
11090
|
var UNSUPPORTED_REASON2 = {
|
|
@@ -11088,7 +11116,7 @@ function sampleDirectoryBytes(dir) {
|
|
|
11088
11116
|
continue;
|
|
11089
11117
|
}
|
|
11090
11118
|
for (const name of entries) {
|
|
11091
|
-
const full =
|
|
11119
|
+
const full = join25(current, name);
|
|
11092
11120
|
try {
|
|
11093
11121
|
const st = statSync6(full);
|
|
11094
11122
|
if (st.isDirectory()) {
|
|
@@ -11236,7 +11264,7 @@ function findMissingFrameRanges(totalFrames, framesDir, frameExt) {
|
|
|
11236
11264
|
const ranges = [];
|
|
11237
11265
|
let rangeStart = null;
|
|
11238
11266
|
for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) {
|
|
11239
|
-
const framePath =
|
|
11267
|
+
const framePath = join25(framesDir, formatCaptureFrameName(frameIndex, frameExt));
|
|
11240
11268
|
const missing = !existsSync16(framePath) || statSync6(framePath).size <= 8;
|
|
11241
11269
|
if (missing && rangeStart === null) {
|
|
11242
11270
|
rangeStart = frameIndex;
|
|
@@ -11259,7 +11287,7 @@ function buildMissingFrameRetryBatches(ranges, maxWorkers, workDir, attempt, ran
|
|
|
11259
11287
|
workerId,
|
|
11260
11288
|
startFrame: rangeStart + range.startFrame,
|
|
11261
11289
|
endFrame: rangeStart + range.endFrame,
|
|
11262
|
-
outputDir:
|
|
11290
|
+
outputDir: join25(workDir, `retry-${attempt}-batch-${batchIndex}-worker-${workerId}`),
|
|
11263
11291
|
outputFrameOffset: rangeStart
|
|
11264
11292
|
}));
|
|
11265
11293
|
batches.push(batch);
|
|
@@ -11287,8 +11315,8 @@ function getNextRetryWorkerCount(currentWorkers) {
|
|
|
11287
11315
|
return Math.max(1, Math.floor(currentWorkers / 2));
|
|
11288
11316
|
}
|
|
11289
11317
|
function resolveRenderWorkDirPrefix(outputPath, jobId, platform2 = process.platform, systemTempDir = tmpdir2()) {
|
|
11290
|
-
if (platform2 === "win32" || platform2 === "darwin") return
|
|
11291
|
-
return
|
|
11318
|
+
if (platform2 === "win32" || platform2 === "darwin") return join25(systemTempDir, "hf-render-");
|
|
11319
|
+
return join25(dirname10(outputPath), `work-${jobId}-`);
|
|
11292
11320
|
}
|
|
11293
11321
|
var MAX_TRANSIENT_CAPTURE_RETRIES = 1;
|
|
11294
11322
|
function isTransientCaptureRetryEligible(failureKind, missing, retriesUsed) {
|
|
@@ -11320,7 +11348,7 @@ The composition is too large for the available memory. To reduce memory pressure
|
|
|
11320
11348
|
function countCapturedFrames(totalFrames, framesDir, frameExt) {
|
|
11321
11349
|
let captured = 0;
|
|
11322
11350
|
for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) {
|
|
11323
|
-
const framePath =
|
|
11351
|
+
const framePath = join25(framesDir, formatCaptureFrameName(frameIndex, frameExt));
|
|
11324
11352
|
if (existsSync16(framePath)) captured++;
|
|
11325
11353
|
}
|
|
11326
11354
|
return captured;
|
|
@@ -11346,7 +11374,7 @@ async function executeDiskCaptureWithAdaptiveRetry(options) {
|
|
|
11346
11374
|
reason: attempt === 0 ? "initial" : pendingTransientRetry ? "transient-retry" : "retry"
|
|
11347
11375
|
});
|
|
11348
11376
|
pendingTransientRetry = false;
|
|
11349
|
-
const attemptWorkDir =
|
|
11377
|
+
const attemptWorkDir = join25(options.workDir, `capture-attempt-${attempt}`);
|
|
11350
11378
|
const batches = missingRanges ? buildMissingFrameRetryBatches(
|
|
11351
11379
|
missingRanges,
|
|
11352
11380
|
currentWorkers,
|
|
@@ -11915,17 +11943,17 @@ function deVerifyFallbackTelemetry(err) {
|
|
|
11915
11943
|
}
|
|
11916
11944
|
function resolveRenderDebugDir() {
|
|
11917
11945
|
const producerRoot = process.env.PRODUCER_RENDERS_DIR ? resolve7(process.env.PRODUCER_RENDERS_DIR, "..") : resolve7(dirname10(fileURLToPath4(import.meta.url)), "../..");
|
|
11918
|
-
return
|
|
11946
|
+
return join25(producerRoot, ".debug");
|
|
11919
11947
|
}
|
|
11920
11948
|
async function executeRenderJob(job, projectDir, outputPath, progressSink, abortSignal, assertRenderActive) {
|
|
11921
11949
|
validateHlsRenderConfig(job.config);
|
|
11922
11950
|
const debugDir = resolveRenderDebugDir();
|
|
11923
11951
|
const outputDir = dirname10(outputPath);
|
|
11924
11952
|
if (!existsSync16(outputDir)) mkdirSync9(outputDir, { recursive: true });
|
|
11925
|
-
const workDir = job.config.debug ?
|
|
11953
|
+
const workDir = job.config.debug ? join25(debugDir, job.id) : createRenderWorkDir(resolveRenderWorkDirPrefix(outputPath, job.id), outputDir);
|
|
11926
11954
|
const pipelineStart = Date.now();
|
|
11927
11955
|
const baseLog = job.config.logger ?? defaultLogger;
|
|
11928
|
-
const logPath = job.config.debug ?
|
|
11956
|
+
const logPath = job.config.debug ? join25(workDir, "render.log") : null;
|
|
11929
11957
|
const execution = new RenderExecutionContext({
|
|
11930
11958
|
request: { renderJobId: job.id, projectDir, outputPath },
|
|
11931
11959
|
logger: logPath ? createRenderFileLogger(logPath, baseLog) : baseLog,
|
|
@@ -11939,7 +11967,7 @@ async function executeRenderJob(job, projectDir, outputPath, progressSink, abort
|
|
|
11939
11967
|
log.info("KEEP_TEMP=1, leaving workDir on disk for inspection", { workDir });
|
|
11940
11968
|
return;
|
|
11941
11969
|
}
|
|
11942
|
-
|
|
11970
|
+
rmSync10(workDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
11943
11971
|
});
|
|
11944
11972
|
try {
|
|
11945
11973
|
await executeRenderPipeline({
|
|
@@ -11983,7 +12011,7 @@ async function executeRenderPipeline(input) {
|
|
|
11983
12011
|
imageDecodeFailures: 0
|
|
11984
12012
|
};
|
|
11985
12013
|
let hdrPerf;
|
|
11986
|
-
const perfOutputPath =
|
|
12014
|
+
const perfOutputPath = join25(workDir, "perf-summary.json");
|
|
11987
12015
|
const cfg = { ...job.config.producerConfig ?? resolveConfig2() };
|
|
11988
12016
|
const observability = new RenderObservabilityRecorder({
|
|
11989
12017
|
pipelineStartMs: pipelineStart,
|
|
@@ -12082,15 +12110,15 @@ async function executeRenderPipeline(input) {
|
|
|
12082
12110
|
requestedWorkers: job.config.workers ?? "auto"
|
|
12083
12111
|
});
|
|
12084
12112
|
const entryFile = job.config.entryFile || "index.html";
|
|
12085
|
-
let htmlPath =
|
|
12113
|
+
let htmlPath = join25(projectDir, entryFile);
|
|
12086
12114
|
if (!existsSync16(htmlPath)) {
|
|
12087
12115
|
throw new Error(`Entry file not found: ${htmlPath}`);
|
|
12088
12116
|
}
|
|
12089
12117
|
assertNotAborted();
|
|
12090
12118
|
const rawEntry = readFileSync8(htmlPath, "utf-8");
|
|
12091
12119
|
if (entryFile !== "index.html" && isTemplateEntry(rawEntry)) {
|
|
12092
|
-
const wrapperPath =
|
|
12093
|
-
const projectIndexPath =
|
|
12120
|
+
const wrapperPath = join25(workDir, "standalone-entry.html");
|
|
12121
|
+
const projectIndexPath = join25(projectDir, "index.html");
|
|
12094
12122
|
if (!existsSync16(projectIndexPath)) {
|
|
12095
12123
|
throw new Error(
|
|
12096
12124
|
`Template entry file "${entryFile}" requires a project index.html to extract its render shell.`
|
|
@@ -12130,7 +12158,8 @@ async function executeRenderPipeline(input) {
|
|
|
12130
12158
|
needsAlpha,
|
|
12131
12159
|
log,
|
|
12132
12160
|
assertNotAborted,
|
|
12133
|
-
variables: job.config.variables
|
|
12161
|
+
variables: job.config.variables,
|
|
12162
|
+
pageMotionBlur: job.config.pageMotionBlur
|
|
12134
12163
|
})
|
|
12135
12164
|
);
|
|
12136
12165
|
let compiled = compileResult.compiled;
|
|
@@ -12252,7 +12281,7 @@ async function executeRenderPipeline(input) {
|
|
|
12252
12281
|
updateJobStatus(job, "preprocessing", "Extracting video frames", 10, onProgress, {
|
|
12253
12282
|
code: "extract_video"
|
|
12254
12283
|
});
|
|
12255
|
-
const compiledDir =
|
|
12284
|
+
const compiledDir = join25(workDir, "compiled");
|
|
12256
12285
|
const extractResult = await observeRenderStage(
|
|
12257
12286
|
observability,
|
|
12258
12287
|
"video_extract",
|
|
@@ -12340,7 +12369,8 @@ async function executeRenderPipeline(input) {
|
|
|
12340
12369
|
projectDir,
|
|
12341
12370
|
workDir,
|
|
12342
12371
|
compiledDir,
|
|
12343
|
-
|
|
12372
|
+
totalFrames: probeResult.totalFrames,
|
|
12373
|
+
fps: job.config.fps,
|
|
12344
12374
|
ffmpegProcessTimeout: cfg.ffmpegProcessTimeout,
|
|
12345
12375
|
audioGain: cfg.audioGain,
|
|
12346
12376
|
audios: composition.audios,
|
|
@@ -12352,6 +12382,7 @@ async function executeRenderPipeline(input) {
|
|
|
12352
12382
|
);
|
|
12353
12383
|
const { audioOutputPath, hasAudio } = audioResult;
|
|
12354
12384
|
perfStages.audioProcessMs = audioResult.audioProcessMs;
|
|
12385
|
+
recordLimiterAttenuation(job, audioResult.audioLoweredDb);
|
|
12355
12386
|
if (audioResult.audioError) {
|
|
12356
12387
|
const audioFailures = audioResult.audioFailures ?? [];
|
|
12357
12388
|
const failureOwner = audioFailures.length === 0 ? void 0 : audioFailures.some((failure) => failure.owner === "system") ? "system" : "user";
|
|
@@ -12384,7 +12415,7 @@ async function executeRenderPipeline(input) {
|
|
|
12384
12415
|
try {
|
|
12385
12416
|
fileServer = await createFileServer({
|
|
12386
12417
|
projectDir,
|
|
12387
|
-
compiledDir:
|
|
12418
|
+
compiledDir: join25(workDir, "compiled"),
|
|
12388
12419
|
port: 0,
|
|
12389
12420
|
preHeadScripts: [VIRTUAL_TIME_SHIM],
|
|
12390
12421
|
fps: job.config.fps
|
|
@@ -12402,7 +12433,7 @@ async function executeRenderPipeline(input) {
|
|
|
12402
12433
|
if (!activeFileServer) {
|
|
12403
12434
|
throw new Error("File server failed to initialize before frame capture");
|
|
12404
12435
|
}
|
|
12405
|
-
const framesDir =
|
|
12436
|
+
const framesDir = join25(workDir, "captured-frames");
|
|
12406
12437
|
if (!existsSync16(framesDir)) mkdirSync9(framesDir, { recursive: true });
|
|
12407
12438
|
updateJobStatus(job, "rendering", "Checking browser GPU", 25, onProgress, {
|
|
12408
12439
|
code: "start_browsers"
|
|
@@ -12434,14 +12465,17 @@ async function executeRenderPipeline(input) {
|
|
|
12434
12465
|
variables: job.config.variables,
|
|
12435
12466
|
deviceScaleFactor,
|
|
12436
12467
|
motionBlur: job.config.motionBlur,
|
|
12468
|
+
pageMotionBlur: job.config.pageMotionBlur,
|
|
12437
12469
|
...videoCaptureBeyondViewport !== void 0 ? { captureBeyondViewport: videoCaptureBeyondViewport } : {}
|
|
12438
12470
|
};
|
|
12439
12471
|
resolvedCaptureBeyondViewport = captureOptions.captureBeyondViewport ?? resolvedCaptureBeyondViewport;
|
|
12440
12472
|
if (resolvedCaptureBeyondViewport !== void 0) {
|
|
12441
12473
|
updateCaptureObservability({ captureBeyondViewport: resolvedCaptureBeyondViewport });
|
|
12442
12474
|
}
|
|
12475
|
+
let routePageMotionBlur;
|
|
12443
12476
|
const buildCaptureOptions = () => ({
|
|
12444
12477
|
...captureOptions,
|
|
12478
|
+
...routePageMotionBlur ? { pageMotionBlur: routePageMotionBlur } : {},
|
|
12445
12479
|
videoMetadataHints,
|
|
12446
12480
|
skipReadinessVideoIds: videoReadinessSkipIds,
|
|
12447
12481
|
subTimelineWaitMemo,
|
|
@@ -12475,6 +12509,7 @@ async function executeRenderPipeline(input) {
|
|
|
12475
12509
|
const nativeHdrIds = /* @__PURE__ */ new Set([...nativeHdrVideoIds, ...nativeHdrImageIds]);
|
|
12476
12510
|
const hasHdrContent = Boolean(effectiveHdr && nativeHdrIds.size > 0);
|
|
12477
12511
|
if (hasHdrContent) assertOutputScaleSupported(deviceScaleFactor, "hdr_layered");
|
|
12512
|
+
routePageMotionBlur = pageMotionBlurForRoute({ hasHdrContent, useLayeredComposite: false });
|
|
12478
12513
|
const deSingleMinFramesRaw = process.env.HF_DE_SINGLE_MIN_FRAMES;
|
|
12479
12514
|
const deSingleMinFramesNum = deSingleMinFramesRaw === void 0 || deSingleMinFramesRaw.trim() === "" ? 900 : Number(deSingleMinFramesRaw);
|
|
12480
12515
|
const deSingleMinFrames = Number.isFinite(deSingleMinFramesNum) ? deSingleMinFramesNum : 900;
|
|
@@ -12848,7 +12883,7 @@ async function executeRenderPipeline(input) {
|
|
|
12848
12883
|
hls: ".mp4"
|
|
12849
12884
|
};
|
|
12850
12885
|
const videoExt = FORMAT_EXT[outputFormat] ?? ".mp4";
|
|
12851
|
-
const videoOnlyPath =
|
|
12886
|
+
const videoOnlyPath = join25(workDir, `video-only${videoExt}`);
|
|
12852
12887
|
const usePageSideCompositingForTransitions = (cfg.enablePageSideCompositing || isGif) && compiled.hasShaderTransitions && !hasHdrContent && outputSupportsPageSideShaderCompositing(outputFormat);
|
|
12853
12888
|
if (usePageSideCompositingForTransitions) {
|
|
12854
12889
|
activeFileServer.addPreHeadScript(HF_PAGE_SIDE_COMPOSITING_STUB);
|
|
@@ -12877,6 +12912,7 @@ async function executeRenderPipeline(input) {
|
|
|
12877
12912
|
hasShaderTransitions: compiled.hasShaderTransitions && !isGif,
|
|
12878
12913
|
isPngSequence
|
|
12879
12914
|
});
|
|
12915
|
+
routePageMotionBlur = pageMotionBlurForRoute({ hasHdrContent, useLayeredComposite });
|
|
12880
12916
|
const inversionFallback = resolveInversionRetryPlan({
|
|
12881
12917
|
deWorkerInversion,
|
|
12882
12918
|
preInversionWorkerCount: preRoutingWorkerCount,
|
|
@@ -13090,9 +13126,10 @@ async function executeRenderPipeline(input) {
|
|
|
13090
13126
|
outputWidth: captureCompositionWidth ?? width,
|
|
13091
13127
|
outputHeight: captureCompositionHeight ?? height,
|
|
13092
13128
|
motionBlur: job.config.motionBlur ? JSON.stringify(job.config.motionBlur) : "",
|
|
13129
|
+
pageMotionBlur: job.config.pageMotionBlur,
|
|
13093
13130
|
captureQuality: captureOptions.quality
|
|
13094
13131
|
});
|
|
13095
|
-
const segmentDir = segmentDirFor(
|
|
13132
|
+
const segmentDir = segmentDirFor(join25(projectDir, "renders"), segmentPlanHash);
|
|
13096
13133
|
let completedSegments = /* @__PURE__ */ new Set();
|
|
13097
13134
|
if (job.config.resumeSegments === true) {
|
|
13098
13135
|
const existing = readSegmentManifest(segmentDir);
|
|
@@ -13107,10 +13144,10 @@ async function executeRenderPipeline(input) {
|
|
|
13107
13144
|
});
|
|
13108
13145
|
}
|
|
13109
13146
|
if (completedSegments.size === 0) {
|
|
13110
|
-
|
|
13147
|
+
rmSync10(segmentDir, { recursive: true, force: true });
|
|
13111
13148
|
}
|
|
13112
13149
|
} else {
|
|
13113
|
-
|
|
13150
|
+
rmSync10(segmentDir, { recursive: true, force: true });
|
|
13114
13151
|
}
|
|
13115
13152
|
const segmentManifest = readSegmentManifest(segmentDir) ?? {
|
|
13116
13153
|
version: 1,
|
|
@@ -13191,7 +13228,7 @@ async function executeRenderPipeline(input) {
|
|
|
13191
13228
|
);
|
|
13192
13229
|
updateCaptureObservability({ segmentRetries: segmentedRes.segmentRetries });
|
|
13193
13230
|
if (job.config.keepSegments !== true) {
|
|
13194
|
-
|
|
13231
|
+
rmSync10(segmentDir, { recursive: true, force: true });
|
|
13195
13232
|
}
|
|
13196
13233
|
} else {
|
|
13197
13234
|
capturePlan = replanAfterFailure(capturePlan, { kind: "streaming_unavailable" });
|
|
@@ -13460,7 +13497,7 @@ async function executeRenderPipeline(input) {
|
|
|
13460
13497
|
"capture_disk",
|
|
13461
13498
|
"drawElement capture failed; retrying with a fresh screenshot session"
|
|
13462
13499
|
);
|
|
13463
|
-
|
|
13500
|
+
rmSync10(framesDir, { recursive: true, force: true });
|
|
13464
13501
|
mkdirSync9(framesDir, { recursive: true });
|
|
13465
13502
|
resetCaptureAttemptProgress(job);
|
|
13466
13503
|
dedupPerfs.length = 0;
|
|
@@ -13647,7 +13684,7 @@ async function executeRenderPipeline(input) {
|
|
|
13647
13684
|
}
|
|
13648
13685
|
if (job.config.debug) {
|
|
13649
13686
|
if (!isPngSequence && !isHls && existsSync16(stagedOutputPath)) {
|
|
13650
|
-
const debugOutput =
|
|
13687
|
+
const debugOutput = join25(workDir, `output${videoExt}`);
|
|
13651
13688
|
copyFileSync4(stagedOutputPath, debugOutput);
|
|
13652
13689
|
}
|
|
13653
13690
|
}
|
|
@@ -13759,7 +13796,7 @@ import {
|
|
|
13759
13796
|
closeSync as closeSync5,
|
|
13760
13797
|
constants as constants3
|
|
13761
13798
|
} from "node:fs";
|
|
13762
|
-
import { resolve as resolve8, join as
|
|
13799
|
+
import { resolve as resolve8, join as join26, relative as relative3, isAbsolute as isAbsolute3, sep as sep2 } from "node:path";
|
|
13763
13800
|
import { lintHyperframeHtml } from "@hyperframes/lint";
|
|
13764
13801
|
function isStringRecord(value) {
|
|
13765
13802
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
@@ -13827,7 +13864,7 @@ function readProjectEntryFile(projectDir, preferredEntryFile) {
|
|
|
13827
13864
|
}
|
|
13828
13865
|
}
|
|
13829
13866
|
return {
|
|
13830
|
-
error: `No HTML entry file found in project directory: ${
|
|
13867
|
+
error: `No HTML entry file found in project directory: ${join26(absProjectDir, preferredEntryFile || "index.html")}`
|
|
13831
13868
|
};
|
|
13832
13869
|
}
|
|
13833
13870
|
function prepareHyperframeLintBody(body) {
|
|
@@ -13873,7 +13910,7 @@ async function runHyperframeLint(prepared) {
|
|
|
13873
13910
|
// src/services/healthWorker.ts
|
|
13874
13911
|
import { Worker as Worker2 } from "node:worker_threads";
|
|
13875
13912
|
import { fileURLToPath as fileURLToPath5 } from "node:url";
|
|
13876
|
-
import { dirname as dirname11, join as
|
|
13913
|
+
import { dirname as dirname11, join as join27 } from "node:path";
|
|
13877
13914
|
import { existsSync as existsSync18 } from "node:fs";
|
|
13878
13915
|
var DEFAULT_HEALTH_PORT = 9848;
|
|
13879
13916
|
async function startHealthWorker(options = {}) {
|
|
@@ -13949,7 +13986,7 @@ function defaultLogger2() {
|
|
|
13949
13986
|
}
|
|
13950
13987
|
function resolveWorkerEntry2() {
|
|
13951
13988
|
const here = dirname11(fileURLToPath5(import.meta.url));
|
|
13952
|
-
const candidates = [
|
|
13989
|
+
const candidates = [join27(here, "healthWorkerThread.js"), join27(here, "healthWorkerThread.ts")];
|
|
13953
13990
|
for (const candidate of candidates) {
|
|
13954
13991
|
if (existsSync18(candidate)) return candidate;
|
|
13955
13992
|
}
|
|
@@ -13985,7 +14022,7 @@ import { DISTRIBUTED_FPS, isDistributedFps } from "@hyperframes/core";
|
|
|
13985
14022
|
|
|
13986
14023
|
// src/services/distributed/plan.ts
|
|
13987
14024
|
import { audioFailureDiagnostics as audioFailureDiagnostics3 } from "@hyperframes/engine";
|
|
13988
|
-
import { extname as extname4, isAbsolute as isAbsolute4, join as
|
|
14025
|
+
import { extname as extname4, isAbsolute as isAbsolute4, join as join28, relative as relative4, sep as sep3 } from "node:path";
|
|
13989
14026
|
import {
|
|
13990
14027
|
fpsToNumber as fpsToNumber10,
|
|
13991
14028
|
HF_COLOR_GRADING_ATTR as HF_COLOR_GRADING_ATTR2
|
|
@@ -14082,9 +14119,9 @@ var PLAN_ROOT_CATEGORY = {
|
|
|
14082
14119
|
var PLAN_DIR_SIZE_LIMIT_BYTES = 2 * 1024 * 1024 * 1024;
|
|
14083
14120
|
var FREEZE_OWNED_PLAN_FILES = [
|
|
14084
14121
|
"plan.json",
|
|
14085
|
-
|
|
14086
|
-
|
|
14087
|
-
|
|
14122
|
+
join28("meta", "composition.json"),
|
|
14123
|
+
join28("meta", "encoder.json"),
|
|
14124
|
+
join28("meta", "chunks.json")
|
|
14088
14125
|
];
|
|
14089
14126
|
|
|
14090
14127
|
// src/services/distributed/renderConfigValidation.ts
|
|
@@ -14234,6 +14271,7 @@ function assertRequestOptionScalars(options) {
|
|
|
14234
14271
|
}
|
|
14235
14272
|
assertOptionalEnum(options, "strictness", ["strict", "best-effort"]);
|
|
14236
14273
|
assertOptionalEnum(options, "hdrMode", ["auto", "force-hdr", "force-sdr"]);
|
|
14274
|
+
assertOptionalEnum(options, "pageMotionBlur", ["off"]);
|
|
14237
14275
|
if (options.videoFrameFormat !== void 0 && !isVideoFrameFormat2(options.videoFrameFormat)) {
|
|
14238
14276
|
throw new Error("Render request videoFrameFormat is invalid");
|
|
14239
14277
|
}
|
|
@@ -14433,6 +14471,7 @@ function buildRenderJobConfig(input, outputPath, log) {
|
|
|
14433
14471
|
outputResolution: input.outputResolution,
|
|
14434
14472
|
outputResolutionAspectAgnostic: input.outputResolutionAspectAgnostic,
|
|
14435
14473
|
videoFrameFormat: input.videoFrameFormat,
|
|
14474
|
+
pageMotionBlur: input.pageMotionBlur,
|
|
14436
14475
|
hdrMode: toRenderHdrMode(input.outputDynamicRange)
|
|
14437
14476
|
}
|
|
14438
14477
|
});
|
|
@@ -14508,8 +14547,8 @@ async function resolveInlineRenderHtml(body) {
|
|
|
14508
14547
|
}
|
|
14509
14548
|
function materializeInlineProject(html, options) {
|
|
14510
14549
|
const tempRoot = process.env.PRODUCER_TMP_PROJECT_DIR || tmpdir3();
|
|
14511
|
-
const tempProjectDir = mkdtempSync4(
|
|
14512
|
-
writeFileSync6(
|
|
14550
|
+
const tempProjectDir = mkdtempSync4(join29(tempRoot, "producer-project-"));
|
|
14551
|
+
writeFileSync6(join29(tempProjectDir, "index.html"), html, "utf-8");
|
|
14513
14552
|
return {
|
|
14514
14553
|
prepared: {
|
|
14515
14554
|
input: { projectDir: tempProjectDir, ...options },
|
|
@@ -14520,7 +14559,11 @@ function materializeInlineProject(html, options) {
|
|
|
14520
14559
|
async function prepareRenderBody(body) {
|
|
14521
14560
|
const overrideError = validateRenderOverrides(body);
|
|
14522
14561
|
if (overrideError) return { error: overrideError };
|
|
14523
|
-
const
|
|
14562
|
+
const pageMotionBlur = body.pageMotionBlur;
|
|
14563
|
+
if (pageMotionBlur !== void 0 && pageMotionBlur !== "off") {
|
|
14564
|
+
return { error: "pageMotionBlur must be off when supplied" };
|
|
14565
|
+
}
|
|
14566
|
+
const options = { ...parseRenderOptions(body), pageMotionBlur };
|
|
14524
14567
|
const project = prepareProjectDirectory(body.projectDir, options);
|
|
14525
14568
|
if (project) return project;
|
|
14526
14569
|
const source = await resolveInlineRenderHtml(body);
|
|
@@ -14566,7 +14609,7 @@ function createArtifactStore(ttlMs) {
|
|
|
14566
14609
|
function cleanupTempDir(dir, log) {
|
|
14567
14610
|
if (!dir) return;
|
|
14568
14611
|
try {
|
|
14569
|
-
|
|
14612
|
+
rmSync11(dir, { recursive: true, force: true });
|
|
14570
14613
|
} catch (error) {
|
|
14571
14614
|
log.warn("Failed to cleanup temp project dir", {
|
|
14572
14615
|
cleanupProjectDir: dir,
|