@hyperframes/producer 0.8.186 → 0.8.189
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 +1301 -1329
- package/dist/hyperframe.manifest.json +1 -1
- package/dist/hyperframe.runtime.iife.js +65 -64
- package/dist/index.js +1673 -1668
- package/dist/public-server.js +1589 -1567
- package/dist/server.d.ts +1 -0
- package/dist/services/distributed/assemble.d.ts +2 -1
- package/dist/services/distributed/chunkFrames.d.ts +1 -1
- package/dist/services/distributed/plan.d.ts +2 -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/freezePlan.d.ts +2 -1
- 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);
|
|
@@ -7212,7 +7213,9 @@ async function discoverMediaFromBrowser(page) {
|
|
|
7212
7213
|
const tagName = isImage ? "image" : htmlEl.tagName.toLowerCase() === "video" ? "video" : "audio";
|
|
7213
7214
|
const id = htmlEl.getAttribute("data-hf-render-id") || htmlEl.id || (isImage ? autoImageIds.get(htmlEl) : void 0);
|
|
7214
7215
|
if (!id) return;
|
|
7215
|
-
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 || "";
|
|
7216
7219
|
const start = parseFloat(htmlEl.getAttribute("data-start") || "0");
|
|
7217
7220
|
const endRaw = htmlEl.getAttribute("data-end");
|
|
7218
7221
|
const durationRaw = htmlEl.getAttribute("data-duration");
|
|
@@ -8679,775 +8682,1213 @@ function appendAutoDetectedVideoAudio(composition, extracted) {
|
|
|
8679
8682
|
}
|
|
8680
8683
|
|
|
8681
8684
|
// src/services/render/stages/audioStage.ts
|
|
8682
|
-
import { join as
|
|
8685
|
+
import { join as join18 } from "node:path";
|
|
8686
|
+
import { rmSync as rmSync8 } from "node:fs";
|
|
8683
8687
|
import {
|
|
8684
8688
|
MIXED_AUDIO_FILENAME,
|
|
8685
8689
|
audioFailureDiagnostics,
|
|
8686
8690
|
processCompositionAudio
|
|
8687
8691
|
} from "@hyperframes/engine";
|
|
8688
|
-
|
|
8689
|
-
|
|
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;
|
|
8690
8722
|
}
|
|
8691
|
-
async function
|
|
8692
|
-
|
|
8693
|
-
|
|
8694
|
-
|
|
8695
|
-
|
|
8696
|
-
|
|
8697
|
-
|
|
8698
|
-
|
|
8699
|
-
|
|
8700
|
-
|
|
8701
|
-
|
|
8702
|
-
|
|
8703
|
-
|
|
8704
|
-
assertNotAborted,
|
|
8705
|
-
log
|
|
8706
|
-
} = input;
|
|
8707
|
-
const stage3Start = Date.now();
|
|
8708
|
-
const audioOutputPath = join17(workDir, MIXED_AUDIO_FILENAME);
|
|
8709
|
-
let hasAudio = false;
|
|
8710
|
-
let audioError;
|
|
8711
|
-
let audioFailures;
|
|
8712
|
-
if (audios.length > 0) {
|
|
8713
|
-
let audioResult;
|
|
8714
|
-
try {
|
|
8715
|
-
audioResult = await processCompositionAudio(
|
|
8716
|
-
audios,
|
|
8717
|
-
projectDir,
|
|
8718
|
-
join17(workDir, "audio-work"),
|
|
8719
|
-
audioOutputPath,
|
|
8720
|
-
duration,
|
|
8721
|
-
abortSignal,
|
|
8722
|
-
{
|
|
8723
|
-
ffmpegProcessTimeout,
|
|
8724
|
-
audioGain,
|
|
8725
|
-
masterFxChain,
|
|
8726
|
-
urlByAbsentLocalPath,
|
|
8727
|
-
keepEncoderInputAt
|
|
8728
|
-
},
|
|
8729
|
-
compiledDir
|
|
8730
|
-
);
|
|
8731
|
-
} catch (err) {
|
|
8732
|
-
assertNotAborted();
|
|
8733
|
-
const detail = err instanceof Error ? err.message : String(err);
|
|
8734
|
-
return {
|
|
8735
|
-
audioOutputPath,
|
|
8736
|
-
hasAudio: false,
|
|
8737
|
-
audioProcessMs: Date.now() - stage3Start,
|
|
8738
|
-
audioError: detail,
|
|
8739
|
-
// Synthesised rather than left undefined. The warning policy reads
|
|
8740
|
-
// owner, retryability, reason and stage off this list, so reporting the
|
|
8741
|
-
// FATAL failure — the one that took the whole mix down — with an empty
|
|
8742
|
-
// one gave it strictly less classification than a single dropped track
|
|
8743
|
-
// gets, which is the opposite of what this stage exists to do.
|
|
8744
|
-
// "internal" is the honest bucket: the stages enumerate ffmpeg steps
|
|
8745
|
-
// and this is the FX render, which is none of them.
|
|
8746
|
-
audioFailures: [
|
|
8747
|
-
{
|
|
8748
|
-
stage: "internal",
|
|
8749
|
-
reason: "internal",
|
|
8750
|
-
owner: "system",
|
|
8751
|
-
retryable: false,
|
|
8752
|
-
detail: detail.slice(0, 2e3)
|
|
8753
|
-
}
|
|
8754
|
-
]
|
|
8755
|
-
};
|
|
8756
|
-
}
|
|
8757
|
-
assertNotAborted();
|
|
8758
|
-
const interrupted = audioResult.failures?.find(
|
|
8759
|
-
(failure) => failure.reason === "external_interruption"
|
|
8760
|
-
);
|
|
8761
|
-
if (interrupted) {
|
|
8762
|
-
throw encoderFailureError("Audio processing failed", {
|
|
8763
|
-
error: interrupted.detail,
|
|
8764
|
-
failureReason: "external_interruption",
|
|
8765
|
-
audioFailures: audioFailureDiagnostics(triggerFirst(audioResult.failures, interrupted))
|
|
8766
|
-
});
|
|
8767
|
-
}
|
|
8768
|
-
hasAudio = audioResult.success;
|
|
8769
|
-
audioFailures = audioResult.failures;
|
|
8770
|
-
if (!hasAudio) {
|
|
8771
|
-
audioError = audioResult.error ?? "audio mix failed for an unknown reason";
|
|
8772
|
-
for (const failure of audioResult.failures ?? []) {
|
|
8773
|
-
log?.warn("Audio track failed", {
|
|
8774
|
-
elementId: failure.elementId,
|
|
8775
|
-
stage: failure.stage,
|
|
8776
|
-
reason: failure.reason,
|
|
8777
|
-
owner: failure.owner,
|
|
8778
|
-
retryable: failure.retryable,
|
|
8779
|
-
detail: failure.detail
|
|
8780
|
-
});
|
|
8781
|
-
}
|
|
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);
|
|
8782
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)}`);
|
|
8783
8744
|
}
|
|
8784
|
-
const
|
|
8745
|
+
const summary = [...result.stderr.matchAll(/^\s*Peak:\s*([+-]?(?:[\d.]+|inf)) dBFS$/gim)];
|
|
8785
8746
|
return {
|
|
8786
|
-
|
|
8787
|
-
|
|
8788
|
-
|
|
8789
|
-
audioError,
|
|
8790
|
-
audioFailures
|
|
8747
|
+
peak: checkedDbfs(toDbfs(summary.at(-1)?.[1] ?? "nan")),
|
|
8748
|
+
firstBlock: checkedDbfs(firstBlock ?? Number.NEGATIVE_INFINITY),
|
|
8749
|
+
laterBlocks: checkedDbfs(laterBlocks)
|
|
8791
8750
|
};
|
|
8792
8751
|
}
|
|
8793
|
-
|
|
8794
|
-
|
|
8795
|
-
|
|
8796
|
-
|
|
8797
|
-
|
|
8798
|
-
|
|
8799
|
-
|
|
8800
|
-
|
|
8801
|
-
|
|
8802
|
-
|
|
8803
|
-
|
|
8804
|
-
|
|
8805
|
-
|
|
8806
|
-
|
|
8807
|
-
|
|
8808
|
-
|
|
8809
|
-
|
|
8810
|
-
|
|
8811
|
-
|
|
8812
|
-
|
|
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();
|
|
8813
8781
|
}
|
|
8814
|
-
|
|
8815
|
-
|
|
8816
|
-
var
|
|
8817
|
-
|
|
8818
|
-
|
|
8819
|
-
|
|
8820
|
-
|
|
8821
|
-
|
|
8822
|
-
return frameFileExtension(captureOptions.format) === "jpg" ? Math.ceil(
|
|
8823
|
-
rawBytes / ((captureOptions.quality ?? 80) > 80 ? HIGH_QUALITY_JPEG_RAW_RGBA_DIVISOR : JPEG_RAW_RGBA_DIVISOR)
|
|
8824
|
-
) : 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)}`;
|
|
8825
8790
|
}
|
|
8826
|
-
function
|
|
8827
|
-
|
|
8828
|
-
|
|
8829
|
-
|
|
8830
|
-
|
|
8831
|
-
|
|
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
|
+
};
|
|
8832
8800
|
}
|
|
8833
|
-
|
|
8834
|
-
|
|
8835
|
-
|
|
8836
|
-
|
|
8837
|
-
|
|
8838
|
-
|
|
8801
|
+
if (delta > 0) {
|
|
8802
|
+
return {
|
|
8803
|
+
operation: "pad",
|
|
8804
|
+
steps: [{ kind: "normalize", filter: `apad,atrim=0:${targetSec}` }],
|
|
8805
|
+
cleanupPaths: []
|
|
8806
|
+
};
|
|
8839
8807
|
}
|
|
8840
|
-
return {
|
|
8808
|
+
return {
|
|
8809
|
+
operation: "trim",
|
|
8810
|
+
steps: [{ kind: "trim", filter: `atrim=duration=${targetSec},asetpts=PTS-STARTPTS` }],
|
|
8811
|
+
cleanupPaths: []
|
|
8812
|
+
};
|
|
8841
8813
|
}
|
|
8842
|
-
function
|
|
8843
|
-
|
|
8844
|
-
if (workerCount > 1 && oneWorker.enabled) {
|
|
8845
|
-
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.`;
|
|
8846
|
-
}
|
|
8847
|
-
switch (oneWorker.reason) {
|
|
8848
|
-
case "format_excluded":
|
|
8849
|
-
return `${outputFormat} output stores every frame at any worker count. Free up disk space or render to mp4.`;
|
|
8850
|
-
case "disabled_by_config":
|
|
8851
|
-
return "PRODUCER_ENABLE_STREAMING_ENCODE=false sends every render to disk. Free up disk space or unset it.";
|
|
8852
|
-
case "duration_cap":
|
|
8853
|
-
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.";
|
|
8854
|
-
default:
|
|
8855
|
-
return "Free up disk space.";
|
|
8856
|
-
}
|
|
8814
|
+
function formatSeconds(sec) {
|
|
8815
|
+
return sec.toFixed(6);
|
|
8857
8816
|
}
|
|
8858
|
-
function
|
|
8859
|
-
const
|
|
8860
|
-
|
|
8861
|
-
|
|
8862
|
-
);
|
|
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);
|
|
8863
8821
|
}
|
|
8864
|
-
function
|
|
8865
|
-
const
|
|
8866
|
-
|
|
8867
|
-
|
|
8868
|
-
|
|
8869
|
-
|
|
8870
|
-
);
|
|
8871
|
-
|
|
8872
|
-
|
|
8873
|
-
|
|
8874
|
-
|
|
8875
|
-
|
|
8876
|
-
|
|
8877
|
-
|
|
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
|
|
8878
8872
|
);
|
|
8879
|
-
|
|
8880
|
-
|
|
8881
|
-
|
|
8882
|
-
|
|
8883
|
-
|
|
8884
|
-
|
|
8885
|
-
|
|
8886
|
-
|
|
8887
|
-
|
|
8888
|
-
|
|
8889
|
-
|
|
8890
|
-
|
|
8891
|
-
|
|
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
|
+
}
|
|
8892
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 });
|
|
8893
8899
|
}
|
|
8894
|
-
|
|
8895
|
-
|
|
8896
|
-
|
|
8897
|
-
|
|
8898
|
-
|
|
8899
|
-
|
|
8900
|
-
|
|
8901
|
-
|
|
8902
|
-
|
|
8903
|
-
|
|
8904
|
-
|
|
8905
|
-
|
|
8906
|
-
|
|
8907
|
-
|
|
8908
|
-
|
|
8909
|
-
|
|
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
|
+
};
|
|
8910
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 } : {}
|
|
8911
8929
|
};
|
|
8912
8930
|
}
|
|
8913
|
-
async function
|
|
8914
|
-
const
|
|
8915
|
-
|
|
8916
|
-
|
|
8917
|
-
|
|
8918
|
-
|
|
8919
|
-
|
|
8920
|
-
|
|
8921
|
-
|
|
8922
|
-
|
|
8923
|
-
|
|
8924
|
-
|
|
8925
|
-
|
|
8926
|
-
|
|
8927
|
-
|
|
8928
|
-
|
|
8929
|
-
|
|
8930
|
-
|
|
8931
|
-
|
|
8932
|
-
|
|
8933
|
-
|
|
8934
|
-
|
|
8935
|
-
let captureBeyondViewport = probeSession?.options.captureBeyondViewport;
|
|
8936
|
-
const captureCfg = cfg.forceScreenshot === forceScreenshot ? cfg : { ...cfg, forceScreenshot };
|
|
8937
|
-
if (frameRange !== void 0) {
|
|
8938
|
-
if (!Number.isFinite(frameRange.startFrame) || !Number.isFinite(frameRange.endFrame) || frameRange.startFrame < 0 || frameRange.endFrame <= frameRange.startFrame) {
|
|
8939
|
-
throw new Error(
|
|
8940
|
-
`[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`
|
|
8941
8953
|
);
|
|
8942
|
-
|
|
8943
|
-
|
|
8944
|
-
|
|
8945
|
-
|
|
8946
|
-
|
|
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)
|
|
8947
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;
|
|
8948
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 });
|
|
8949
8997
|
}
|
|
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
|
-
|
|
8988
|
-
|
|
8989
|
-
|
|
8990
|
-
|
|
8991
|
-
|
|
8992
|
-
|
|
8993
|
-
|
|
8994
|
-
|
|
8995
|
-
|
|
8996
|
-
|
|
8997
|
-
|
|
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.");
|
|
8998
9113
|
}
|
|
8999
|
-
|
|
9000
|
-
|
|
9001
|
-
|
|
9002
|
-
|
|
9003
|
-
|
|
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;
|
|
9004
9158
|
}
|
|
9005
|
-
|
|
9006
|
-
|
|
9007
|
-
|
|
9008
|
-
captureOptions,
|
|
9009
|
-
captureCfg,
|
|
9010
|
-
checkDiskProjection
|
|
9011
|
-
);
|
|
9012
|
-
probeSession = null;
|
|
9013
|
-
lastBrowserConsole = sequential.lastBrowserConsole;
|
|
9014
|
-
captureBeyondViewport = sequential.captureBeyondViewport;
|
|
9159
|
+
return { ...result, audioLoweredDb, audioProcessMs: Date.now() - stageStart };
|
|
9160
|
+
} finally {
|
|
9161
|
+
rmSync8(pcmSourcePath, { force: true });
|
|
9015
9162
|
}
|
|
9016
|
-
return { workerCount, probeSession, lastBrowserConsole, captureBeyondViewport };
|
|
9017
9163
|
}
|
|
9018
|
-
async function
|
|
9164
|
+
async function mixAudioForDelivery(input, duration, pcmSourcePath) {
|
|
9019
9165
|
const {
|
|
9020
|
-
|
|
9021
|
-
|
|
9022
|
-
|
|
9023
|
-
|
|
9024
|
-
|
|
9166
|
+
projectDir,
|
|
9167
|
+
workDir,
|
|
9168
|
+
compiledDir,
|
|
9169
|
+
ffmpegProcessTimeout,
|
|
9170
|
+
audioGain,
|
|
9171
|
+
audios,
|
|
9172
|
+
masterFxChain,
|
|
9173
|
+
urlByAbsentLocalPath,
|
|
9025
9174
|
abortSignal,
|
|
9026
9175
|
assertNotAborted,
|
|
9027
|
-
|
|
9176
|
+
log
|
|
9028
9177
|
} = input;
|
|
9029
|
-
|
|
9030
|
-
let
|
|
9031
|
-
let
|
|
9032
|
-
|
|
9033
|
-
|
|
9034
|
-
|
|
9035
|
-
let transientRetriesUsed = 0;
|
|
9036
|
-
let resumeFrom = 0;
|
|
9037
|
-
while (true) {
|
|
9038
|
-
const videoInjector = createRenderVideoFrameInjector();
|
|
9039
|
-
const session = probeSession ?? await createCaptureSession5(
|
|
9040
|
-
fileServer.url,
|
|
9041
|
-
framesDir,
|
|
9042
|
-
captureOptions,
|
|
9043
|
-
videoInjector,
|
|
9044
|
-
captureCfg
|
|
9045
|
-
);
|
|
9046
|
-
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;
|
|
9047
9184
|
try {
|
|
9048
|
-
|
|
9049
|
-
|
|
9050
|
-
|
|
9051
|
-
|
|
9052
|
-
|
|
9053
|
-
|
|
9054
|
-
lastBrowserConsole = session.browserConsoleBuffer;
|
|
9055
|
-
await captureSessionFrames(session, {
|
|
9056
|
-
input,
|
|
9057
|
-
rangeStart,
|
|
9058
|
-
rangeEnd,
|
|
9059
|
-
resumeFrom,
|
|
9060
|
-
checkDiskProjection
|
|
9061
|
-
});
|
|
9062
|
-
break;
|
|
9063
|
-
} catch (error) {
|
|
9064
|
-
lastBrowserConsole = session.browserConsoleBuffer;
|
|
9065
|
-
const resume = planTransientResume(error, {
|
|
9185
|
+
audioResult = await processCompositionAudio(
|
|
9186
|
+
audios,
|
|
9187
|
+
projectDir,
|
|
9188
|
+
join18(workDir, "audio-work"),
|
|
9189
|
+
audioOutputPath,
|
|
9190
|
+
duration,
|
|
9066
9191
|
abortSignal,
|
|
9067
|
-
|
|
9068
|
-
|
|
9069
|
-
|
|
9070
|
-
|
|
9071
|
-
|
|
9072
|
-
|
|
9073
|
-
|
|
9074
|
-
|
|
9075
|
-
|
|
9076
|
-
|
|
9077
|
-
|
|
9078
|
-
|
|
9079
|
-
|
|
9080
|
-
|
|
9081
|
-
|
|
9082
|
-
|
|
9083
|
-
|
|
9084
|
-
|
|
9085
|
-
|
|
9086
|
-
|
|
9087
|
-
|
|
9088
|
-
|
|
9089
|
-
|
|
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
|
+
}
|
|
9090
9242
|
}
|
|
9091
9243
|
}
|
|
9092
|
-
return {
|
|
9244
|
+
return {
|
|
9245
|
+
audioOutputPath,
|
|
9246
|
+
hasAudio,
|
|
9247
|
+
audioError,
|
|
9248
|
+
audioFailures
|
|
9249
|
+
};
|
|
9093
9250
|
}
|
|
9094
|
-
|
|
9095
|
-
|
|
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;
|
|
9096
9271
|
}
|
|
9097
|
-
|
|
9098
|
-
|
|
9099
|
-
|
|
9100
|
-
|
|
9101
|
-
|
|
9102
|
-
|
|
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;
|
|
9103
9283
|
}
|
|
9104
|
-
|
|
9105
|
-
|
|
9106
|
-
|
|
9107
|
-
return;
|
|
9108
|
-
}
|
|
9109
|
-
|
|
9110
|
-
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;
|
|
9111
9290
|
}
|
|
9112
9291
|
}
|
|
9113
|
-
|
|
9114
|
-
const
|
|
9115
|
-
const
|
|
9116
|
-
|
|
9117
|
-
|
|
9118
|
-
checkDiskProjection(fileIndex + 1);
|
|
9119
|
-
reportFrameProgress(
|
|
9120
|
-
job,
|
|
9121
|
-
`Capturing frame ${fileIndex + 1}/${rangeFrames}`,
|
|
9122
|
-
Math.round(25 + (fileIndex + 1) / rangeFrames * 45),
|
|
9123
|
-
onProgress,
|
|
9124
|
-
fileIndex + 1,
|
|
9125
|
-
rangeFrames
|
|
9126
|
-
);
|
|
9127
|
-
};
|
|
9128
|
-
if (session.workerEncodeEnabled) {
|
|
9129
|
-
let prev = null;
|
|
9130
|
-
const drainPrev = async () => {
|
|
9131
|
-
if (!prev) return;
|
|
9132
|
-
assertNotAborted();
|
|
9133
|
-
const buf = await prev.encodeResult;
|
|
9134
|
-
writeCapturedFrame(session, prev.fileIndex, buf);
|
|
9135
|
-
reportFrame(prev.fileIndex);
|
|
9136
|
-
};
|
|
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
|
-
const { encodeResult } = await captureFrameToBufferPipelined2(session, i, time);
|
|
9142
|
-
await drainPrev();
|
|
9143
|
-
prev = { fileIndex: i, encodeResult };
|
|
9144
|
-
}
|
|
9145
|
-
await drainPrev();
|
|
9146
|
-
} else {
|
|
9147
|
-
for (let i = resumeFrom; i < rangeFrames; i++) {
|
|
9148
|
-
assertNotAborted();
|
|
9149
|
-
const absoluteIdx = rangeStart + i;
|
|
9150
|
-
const time = absoluteIdx * job.config.fps.den / job.config.fps.num;
|
|
9151
|
-
await captureFrame(session, i, time);
|
|
9152
|
-
reportFrame(i);
|
|
9153
|
-
}
|
|
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 };
|
|
9154
9297
|
}
|
|
9155
|
-
|
|
9156
|
-
session,
|
|
9157
|
-
{
|
|
9158
|
-
workerId: 0,
|
|
9159
|
-
startFrame: rangeStart + resumeFrom,
|
|
9160
|
-
endFrame: rangeEnd,
|
|
9161
|
-
outputDir: framesDir,
|
|
9162
|
-
outputFrameOffset: rangeStart
|
|
9163
|
-
},
|
|
9164
|
-
false
|
|
9165
|
-
);
|
|
9166
|
-
dedupPerfs.push(getCapturePerfSummary3(session));
|
|
9298
|
+
return { available: false, estimatedBytes, freeBytes };
|
|
9167
9299
|
}
|
|
9168
|
-
|
|
9169
|
-
|
|
9170
|
-
|
|
9171
|
-
|
|
9172
|
-
import {
|
|
9173
|
-
closeCaptureSession as closeCaptureSession7,
|
|
9174
|
-
cloneCaptureWarnings as cloneCaptureWarnings2,
|
|
9175
|
-
createCaptureSession as createCaptureSession7,
|
|
9176
|
-
initTransparentBackground as initTransparentBackground2,
|
|
9177
|
-
initializeSession as initializeSession7,
|
|
9178
|
-
spawnStreamingEncoder as spawnStreamingEncoder3
|
|
9179
|
-
} from "@hyperframes/engine";
|
|
9180
|
-
import { fpsToNumber as fpsToNumber8 } from "@hyperframes/core";
|
|
9181
|
-
|
|
9182
|
-
// src/services/hdrImageTransferCache.ts
|
|
9183
|
-
import { convertTransfer as convertTransfer2 } from "@hyperframes/engine";
|
|
9184
|
-
var DEFAULT_MAX_BYTES = 200 * 1024 * 1024;
|
|
9185
|
-
function createHdrImageTransferCache(options = {}) {
|
|
9186
|
-
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
9187
|
-
if (!Number.isInteger(maxBytes) || maxBytes < 0) {
|
|
9188
|
-
throw new Error(
|
|
9189
|
-
`createHdrImageTransferCache: maxBytes must be a non-negative integer, got ${String(maxBytes)}`
|
|
9190
|
-
);
|
|
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.`;
|
|
9191
9304
|
}
|
|
9192
|
-
|
|
9193
|
-
|
|
9194
|
-
|
|
9195
|
-
|
|
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.";
|
|
9196
9314
|
}
|
|
9197
|
-
|
|
9198
|
-
|
|
9199
|
-
|
|
9200
|
-
|
|
9201
|
-
|
|
9202
|
-
|
|
9203
|
-
|
|
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));
|
|
9204
9334
|
}
|
|
9205
9335
|
}
|
|
9206
|
-
return
|
|
9207
|
-
|
|
9208
|
-
|
|
9209
|
-
|
|
9210
|
-
|
|
9211
|
-
|
|
9212
|
-
|
|
9213
|
-
|
|
9214
|
-
|
|
9215
|
-
|
|
9216
|
-
|
|
9217
|
-
|
|
9218
|
-
|
|
9219
|
-
|
|
9220
|
-
|
|
9221
|
-
|
|
9222
|
-
}
|
|
9223
|
-
const converted = Buffer.from(source);
|
|
9224
|
-
convertTransfer2(converted, sourceTransfer, targetTransfer);
|
|
9225
|
-
if (converted.byteLength > maxBytes) {
|
|
9226
|
-
return converted;
|
|
9227
|
-
}
|
|
9228
|
-
evictUntilRoom(converted.byteLength);
|
|
9229
|
-
entries.set(key, converted);
|
|
9230
|
-
totalBytes += converted.byteLength;
|
|
9231
|
-
return converted;
|
|
9232
|
-
},
|
|
9233
|
-
size() {
|
|
9234
|
-
return entries.size;
|
|
9235
|
-
},
|
|
9236
|
-
bytesUsed() {
|
|
9237
|
-
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);
|
|
9238
9352
|
}
|
|
9239
9353
|
};
|
|
9240
9354
|
}
|
|
9241
|
-
|
|
9242
|
-
// src/services/render/stages/captureHdrSequentialLoop.ts
|
|
9243
|
-
import { join as join19 } from "node:path";
|
|
9244
|
-
import {
|
|
9245
|
-
TRANSITIONS,
|
|
9246
|
-
crossfade
|
|
9247
|
-
} from "@hyperframes/engine";
|
|
9248
|
-
async function runSequentialLayeredFrameLoop(input) {
|
|
9355
|
+
async function runCaptureStage(input) {
|
|
9249
9356
|
const {
|
|
9357
|
+
fileServer,
|
|
9358
|
+
workDir,
|
|
9359
|
+
framesDir,
|
|
9250
9360
|
job,
|
|
9251
|
-
log,
|
|
9252
|
-
width,
|
|
9253
|
-
height,
|
|
9254
9361
|
totalFrames,
|
|
9255
|
-
|
|
9256
|
-
|
|
9257
|
-
|
|
9258
|
-
|
|
9259
|
-
|
|
9260
|
-
|
|
9261
|
-
|
|
9262
|
-
|
|
9263
|
-
|
|
9264
|
-
|
|
9265
|
-
hdrVideoEndTimes,
|
|
9266
|
-
cleanedUpVideos,
|
|
9267
|
-
hdrVideoFrameSources,
|
|
9268
|
-
debugDumpEnabled,
|
|
9269
|
-
debugDumpDir,
|
|
9270
|
-
assertNotAborted,
|
|
9271
|
-
onProgress
|
|
9362
|
+
cfg,
|
|
9363
|
+
plan,
|
|
9364
|
+
log,
|
|
9365
|
+
captureAttempts,
|
|
9366
|
+
buildCaptureOptions,
|
|
9367
|
+
createRenderVideoFrameInjector,
|
|
9368
|
+
abortSignal,
|
|
9369
|
+
onProgress,
|
|
9370
|
+
frameRange,
|
|
9371
|
+
dedupPerfs
|
|
9272
9372
|
} = input;
|
|
9273
|
-
|
|
9274
|
-
|
|
9275
|
-
const
|
|
9276
|
-
|
|
9277
|
-
|
|
9278
|
-
|
|
9279
|
-
|
|
9280
|
-
|
|
9281
|
-
|
|
9282
|
-
|
|
9283
|
-
assertNotAborted();
|
|
9284
|
-
const time = i * job.config.fps.den / job.config.fps.num;
|
|
9285
|
-
if (hdrPerf) hdrPerf.frames += 1;
|
|
9286
|
-
const stackingInfo = await seekInjectAndQueryStacking(
|
|
9287
|
-
domSession.page,
|
|
9288
|
-
time,
|
|
9289
|
-
beforeCaptureHook,
|
|
9290
|
-
nativeHdrIds,
|
|
9291
|
-
hdrPerf,
|
|
9292
|
-
"frameSeekMs",
|
|
9293
|
-
"frameInjectMs",
|
|
9294
|
-
"stackingQueryMs"
|
|
9295
|
-
);
|
|
9296
|
-
const activeTransition = transitionRanges.find((t) => i >= t.startFrame && i <= t.endFrame);
|
|
9297
|
-
if (i % 30 === 0 && (log.isLevelEnabled?.("debug") ?? true)) {
|
|
9298
|
-
const hdrEl = stackingInfo.find((e) => e.isHdr);
|
|
9299
|
-
log.debug("[Render] HDR layer composite frame", {
|
|
9300
|
-
frame: i,
|
|
9301
|
-
time: time.toFixed(2),
|
|
9302
|
-
hdrElement: hdrEl ? { z: hdrEl.zIndex, visible: hdrEl.visible, width: hdrEl.width } : null,
|
|
9303
|
-
stackingCount: stackingInfo.length,
|
|
9304
|
-
activeTransition: activeTransition?.shader
|
|
9305
|
-
});
|
|
9306
|
-
}
|
|
9307
|
-
if (activeTransition && transitionBuffers) {
|
|
9308
|
-
if (hdrPerf) hdrPerf.transitionFrames += 1;
|
|
9309
|
-
const transitionTimingStart = Date.now();
|
|
9310
|
-
const progress = activeTransition.endFrame === activeTransition.startFrame ? 1 : (i - activeTransition.startFrame) / (activeTransition.endFrame - activeTransition.startFrame);
|
|
9311
|
-
const sceneAIds = new Set(sceneElements[activeTransition.fromScene] ?? []);
|
|
9312
|
-
const sceneBIds = new Set(sceneElements[activeTransition.toScene] ?? []);
|
|
9313
|
-
timeHdrPhase(hdrPerf, "canvasClearMs", () => {
|
|
9314
|
-
transitionBuffers.bufferA.fill(0);
|
|
9315
|
-
transitionBuffers.bufferB.fill(0);
|
|
9316
|
-
});
|
|
9317
|
-
const sceneCaptures = [
|
|
9318
|
-
[transitionBuffers.bufferA, sceneAIds],
|
|
9319
|
-
[transitionBuffers.bufferB, sceneBIds]
|
|
9320
|
-
];
|
|
9321
|
-
for (const [sceneBuf, sceneIds] of sceneCaptures) {
|
|
9322
|
-
assertNotAborted();
|
|
9323
|
-
await captureSceneIntoBuffer({
|
|
9324
|
-
session: domSession,
|
|
9325
|
-
sceneBuf,
|
|
9326
|
-
sceneIds,
|
|
9327
|
-
stackingInfo,
|
|
9328
|
-
time,
|
|
9329
|
-
width,
|
|
9330
|
-
height,
|
|
9331
|
-
nativeHdrIds,
|
|
9332
|
-
nativeHdrImageIds,
|
|
9333
|
-
beforeCaptureHook,
|
|
9334
|
-
hdrCompositeCtx,
|
|
9335
|
-
compositeTransfer,
|
|
9336
|
-
hdrTargetTransfer,
|
|
9337
|
-
hdrPerf,
|
|
9338
|
-
log,
|
|
9339
|
-
frameIdx: i
|
|
9340
|
-
});
|
|
9341
|
-
}
|
|
9342
|
-
const shaderName = activeTransition.shader;
|
|
9343
|
-
const transitionFn = shaderName ? TRANSITIONS[shaderName] ?? crossfade : crossfade;
|
|
9344
|
-
transitionFn(
|
|
9345
|
-
transitionBuffers.bufferA,
|
|
9346
|
-
transitionBuffers.bufferB,
|
|
9347
|
-
transitionBuffers.output,
|
|
9348
|
-
width,
|
|
9349
|
-
height,
|
|
9350
|
-
progress
|
|
9351
|
-
);
|
|
9352
|
-
addHdrTiming(hdrPerf, "transitionCompositeMs", transitionTimingStart);
|
|
9353
|
-
await timeHdrPhaseAsync(
|
|
9354
|
-
hdrPerf,
|
|
9355
|
-
"encoderWriteMs",
|
|
9356
|
-
async () => ensureFrameWritten(await hdrEncoder.writeFrame(transitionBuffers.output), i, hdrEncoder)
|
|
9357
|
-
);
|
|
9358
|
-
} else {
|
|
9359
|
-
if (hdrPerf) hdrPerf.normalFrames += 1;
|
|
9360
|
-
timeHdrPhase(hdrPerf, "canvasClearMs", () => normalCanvas.fill(0));
|
|
9361
|
-
await timeHdrPhaseAsync(
|
|
9362
|
-
hdrPerf,
|
|
9363
|
-
"normalCompositeMs",
|
|
9364
|
-
() => 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.`
|
|
9365
9383
|
);
|
|
9366
|
-
|
|
9367
|
-
|
|
9368
|
-
|
|
9369
|
-
|
|
9370
|
-
)
|
|
9371
|
-
}
|
|
9372
|
-
await timeHdrPhaseAsync(
|
|
9373
|
-
hdrPerf,
|
|
9374
|
-
"encoderWriteMs",
|
|
9375
|
-
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)}.`
|
|
9376
9389
|
);
|
|
9377
9390
|
}
|
|
9378
|
-
|
|
9379
|
-
|
|
9380
|
-
|
|
9381
|
-
|
|
9382
|
-
|
|
9383
|
-
|
|
9384
|
-
|
|
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,
|
|
9385
9433
|
log
|
|
9386
9434
|
});
|
|
9387
|
-
|
|
9388
|
-
|
|
9389
|
-
|
|
9390
|
-
|
|
9391
|
-
|
|
9392
|
-
|
|
9393
|
-
|
|
9394
|
-
|
|
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
|
|
9395
9452
|
);
|
|
9453
|
+
probeSession = null;
|
|
9454
|
+
lastBrowserConsole = sequential.lastBrowserConsole;
|
|
9455
|
+
captureBeyondViewport = sequential.captureBeyondViewport;
|
|
9396
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
|
+
}
|
|
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));
|
|
9397
9608
|
}
|
|
9398
9609
|
|
|
9399
|
-
// src/services/render/stages/
|
|
9400
|
-
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";
|
|
9401
9613
|
import {
|
|
9402
|
-
|
|
9403
|
-
|
|
9404
|
-
createCaptureSession as
|
|
9405
|
-
|
|
9406
|
-
|
|
9407
|
-
|
|
9408
|
-
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
|
|
9409
9620
|
} from "@hyperframes/engine";
|
|
9621
|
+
import { fpsToNumber as fpsToNumber8 } from "@hyperframes/core";
|
|
9410
9622
|
|
|
9411
|
-
// src/services/
|
|
9412
|
-
import {
|
|
9413
|
-
|
|
9414
|
-
|
|
9415
|
-
|
|
9416
|
-
|
|
9417
|
-
|
|
9418
|
-
|
|
9419
|
-
|
|
9420
|
-
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
|
+
);
|
|
9421
9632
|
}
|
|
9422
|
-
const
|
|
9423
|
-
|
|
9424
|
-
|
|
9425
|
-
return {
|
|
9633
|
+
const entries = /* @__PURE__ */ new Map();
|
|
9634
|
+
let totalBytes = 0;
|
|
9635
|
+
function makeKey(imageId, targetTransfer) {
|
|
9636
|
+
return `${imageId}|${targetTransfer}`;
|
|
9426
9637
|
}
|
|
9427
|
-
|
|
9428
|
-
|
|
9429
|
-
|
|
9430
|
-
|
|
9431
|
-
|
|
9432
|
-
|
|
9433
|
-
|
|
9434
|
-
|
|
9435
|
-
if (!entryIsTs) return inherited;
|
|
9436
|
-
const hasLoader = inherited.some(
|
|
9437
|
-
(a) => a.includes("tsx/esm") || a.includes("ts-node/esm") || a.includes("--import")
|
|
9438
|
-
);
|
|
9439
|
-
if (hasLoader) return inherited;
|
|
9440
|
-
try {
|
|
9441
|
-
const require2 = createRequire(import.meta.url);
|
|
9442
|
-
const tsxEsm = require2.resolve("tsx/esm");
|
|
9443
|
-
inherited.push("--import", pathToFileURL(tsxEsm).href);
|
|
9444
|
-
} 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
|
+
}
|
|
9445
9646
|
}
|
|
9446
|
-
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
|
+
};
|
|
9447
9681
|
}
|
|
9448
|
-
|
|
9449
|
-
|
|
9450
|
-
|
|
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;
|
|
9888
|
+
}
|
|
9889
|
+
async function createShaderTransitionWorkerPool(opts) {
|
|
9890
|
+
const cpuCount = Math.max(1, cpus2().length);
|
|
9891
|
+
const size = Math.max(1, Math.min(opts.size, cpuCount));
|
|
9451
9892
|
const log = opts.log ?? {};
|
|
9452
9893
|
const { path: entry, isTs: entryIsTs } = resolveWorkerEntry(opts.workerEntryPath);
|
|
9453
9894
|
const slots = [];
|
|
@@ -9795,7 +10236,7 @@ async function runHybridLayeredFrameLoop(input) {
|
|
|
9795
10236
|
);
|
|
9796
10237
|
if (debugDumpEnabled && debugDumpDir && i % 30 === 0) {
|
|
9797
10238
|
writeFileExclusiveSync(
|
|
9798
|
-
|
|
10239
|
+
join22(debugDumpDir, `frame_${String(i).padStart(4, "0")}_final_rgb48le.bin`),
|
|
9799
10240
|
canvas
|
|
9800
10241
|
);
|
|
9801
10242
|
}
|
|
@@ -9981,7 +10422,7 @@ async function runCaptureHdrStage(input) {
|
|
|
9981
10422
|
if (hdrVideoFrameSources.has(v.id)) hdrVideoEndTimes.set(v.id, v.end);
|
|
9982
10423
|
}
|
|
9983
10424
|
const debugDumpEnabled = process.env.KEEP_TEMP === "1";
|
|
9984
|
-
const debugDumpDir = debugDumpEnabled ?
|
|
10425
|
+
const debugDumpDir = debugDumpEnabled ? join23(framesDir, "debug-composite") : null;
|
|
9985
10426
|
if (debugDumpDir && !existsSync14(debugDumpDir)) {
|
|
9986
10427
|
mkdirSync7(debugDumpDir, { recursive: true });
|
|
9987
10428
|
}
|
|
@@ -10081,862 +10522,435 @@ async function runCaptureHdrStage(input) {
|
|
|
10081
10522
|
onProgress
|
|
10082
10523
|
});
|
|
10083
10524
|
}
|
|
10084
|
-
} finally {
|
|
10085
|
-
lastBrowserConsole = domSession.browserConsoleBuffer;
|
|
10086
|
-
await closeCaptureSession7(domSession);
|
|
10087
|
-
domSessionClosed = true;
|
|
10088
|
-
}
|
|
10089
|
-
const encodeFrom = job.progress;
|
|
10090
|
-
const hdrEncodeResult = await hdrEncoder.close(
|
|
10091
|
-
(frames) => reportEncodeProgress(job, frames, totalFrames, onProgress, encodeFrom)
|
|
10092
|
-
);
|
|
10093
|
-
hdrEncoderClosed = true;
|
|
10094
|
-
assertNotAborted();
|
|
10095
|
-
if (!hdrEncodeResult.success) {
|
|
10096
|
-
throw encoderFailureError("HDR encode failed", hdrEncodeResult);
|
|
10097
|
-
}
|
|
10098
|
-
reportEncodeProgress(job, totalFrames, totalFrames, onProgress, encodeFrom);
|
|
10099
|
-
captureDurationMs = Date.now() - stageStart;
|
|
10100
|
-
encodeMs = hdrEncodeResult.durationMs;
|
|
10101
|
-
} catch (error) {
|
|
10102
|
-
lastBrowserConsole = domSession.browserConsoleBuffer;
|
|
10103
|
-
throw wrapCaptureStageError(error, lastBrowserConsole);
|
|
10104
|
-
} finally {
|
|
10105
|
-
if (hdrEncoder && !hdrEncoderClosed) {
|
|
10106
|
-
try {
|
|
10107
|
-
await hdrEncoder.close();
|
|
10108
|
-
} catch (err) {
|
|
10109
|
-
log.warn("hdrEncoder defensive close failed", {
|
|
10110
|
-
err: err instanceof Error ? err.message : String(err)
|
|
10111
|
-
});
|
|
10112
|
-
}
|
|
10113
|
-
}
|
|
10114
|
-
if (!domSessionClosed) {
|
|
10115
|
-
await closeCaptureSession7(domSession).catch((err) => {
|
|
10116
|
-
log.warn("closeCaptureSession defensive close failed", {
|
|
10117
|
-
err: err instanceof Error ? err.message : String(err)
|
|
10118
|
-
});
|
|
10119
|
-
});
|
|
10120
|
-
}
|
|
10121
|
-
for (const frameSource of hdrVideoFrameSources.values()) {
|
|
10122
|
-
cleanupHdrVideoFrameSource(frameSource, log);
|
|
10123
|
-
}
|
|
10124
|
-
hdrVideoFrameSources.clear();
|
|
10125
|
-
releaseHdrExtractionReservation?.();
|
|
10126
|
-
releaseHdrExtractionReservation = null;
|
|
10127
|
-
}
|
|
10128
|
-
return {
|
|
10129
|
-
lastBrowserConsole,
|
|
10130
|
-
hdrPerf,
|
|
10131
|
-
captureDurationMs,
|
|
10132
|
-
encodeMs,
|
|
10133
|
-
warnings: cloneCaptureWarnings2(domSession.warnings)
|
|
10134
|
-
};
|
|
10135
|
-
}
|
|
10136
|
-
|
|
10137
|
-
// src/services/render/stages/encodeStage.ts
|
|
10138
|
-
import {
|
|
10139
|
-
copyFileSync as copyFileSync3,
|
|
10140
|
-
existsSync as existsSync15,
|
|
10141
|
-
mkdirSync as mkdirSync8,
|
|
10142
|
-
readdirSync as readdirSync4,
|
|
10143
|
-
readFileSync as readFileSync7,
|
|
10144
|
-
rmSync as rmSync7,
|
|
10145
|
-
statSync as statSync5,
|
|
10146
|
-
writeFileSync as writeFileSync4
|
|
10147
|
-
} from "node:fs";
|
|
10148
|
-
import { readFile as readFile2 } from "node:fs/promises";
|
|
10149
|
-
import { dirname as dirname8, join as join24 } from "node:path";
|
|
10150
|
-
import {
|
|
10151
|
-
encodeFramesChunkedConcat,
|
|
10152
|
-
encodeFramesFromDir,
|
|
10153
|
-
formatFfmpegError,
|
|
10154
|
-
MIXED_AUDIO_FILENAME as MIXED_AUDIO_FILENAME2,
|
|
10155
|
-
resolveConfig,
|
|
10156
|
-
runFfmpeg as runFfmpeg3,
|
|
10157
|
-
spawnStreamingEncoder as spawnStreamingEncoder4
|
|
10158
|
-
} from "@hyperframes/engine";
|
|
10159
|
-
import {
|
|
10160
|
-
clearGifFramesBeforeNext,
|
|
10161
|
-
gifClearsAfterLeavingFrameInPlace
|
|
10162
|
-
} from "@hyperframes/core";
|
|
10163
|
-
|
|
10164
|
-
// src/services/render/stages/gifEncodeArgs.ts
|
|
10165
|
-
import { join as join23 } from "node:path";
|
|
10166
|
-
function fpsToFfmpegArg2(fps) {
|
|
10167
|
-
return fps.den === 1 ? String(fps.num) : `${fps.num}/${fps.den}`;
|
|
10168
|
-
}
|
|
10169
|
-
var KEEP_FILTER_GRAPH_WHEN_FRAMES_DROP_ALPHA = ["-reinit_filter", "0"];
|
|
10170
|
-
var WRITE_EVERY_FRAME_WHOLE = ["-gifflags", "0"];
|
|
10171
|
-
var FAIL_ON_UNREADABLE_FRAME_INSTEAD_OF_ENDING_THERE = ["-xerror"];
|
|
10172
|
-
function framesInput(input, fpsArg) {
|
|
10173
|
-
return [
|
|
10174
|
-
...FAIL_ON_UNREADABLE_FRAME_INSTEAD_OF_ENDING_THERE,
|
|
10175
|
-
"-framerate",
|
|
10176
|
-
fpsArg,
|
|
10177
|
-
...KEEP_FILTER_GRAPH_WHEN_FRAMES_DROP_ALPHA,
|
|
10178
|
-
"-i",
|
|
10179
|
-
join23(input.framesDir, input.framePattern)
|
|
10180
|
-
];
|
|
10181
|
-
}
|
|
10182
|
-
function buildGifPalettegenArgs(input) {
|
|
10183
|
-
const fpsArg = fpsToFfmpegArg2(input.fps);
|
|
10184
|
-
const transparency = input.preserveAlpha ? ":reserve_transparent=1" : "";
|
|
10185
|
-
return [
|
|
10186
|
-
"-y",
|
|
10187
|
-
...framesInput(input, fpsArg),
|
|
10188
|
-
"-vf",
|
|
10189
|
-
`fps=${fpsArg},palettegen=stats_mode=diff${transparency}`,
|
|
10190
|
-
input.palettePath
|
|
10191
|
-
];
|
|
10192
|
-
}
|
|
10193
|
-
function buildGifPaletteuseArgs(input) {
|
|
10194
|
-
const fpsArg = fpsToFfmpegArg2(input.fps);
|
|
10195
|
-
const transparency = input.preserveAlpha ? ":alpha_threshold=128" : "";
|
|
10196
|
-
return [
|
|
10197
|
-
"-y",
|
|
10198
|
-
...framesInput(input, fpsArg),
|
|
10199
|
-
"-i",
|
|
10200
|
-
input.palettePath,
|
|
10201
|
-
"-lavfi",
|
|
10202
|
-
`fps=${fpsArg} [x]; [x][1:v] paletteuse=dither=sierra2_4a${transparency}`,
|
|
10203
|
-
"-loop",
|
|
10204
|
-
String(input.loop),
|
|
10205
|
-
...input.wholeFrames ? WRITE_EVERY_FRAME_WHOLE : [],
|
|
10206
|
-
input.outputPath
|
|
10207
|
-
];
|
|
10208
|
-
}
|
|
10209
|
-
|
|
10210
|
-
// src/services/render/stages/encodeStage.ts
|
|
10211
|
-
import { frameFileExtension as frameFileExtension2 } from "@hyperframes/engine";
|
|
10212
|
-
function resolveGifLoop(loop) {
|
|
10213
|
-
const resolved = loop ?? 0;
|
|
10214
|
-
if (!Number.isInteger(resolved) || resolved < 0 || resolved > 65535) {
|
|
10215
|
-
throw new Error(`[Render] gifLoop must be an integer between 0 and 65535 (got ${resolved})`);
|
|
10216
|
-
}
|
|
10217
|
-
return resolved;
|
|
10218
|
-
}
|
|
10219
|
-
function gifEncodeFailure(startTime, outputPath, error, failureReason) {
|
|
10220
|
-
return {
|
|
10221
|
-
success: false,
|
|
10222
|
-
outputPath,
|
|
10223
|
-
durationMs: Date.now() - startTime,
|
|
10224
|
-
framesEncoded: 0,
|
|
10225
|
-
fileSize: 0,
|
|
10226
|
-
error,
|
|
10227
|
-
failureReason
|
|
10228
|
-
};
|
|
10229
|
-
}
|
|
10230
|
-
async function reencodeWithClearedWholeFrames(argsInput, run, startTime) {
|
|
10231
|
-
const { outputPath } = argsInput;
|
|
10232
|
-
const result = await runFfmpeg3(buildGifPaletteuseArgs({ ...argsInput, wholeFrames: true }), run);
|
|
10233
|
-
if (!result.success) {
|
|
10234
|
-
const error = formatFfmpegError(result.exitCode, result.stderr);
|
|
10235
|
-
return gifEncodeFailure(startTime, outputPath, error, result.failureReason);
|
|
10236
|
-
}
|
|
10237
|
-
const gif = readFileSync7(outputPath);
|
|
10238
|
-
if (!clearGifFramesBeforeNext(gif)) {
|
|
10239
|
-
const error = "[FFmpeg] GIF output could not be parsed to clear its frames";
|
|
10240
|
-
return gifEncodeFailure(startTime, outputPath, error);
|
|
10241
|
-
}
|
|
10242
|
-
writeFileSync4(outputPath, gif);
|
|
10243
|
-
return null;
|
|
10244
|
-
}
|
|
10245
|
-
async function encodeGifFromDir(framesDir, framePattern, outputPath, input) {
|
|
10246
|
-
const startTime = Date.now();
|
|
10247
|
-
const files = readdirSync4(framesDir).filter((file) => file.match(/\.(jpg|jpeg|png)$/i));
|
|
10248
|
-
const frameCount = files.length;
|
|
10249
|
-
if (frameCount === 0) {
|
|
10250
|
-
return gifEncodeFailure(startTime, outputPath, "[FFmpeg] No frame files found in directory");
|
|
10251
|
-
}
|
|
10252
|
-
const argsInput = {
|
|
10253
|
-
framesDir,
|
|
10254
|
-
framePattern,
|
|
10255
|
-
palettePath: input.palettePath,
|
|
10256
|
-
outputPath,
|
|
10257
|
-
fps: input.fps,
|
|
10258
|
-
loop: input.loop,
|
|
10259
|
-
preserveAlpha: input.preserveAlpha
|
|
10260
|
-
};
|
|
10261
|
-
try {
|
|
10262
|
-
const run = { signal: input.signal, timeout: input.timeout };
|
|
10263
|
-
for (const args of [buildGifPalettegenArgs(argsInput), buildGifPaletteuseArgs(argsInput)]) {
|
|
10264
|
-
const result = await runFfmpeg3(args, run);
|
|
10265
|
-
if (!result.success) {
|
|
10266
|
-
const error = formatFfmpegError(result.exitCode, result.stderr);
|
|
10267
|
-
return gifEncodeFailure(startTime, outputPath, error, result.failureReason);
|
|
10268
|
-
}
|
|
10269
|
-
}
|
|
10270
|
-
if (input.preserveAlpha && gifClearsAfterLeavingFrameInPlace(readFileSync7(outputPath)) !== false) {
|
|
10271
|
-
const failure = await reencodeWithClearedWholeFrames(argsInput, run, startTime);
|
|
10272
|
-
if (failure) return failure;
|
|
10273
|
-
}
|
|
10274
|
-
const fileSize = existsSync15(outputPath) ? statSync5(outputPath).size : 0;
|
|
10275
|
-
return {
|
|
10276
|
-
success: true,
|
|
10277
|
-
outputPath,
|
|
10278
|
-
durationMs: Date.now() - startTime,
|
|
10279
|
-
framesEncoded: frameCount,
|
|
10280
|
-
fileSize
|
|
10281
|
-
};
|
|
10282
|
-
} finally {
|
|
10283
|
-
rmSync7(input.palettePath, { force: true });
|
|
10284
|
-
}
|
|
10285
|
-
}
|
|
10286
|
-
function startEncodeProgress(job, stage, onProgress) {
|
|
10287
|
-
const total = job.totalFrames ?? 0;
|
|
10288
|
-
updateJobStatus(job, "encoding", stage, 75, onProgress, {
|
|
10289
|
-
code: "encode",
|
|
10290
|
-
...total > 0 && { done: 0, total }
|
|
10291
|
-
});
|
|
10292
|
-
return (frames = total) => reportEncodeProgress(job, frames, total, onProgress, 75);
|
|
10293
|
-
}
|
|
10294
|
-
function resolveEngineConfig(input) {
|
|
10295
|
-
return input.engineConfig ?? input.job.config.producerConfig ?? resolveConfig();
|
|
10296
|
-
}
|
|
10297
|
-
function videoEncoderOptions(input, engineCfg) {
|
|
10298
|
-
const { job, preset } = input;
|
|
10299
|
-
return {
|
|
10300
|
-
fps: job.config.fps,
|
|
10301
|
-
width: input.width,
|
|
10302
|
-
height: input.height,
|
|
10303
|
-
codec: preset.codec,
|
|
10304
|
-
preset: preset.preset,
|
|
10305
|
-
quality: input.effectiveQuality,
|
|
10306
|
-
bitrate: input.effectiveBitrate,
|
|
10307
|
-
pixelFormat: preset.pixelFormat,
|
|
10308
|
-
vp9CpuUsed: engineCfg.vp9CpuUsed,
|
|
10309
|
-
useGpu: job.config.useGpu,
|
|
10310
|
-
hdr: preset.hdr,
|
|
10311
|
-
// Distributed chunk renders pass these so the encoder writes closed-GOP
|
|
10312
|
-
// keyframes that survive `-f concat -c copy` at assemble time. In-process
|
|
10313
|
-
// renders leave both undefined → preserves the existing open-GOP output.
|
|
10314
|
-
lockGopForChunkConcat: input.lockGopForChunkConcat === true,
|
|
10315
|
-
gopSize: input.gopSize
|
|
10316
|
-
};
|
|
10317
|
-
}
|
|
10318
|
-
function canEncodeWhileCapturing(input, workerCount, cpuCount) {
|
|
10319
|
-
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;
|
|
10320
|
-
}
|
|
10321
|
-
async function readLandedFrame(workerPath, mergedPath) {
|
|
10322
|
-
if (workerPath !== void 0) {
|
|
10323
|
-
try {
|
|
10324
|
-
return await readFile2(workerPath);
|
|
10325
|
-
} catch (error) {
|
|
10326
|
-
if (error.code !== "ENOENT") throw error;
|
|
10327
|
-
}
|
|
10328
|
-
}
|
|
10329
|
-
return readFile2(mergedPath);
|
|
10330
|
-
}
|
|
10331
|
-
function encodeWhileCapturing(input, totalFrames) {
|
|
10332
|
-
const engineCfg = resolveEngineConfig(input);
|
|
10333
|
-
const stop = new AbortController();
|
|
10334
|
-
const signal = input.abortSignal ? AbortSignal.any([input.abortSignal, stop.signal]) : stop.signal;
|
|
10335
|
-
const landed = /* @__PURE__ */ new Map();
|
|
10336
|
-
let captureDone = false;
|
|
10337
|
-
let framesFed = 0;
|
|
10338
|
-
let wake = null;
|
|
10339
|
-
const nudge = () => {
|
|
10340
|
-
wake?.();
|
|
10341
|
-
wake = null;
|
|
10342
|
-
};
|
|
10343
|
-
signal.addEventListener("abort", nudge, { once: true });
|
|
10344
|
-
let reportFed = null;
|
|
10345
|
-
const encoder = spawnStreamingEncoder4(
|
|
10346
|
-
input.videoOnlyPath,
|
|
10347
|
-
{ ...videoEncoderOptions(input, engineCfg), imageFormat: "jpeg" },
|
|
10348
|
-
signal,
|
|
10349
|
-
engineCfg
|
|
10350
|
-
);
|
|
10351
|
-
const fed = (async () => {
|
|
10352
|
-
const stream = await encoder;
|
|
10353
|
-
for (let next = 0; next < totalFrames; next++) {
|
|
10354
|
-
while (!landed.has(next) && !captureDone && !signal.aborted) {
|
|
10355
|
-
await new Promise((resolve10) => wake = resolve10);
|
|
10356
|
-
}
|
|
10357
|
-
if (signal.aborted) return;
|
|
10358
|
-
const mergedPath = join24(input.framesDir, formatCaptureFrameName(next, "jpg"));
|
|
10359
|
-
const frame = await readLandedFrame(landed.get(next), mergedPath);
|
|
10360
|
-
landed.delete(next);
|
|
10361
|
-
if (!await stream.writeFrame(frame)) {
|
|
10362
|
-
throw new Error(stream.getExitError() ?? "encoder exited before the last frame");
|
|
10363
|
-
}
|
|
10364
|
-
framesFed++;
|
|
10365
|
-
reportFed?.(framesFed);
|
|
10366
|
-
}
|
|
10367
|
-
})().then(
|
|
10368
|
-
() => void 0,
|
|
10369
|
-
(error) => error ?? new Error("encode failed")
|
|
10370
|
-
);
|
|
10371
|
-
const closeEncoder = async () => (await encoder.catch(() => null))?.close();
|
|
10372
|
-
return {
|
|
10373
|
-
onFrameLanded: (frame) => {
|
|
10374
|
-
landed.set(frame.index, frame.path);
|
|
10375
|
-
nudge();
|
|
10376
|
-
},
|
|
10377
|
-
finish: async () => {
|
|
10378
|
-
const start = Date.now();
|
|
10379
|
-
captureDone = true;
|
|
10380
|
-
input.log.info("[Render] Encoded frames while capturing", {
|
|
10381
|
-
framesFed,
|
|
10382
|
-
totalFrames
|
|
10383
|
-
});
|
|
10384
|
-
const encoded = startEncodeProgress(input.job, "Encoding video", input.onProgress);
|
|
10385
|
-
reportFed = encoded;
|
|
10386
|
-
nudge();
|
|
10387
|
-
const failure = await fed;
|
|
10388
|
-
const result = await closeEncoder();
|
|
10389
|
-
input.assertNotAborted();
|
|
10390
|
-
if (failure === void 0 && result?.success) {
|
|
10391
|
-
encoded();
|
|
10392
|
-
return { encodeMs: Date.now() - start };
|
|
10393
|
-
}
|
|
10394
|
-
input.log.warn(
|
|
10395
|
-
"[Render] Encoding while capturing failed; encoding the frames after capture",
|
|
10396
|
-
{
|
|
10397
|
-
error: String(failure ?? result?.error)
|
|
10398
|
-
}
|
|
10399
|
-
);
|
|
10400
|
-
return runEncodeStage(input);
|
|
10401
|
-
},
|
|
10402
|
-
abort: async () => {
|
|
10403
|
-
stop.abort();
|
|
10404
|
-
await fed;
|
|
10405
|
-
await closeEncoder();
|
|
10406
|
-
},
|
|
10407
|
-
get framesFed() {
|
|
10408
|
-
return framesFed;
|
|
10409
|
-
}
|
|
10410
|
-
};
|
|
10411
|
-
}
|
|
10412
|
-
async function runEncodeStage(input) {
|
|
10413
|
-
const {
|
|
10414
|
-
job,
|
|
10415
|
-
log,
|
|
10416
|
-
outputPath,
|
|
10417
|
-
framesDir,
|
|
10418
|
-
videoOnlyPath,
|
|
10419
|
-
needsAlpha,
|
|
10420
|
-
hasAudio,
|
|
10421
|
-
audioOutputPath,
|
|
10422
|
-
isPngSequence,
|
|
10423
|
-
isGif,
|
|
10424
|
-
enableChunkedEncode,
|
|
10425
|
-
chunkedEncodeSize,
|
|
10426
|
-
abortSignal,
|
|
10427
|
-
assertNotAborted,
|
|
10428
|
-
onProgress
|
|
10429
|
-
} = input;
|
|
10430
|
-
const stage5Start = Date.now();
|
|
10431
|
-
if (isPngSequence) {
|
|
10432
|
-
const encoded = startEncodeProgress(job, "Writing PNG sequence", onProgress);
|
|
10433
|
-
if (!existsSync15(outputPath)) mkdirSync8(outputPath, { recursive: true });
|
|
10434
|
-
const captured = readdirSync4(framesDir).filter((name) => name.endsWith(".png")).sort();
|
|
10435
|
-
if (captured.length === 0) {
|
|
10436
|
-
throw new Error(
|
|
10437
|
-
`[Render] png-sequence output requested but no PNGs were captured to ${framesDir}`
|
|
10438
|
-
);
|
|
10439
|
-
}
|
|
10440
|
-
captured.forEach((name, i) => {
|
|
10441
|
-
const dst = join24(outputPath, formatExportFrameName(i, "png"));
|
|
10442
|
-
copyFileSync3(join24(framesDir, name), dst);
|
|
10443
|
-
});
|
|
10444
|
-
if (hasAudio && audioOutputPath && existsSync15(audioOutputPath)) {
|
|
10445
|
-
copyFileSync3(audioOutputPath, join24(outputPath, MIXED_AUDIO_FILENAME2));
|
|
10446
|
-
log.info(
|
|
10447
|
-
`[Render] png-sequence: ${MIXED_AUDIO_FILENAME2} sidecar written to ${outputPath}/${MIXED_AUDIO_FILENAME2}`
|
|
10448
|
-
);
|
|
10449
|
-
}
|
|
10450
|
-
encoded();
|
|
10451
|
-
return { encodeMs: Date.now() - stage5Start };
|
|
10452
|
-
}
|
|
10453
|
-
const engineCfg = resolveEngineConfig(input);
|
|
10454
|
-
if (isGif) {
|
|
10455
|
-
const encoded = startEncodeProgress(job, "Encoding GIF", onProgress);
|
|
10456
|
-
if (hasAudio) {
|
|
10457
|
-
log.warn("[Render] GIF output does not support audio; audio tracks will be ignored.");
|
|
10458
|
-
}
|
|
10459
|
-
const frameExt2 = frameFileExtension2(input.captureImageFormat);
|
|
10460
|
-
const framePattern2 = `frame_%06d.${frameExt2}`;
|
|
10461
|
-
const loop = resolveGifLoop(job.config.gifLoop);
|
|
10462
|
-
const encodeResult2 = await encodeGifFromDir(framesDir, framePattern2, outputPath, {
|
|
10463
|
-
fps: job.config.fps,
|
|
10464
|
-
loop,
|
|
10465
|
-
palettePath: join24(dirname8(videoOnlyPath), "gif-palette.png"),
|
|
10466
|
-
preserveAlpha: needsAlpha,
|
|
10467
|
-
signal: abortSignal,
|
|
10468
|
-
timeout: engineCfg.ffmpegEncodeTimeout
|
|
10469
|
-
});
|
|
10470
|
-
assertNotAborted();
|
|
10471
|
-
if (!encodeResult2.success) {
|
|
10472
|
-
throw encoderFailureError("Encoding failed", encodeResult2);
|
|
10473
|
-
}
|
|
10474
|
-
encoded();
|
|
10475
|
-
return { encodeMs: Date.now() - stage5Start };
|
|
10476
|
-
}
|
|
10477
|
-
const onFramesEncoded = startEncodeProgress(job, "Encoding video", onProgress);
|
|
10478
|
-
const baseScaledEncodeTimeout = Math.ceil((job.duration ?? 0) * 4e3);
|
|
10479
|
-
const scaledEncodeTimeout = baseScaledEncodeTimeout * (job.config.quality === "high" ? 6 : 1);
|
|
10480
|
-
const videoEngineCfg = scaledEncodeTimeout > engineCfg.ffmpegEncodeTimeout ? { ...engineCfg, ffmpegEncodeTimeout: scaledEncodeTimeout } : engineCfg;
|
|
10481
|
-
const frameExt = frameFileExtension2(input.captureImageFormat);
|
|
10482
|
-
const framePattern = `frame_%06d.${frameExt}`;
|
|
10483
|
-
const encoderOpts = videoEncoderOptions(input, engineCfg);
|
|
10484
|
-
const encodeResult = enableChunkedEncode ? await encodeFramesChunkedConcat(
|
|
10485
|
-
framesDir,
|
|
10486
|
-
framePattern,
|
|
10487
|
-
videoOnlyPath,
|
|
10488
|
-
encoderOpts,
|
|
10489
|
-
chunkedEncodeSize,
|
|
10490
|
-
abortSignal,
|
|
10491
|
-
videoEngineCfg,
|
|
10492
|
-
onFramesEncoded
|
|
10493
|
-
) : await encodeFramesFromDir(
|
|
10494
|
-
framesDir,
|
|
10495
|
-
framePattern,
|
|
10496
|
-
videoOnlyPath,
|
|
10497
|
-
encoderOpts,
|
|
10498
|
-
abortSignal,
|
|
10499
|
-
videoEngineCfg,
|
|
10500
|
-
onFramesEncoded
|
|
10501
|
-
);
|
|
10502
|
-
assertNotAborted();
|
|
10503
|
-
if (!encodeResult.success) {
|
|
10504
|
-
throw encoderFailureError("Encoding failed", encodeResult);
|
|
10505
|
-
}
|
|
10506
|
-
onFramesEncoded();
|
|
10507
|
-
return { encodeMs: Date.now() - stage5Start };
|
|
10508
|
-
}
|
|
10509
|
-
|
|
10510
|
-
// src/services/render/stages/assembleStage.ts
|
|
10511
|
-
import { applyFaststart, muxVideoWithAudio, packageHls } from "@hyperframes/engine";
|
|
10512
|
-
import { extname as extname3 } from "node:path";
|
|
10513
|
-
|
|
10514
|
-
// src/services/render/audioPadTrim.ts
|
|
10515
|
-
import { spawn } from "node:child_process";
|
|
10516
|
-
import { mkdtempSync as mkdtempSync3, renameSync as renameSync4, rmSync as rmSync8 } from "node:fs";
|
|
10517
|
-
import { dirname as dirname9, join as join25 } from "node:path";
|
|
10518
|
-
import {
|
|
10519
|
-
DEFAULT_CONFIG,
|
|
10520
|
-
describeFfmpegFailure as describeFfmpegFailure2,
|
|
10521
|
-
encodeAac,
|
|
10522
|
-
extractAudioMetadata as extractAudioMetadata3,
|
|
10523
|
-
getFfprobeBinary,
|
|
10524
|
-
ManagedChildProcess,
|
|
10525
|
-
runFfmpeg as runFfmpeg5,
|
|
10526
|
-
trackChildProcess
|
|
10527
|
-
} from "@hyperframes/engine";
|
|
10528
|
-
import { redactKnownPaths, redactTelemetryString as redactTelemetryString3 } from "@hyperframes/core";
|
|
10529
|
-
|
|
10530
|
-
// src/services/render/audioTruePeak.ts
|
|
10531
|
-
import {
|
|
10532
|
-
aacSegmentCount,
|
|
10533
|
-
describeFfmpegFailure,
|
|
10534
|
-
extractAudioMetadata as extractAudioMetadata2,
|
|
10535
|
-
runFfmpeg as runFfmpeg4
|
|
10536
|
-
} from "@hyperframes/engine";
|
|
10537
|
-
var toDbfs = (token) => token.toLowerCase() === "-inf" ? Number.NEGATIVE_INFINITY : Number(token);
|
|
10538
|
-
function checkedDbfs(value) {
|
|
10539
|
-
if (Number.isNaN(value) || value === Number.POSITIVE_INFINITY) {
|
|
10540
|
-
throw new Error("[audioTruePeak] FFmpeg did not report AAC true peak");
|
|
10541
|
-
}
|
|
10542
|
-
return value;
|
|
10543
|
-
}
|
|
10544
|
-
async function scanTruePeak(audioPath, trim, timeoutMs, signal) {
|
|
10545
|
-
let firstBlock;
|
|
10546
|
-
let laterBlocks = Number.NEGATIVE_INFINITY;
|
|
10547
|
-
let partialLine = "";
|
|
10548
|
-
const readLines = (chunk) => {
|
|
10549
|
-
const lines = (partialLine + chunk).split(/\r|\n/);
|
|
10550
|
-
partialLine = lines.pop() ?? "";
|
|
10551
|
-
for (const line of lines) {
|
|
10552
|
-
const ftpk = /FTPK:([^d]*)dBFS/.exec(line)?.[1];
|
|
10553
|
-
if (!ftpk) continue;
|
|
10554
|
-
const block = Math.max(...ftpk.trim().split(/\s+/).map(toDbfs));
|
|
10555
|
-
if (firstBlock === void 0) firstBlock = block;
|
|
10556
|
-
else laterBlocks = Math.max(laterBlocks, block);
|
|
10557
|
-
}
|
|
10558
|
-
};
|
|
10559
|
-
const result = await runFfmpeg4(
|
|
10560
|
-
["-i", audioPath, "-map", "0:a:0", "-vn", "-af", `${trim}ebur128=peak=true`, "-f", "null", "-"],
|
|
10561
|
-
{ signal, timeout: timeoutMs, onStderr: readLines }
|
|
10562
|
-
);
|
|
10563
|
-
if (!result.success) {
|
|
10564
|
-
throw new Error(`[audioTruePeak] ${describeFfmpegFailure(result, timeoutMs)}`);
|
|
10565
|
-
}
|
|
10566
|
-
const summary = [...result.stderr.matchAll(/^\s*Peak:\s*([+-]?(?:[\d.]+|inf)) dBFS$/gim)];
|
|
10567
|
-
return {
|
|
10568
|
-
peak: checkedDbfs(toDbfs(summary.at(-1)?.[1] ?? "nan")),
|
|
10569
|
-
firstBlock: checkedDbfs(firstBlock ?? Number.NEGATIVE_INFINITY),
|
|
10570
|
-
laterBlocks: checkedDbfs(laterBlocks)
|
|
10571
|
-
};
|
|
10572
|
-
}
|
|
10573
|
-
async function measureTruePeakDbfs(audioPath, options) {
|
|
10574
|
-
const { timeoutMs, signal } = options;
|
|
10575
|
-
const wholeFile = async () => (await scanTruePeak(audioPath, "", timeoutMs, signal)).peak;
|
|
10576
|
-
const { durationSeconds, sampleRate } = await extractAudioMetadata2(audioPath, { signal });
|
|
10577
|
-
const block = 4800;
|
|
10578
|
-
const count = sampleRate === 48e3 && Number.isFinite(durationSeconds) ? aacSegmentCount(durationSeconds) : 1;
|
|
10579
|
-
if (count === 1) return wholeFile();
|
|
10580
|
-
const cut = (k) => k * Math.floor(durationSeconds * 10 / count) * block;
|
|
10581
|
-
const peers = new AbortController();
|
|
10582
|
-
const segmentSignal = signal ? AbortSignal.any([signal, peers.signal]) : peers.signal;
|
|
10583
|
-
const scans = await Promise.all(
|
|
10584
|
-
Array.from({ length: count }, (_, k) => {
|
|
10585
|
-
const start = k === 0 ? 0 : cut(k) - block;
|
|
10586
|
-
const end = k === count - 1 ? "" : `:end_sample=${cut(k + 1)}`;
|
|
10587
|
-
return scanTruePeak(
|
|
10588
|
-
audioPath,
|
|
10589
|
-
`atrim=start_sample=${start}${end},`,
|
|
10590
|
-
timeoutMs,
|
|
10591
|
-
segmentSignal
|
|
10592
|
-
);
|
|
10593
|
-
})
|
|
10594
|
-
).catch((error) => {
|
|
10595
|
-
peers.abort();
|
|
10596
|
-
throw error;
|
|
10597
|
-
});
|
|
10598
|
-
const last = scans[count - 1];
|
|
10599
|
-
const counted = Math.max(scans[0].peak, ...scans.slice(1).map((scan) => scan.laterBlocks));
|
|
10600
|
-
if (last.peak > last.firstBlock || last.peak <= counted) return Math.max(counted, last.peak);
|
|
10601
|
-
return wholeFile();
|
|
10602
|
-
}
|
|
10603
|
-
|
|
10604
|
-
// src/services/render/audioPadTrim.ts
|
|
10605
|
-
var AUDIO_DURATION_TOLERANCE_SECONDS = 1e-3;
|
|
10606
|
-
var AAC_DELIVERY_TRUE_PEAK_DBFS = -1;
|
|
10607
|
-
var AAC_TRUE_PEAK_CORRECTION_HEADROOM_DB = 0.5;
|
|
10608
|
-
var MAX_TRUE_PEAK_CORRECTION_PASSES = 3;
|
|
10609
|
-
function aacTruePeakCorrectionFilter(targetDurationSeconds, attenuationDb) {
|
|
10610
|
-
return `volume=${attenuationDb.toFixed(3)}dB,apad,atrim=0:${formatSeconds(targetDurationSeconds)}`;
|
|
10611
|
-
}
|
|
10612
|
-
function buildPadTrimAudioPlan(audioPath, outputPath, sourceDurationSeconds, targetDurationSeconds) {
|
|
10613
|
-
const delta = targetDurationSeconds - sourceDurationSeconds;
|
|
10614
|
-
const targetSec = formatSeconds(targetDurationSeconds);
|
|
10615
|
-
if (Math.abs(delta) < AUDIO_DURATION_TOLERANCE_SECONDS) {
|
|
10616
|
-
return {
|
|
10617
|
-
operation: "copy",
|
|
10618
|
-
steps: [{ kind: "copy", args: ["-i", audioPath, "-c:a", "copy", "-y", outputPath] }],
|
|
10619
|
-
cleanupPaths: []
|
|
10620
|
-
};
|
|
10621
|
-
}
|
|
10622
|
-
if (delta > 0) {
|
|
10623
|
-
return {
|
|
10624
|
-
operation: "pad",
|
|
10625
|
-
steps: [{ kind: "normalize", filter: `apad,atrim=0:${targetSec}` }],
|
|
10626
|
-
cleanupPaths: []
|
|
10627
|
-
};
|
|
10628
|
-
}
|
|
10629
|
-
return {
|
|
10630
|
-
operation: "trim",
|
|
10631
|
-
steps: [{ kind: "trim", filter: `atrim=duration=${targetSec},asetpts=PTS-STARTPTS` }],
|
|
10632
|
-
cleanupPaths: []
|
|
10633
|
-
};
|
|
10634
|
-
}
|
|
10635
|
-
function formatSeconds(sec) {
|
|
10636
|
-
return sec.toFixed(6);
|
|
10637
|
-
}
|
|
10638
|
-
function sanitizeProbeFailure(reason, paths) {
|
|
10639
|
-
const message = reason instanceof Error ? reason.message : String(reason);
|
|
10640
|
-
const keepFfmpegsLastLineUncapped = Number.POSITIVE_INFINITY;
|
|
10641
|
-
return redactTelemetryString3(redactKnownPaths(message, paths), keepFfmpegsLastLineUncapped);
|
|
10642
|
-
}
|
|
10643
|
-
async function padOrTrimAudioToVideoFrameCount(input) {
|
|
10644
|
-
const probeVideo = input.probeVideoFrameInfo ?? ((videoPath) => defaultProbeVideoFrameInfo(videoPath, input.signal));
|
|
10645
|
-
const probeAudio = input.probeAudioInfo ?? defaultProbeAudioInfo;
|
|
10646
|
-
const timeoutMs = input.timeoutMs ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
|
|
10647
|
-
const runner = input.runFfmpeg ?? ((args) => defaultRunFfmpeg(args, timeoutMs, input.signal));
|
|
10648
|
-
const encode = input.encodeAac ?? defaultAacEncoder(timeoutMs, input.signal);
|
|
10649
|
-
const probeTruePeak = input.probeAudioTruePeakDbfs ?? (input.runFfmpeg || input.encodeAac ? void 0 : (audioPath, signal) => measureTruePeakDbfs(audioPath, { timeoutMs, signal }));
|
|
10650
|
-
const [videoResult, audioResult] = await Promise.allSettled([
|
|
10651
|
-
probeVideo(input.videoPath),
|
|
10652
|
-
probeAudio(input.audioPath, input.signal)
|
|
10653
|
-
]);
|
|
10654
|
-
const probePaths = [input.videoPath, input.audioPath, input.outputPath];
|
|
10655
|
-
if (videoResult.status === "rejected") {
|
|
10656
|
-
return failResult(
|
|
10657
|
-
input.outputPath,
|
|
10658
|
-
0,
|
|
10659
|
-
audioResult.status === "fulfilled" ? audioResult.value.durationSeconds : 0,
|
|
10660
|
-
`audioPadTrim: failed to probe video: ${sanitizeProbeFailure(
|
|
10661
|
-
videoResult.reason,
|
|
10662
|
-
probePaths
|
|
10663
|
-
)}`
|
|
10664
|
-
);
|
|
10665
|
-
}
|
|
10666
|
-
if (audioResult.status === "rejected") {
|
|
10667
|
-
return failResult(
|
|
10668
|
-
input.outputPath,
|
|
10669
|
-
0,
|
|
10670
|
-
0,
|
|
10671
|
-
`audioPadTrim: failed to probe audio: ${sanitizeProbeFailure(
|
|
10672
|
-
audioResult.reason,
|
|
10673
|
-
probePaths
|
|
10674
|
-
)}`
|
|
10675
|
-
);
|
|
10676
|
-
}
|
|
10677
|
-
const videoInfo = videoResult.value;
|
|
10678
|
-
const audioInfo = audioResult.value;
|
|
10679
|
-
if (!Number.isFinite(videoInfo.frameCount) || videoInfo.frameCount <= 0 || !Number.isFinite(videoInfo.fpsNum) || videoInfo.fpsNum <= 0 || !Number.isFinite(videoInfo.fpsDen) || videoInfo.fpsDen <= 0) {
|
|
10680
|
-
return failResult(
|
|
10681
|
-
input.outputPath,
|
|
10682
|
-
0,
|
|
10683
|
-
audioInfo.durationSeconds,
|
|
10684
|
-
`audioPadTrim: invalid video frame info: ${JSON.stringify(videoInfo)}`
|
|
10685
|
-
);
|
|
10686
|
-
}
|
|
10687
|
-
const targetDurationSeconds = videoInfo.frameCount * videoInfo.fpsDen / videoInfo.fpsNum;
|
|
10688
|
-
const plan = buildPadTrimAudioPlan(
|
|
10689
|
-
input.audioPath,
|
|
10690
|
-
input.outputPath,
|
|
10691
|
-
audioInfo.durationSeconds,
|
|
10692
|
-
targetDurationSeconds
|
|
10693
|
-
);
|
|
10694
|
-
try {
|
|
10695
|
-
for (const step of plan.steps) {
|
|
10696
|
-
const ffmpegResult = step.kind === "copy" ? await runner(step.args) : await encode(input.audioPath, input.outputPath, targetDurationSeconds, step.filter);
|
|
10697
|
-
if (!ffmpegResult.success) {
|
|
10698
|
-
return {
|
|
10699
|
-
success: false,
|
|
10700
|
-
outputPath: input.outputPath,
|
|
10701
|
-
targetDurationSeconds,
|
|
10702
|
-
sourceDurationSeconds: audioInfo.durationSeconds,
|
|
10703
|
-
operation: plan.operation,
|
|
10704
|
-
error: ffmpegResult.error,
|
|
10705
|
-
failureReason: ffmpegResult.failureReason
|
|
10706
|
-
};
|
|
10707
|
-
}
|
|
10708
|
-
}
|
|
10709
|
-
} catch (err) {
|
|
10710
|
-
return {
|
|
10711
|
-
success: false,
|
|
10712
|
-
outputPath: input.outputPath,
|
|
10713
|
-
targetDurationSeconds,
|
|
10714
|
-
sourceDurationSeconds: audioInfo.durationSeconds,
|
|
10715
|
-
operation: plan.operation,
|
|
10716
|
-
error: `audioPadTrim: failed to materialize ${plan.operation}: ${err instanceof Error ? err.message : String(err)}`
|
|
10717
|
-
};
|
|
10718
|
-
} finally {
|
|
10719
|
-
for (const path of plan.cleanupPaths) rmSync8(path, { force: true });
|
|
10720
|
-
}
|
|
10721
|
-
let loweredDb = 0;
|
|
10722
|
-
if (probeTruePeak) {
|
|
10723
|
-
const correction = await enforceAacTruePeak({
|
|
10724
|
-
audioPath: input.outputPath,
|
|
10725
|
-
targetDurationSeconds,
|
|
10726
|
-
signal: input.signal,
|
|
10727
|
-
probeTruePeak,
|
|
10728
|
-
encode
|
|
10729
|
-
});
|
|
10730
|
-
if (!correction.success) {
|
|
10731
|
-
return {
|
|
10732
|
-
success: false,
|
|
10733
|
-
outputPath: input.outputPath,
|
|
10734
|
-
targetDurationSeconds,
|
|
10735
|
-
sourceDurationSeconds: audioInfo.durationSeconds,
|
|
10736
|
-
operation: plan.operation,
|
|
10737
|
-
error: correction.error,
|
|
10738
|
-
failureReason: correction.failureReason
|
|
10739
|
-
};
|
|
10740
|
-
}
|
|
10741
|
-
loweredDb = correction.loweredDb ?? 0;
|
|
10742
|
-
}
|
|
10743
|
-
return {
|
|
10744
|
-
success: true,
|
|
10745
|
-
outputPath: input.outputPath,
|
|
10746
|
-
targetDurationSeconds,
|
|
10747
|
-
sourceDurationSeconds: audioInfo.durationSeconds,
|
|
10748
|
-
operation: plan.operation,
|
|
10749
|
-
...loweredDb > 0 ? { audioLoweredDb: loweredDb } : {}
|
|
10750
|
-
};
|
|
10751
|
-
}
|
|
10752
|
-
async function enforceAacTruePeak(input) {
|
|
10753
|
-
let scratchDir;
|
|
10754
|
-
try {
|
|
10755
|
-
scratchDir = mkdtempSync3(join25(dirname9(input.audioPath), ".true-peak-"));
|
|
10756
|
-
const correctedPath = join25(scratchDir, "audio.m4a");
|
|
10757
|
-
const measurements = [];
|
|
10758
|
-
let attenuationDb = 0;
|
|
10759
|
-
let measuredPath = input.audioPath;
|
|
10760
|
-
for (let pass = 0; pass <= MAX_TRUE_PEAK_CORRECTION_PASSES; pass += 1) {
|
|
10761
|
-
const truePeakDbfs = await input.probeTruePeak(measuredPath, input.signal);
|
|
10762
|
-
measurements.push(
|
|
10763
|
-
`pass ${pass}: ${truePeakDbfs.toFixed(3)} dBFS at ${attenuationDb.toFixed(3)} dB attenuation`
|
|
10764
|
-
);
|
|
10765
|
-
if (Number.isNaN(truePeakDbfs) || truePeakDbfs === Number.POSITIVE_INFINITY) {
|
|
10766
|
-
return { success: false, error: "audioPadTrim: FFmpeg reported an invalid true peak" };
|
|
10767
|
-
}
|
|
10768
|
-
if (truePeakDbfs <= AAC_DELIVERY_TRUE_PEAK_DBFS) {
|
|
10769
|
-
if (measuredPath !== correctedPath) return { success: true };
|
|
10770
|
-
renameSync4(correctedPath, input.audioPath);
|
|
10771
|
-
return { success: true, loweredDb: Number((-attenuationDb).toFixed(2)) };
|
|
10772
|
-
}
|
|
10773
|
-
if (pass === MAX_TRUE_PEAK_CORRECTION_PASSES) break;
|
|
10774
|
-
const headroomDb = AAC_TRUE_PEAK_CORRECTION_HEADROOM_DB * 2 ** pass;
|
|
10775
|
-
attenuationDb += AAC_DELIVERY_TRUE_PEAK_DBFS - headroomDb - truePeakDbfs;
|
|
10776
|
-
const result = await input.encode(
|
|
10777
|
-
input.audioPath,
|
|
10778
|
-
correctedPath,
|
|
10779
|
-
input.targetDurationSeconds,
|
|
10780
|
-
aacTruePeakCorrectionFilter(input.targetDurationSeconds, attenuationDb)
|
|
10781
|
-
);
|
|
10782
|
-
if (!result.success) {
|
|
10783
|
-
return {
|
|
10784
|
-
success: false,
|
|
10785
|
-
error: sanitizeProbeFailure(
|
|
10786
|
-
result.error ?? "audioPadTrim: failed to constrain AAC true peak",
|
|
10787
|
-
[input.audioPath, correctedPath]
|
|
10788
|
-
),
|
|
10789
|
-
failureReason: result.failureReason
|
|
10790
|
-
};
|
|
10791
|
-
}
|
|
10792
|
-
measuredPath = correctedPath;
|
|
10525
|
+
} finally {
|
|
10526
|
+
lastBrowserConsole = domSession.browserConsoleBuffer;
|
|
10527
|
+
await closeCaptureSession7(domSession);
|
|
10528
|
+
domSessionClosed = true;
|
|
10793
10529
|
}
|
|
10794
|
-
|
|
10795
|
-
|
|
10796
|
-
|
|
10797
|
-
|
|
10798
|
-
|
|
10799
|
-
|
|
10800
|
-
|
|
10801
|
-
|
|
10802
|
-
|
|
10803
|
-
|
|
10804
|
-
|
|
10805
|
-
|
|
10530
|
+
const encodeFrom = job.progress;
|
|
10531
|
+
const hdrEncodeResult = await hdrEncoder.close(
|
|
10532
|
+
(frames) => reportEncodeProgress(job, frames, totalFrames, onProgress, encodeFrom)
|
|
10533
|
+
);
|
|
10534
|
+
hdrEncoderClosed = true;
|
|
10535
|
+
assertNotAborted();
|
|
10536
|
+
if (!hdrEncodeResult.success) {
|
|
10537
|
+
throw encoderFailureError("HDR encode failed", hdrEncodeResult);
|
|
10538
|
+
}
|
|
10539
|
+
reportEncodeProgress(job, totalFrames, totalFrames, onProgress, encodeFrom);
|
|
10540
|
+
captureDurationMs = Date.now() - stageStart;
|
|
10541
|
+
encodeMs = hdrEncodeResult.durationMs;
|
|
10542
|
+
} catch (error) {
|
|
10543
|
+
lastBrowserConsole = domSession.browserConsoleBuffer;
|
|
10544
|
+
throw wrapCaptureStageError(error, lastBrowserConsole);
|
|
10806
10545
|
} finally {
|
|
10807
|
-
if (
|
|
10546
|
+
if (hdrEncoder && !hdrEncoderClosed) {
|
|
10547
|
+
try {
|
|
10548
|
+
await hdrEncoder.close();
|
|
10549
|
+
} catch (err) {
|
|
10550
|
+
log.warn("hdrEncoder defensive close failed", {
|
|
10551
|
+
err: err instanceof Error ? err.message : String(err)
|
|
10552
|
+
});
|
|
10553
|
+
}
|
|
10554
|
+
}
|
|
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
|
+
});
|
|
10561
|
+
}
|
|
10562
|
+
for (const frameSource of hdrVideoFrameSources.values()) {
|
|
10563
|
+
cleanupHdrVideoFrameSource(frameSource, log);
|
|
10564
|
+
}
|
|
10565
|
+
hdrVideoFrameSources.clear();
|
|
10566
|
+
releaseHdrExtractionReservation?.();
|
|
10567
|
+
releaseHdrExtractionReservation = null;
|
|
10808
10568
|
}
|
|
10569
|
+
return {
|
|
10570
|
+
lastBrowserConsole,
|
|
10571
|
+
hdrPerf,
|
|
10572
|
+
captureDurationMs,
|
|
10573
|
+
encodeMs,
|
|
10574
|
+
warnings: cloneCaptureWarnings2(domSession.warnings)
|
|
10575
|
+
};
|
|
10809
10576
|
}
|
|
10810
|
-
|
|
10577
|
+
|
|
10578
|
+
// src/services/render/stages/encodeStage.ts
|
|
10579
|
+
import {
|
|
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,
|
|
10597
|
+
runFfmpeg as runFfmpeg5,
|
|
10598
|
+
spawnStreamingEncoder as spawnStreamingEncoder4
|
|
10599
|
+
} from "@hyperframes/engine";
|
|
10600
|
+
import {
|
|
10601
|
+
clearGifFramesBeforeNext,
|
|
10602
|
+
gifClearsAfterLeavingFrameInPlace
|
|
10603
|
+
} from "@hyperframes/core";
|
|
10604
|
+
|
|
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}`;
|
|
10609
|
+
}
|
|
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
|
+
];
|
|
10622
|
+
}
|
|
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
|
+
];
|
|
10633
|
+
}
|
|
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})`);
|
|
10657
|
+
}
|
|
10658
|
+
return resolved;
|
|
10659
|
+
}
|
|
10660
|
+
function gifEncodeFailure(startTime, outputPath, error, failureReason) {
|
|
10811
10661
|
return {
|
|
10812
10662
|
success: false,
|
|
10813
10663
|
outputPath,
|
|
10814
|
-
|
|
10815
|
-
|
|
10816
|
-
|
|
10817
|
-
error
|
|
10664
|
+
durationMs: Date.now() - startTime,
|
|
10665
|
+
framesEncoded: 0,
|
|
10666
|
+
fileSize: 0,
|
|
10667
|
+
error,
|
|
10668
|
+
failureReason
|
|
10818
10669
|
};
|
|
10819
10670
|
}
|
|
10820
|
-
async function
|
|
10821
|
-
const
|
|
10822
|
-
|
|
10823
|
-
|
|
10824
|
-
|
|
10825
|
-
|
|
10826
|
-
"v:0",
|
|
10827
|
-
"-show_entries",
|
|
10828
|
-
"stream=nb_frames,r_frame_rate",
|
|
10829
|
-
"-of",
|
|
10830
|
-
"json",
|
|
10831
|
-
"--",
|
|
10832
|
-
videoPath
|
|
10833
|
-
],
|
|
10834
|
-
signal
|
|
10835
|
-
);
|
|
10836
|
-
let stream = fastInfo.streams?.[0];
|
|
10837
|
-
const fastCount = Number(stream?.nb_frames);
|
|
10838
|
-
if (stream && Number.isFinite(fastCount) && fastCount > 0) {
|
|
10839
|
-
return { frameCount: fastCount, ...parseFrameRate(stream.r_frame_rate ?? "") };
|
|
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);
|
|
10840
10677
|
}
|
|
10841
|
-
const
|
|
10842
|
-
|
|
10843
|
-
|
|
10844
|
-
|
|
10845
|
-
"-select_streams",
|
|
10846
|
-
"v:0",
|
|
10847
|
-
"-count_packets",
|
|
10848
|
-
"-show_entries",
|
|
10849
|
-
"stream=nb_read_packets,r_frame_rate",
|
|
10850
|
-
"-of",
|
|
10851
|
-
"json",
|
|
10852
|
-
"--",
|
|
10853
|
-
videoPath
|
|
10854
|
-
],
|
|
10855
|
-
signal
|
|
10856
|
-
);
|
|
10857
|
-
stream = slowInfo.streams?.[0];
|
|
10858
|
-
if (!stream) throw new Error(`ffprobe found no video stream in ${videoPath}`);
|
|
10859
|
-
const slowCount = Number(stream.nb_read_packets);
|
|
10860
|
-
if (!Number.isFinite(slowCount) || slowCount <= 0) {
|
|
10861
|
-
throw new Error(`ffprobe returned no frame count: ${JSON.stringify(stream)}`);
|
|
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);
|
|
10862
10682
|
}
|
|
10863
|
-
|
|
10683
|
+
writeFileSync4(outputPath, gif);
|
|
10684
|
+
return null;
|
|
10864
10685
|
}
|
|
10865
|
-
function
|
|
10866
|
-
const
|
|
10867
|
-
const
|
|
10868
|
-
const
|
|
10869
|
-
if (
|
|
10870
|
-
|
|
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");
|
|
10871
10692
|
}
|
|
10872
|
-
|
|
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
|
|
10701
|
+
};
|
|
10702
|
+
try {
|
|
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);
|
|
10706
|
+
if (!result.success) {
|
|
10707
|
+
const error = formatFfmpegError(result.exitCode, result.stderr);
|
|
10708
|
+
return gifEncodeFailure(startTime, outputPath, error, result.failureReason);
|
|
10709
|
+
}
|
|
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;
|
|
10716
|
+
return {
|
|
10717
|
+
success: true,
|
|
10718
|
+
outputPath,
|
|
10719
|
+
durationMs: Date.now() - startTime,
|
|
10720
|
+
framesEncoded: frameCount,
|
|
10721
|
+
fileSize
|
|
10722
|
+
};
|
|
10723
|
+
} finally {
|
|
10724
|
+
rmSync9(input.palettePath, { force: true });
|
|
10725
|
+
}
|
|
10726
|
+
}
|
|
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);
|
|
10873
10734
|
}
|
|
10874
|
-
|
|
10875
|
-
|
|
10735
|
+
function resolveEngineConfig(input) {
|
|
10736
|
+
return input.engineConfig ?? input.job.config.producerConfig ?? resolveConfig();
|
|
10737
|
+
}
|
|
10738
|
+
function videoEncoderOptions(input, engineCfg) {
|
|
10739
|
+
const { job, preset } = input;
|
|
10876
10740
|
return {
|
|
10877
|
-
|
|
10878
|
-
|
|
10879
|
-
|
|
10880
|
-
|
|
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
|
|
10881
10757
|
};
|
|
10882
10758
|
}
|
|
10883
|
-
|
|
10884
|
-
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;
|
|
10885
10761
|
}
|
|
10886
|
-
function
|
|
10887
|
-
|
|
10888
|
-
|
|
10889
|
-
|
|
10890
|
-
|
|
10891
|
-
|
|
10892
|
-
|
|
10893
|
-
}
|
|
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);
|
|
10894
10771
|
}
|
|
10895
|
-
function
|
|
10896
|
-
|
|
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();
|
|
10897
10813
|
return {
|
|
10898
|
-
|
|
10899
|
-
|
|
10900
|
-
|
|
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
|
+
}
|
|
10901
10851
|
};
|
|
10902
10852
|
}
|
|
10903
|
-
async function
|
|
10904
|
-
|
|
10905
|
-
|
|
10906
|
-
|
|
10907
|
-
|
|
10908
|
-
|
|
10909
|
-
|
|
10910
|
-
|
|
10911
|
-
|
|
10912
|
-
|
|
10913
|
-
|
|
10914
|
-
|
|
10915
|
-
|
|
10916
|
-
|
|
10917
|
-
|
|
10918
|
-
|
|
10919
|
-
|
|
10920
|
-
|
|
10921
|
-
|
|
10922
|
-
|
|
10923
|
-
|
|
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
|
+
);
|
|
10924
10880
|
}
|
|
10925
|
-
|
|
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 };
|
|
10926
10893
|
}
|
|
10927
|
-
|
|
10928
|
-
|
|
10929
|
-
const
|
|
10930
|
-
|
|
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 };
|
|
10931
10917
|
}
|
|
10932
|
-
|
|
10933
|
-
|
|
10934
|
-
|
|
10935
|
-
|
|
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);
|
|
10936
10946
|
}
|
|
10947
|
+
onFramesEncoded();
|
|
10948
|
+
return { encodeMs: Date.now() - stage5Start };
|
|
10937
10949
|
}
|
|
10938
10950
|
|
|
10939
10951
|
// src/services/render/stages/assembleStage.ts
|
|
10952
|
+
import { applyFaststart, muxVideoWithAudio, packageHls } from "@hyperframes/engine";
|
|
10953
|
+
import { extname as extname3 } from "node:path";
|
|
10940
10954
|
function recordLimiterAttenuation(job, audioLoweredDb) {
|
|
10941
10955
|
if (audioLoweredDb === void 0) return;
|
|
10942
10956
|
job.audioLoweredDb = audioLoweredDb;
|
|
@@ -11102,7 +11116,7 @@ function sampleDirectoryBytes(dir) {
|
|
|
11102
11116
|
continue;
|
|
11103
11117
|
}
|
|
11104
11118
|
for (const name of entries) {
|
|
11105
|
-
const full =
|
|
11119
|
+
const full = join25(current, name);
|
|
11106
11120
|
try {
|
|
11107
11121
|
const st = statSync6(full);
|
|
11108
11122
|
if (st.isDirectory()) {
|
|
@@ -11250,7 +11264,7 @@ function findMissingFrameRanges(totalFrames, framesDir, frameExt) {
|
|
|
11250
11264
|
const ranges = [];
|
|
11251
11265
|
let rangeStart = null;
|
|
11252
11266
|
for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) {
|
|
11253
|
-
const framePath =
|
|
11267
|
+
const framePath = join25(framesDir, formatCaptureFrameName(frameIndex, frameExt));
|
|
11254
11268
|
const missing = !existsSync16(framePath) || statSync6(framePath).size <= 8;
|
|
11255
11269
|
if (missing && rangeStart === null) {
|
|
11256
11270
|
rangeStart = frameIndex;
|
|
@@ -11273,7 +11287,7 @@ function buildMissingFrameRetryBatches(ranges, maxWorkers, workDir, attempt, ran
|
|
|
11273
11287
|
workerId,
|
|
11274
11288
|
startFrame: rangeStart + range.startFrame,
|
|
11275
11289
|
endFrame: rangeStart + range.endFrame,
|
|
11276
|
-
outputDir:
|
|
11290
|
+
outputDir: join25(workDir, `retry-${attempt}-batch-${batchIndex}-worker-${workerId}`),
|
|
11277
11291
|
outputFrameOffset: rangeStart
|
|
11278
11292
|
}));
|
|
11279
11293
|
batches.push(batch);
|
|
@@ -11301,8 +11315,8 @@ function getNextRetryWorkerCount(currentWorkers) {
|
|
|
11301
11315
|
return Math.max(1, Math.floor(currentWorkers / 2));
|
|
11302
11316
|
}
|
|
11303
11317
|
function resolveRenderWorkDirPrefix(outputPath, jobId, platform2 = process.platform, systemTempDir = tmpdir2()) {
|
|
11304
|
-
if (platform2 === "win32" || platform2 === "darwin") return
|
|
11305
|
-
return
|
|
11318
|
+
if (platform2 === "win32" || platform2 === "darwin") return join25(systemTempDir, "hf-render-");
|
|
11319
|
+
return join25(dirname10(outputPath), `work-${jobId}-`);
|
|
11306
11320
|
}
|
|
11307
11321
|
var MAX_TRANSIENT_CAPTURE_RETRIES = 1;
|
|
11308
11322
|
function isTransientCaptureRetryEligible(failureKind, missing, retriesUsed) {
|
|
@@ -11334,7 +11348,7 @@ The composition is too large for the available memory. To reduce memory pressure
|
|
|
11334
11348
|
function countCapturedFrames(totalFrames, framesDir, frameExt) {
|
|
11335
11349
|
let captured = 0;
|
|
11336
11350
|
for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) {
|
|
11337
|
-
const framePath =
|
|
11351
|
+
const framePath = join25(framesDir, formatCaptureFrameName(frameIndex, frameExt));
|
|
11338
11352
|
if (existsSync16(framePath)) captured++;
|
|
11339
11353
|
}
|
|
11340
11354
|
return captured;
|
|
@@ -11360,7 +11374,7 @@ async function executeDiskCaptureWithAdaptiveRetry(options) {
|
|
|
11360
11374
|
reason: attempt === 0 ? "initial" : pendingTransientRetry ? "transient-retry" : "retry"
|
|
11361
11375
|
});
|
|
11362
11376
|
pendingTransientRetry = false;
|
|
11363
|
-
const attemptWorkDir =
|
|
11377
|
+
const attemptWorkDir = join25(options.workDir, `capture-attempt-${attempt}`);
|
|
11364
11378
|
const batches = missingRanges ? buildMissingFrameRetryBatches(
|
|
11365
11379
|
missingRanges,
|
|
11366
11380
|
currentWorkers,
|
|
@@ -11929,17 +11943,17 @@ function deVerifyFallbackTelemetry(err) {
|
|
|
11929
11943
|
}
|
|
11930
11944
|
function resolveRenderDebugDir() {
|
|
11931
11945
|
const producerRoot = process.env.PRODUCER_RENDERS_DIR ? resolve7(process.env.PRODUCER_RENDERS_DIR, "..") : resolve7(dirname10(fileURLToPath4(import.meta.url)), "../..");
|
|
11932
|
-
return
|
|
11946
|
+
return join25(producerRoot, ".debug");
|
|
11933
11947
|
}
|
|
11934
11948
|
async function executeRenderJob(job, projectDir, outputPath, progressSink, abortSignal, assertRenderActive) {
|
|
11935
11949
|
validateHlsRenderConfig(job.config);
|
|
11936
11950
|
const debugDir = resolveRenderDebugDir();
|
|
11937
11951
|
const outputDir = dirname10(outputPath);
|
|
11938
11952
|
if (!existsSync16(outputDir)) mkdirSync9(outputDir, { recursive: true });
|
|
11939
|
-
const workDir = job.config.debug ?
|
|
11953
|
+
const workDir = job.config.debug ? join25(debugDir, job.id) : createRenderWorkDir(resolveRenderWorkDirPrefix(outputPath, job.id), outputDir);
|
|
11940
11954
|
const pipelineStart = Date.now();
|
|
11941
11955
|
const baseLog = job.config.logger ?? defaultLogger;
|
|
11942
|
-
const logPath = job.config.debug ?
|
|
11956
|
+
const logPath = job.config.debug ? join25(workDir, "render.log") : null;
|
|
11943
11957
|
const execution = new RenderExecutionContext({
|
|
11944
11958
|
request: { renderJobId: job.id, projectDir, outputPath },
|
|
11945
11959
|
logger: logPath ? createRenderFileLogger(logPath, baseLog) : baseLog,
|
|
@@ -11953,7 +11967,7 @@ async function executeRenderJob(job, projectDir, outputPath, progressSink, abort
|
|
|
11953
11967
|
log.info("KEEP_TEMP=1, leaving workDir on disk for inspection", { workDir });
|
|
11954
11968
|
return;
|
|
11955
11969
|
}
|
|
11956
|
-
|
|
11970
|
+
rmSync10(workDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
11957
11971
|
});
|
|
11958
11972
|
try {
|
|
11959
11973
|
await executeRenderPipeline({
|
|
@@ -11997,7 +12011,7 @@ async function executeRenderPipeline(input) {
|
|
|
11997
12011
|
imageDecodeFailures: 0
|
|
11998
12012
|
};
|
|
11999
12013
|
let hdrPerf;
|
|
12000
|
-
const perfOutputPath =
|
|
12014
|
+
const perfOutputPath = join25(workDir, "perf-summary.json");
|
|
12001
12015
|
const cfg = { ...job.config.producerConfig ?? resolveConfig2() };
|
|
12002
12016
|
const observability = new RenderObservabilityRecorder({
|
|
12003
12017
|
pipelineStartMs: pipelineStart,
|
|
@@ -12096,15 +12110,15 @@ async function executeRenderPipeline(input) {
|
|
|
12096
12110
|
requestedWorkers: job.config.workers ?? "auto"
|
|
12097
12111
|
});
|
|
12098
12112
|
const entryFile = job.config.entryFile || "index.html";
|
|
12099
|
-
let htmlPath =
|
|
12113
|
+
let htmlPath = join25(projectDir, entryFile);
|
|
12100
12114
|
if (!existsSync16(htmlPath)) {
|
|
12101
12115
|
throw new Error(`Entry file not found: ${htmlPath}`);
|
|
12102
12116
|
}
|
|
12103
12117
|
assertNotAborted();
|
|
12104
12118
|
const rawEntry = readFileSync8(htmlPath, "utf-8");
|
|
12105
12119
|
if (entryFile !== "index.html" && isTemplateEntry(rawEntry)) {
|
|
12106
|
-
const wrapperPath =
|
|
12107
|
-
const projectIndexPath =
|
|
12120
|
+
const wrapperPath = join25(workDir, "standalone-entry.html");
|
|
12121
|
+
const projectIndexPath = join25(projectDir, "index.html");
|
|
12108
12122
|
if (!existsSync16(projectIndexPath)) {
|
|
12109
12123
|
throw new Error(
|
|
12110
12124
|
`Template entry file "${entryFile}" requires a project index.html to extract its render shell.`
|
|
@@ -12267,7 +12281,7 @@ async function executeRenderPipeline(input) {
|
|
|
12267
12281
|
updateJobStatus(job, "preprocessing", "Extracting video frames", 10, onProgress, {
|
|
12268
12282
|
code: "extract_video"
|
|
12269
12283
|
});
|
|
12270
|
-
const compiledDir =
|
|
12284
|
+
const compiledDir = join25(workDir, "compiled");
|
|
12271
12285
|
const extractResult = await observeRenderStage(
|
|
12272
12286
|
observability,
|
|
12273
12287
|
"video_extract",
|
|
@@ -12355,7 +12369,8 @@ async function executeRenderPipeline(input) {
|
|
|
12355
12369
|
projectDir,
|
|
12356
12370
|
workDir,
|
|
12357
12371
|
compiledDir,
|
|
12358
|
-
|
|
12372
|
+
totalFrames: probeResult.totalFrames,
|
|
12373
|
+
fps: job.config.fps,
|
|
12359
12374
|
ffmpegProcessTimeout: cfg.ffmpegProcessTimeout,
|
|
12360
12375
|
audioGain: cfg.audioGain,
|
|
12361
12376
|
audios: composition.audios,
|
|
@@ -12367,6 +12382,7 @@ async function executeRenderPipeline(input) {
|
|
|
12367
12382
|
);
|
|
12368
12383
|
const { audioOutputPath, hasAudio } = audioResult;
|
|
12369
12384
|
perfStages.audioProcessMs = audioResult.audioProcessMs;
|
|
12385
|
+
recordLimiterAttenuation(job, audioResult.audioLoweredDb);
|
|
12370
12386
|
if (audioResult.audioError) {
|
|
12371
12387
|
const audioFailures = audioResult.audioFailures ?? [];
|
|
12372
12388
|
const failureOwner = audioFailures.length === 0 ? void 0 : audioFailures.some((failure) => failure.owner === "system") ? "system" : "user";
|
|
@@ -12399,7 +12415,7 @@ async function executeRenderPipeline(input) {
|
|
|
12399
12415
|
try {
|
|
12400
12416
|
fileServer = await createFileServer({
|
|
12401
12417
|
projectDir,
|
|
12402
|
-
compiledDir:
|
|
12418
|
+
compiledDir: join25(workDir, "compiled"),
|
|
12403
12419
|
port: 0,
|
|
12404
12420
|
preHeadScripts: [VIRTUAL_TIME_SHIM],
|
|
12405
12421
|
fps: job.config.fps
|
|
@@ -12417,7 +12433,7 @@ async function executeRenderPipeline(input) {
|
|
|
12417
12433
|
if (!activeFileServer) {
|
|
12418
12434
|
throw new Error("File server failed to initialize before frame capture");
|
|
12419
12435
|
}
|
|
12420
|
-
const framesDir =
|
|
12436
|
+
const framesDir = join25(workDir, "captured-frames");
|
|
12421
12437
|
if (!existsSync16(framesDir)) mkdirSync9(framesDir, { recursive: true });
|
|
12422
12438
|
updateJobStatus(job, "rendering", "Checking browser GPU", 25, onProgress, {
|
|
12423
12439
|
code: "start_browsers"
|
|
@@ -12867,7 +12883,7 @@ async function executeRenderPipeline(input) {
|
|
|
12867
12883
|
hls: ".mp4"
|
|
12868
12884
|
};
|
|
12869
12885
|
const videoExt = FORMAT_EXT[outputFormat] ?? ".mp4";
|
|
12870
|
-
const videoOnlyPath =
|
|
12886
|
+
const videoOnlyPath = join25(workDir, `video-only${videoExt}`);
|
|
12871
12887
|
const usePageSideCompositingForTransitions = (cfg.enablePageSideCompositing || isGif) && compiled.hasShaderTransitions && !hasHdrContent && outputSupportsPageSideShaderCompositing(outputFormat);
|
|
12872
12888
|
if (usePageSideCompositingForTransitions) {
|
|
12873
12889
|
activeFileServer.addPreHeadScript(HF_PAGE_SIDE_COMPOSITING_STUB);
|
|
@@ -13110,9 +13126,10 @@ async function executeRenderPipeline(input) {
|
|
|
13110
13126
|
outputWidth: captureCompositionWidth ?? width,
|
|
13111
13127
|
outputHeight: captureCompositionHeight ?? height,
|
|
13112
13128
|
motionBlur: job.config.motionBlur ? JSON.stringify(job.config.motionBlur) : "",
|
|
13129
|
+
pageMotionBlur: job.config.pageMotionBlur,
|
|
13113
13130
|
captureQuality: captureOptions.quality
|
|
13114
13131
|
});
|
|
13115
|
-
const segmentDir = segmentDirFor(
|
|
13132
|
+
const segmentDir = segmentDirFor(join25(projectDir, "renders"), segmentPlanHash);
|
|
13116
13133
|
let completedSegments = /* @__PURE__ */ new Set();
|
|
13117
13134
|
if (job.config.resumeSegments === true) {
|
|
13118
13135
|
const existing = readSegmentManifest(segmentDir);
|
|
@@ -13127,10 +13144,10 @@ async function executeRenderPipeline(input) {
|
|
|
13127
13144
|
});
|
|
13128
13145
|
}
|
|
13129
13146
|
if (completedSegments.size === 0) {
|
|
13130
|
-
|
|
13147
|
+
rmSync10(segmentDir, { recursive: true, force: true });
|
|
13131
13148
|
}
|
|
13132
13149
|
} else {
|
|
13133
|
-
|
|
13150
|
+
rmSync10(segmentDir, { recursive: true, force: true });
|
|
13134
13151
|
}
|
|
13135
13152
|
const segmentManifest = readSegmentManifest(segmentDir) ?? {
|
|
13136
13153
|
version: 1,
|
|
@@ -13211,7 +13228,7 @@ async function executeRenderPipeline(input) {
|
|
|
13211
13228
|
);
|
|
13212
13229
|
updateCaptureObservability({ segmentRetries: segmentedRes.segmentRetries });
|
|
13213
13230
|
if (job.config.keepSegments !== true) {
|
|
13214
|
-
|
|
13231
|
+
rmSync10(segmentDir, { recursive: true, force: true });
|
|
13215
13232
|
}
|
|
13216
13233
|
} else {
|
|
13217
13234
|
capturePlan = replanAfterFailure(capturePlan, { kind: "streaming_unavailable" });
|
|
@@ -13480,7 +13497,7 @@ async function executeRenderPipeline(input) {
|
|
|
13480
13497
|
"capture_disk",
|
|
13481
13498
|
"drawElement capture failed; retrying with a fresh screenshot session"
|
|
13482
13499
|
);
|
|
13483
|
-
|
|
13500
|
+
rmSync10(framesDir, { recursive: true, force: true });
|
|
13484
13501
|
mkdirSync9(framesDir, { recursive: true });
|
|
13485
13502
|
resetCaptureAttemptProgress(job);
|
|
13486
13503
|
dedupPerfs.length = 0;
|
|
@@ -13667,7 +13684,7 @@ async function executeRenderPipeline(input) {
|
|
|
13667
13684
|
}
|
|
13668
13685
|
if (job.config.debug) {
|
|
13669
13686
|
if (!isPngSequence && !isHls && existsSync16(stagedOutputPath)) {
|
|
13670
|
-
const debugOutput =
|
|
13687
|
+
const debugOutput = join25(workDir, `output${videoExt}`);
|
|
13671
13688
|
copyFileSync4(stagedOutputPath, debugOutput);
|
|
13672
13689
|
}
|
|
13673
13690
|
}
|
|
@@ -13779,7 +13796,7 @@ import {
|
|
|
13779
13796
|
closeSync as closeSync5,
|
|
13780
13797
|
constants as constants3
|
|
13781
13798
|
} from "node:fs";
|
|
13782
|
-
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";
|
|
13783
13800
|
import { lintHyperframeHtml } from "@hyperframes/lint";
|
|
13784
13801
|
function isStringRecord(value) {
|
|
13785
13802
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
@@ -13847,7 +13864,7 @@ function readProjectEntryFile(projectDir, preferredEntryFile) {
|
|
|
13847
13864
|
}
|
|
13848
13865
|
}
|
|
13849
13866
|
return {
|
|
13850
|
-
error: `No HTML entry file found in project directory: ${
|
|
13867
|
+
error: `No HTML entry file found in project directory: ${join26(absProjectDir, preferredEntryFile || "index.html")}`
|
|
13851
13868
|
};
|
|
13852
13869
|
}
|
|
13853
13870
|
function prepareHyperframeLintBody(body) {
|
|
@@ -13893,7 +13910,7 @@ async function runHyperframeLint(prepared) {
|
|
|
13893
13910
|
// src/services/healthWorker.ts
|
|
13894
13911
|
import { Worker as Worker2 } from "node:worker_threads";
|
|
13895
13912
|
import { fileURLToPath as fileURLToPath5 } from "node:url";
|
|
13896
|
-
import { dirname as dirname11, join as
|
|
13913
|
+
import { dirname as dirname11, join as join27 } from "node:path";
|
|
13897
13914
|
import { existsSync as existsSync18 } from "node:fs";
|
|
13898
13915
|
var DEFAULT_HEALTH_PORT = 9848;
|
|
13899
13916
|
async function startHealthWorker(options = {}) {
|
|
@@ -13969,7 +13986,7 @@ function defaultLogger2() {
|
|
|
13969
13986
|
}
|
|
13970
13987
|
function resolveWorkerEntry2() {
|
|
13971
13988
|
const here = dirname11(fileURLToPath5(import.meta.url));
|
|
13972
|
-
const candidates = [
|
|
13989
|
+
const candidates = [join27(here, "healthWorkerThread.js"), join27(here, "healthWorkerThread.ts")];
|
|
13973
13990
|
for (const candidate of candidates) {
|
|
13974
13991
|
if (existsSync18(candidate)) return candidate;
|
|
13975
13992
|
}
|
|
@@ -14005,7 +14022,7 @@ import { DISTRIBUTED_FPS, isDistributedFps } from "@hyperframes/core";
|
|
|
14005
14022
|
|
|
14006
14023
|
// src/services/distributed/plan.ts
|
|
14007
14024
|
import { audioFailureDiagnostics as audioFailureDiagnostics3 } from "@hyperframes/engine";
|
|
14008
|
-
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";
|
|
14009
14026
|
import {
|
|
14010
14027
|
fpsToNumber as fpsToNumber10,
|
|
14011
14028
|
HF_COLOR_GRADING_ATTR as HF_COLOR_GRADING_ATTR2
|
|
@@ -14102,9 +14119,9 @@ var PLAN_ROOT_CATEGORY = {
|
|
|
14102
14119
|
var PLAN_DIR_SIZE_LIMIT_BYTES = 2 * 1024 * 1024 * 1024;
|
|
14103
14120
|
var FREEZE_OWNED_PLAN_FILES = [
|
|
14104
14121
|
"plan.json",
|
|
14105
|
-
|
|
14106
|
-
|
|
14107
|
-
|
|
14122
|
+
join28("meta", "composition.json"),
|
|
14123
|
+
join28("meta", "encoder.json"),
|
|
14124
|
+
join28("meta", "chunks.json")
|
|
14108
14125
|
];
|
|
14109
14126
|
|
|
14110
14127
|
// src/services/distributed/renderConfigValidation.ts
|
|
@@ -14454,6 +14471,7 @@ function buildRenderJobConfig(input, outputPath, log) {
|
|
|
14454
14471
|
outputResolution: input.outputResolution,
|
|
14455
14472
|
outputResolutionAspectAgnostic: input.outputResolutionAspectAgnostic,
|
|
14456
14473
|
videoFrameFormat: input.videoFrameFormat,
|
|
14474
|
+
pageMotionBlur: input.pageMotionBlur,
|
|
14457
14475
|
hdrMode: toRenderHdrMode(input.outputDynamicRange)
|
|
14458
14476
|
}
|
|
14459
14477
|
});
|
|
@@ -14529,8 +14547,8 @@ async function resolveInlineRenderHtml(body) {
|
|
|
14529
14547
|
}
|
|
14530
14548
|
function materializeInlineProject(html, options) {
|
|
14531
14549
|
const tempRoot = process.env.PRODUCER_TMP_PROJECT_DIR || tmpdir3();
|
|
14532
|
-
const tempProjectDir = mkdtempSync4(
|
|
14533
|
-
writeFileSync6(
|
|
14550
|
+
const tempProjectDir = mkdtempSync4(join29(tempRoot, "producer-project-"));
|
|
14551
|
+
writeFileSync6(join29(tempProjectDir, "index.html"), html, "utf-8");
|
|
14534
14552
|
return {
|
|
14535
14553
|
prepared: {
|
|
14536
14554
|
input: { projectDir: tempProjectDir, ...options },
|
|
@@ -14541,7 +14559,11 @@ function materializeInlineProject(html, options) {
|
|
|
14541
14559
|
async function prepareRenderBody(body) {
|
|
14542
14560
|
const overrideError = validateRenderOverrides(body);
|
|
14543
14561
|
if (overrideError) return { error: overrideError };
|
|
14544
|
-
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 };
|
|
14545
14567
|
const project = prepareProjectDirectory(body.projectDir, options);
|
|
14546
14568
|
if (project) return project;
|
|
14547
14569
|
const source = await resolveInlineRenderHtml(body);
|
|
@@ -14587,7 +14609,7 @@ function createArtifactStore(ttlMs) {
|
|
|
14587
14609
|
function cleanupTempDir(dir, log) {
|
|
14588
14610
|
if (!dir) return;
|
|
14589
14611
|
try {
|
|
14590
|
-
|
|
14612
|
+
rmSync11(dir, { recursive: true, force: true });
|
|
14591
14613
|
} catch (error) {
|
|
14592
14614
|
log.warn("Failed to cleanup temp project dir", {
|
|
14593
14615
|
cleanupProjectDir: dir,
|