@hyperframes/engine 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/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/services/aacEncode.js +2 -0
- package/dist/services/audioFxRender.d.ts +1 -0
- package/dist/services/audioFxRender.js +7 -0
- package/dist/services/audioMixer.d.ts +5 -2
- package/dist/services/audioMixer.js +149 -40
- package/dist/services/chunkEncoder.js +5 -2
- package/dist/services/extractedFrameIndex.d.ts +1 -0
- package/dist/services/extractedFrameIndex.js +3 -0
- package/dist/services/mediaRenderIdBridge.d.ts +4 -4
- package/dist/services/mediaRenderIdBridge.js +4 -4
- package/dist/services/screenshotService.js +7 -2
- package/dist/services/softwareEncoderThreads.d.ts +2 -0
- package/dist/services/softwareEncoderThreads.js +5 -0
- package/dist/services/streamingEncoder.js +2 -0
- package/dist/services/videoFrameExtractor.js +6 -11
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
|
@@ -41,6 +41,7 @@ export { augmentPageNavigationTimeoutError, isPageNavigationTimeoutError, type N
|
|
|
41
41
|
export { createCaptureSession, initializeSession, closeCaptureSession, captureFrame, captureFrameToBuffer, captureFrameToBufferPipelined, captureFramesBatchPipelined, DrawElementVerificationError, isDrawElementVerificationError, getDrawElementVerificationDetails, type DrawElementVerificationDetails, recaptureDrawElementFrameForVerify, completeDeferredDrawElementInit, writeCapturedFrame, discardWarmupCapture, getCompositionDuration, getCapturePerfSummary, percentileOf, prepareCaptureSessionForReuse, deriveBeginFrameProbeTimeTicks, type CaptureSession, isTransientBrowserError, isMemoryExhaustionError, type BeforeCaptureHook, type DiscardWarmupInnerCapture, type StaticVerificationOutcome, VfxFailureError, waitForImagesReady, } from "./services/frameCapture.js";
|
|
42
42
|
export { CaptureFailure, classifyCaptureFailure, isFatalCaptureFailure, type CaptureFailureKind, type CaptureWorkerDiagnostic, } from "./services/captureFailure.js";
|
|
43
43
|
export { createChromeMemorySampler, type ChromeMemoryStats, } from "./services/chromeMemorySampler.js";
|
|
44
|
+
export { installMediaRenderIdBridge } from "./services/mediaRenderIdBridge.js";
|
|
44
45
|
export { beginFrameCapture, pageScreenshotCapture, getCdpSession, injectVideoFramesBatch, syncVideoFrameVisibility, cdpSessionCache, probeBeginFrameLiveness, initTransparentBackground, captureAlphaPng, applyDomLayerMask, removeDomLayerMask, DOM_LAYER_MASK_STYLE_ID, type BeginFrameResult, } from "./services/screenshotService.js";
|
|
45
46
|
export { buildEncoderArgs, encodeFramesFromDir, buildConcatArgs, concatVideoFiles, encodeFramesChunkedConcat, muxVideoWithAudio, applyFaststart, packageHls, detectGpuEncoder, ENCODER_PRESETS, getEncoderPreset, HLS_MASTER_PLAYLIST, HLS_VIDEO_PLAYLIST, HLS_AUDIO_PLAYLIST, type GpuEncoder, type PackageHlsOptions, } from "./services/chunkEncoder.js";
|
|
46
47
|
export type { EncoderOptions, EncodeResult, MuxResult } from "./services/chunkEncoder.types.js";
|
|
@@ -57,6 +58,8 @@ export { calculateOptimalWorkers, computeWorkerSizing, selectVerifySampleIndices
|
|
|
57
58
|
export { createFileServer, type FileServerOptions, type FileServerHandle, } from "./services/fileServer.js";
|
|
58
59
|
export { quantizeTimeToFrame, MEDIA_VISUAL_STYLE_PROPERTIES } from "@hyperframes/core";
|
|
59
60
|
export { frameFileExtension } from "./services/frameCapture.js";
|
|
61
|
+
export { appendSoftwareEncoderThreadsArg } from "./services/softwareEncoderThreads.js";
|
|
62
|
+
export { frameSequencePath } from "./services/extractedFrameIndex.js";
|
|
60
63
|
export type { MotionBlurOptions, MotionBlurBlendSpace } from "./services/motionBlur.js";
|
|
61
64
|
export type { PageMotionBlurOff } from "./types.js";
|
|
62
65
|
export { assertSwiftShader, readWebGlVendorInfo, SwiftShaderAssertionError, BROWSER_GPU_NOT_SOFTWARE, } from "./utils/assertSwiftShader.js";
|
package/dist/index.js
CHANGED
|
@@ -44,6 +44,7 @@ export { createCaptureSession, initializeSession, closeCaptureSession, captureFr
|
|
|
44
44
|
export { CaptureFailure, classifyCaptureFailure, isFatalCaptureFailure, } from "./services/captureFailure.js";
|
|
45
45
|
export { createChromeMemorySampler, } from "./services/chromeMemorySampler.js";
|
|
46
46
|
// ── Screenshot (BeginFrame) ─────────────────────────────────────────────────────
|
|
47
|
+
export { installMediaRenderIdBridge } from "./services/mediaRenderIdBridge.js";
|
|
47
48
|
export { beginFrameCapture, pageScreenshotCapture, getCdpSession, injectVideoFramesBatch, syncVideoFrameVisibility, cdpSessionCache, probeBeginFrameLiveness, initTransparentBackground, captureAlphaPng, applyDomLayerMask, removeDomLayerMask, DOM_LAYER_MASK_STYLE_ID, } from "./services/screenshotService.js";
|
|
48
49
|
// ── Encoding ───────────────────────────────────────────────────────────────────
|
|
49
50
|
export { buildEncoderArgs, encodeFramesFromDir, buildConcatArgs, concatVideoFiles, encodeFramesChunkedConcat, muxVideoWithAudio, applyFaststart, packageHls, detectGpuEncoder, ENCODER_PRESETS, getEncoderPreset, HLS_MASTER_PLAYLIST, HLS_VIDEO_PLAYLIST, HLS_AUDIO_PLAYLIST, } from "./services/chunkEncoder.js";
|
|
@@ -63,6 +64,8 @@ export { createFileServer, } from "./services/fileServer.js";
|
|
|
63
64
|
// ── Utilities ──────────────────────────────────────────────────────────────────
|
|
64
65
|
export { quantizeTimeToFrame, MEDIA_VISUAL_STYLE_PROPERTIES } from "@hyperframes/core";
|
|
65
66
|
export { frameFileExtension } from "./services/frameCapture.js";
|
|
67
|
+
export { appendSoftwareEncoderThreadsArg } from "./services/softwareEncoderThreads.js";
|
|
68
|
+
export { frameSequencePath } from "./services/extractedFrameIndex.js";
|
|
66
69
|
export { assertSwiftShader, readWebGlVendorInfo, SwiftShaderAssertionError, BROWSER_GPU_NOT_SOFTWARE, } from "./utils/assertSwiftShader.js";
|
|
67
70
|
export { readWebGlVendorInfoFromCanvas } from "./utils/readWebGlVendorInfoFromCanvas.js";
|
|
68
71
|
export { extractMediaMetadata, extractVideoMetadata, extractFinalVideoFrameTimestamp, extractAudioMetadata, probeMediaProfile, analyzeKeyframeIntervals, } from "./utils/ffprobe.js";
|
|
@@ -107,6 +107,7 @@ export declare function applyAudioFxChain(inputWav: string, chain: HfAudioFxChai
|
|
|
107
107
|
signal?: AbortSignal;
|
|
108
108
|
automation?: HfAutomation;
|
|
109
109
|
envelope?: AudioFxEnvelope;
|
|
110
|
+
inputGain?: number;
|
|
110
111
|
streaming?: Partial<AudioFxStreamingLimits>;
|
|
111
112
|
}): Promise<{
|
|
112
113
|
path: string;
|
|
@@ -258,6 +258,11 @@ function applyEnvelopeToPlanes(planes, sampleRate, gainAt, startFrame = 0) {
|
|
|
258
258
|
plane[frame] = (plane[frame] ?? 0) * gain;
|
|
259
259
|
}
|
|
260
260
|
}
|
|
261
|
+
/** The clip's `data-volume`, ahead of the chain where an editor puts clip gain. */
|
|
262
|
+
function applyInputGain(planes, gain = 1) {
|
|
263
|
+
if (gain !== 1)
|
|
264
|
+
applyEnvelopeToPlanes(planes, 1, () => gain);
|
|
265
|
+
}
|
|
261
266
|
const DEFAULT_STREAMING_LIMITS = {
|
|
262
267
|
aboveBytes: 512 * 1024 * 1024,
|
|
263
268
|
segmentSeconds: 300,
|
|
@@ -324,6 +329,7 @@ async function renderInSegments(openPage, inputWav, header, chain, outputWav, op
|
|
|
324
329
|
throw new AudioFxRenderError(`Audio FX cancelled for track ${options.trackId}`);
|
|
325
330
|
}
|
|
326
331
|
const planes = deinterleave(readFrames(inFd, header, seg.readFrom, seg.readTo), channels);
|
|
332
|
+
applyInputGain(planes, options.inputGain);
|
|
327
333
|
// A fresh page per window, so the browser never holds more than one window of audio.
|
|
328
334
|
const page = await openPage();
|
|
329
335
|
let outPlanes;
|
|
@@ -397,6 +403,7 @@ export async function applyAudioFxChain(inputWav, chain, outputWav, options) {
|
|
|
397
403
|
}
|
|
398
404
|
const input = readWav(inputWav);
|
|
399
405
|
const planes = deinterleave(input.samples, input.channels);
|
|
406
|
+
applyInputGain(planes, options.inputGain);
|
|
400
407
|
// An empty track has nothing to process — and an OfflineAudioContext of zero
|
|
401
408
|
// length throws, which is fatal for the WHOLE render rather than this track:
|
|
402
409
|
// the error travels past the mixer's per-track failure collector. ffmpeg
|
|
@@ -18,11 +18,12 @@ export declare function buildTrackInputFilter(track: Pick<AudioTrack, "start" |
|
|
|
18
18
|
* sampled, or the bake would quietly straighten it.
|
|
19
19
|
*
|
|
20
20
|
* Times come out in composition seconds, which is what the baker subtracts the
|
|
21
|
-
* track start from.
|
|
21
|
+
* track start from. Values are `gainedVolume` of the track's `data-volume`.
|
|
22
|
+
* Returns null when the track has no volume lane.
|
|
22
23
|
*/
|
|
23
24
|
export declare function volumeLaneKeyframes(automation: {
|
|
24
25
|
lanes: HfAutomationLane[];
|
|
25
|
-
}, trackStart: number, duration: number): AudioVolumeKeyframe[] | null;
|
|
26
|
+
}, trackStart: number, duration: number, gain: number, gainAhead?: boolean): AudioVolumeKeyframe[] | null;
|
|
26
27
|
/**
|
|
27
28
|
* The top-level root's `data-fx-chain`. It belongs to the composition, not to a clip,
|
|
28
29
|
* so `processCompositionAudio` takes it beside the tracks: a track the render adds
|
|
@@ -30,6 +31,8 @@ export declare function volumeLaneKeyframes(automation: {
|
|
|
30
31
|
*/
|
|
31
32
|
export declare function parseMasterFxChain(html: string): string | undefined;
|
|
32
33
|
export declare function parseAudioElements(html: string): AudioElement[];
|
|
34
|
+
/** Tracks per ffmpeg graph; a larger mix is summed in stages, so there is no cap on the total. */
|
|
35
|
+
export declare const MAX_MIX_INPUTS = 96;
|
|
33
36
|
export declare function processCompositionAudio(elements: AudioElement[], baseDir: string, workDir: string, outputPath: string, totalDuration: number, signal?: AbortSignal, config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout" | "audioGain">> & {
|
|
34
37
|
/** Serialised master bus chain, from `parseMasterFxChain`. */
|
|
35
38
|
masterFxChain?: string;
|
|
@@ -6,11 +6,11 @@ import { parseHTML } from "linkedom";
|
|
|
6
6
|
import { extractAudioMetadata } from "../utils/ffprobe.js";
|
|
7
7
|
import { isNotMediaPayload } from "../utils/notMediaPayload.js";
|
|
8
8
|
import { urlInputArgs } from "../utils/urlInput.js";
|
|
9
|
-
import { clampAudioGain } from "@hyperframes/core/audio-gain";
|
|
9
|
+
import { clampAudioGain, clipGain } from "@hyperframes/core/audio-gain";
|
|
10
10
|
import { clampFadesToDuration, readElementFades } from "@hyperframes/core/audio-fade";
|
|
11
11
|
import { downloadToTemp, isHttpUrl, UrlDownloadError, writeUrlDownloadTelemetry, } from "../utils/urlDownloader.js";
|
|
12
12
|
import { DEFAULT_CONFIG } from "../config.js";
|
|
13
|
-
import {
|
|
13
|
+
import { describeFfmpegFailure, runFfmpeg } from "../utils/runFfmpeg.js";
|
|
14
14
|
import { encodeAac } from "./aacEncode.js";
|
|
15
15
|
import { audioSubprocessEvidence } from "./audioFailureDiagnostic.js";
|
|
16
16
|
import { unwrapTemplate } from "../utils/htmlTemplate.js";
|
|
@@ -18,8 +18,8 @@ import { resolveMediaElementSrc, resolveProjectRelativeSrc } from "./videoFrameE
|
|
|
18
18
|
import { resolveReferencedStart } from "./referenceResolver.js";
|
|
19
19
|
import { isKnownInactiveTimelineWindow } from "./mediaTimelineWindow.js";
|
|
20
20
|
import { applyVolumeEnvelopeToWav } from "./audioVolumeEnvelope.js";
|
|
21
|
-
import { HF_AUDIO_FX_ATTR, parseAudioFxChain, } from "@hyperframes/core/audio-fx";
|
|
22
|
-
import { HF_AUDIO_AUTOMATION_ATTR, parseAutomation, resolveAutomation, sampleAutomationLane, VOLUME_TARGET, } from "@hyperframes/core/audio-automation";
|
|
21
|
+
import { enabledAudioFxNodes, HF_AUDIO_FX_ATTR, parseAudioFxChain, } from "@hyperframes/core/audio-fx";
|
|
22
|
+
import { gainedVolume, gainedVolumePoints, HF_AUDIO_AUTOMATION_ATTR, isBentSegment, volumeSegmentSampleCount, parseAutomation, resolveAutomation, sampleAutomationLane, VOLUME_TARGET, } from "@hyperframes/core/audio-automation";
|
|
23
23
|
import { chainTailSeconds } from "@hyperframes/core/audio-fx-tail";
|
|
24
24
|
import { isAudibleVideoElement, MEDIA_RENDER_ID_ATTR, normalizePlaybackRate, normalizeRateSpec, parseStrictFiniteTimingNumber, readElementRateSpec, readMediaStart, sourceTimeAt, timeAtSourceTime, } from "@hyperframes/core";
|
|
25
25
|
import { readMasterFxChain, resolveAudioGroups } from "@hyperframes/core/audio-groups";
|
|
@@ -273,7 +273,11 @@ function boundedDetail(message, maxLength = 2_000) {
|
|
|
273
273
|
.replace(/\bfile:\/\/[^\s"'<>]+/gi, "<redacted-path>")
|
|
274
274
|
.replace(/\b[A-Za-z]:[\\/].+?(?=:\s[A-Z]|\s(?:ENOENT|EACCES|EPERM)\b|\r?$)/gm, "<redacted-path>")
|
|
275
275
|
.replace(/(^|[\s"'(])\/.+?(?=:\s[A-Z]|\s(?:ENOENT|EACCES|EPERM)\b|\r?$)/gm, "$1<redacted-path>");
|
|
276
|
-
|
|
276
|
+
// Keep both ends: the head names the failure, the tail holds ffmpeg's last words.
|
|
277
|
+
const head = Math.floor(maxLength / 4);
|
|
278
|
+
return redacted.length <= maxLength
|
|
279
|
+
? redacted
|
|
280
|
+
: `${redacted.slice(0, head)}…${redacted.slice(-(maxLength - head - 1))}`;
|
|
277
281
|
}
|
|
278
282
|
function probeFailure(message, elementId) {
|
|
279
283
|
const unavailable = /(?:not found|ENOENT|spawn)/i.test(message);
|
|
@@ -315,7 +319,7 @@ function downloadFailure(error, elementId) {
|
|
|
315
319
|
detail: boundedDetail(`Download failed for audio element ${elementId}: ${message}`),
|
|
316
320
|
};
|
|
317
321
|
}
|
|
318
|
-
function ffmpegFailure(stage, result, elementId) {
|
|
322
|
+
function ffmpegFailure(stage, result, timeoutMs, elementId) {
|
|
319
323
|
const stderr = result.stderr ?? "";
|
|
320
324
|
let reason = "ffmpeg_failed";
|
|
321
325
|
let owner = "system";
|
|
@@ -351,13 +355,12 @@ function ffmpegFailure(stage, result, elementId) {
|
|
|
351
355
|
retryable,
|
|
352
356
|
elementId,
|
|
353
357
|
subprocess: audioSubprocessEvidence(result),
|
|
354
|
-
detail: boundedDetail(result.
|
|
355
|
-
|
|
356
|
-
|
|
358
|
+
detail: boundedDetail(`(ran ${result.durationMs} ms, limit ${timeoutMs} ms) ` +
|
|
359
|
+
(result.error?.message
|
|
360
|
+
? `${describeFfmpegFailure(result, timeoutMs)}: ${result.error.message}`
|
|
361
|
+
: describeFfmpegFailure(result, timeoutMs))),
|
|
357
362
|
};
|
|
358
363
|
}
|
|
359
|
-
/** Extra samples per second inside a segment the baker cannot draw straight. */
|
|
360
|
-
const CURVED_SEGMENT_SAMPLES_PER_SEC = 30;
|
|
361
364
|
/**
|
|
362
365
|
* A volume lane as keyframes for the PCM baker.
|
|
363
366
|
*
|
|
@@ -366,36 +369,38 @@ const CURVED_SEGMENT_SAMPLES_PER_SEC = 30;
|
|
|
366
369
|
* sampled, or the bake would quietly straighten it.
|
|
367
370
|
*
|
|
368
371
|
* Times come out in composition seconds, which is what the baker subtracts the
|
|
369
|
-
* track start from.
|
|
372
|
+
* track start from. Values are `gainedVolume` of the track's `data-volume`.
|
|
373
|
+
* Returns null when the track has no volume lane.
|
|
370
374
|
*/
|
|
371
|
-
export function volumeLaneKeyframes(automation, trackStart, duration) {
|
|
375
|
+
export function volumeLaneKeyframes(automation, trackStart, duration, gain, gainAhead = false) {
|
|
372
376
|
const lane = automation.lanes.find((l) => l.target === VOLUME_TARGET);
|
|
373
377
|
if (!lane || lane.points.length === 0)
|
|
374
378
|
return null;
|
|
379
|
+
const points = gainedVolumePoints(lane, gain, gainAhead);
|
|
375
380
|
const out = [];
|
|
376
381
|
const push = (t, v) => {
|
|
377
382
|
out.push({ time: trackStart + t, volume: v });
|
|
378
383
|
};
|
|
379
384
|
// The envelope holds its first value before the first point, rather than
|
|
380
385
|
// falling back to `data-volume`.
|
|
381
|
-
const first =
|
|
386
|
+
const first = points[0];
|
|
382
387
|
if (first.t > 0)
|
|
383
388
|
push(0, first.v);
|
|
384
|
-
for (let i = 0; i <
|
|
385
|
-
const a =
|
|
389
|
+
for (let i = 0; i < points.length; i += 1) {
|
|
390
|
+
const a = points[i];
|
|
386
391
|
push(a.t, a.v);
|
|
387
|
-
const b =
|
|
388
|
-
if (!b || !a
|
|
392
|
+
const b = points[i + 1];
|
|
393
|
+
if (!b || !isBentSegment(a))
|
|
389
394
|
continue;
|
|
390
|
-
const steps =
|
|
395
|
+
const steps = volumeSegmentSampleCount(b.t - a.t);
|
|
391
396
|
for (let k = 1; k < steps; k += 1) {
|
|
392
397
|
const t = a.t + ((b.t - a.t) * k) / steps;
|
|
393
|
-
push(t, sampleAutomationLane(lane, t));
|
|
398
|
+
push(t, gainedVolume(sampleAutomationLane(lane, t), gain, gainAhead));
|
|
394
399
|
}
|
|
395
400
|
}
|
|
396
401
|
// Hold the last value to the clip's end, so the baker does not ramp away
|
|
397
402
|
// from it toward whatever it would otherwise assume.
|
|
398
|
-
const last =
|
|
403
|
+
const last = points[points.length - 1];
|
|
399
404
|
if (duration > last.t)
|
|
400
405
|
push(duration, last.v);
|
|
401
406
|
return out;
|
|
@@ -547,7 +552,7 @@ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, durat
|
|
|
547
552
|
failure,
|
|
548
553
|
};
|
|
549
554
|
}
|
|
550
|
-
let failure = result.success ? undefined : ffmpegFailure(stage, result);
|
|
555
|
+
let failure = result.success ? undefined : ffmpegFailure(stage, result, ffmpegProcessTimeout);
|
|
551
556
|
const probedLinkFailedToRead = readsUrl && (failure?.reason === "invalid_media" || failure?.reason === "ffmpeg_failed");
|
|
552
557
|
if (failure && probedLinkFailedToRead) {
|
|
553
558
|
failure = { ...failure, reason: "download_failed", owner: "system", retryable: true };
|
|
@@ -594,7 +599,9 @@ async function generateSilence(outputPath, duration, signal, config) {
|
|
|
594
599
|
failure,
|
|
595
600
|
};
|
|
596
601
|
}
|
|
597
|
-
const failure = !result.success
|
|
602
|
+
const failure = !result.success
|
|
603
|
+
? ffmpegFailure("silence", result, ffmpegProcessTimeout)
|
|
604
|
+
: undefined;
|
|
598
605
|
return {
|
|
599
606
|
success: result.success,
|
|
600
607
|
outputPath,
|
|
@@ -603,8 +610,14 @@ async function generateSilence(outputPath, duration, signal, config) {
|
|
|
603
610
|
failure,
|
|
604
611
|
};
|
|
605
612
|
}
|
|
606
|
-
|
|
607
|
-
|
|
613
|
+
/** A mix graph's work grows with the film length (about 11 ms per film second at 96 inputs); so does its deadline. */
|
|
614
|
+
const MIX_TIMEOUT_MS_PER_FILM_SECOND = 100;
|
|
615
|
+
function mixProcessTimeout(config, totalDuration) {
|
|
616
|
+
const configured = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
|
|
617
|
+
return Math.max(configured, Math.ceil(totalDuration * MIX_TIMEOUT_MS_PER_FILM_SECOND));
|
|
618
|
+
}
|
|
619
|
+
async function mixAudioTracksFlat(tracks, outputPath, totalDuration, signal, config) {
|
|
620
|
+
const ffmpegProcessTimeout = mixProcessTimeout(config, totalDuration);
|
|
608
621
|
const masterOutputGain = config?.audioGain ?? DEFAULT_CONFIG.audioGain;
|
|
609
622
|
if (tracks.length === 0) {
|
|
610
623
|
const result = await generateSilence(outputPath, totalDuration, signal, config);
|
|
@@ -732,7 +745,7 @@ async function mixAudioTracks(tracks, outputPath, totalDuration, signal, config)
|
|
|
732
745
|
};
|
|
733
746
|
}
|
|
734
747
|
if (!result.success) {
|
|
735
|
-
const failure = ffmpegFailure("mix", result);
|
|
748
|
+
const failure = ffmpegFailure("mix", result, ffmpegProcessTimeout);
|
|
736
749
|
return {
|
|
737
750
|
success: false,
|
|
738
751
|
outputPath,
|
|
@@ -780,20 +793,21 @@ function groupNormalizeOptionUnsupported(stderr) {
|
|
|
780
793
|
* compensation at all and is preferred; the manual per-own-count fallback
|
|
781
794
|
* only runs when this ffmpeg build's `amix` rejects the option.
|
|
782
795
|
*/
|
|
783
|
-
async function
|
|
784
|
-
const ffmpegProcessTimeout = config
|
|
796
|
+
async function mixGroupMembersFlat(memberTracks, outputPath, totalDuration, signal, config) {
|
|
797
|
+
const ffmpegProcessTimeout = mixProcessTimeout(config, totalDuration);
|
|
785
798
|
const outputDir = dirname(outputPath);
|
|
786
799
|
if (!existsSync(outputDir))
|
|
787
800
|
mkdirSync(outputDir, { recursive: true });
|
|
788
|
-
const buildInputFilters = (ignoreKeyframes) => memberTracks.map((track, i) => {
|
|
801
|
+
const buildInputFilters = (ignoreKeyframes, padMembers) => memberTracks.map((track, i) => {
|
|
789
802
|
const volumeFilter = buildVolumeExpression(track, ignoreKeyframes);
|
|
790
|
-
return buildTrackInputFilter(track, i, volumeFilter, totalDuration);
|
|
803
|
+
return buildTrackInputFilter(track, i, volumeFilter, totalDuration, padMembers);
|
|
791
804
|
});
|
|
792
805
|
const mixInputs = memberTracks.map((_, i) => `[a${i}]`).join("");
|
|
793
806
|
const runOnce = async (useNormalize, ignoreKeyframes = false) => {
|
|
794
|
-
const inputFilters = buildInputFilters(ignoreKeyframes);
|
|
807
|
+
const inputFilters = buildInputFilters(ignoreKeyframes, !useNormalize);
|
|
808
|
+
const padUnitySumOnceToFilm = `[mixed];[mixed]apad,asetpts=N/SR/TB,atrim=0:${formatFilterNumber(totalDuration)}`;
|
|
795
809
|
const mixFilter = useNormalize
|
|
796
|
-
? `${mixInputs}amix=inputs=${memberTracks.length}:duration=longest:dropout_transition=0:normalize=0[out]`
|
|
810
|
+
? `${mixInputs}amix=inputs=${memberTracks.length}:duration=longest:dropout_transition=0:normalize=0${padUnitySumOnceToFilm}[out]`
|
|
797
811
|
: // Every member is padded and trimmed to totalDuration above, and
|
|
798
812
|
// dropout_transition=0 keeps all N inputs active for that whole span.
|
|
799
813
|
// amix's default normalize therefore divides by exactly N throughout;
|
|
@@ -868,11 +882,99 @@ async function mixGroupMembers(memberTracks, outputPath, totalDuration, signal,
|
|
|
868
882
|
}
|
|
869
883
|
}
|
|
870
884
|
if (signal?.aborted || !result.success) {
|
|
871
|
-
const failure = ffmpegFailure("mix", result);
|
|
885
|
+
const failure = ffmpegFailure("mix", result, ffmpegProcessTimeout);
|
|
872
886
|
return { success: false, error: failure.detail, failure };
|
|
873
887
|
}
|
|
874
888
|
return { success: true, degradedAutomation };
|
|
875
889
|
}
|
|
890
|
+
/** Tracks per ffmpeg graph; a larger mix is summed in stages, so there is no cap on the total. */
|
|
891
|
+
export const MAX_MIX_INPUTS = 96;
|
|
892
|
+
/** Unity-sum `tracks` (the group gain law) slice by slice into one float WAV, with few full-length files at once. */
|
|
893
|
+
async function sumInSlices(tracks, scratchDir, totalDuration, signal, config) {
|
|
894
|
+
const wavTrack = (id, srcPath) => ({
|
|
895
|
+
id,
|
|
896
|
+
srcPath,
|
|
897
|
+
start: 0,
|
|
898
|
+
end: totalDuration,
|
|
899
|
+
mediaStart: 0,
|
|
900
|
+
duration: totalDuration,
|
|
901
|
+
volume: 1,
|
|
902
|
+
});
|
|
903
|
+
let running = null;
|
|
904
|
+
let degradedAutomation = false;
|
|
905
|
+
for (let at = 0; at < tracks.length; at += MAX_MIX_INPUTS) {
|
|
906
|
+
const id = `sum-${at / MAX_MIX_INPUTS}`;
|
|
907
|
+
const slicePath = join(scratchDir, `${id}-slice.wav`);
|
|
908
|
+
const slice = await mixGroupMembersFlat(tracks.slice(at, at + MAX_MIX_INPUTS), slicePath, totalDuration, signal, config);
|
|
909
|
+
if (!slice.success)
|
|
910
|
+
return slice;
|
|
911
|
+
degradedAutomation ||= slice.degradedAutomation === true;
|
|
912
|
+
if (!running) {
|
|
913
|
+
running = wavTrack(id, slicePath);
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
const foldedPath = join(scratchDir, `${id}.wav`);
|
|
917
|
+
const folded = await mixGroupMembersFlat([running, wavTrack(`${id}-slice`, slicePath)], foldedPath, totalDuration, signal, config);
|
|
918
|
+
rmSync(running.srcPath, { force: true });
|
|
919
|
+
rmSync(slicePath, { force: true });
|
|
920
|
+
if (!folded.success)
|
|
921
|
+
return folded;
|
|
922
|
+
running = wavTrack(id, foldedPath);
|
|
923
|
+
}
|
|
924
|
+
if (!running)
|
|
925
|
+
throw new Error("[audioMixer] sumInSlices needs at least one track");
|
|
926
|
+
return { success: true, track: running, degradedAutomation };
|
|
927
|
+
}
|
|
928
|
+
async function mixGroupMembers(memberTracks, outputPath, totalDuration, signal, config) {
|
|
929
|
+
if (memberTracks.length <= MAX_MIX_INPUTS) {
|
|
930
|
+
return mixGroupMembersFlat(memberTracks, outputPath, totalDuration, signal, config);
|
|
931
|
+
}
|
|
932
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
933
|
+
const scratchDir = mkdtempSync(join(dirname(outputPath), ".mix-batches-"));
|
|
934
|
+
try {
|
|
935
|
+
const summed = await sumInSlices(memberTracks, scratchDir, totalDuration, signal, config);
|
|
936
|
+
if (!summed.success)
|
|
937
|
+
return summed;
|
|
938
|
+
renameSync(summed.track.srcPath, outputPath);
|
|
939
|
+
return { success: true, degradedAutomation: summed.degradedAutomation };
|
|
940
|
+
}
|
|
941
|
+
finally {
|
|
942
|
+
rmSync(scratchDir, { recursive: true, force: true });
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
async function mixAudioTracks(tracks, outputPath, totalDuration, signal, config) {
|
|
946
|
+
if (tracks.length <= MAX_MIX_INPUTS) {
|
|
947
|
+
return mixAudioTracksFlat(tracks, outputPath, totalDuration, signal, config);
|
|
948
|
+
}
|
|
949
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
950
|
+
const scratchDir = mkdtempSync(join(dirname(outputPath), ".mix-batches-"));
|
|
951
|
+
try {
|
|
952
|
+
const summed = await sumInSlices(tracks, scratchDir, totalDuration, signal, config);
|
|
953
|
+
if (!summed.success) {
|
|
954
|
+
return {
|
|
955
|
+
success: false,
|
|
956
|
+
outputPath,
|
|
957
|
+
durationMs: 0,
|
|
958
|
+
tracksProcessed: 0,
|
|
959
|
+
error: summed.error,
|
|
960
|
+
failures: [summed.failure],
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
const mixed = await mixAudioTracksFlat([summed.track], outputPath, totalDuration, signal, config);
|
|
964
|
+
return mixed.success
|
|
965
|
+
? {
|
|
966
|
+
...mixed,
|
|
967
|
+
tracksProcessed: tracks.length,
|
|
968
|
+
error: summed.degradedAutomation
|
|
969
|
+
? "Volume automation exceeded this ffmpeg build's expression limits; rendered at base volume"
|
|
970
|
+
: mixed.error,
|
|
971
|
+
}
|
|
972
|
+
: mixed;
|
|
973
|
+
}
|
|
974
|
+
finally {
|
|
975
|
+
rmSync(scratchDir, { recursive: true, force: true });
|
|
976
|
+
}
|
|
977
|
+
}
|
|
876
978
|
/**
|
|
877
979
|
* Sum the tracks to a float WAV, run the master chain (if any) over the sum, then AAC-encode.
|
|
878
980
|
* Groups are already folded into `tracks`, and the sum stays float until the encoder.
|
|
@@ -893,7 +995,7 @@ async function mixThroughFloatSum(tracks, masterChain, outputPath, workDir, tota
|
|
|
893
995
|
timeout: config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout,
|
|
894
996
|
});
|
|
895
997
|
if (!encoded.success) {
|
|
896
|
-
const failure = ffmpegFailure("mix", encoded);
|
|
998
|
+
const failure = ffmpegFailure("mix", encoded, config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout);
|
|
897
999
|
return {
|
|
898
1000
|
success: false,
|
|
899
1001
|
outputPath,
|
|
@@ -1060,8 +1162,9 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
|
|
|
1060
1162
|
// envelope belongs on their output. A missing or broken chain is fatal
|
|
1061
1163
|
// for the whole mix rather than a per-track warning — quietly rendering
|
|
1062
1164
|
// the dry signal ships a mix that sounds plausible and is wrong.
|
|
1165
|
+
const chain = element.fxChain ? parseAudioFxChain(element.fxChain) : undefined;
|
|
1063
1166
|
const automation = element.automation
|
|
1064
|
-
? resolveAutomation(parseAutomation(element.automation),
|
|
1167
|
+
? resolveAutomation(parseAutomation(element.automation), chain)
|
|
1065
1168
|
: null;
|
|
1066
1169
|
// Computed before the chain runs, not after: the FX pass bakes the
|
|
1067
1170
|
// envelope into its float output so the duck lands before the ±1 clamp
|
|
@@ -1070,9 +1173,13 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
|
|
|
1070
1173
|
// A volume lane supersedes keyframes probed from the timeline: the two
|
|
1071
1174
|
// would fight, and the lane is the explicit one. `lint` warns when a
|
|
1072
1175
|
// track carries both.
|
|
1176
|
+
//
|
|
1177
|
+
// With clip gain ahead of the chain, the lane carries its share of the capped product.
|
|
1178
|
+
const gainBeforeFx = chain !== undefined && enabledAudioFxNodes(chain).length > 0;
|
|
1073
1179
|
const laneKeyframes = automation
|
|
1074
|
-
? volumeLaneKeyframes(automation, element.start, element.end - element.start)
|
|
1180
|
+
? volumeLaneKeyframes(automation, element.start, element.end - element.start, element.volume ?? 1, gainBeforeFx)
|
|
1075
1181
|
: null;
|
|
1182
|
+
const tweened = !laneKeyframes && (element.volumeKeyframes?.length ?? 0) > 0;
|
|
1076
1183
|
const envelopeKeyframes = laneKeyframes ?? element.volumeKeyframes;
|
|
1077
1184
|
const envelope = envelopeKeyframes && envelopeKeyframes.length > 0
|
|
1078
1185
|
? {
|
|
@@ -1083,11 +1190,10 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
|
|
|
1083
1190
|
: null;
|
|
1084
1191
|
let bakedEnvelope = false;
|
|
1085
1192
|
let tailSeconds = 0;
|
|
1086
|
-
if (
|
|
1193
|
+
if (chain) {
|
|
1087
1194
|
// The chain is serialised into the attribute, the same way colour
|
|
1088
1195
|
// grading carries its config, so there is no side-car file to find,
|
|
1089
1196
|
// resolve or lose.
|
|
1090
|
-
const chain = parseAudioFxChain(element.fxChain);
|
|
1091
1197
|
// The rendered WAV is longer than the input by exactly this much, so
|
|
1092
1198
|
// the mix has to be told to let it through.
|
|
1093
1199
|
tailSeconds = chainTailSeconds(chain, automation ?? undefined);
|
|
@@ -1096,6 +1202,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
|
|
|
1096
1202
|
signal: effectiveSignal,
|
|
1097
1203
|
...(automation ? { automation } : {}),
|
|
1098
1204
|
...(envelope ? { envelope } : {}),
|
|
1205
|
+
...(gainBeforeFx ? { inputGain: clipGain(element.volume ?? 1, tweened) } : {}),
|
|
1099
1206
|
});
|
|
1100
1207
|
audioSrcPath = fxResult.path;
|
|
1101
1208
|
bakedEnvelope = fxResult.envelopeBaked;
|
|
@@ -1116,7 +1223,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
|
|
|
1116
1223
|
mediaStart: element.mediaStart,
|
|
1117
1224
|
duration: element.end - element.start,
|
|
1118
1225
|
// Gain is already in the samples when baked, so mix at unity.
|
|
1119
|
-
volume: bakedEnvelope ? 1.0 : (element.volume ?? 1.0),
|
|
1226
|
+
volume: bakedEnvelope || (gainBeforeFx && !tweened) ? 1.0 : (element.volume ?? 1.0),
|
|
1120
1227
|
volumeKeyframes: bakedEnvelope ? undefined : (envelopeKeyframes ?? undefined),
|
|
1121
1228
|
...(element.fadeIn ? { fadeIn: element.fadeIn } : {}),
|
|
1122
1229
|
...(element.fadeOut ? { fadeOut: element.fadeOut } : {}),
|
|
@@ -1260,7 +1367,9 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
|
|
|
1260
1367
|
const automation = meta.automation
|
|
1261
1368
|
? resolveAutomation(parseAutomation(meta.automation), meta.fxChain ? parseAudioFxChain(meta.fxChain) : undefined)
|
|
1262
1369
|
: null;
|
|
1263
|
-
const laneKeyframes = automation
|
|
1370
|
+
const laneKeyframes = automation
|
|
1371
|
+
? volumeLaneKeyframes(automation, 0, totalDuration, meta.volume)
|
|
1372
|
+
: null;
|
|
1264
1373
|
const envelope = laneKeyframes && laneKeyframes.length > 0
|
|
1265
1374
|
? { keyframes: laneKeyframes, trackStart: 0, baseVolume: meta.volume }
|
|
1266
1375
|
: null;
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Encodes captured frames into video using FFmpeg.
|
|
6
6
|
* Supports CPU (libx264) and GPU encoding.
|
|
7
7
|
*/
|
|
8
|
+
import { frameSequencePath } from "./extractedFrameIndex.js";
|
|
8
9
|
import { closeSync, copyFileSync, existsSync, ftruncateSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, statSync, writeFileSync, writeSync, } from "fs";
|
|
9
10
|
import { join, dirname, extname } from "path";
|
|
10
11
|
import { DEFAULT_CONFIG } from "../config.js";
|
|
@@ -16,6 +17,7 @@ import { describeFfmpegFailure, ffmpegStatsReader, formatFfmpegError, isExternal
|
|
|
16
17
|
import { extractAudioMetadata } from "../utils/ffprobe.js";
|
|
17
18
|
import { fpsToFfmpegArg, fpsToNumber } from "@hyperframes/core";
|
|
18
19
|
import { appendVp9CpuUsedArg } from "./vp9Options.js";
|
|
20
|
+
import { appendSoftwareEncoderThreadsArg } from "./softwareEncoderThreads.js";
|
|
19
21
|
import { appendRenderProvenanceArgs } from "../utils/renderProvenance.js";
|
|
20
22
|
export const ENCODER_PRESETS = {
|
|
21
23
|
draft: { preset: "ultrafast", quality: 28, codec: "h264" },
|
|
@@ -212,6 +214,7 @@ export function buildEncoderArgs(options, inputArgs, outputPath, gpuEncoder = nu
|
|
|
212
214
|
else {
|
|
213
215
|
const encoderName = codec === "h264" ? "libx264" : "libx265";
|
|
214
216
|
args.push("-c:v", encoderName, "-preset", preset);
|
|
217
|
+
appendSoftwareEncoderThreadsArg(args);
|
|
215
218
|
if (bitrate)
|
|
216
219
|
args.push("-b:v", bitrate);
|
|
217
220
|
else
|
|
@@ -396,7 +399,7 @@ export async function encodeFramesFromDir(framesDir, framePattern, outputPath, o
|
|
|
396
399
|
if (options.useGpu) {
|
|
397
400
|
gpuEncoder = await getCachedGpuEncoder();
|
|
398
401
|
}
|
|
399
|
-
const inputPath =
|
|
402
|
+
const inputPath = frameSequencePath(framesDir, framePattern);
|
|
400
403
|
const inputArgs = ["-framerate", fpsToFfmpegArg(options.fps), "-i", inputPath];
|
|
401
404
|
const args = buildEncoderArgs(options, inputArgs, outputPath, gpuEncoder);
|
|
402
405
|
const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout;
|
|
@@ -534,7 +537,7 @@ export async function encodeFramesChunkedConcat(framesDir, framePattern, outputP
|
|
|
534
537
|
? ".mov"
|
|
535
538
|
: ".mp4";
|
|
536
539
|
const chunkPath = join(chunkDir, `chunk_${String(i).padStart(4, "0")}${ext}`);
|
|
537
|
-
const inputPath =
|
|
540
|
+
const inputPath = frameSequencePath(framesDir, framePattern);
|
|
538
541
|
const inputArgs = [
|
|
539
542
|
"-framerate",
|
|
540
543
|
fpsToFfmpegArg(options.fps),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export type ExtractedFrameFormat = "jpg" | "png";
|
|
2
2
|
export declare const FRAME_FILENAME_PREFIX = "frame_";
|
|
3
|
+
export declare function frameSequencePath(dir: string, framePattern: string): string;
|
|
3
4
|
export declare class ExtractedFrameSequenceError extends Error {
|
|
4
5
|
constructor(message: string);
|
|
5
6
|
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { readdirSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
export const FRAME_FILENAME_PREFIX = "frame_";
|
|
4
|
+
export function frameSequencePath(dir, framePattern) {
|
|
5
|
+
return join(dir.replaceAll("%", "%%"), framePattern);
|
|
6
|
+
}
|
|
4
7
|
export class ExtractedFrameSequenceError extends Error {
|
|
5
8
|
constructor(message) {
|
|
6
9
|
super(message);
|
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
* The pipeline addresses media by id, but element ids are only unique within
|
|
5
5
|
* one composition FILE and the render document inlines many, so
|
|
6
6
|
* `document.getElementById` silently resolves duplicates to whichever element
|
|
7
|
-
* comes first. The
|
|
7
|
+
* comes first. The compiler stamps a document-unique `data-hf-render-id`
|
|
8
8
|
* (core's mediaRenderIds.ts); these helpers are the single place that knows to
|
|
9
9
|
* prefer it, so every capture stage addresses the same element.
|
|
10
10
|
*
|
|
11
11
|
* Installed via `evaluateOnNewDocument` so it exists before any page script.
|
|
12
|
-
* Every call site keeps a `getElementById` fallback for documents
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* Every call site keeps a `getElementById` fallback for uncompiled documents
|
|
13
|
+
* passed directly to the engine. CLI captures and producer renders both carry
|
|
14
|
+
* document-unique IDs from the compiler.
|
|
15
15
|
*
|
|
16
16
|
* `__hfMediaId` mirrors core's `readMediaRenderId`, which is the definition of
|
|
17
17
|
* this rule; the copy exists only because code serialized into `page.evaluate`
|
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
* The pipeline addresses media by id, but element ids are only unique within
|
|
5
5
|
* one composition FILE and the render document inlines many, so
|
|
6
6
|
* `document.getElementById` silently resolves duplicates to whichever element
|
|
7
|
-
* comes first. The
|
|
7
|
+
* comes first. The compiler stamps a document-unique `data-hf-render-id`
|
|
8
8
|
* (core's mediaRenderIds.ts); these helpers are the single place that knows to
|
|
9
9
|
* prefer it, so every capture stage addresses the same element.
|
|
10
10
|
*
|
|
11
11
|
* Installed via `evaluateOnNewDocument` so it exists before any page script.
|
|
12
|
-
* Every call site keeps a `getElementById` fallback for documents
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* Every call site keeps a `getElementById` fallback for uncompiled documents
|
|
13
|
+
* passed directly to the engine. CLI captures and producer renders both carry
|
|
14
|
+
* document-unique IDs from the compiler.
|
|
15
15
|
*
|
|
16
16
|
* `__hfMediaId` mirrors core's `readMediaRenderId`, which is the definition of
|
|
17
17
|
* this rule; the copy exists only because code serialized into `page.evaluate`
|
|
@@ -556,6 +556,7 @@ async function injectResolvedVideoFramesBatch(page, updates) {
|
|
|
556
556
|
async (items, visualProperties, colorGradingSourceHiddenAttr) => {
|
|
557
557
|
const injectedIds = [];
|
|
558
558
|
const pendingDecodes = [];
|
|
559
|
+
const imagesToPublish = [];
|
|
559
560
|
const replacementLayoutProperties = new Set([
|
|
560
561
|
"width",
|
|
561
562
|
"height",
|
|
@@ -692,6 +693,8 @@ async function injectResolvedVideoFramesBatch(page, updates) {
|
|
|
692
693
|
img.style.objectFit = computedStyle.objectFit;
|
|
693
694
|
img.style.objectPosition = computedStyle.objectPosition;
|
|
694
695
|
img.style.zIndex = computedStyle.zIndex;
|
|
696
|
+
// Publish after full decode so raster cannot race it with a reduced JPEG decode.
|
|
697
|
+
img.style.setProperty("visibility", "hidden", "important");
|
|
695
698
|
img.decoding = "sync";
|
|
696
699
|
if (img.getAttribute("src") !== item.dataUri) {
|
|
697
700
|
if (item.crossOrigin)
|
|
@@ -705,7 +708,7 @@ async function injectResolvedVideoFramesBatch(page, updates) {
|
|
|
705
708
|
}));
|
|
706
709
|
}
|
|
707
710
|
img.style.opacity = String(computedOpacity);
|
|
708
|
-
img
|
|
711
|
+
imagesToPublish.push(img);
|
|
709
712
|
// Hide the native <video> with visibility only — never clobber inline
|
|
710
713
|
// opacity, so subsequent reads (and queryElementStacking) see the real
|
|
711
714
|
// GSAP-controlled value.
|
|
@@ -716,6 +719,8 @@ async function injectResolvedVideoFramesBatch(page, updates) {
|
|
|
716
719
|
if (pendingDecodes.length > 0) {
|
|
717
720
|
await Promise.all(pendingDecodes);
|
|
718
721
|
}
|
|
722
|
+
for (const img of imagesToPublish)
|
|
723
|
+
img.style.visibility = "visible";
|
|
719
724
|
if (injectedIds.length > 0) {
|
|
720
725
|
const redraw = window
|
|
721
726
|
.__hf?.colorGrading?.redraw;
|
|
@@ -757,7 +762,7 @@ export async function syncVideoFrameVisibility(page, activeVideoIds) {
|
|
|
757
762
|
const img = video.nextElementSibling;
|
|
758
763
|
const hasImg = img && img.classList.contains("__render_frame__");
|
|
759
764
|
const ancestorHidden = isVisualAncestorHidden(video);
|
|
760
|
-
const visible = active.has(video.id) && !ancestorHidden;
|
|
765
|
+
const visible = active.has(window.__hfMediaId?.(video) || video.id) && !ancestorHidden;
|
|
761
766
|
if (visible) {
|
|
762
767
|
// Active video: show injected <img>, hide native <video>.
|
|
763
768
|
// Do NOT clobber inline opacity here — GSAP-controlled opacity must
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { availableParallelism } from "node:os";
|
|
2
|
+
/** x264/x265 bitstreams depend on the thread count, so it comes from the CPU quota, never ffmpeg's host-core guess. */
|
|
3
|
+
export function appendSoftwareEncoderThreadsArg(args, parallelism = availableParallelism()) {
|
|
4
|
+
args.push("-threads", String(Math.max(1, Math.floor(parallelism))));
|
|
5
|
+
}
|
|
@@ -28,6 +28,7 @@ import { SDR_CAPTURE_TO_BT709_FILTER, SDR_RGB_TO_TAGGED_BT709_FILTER, } from "..
|
|
|
28
28
|
import { DEFAULT_CONFIG } from "../config.js";
|
|
29
29
|
import { fpsToFfmpegArg, fpsToNumber } from "@hyperframes/core";
|
|
30
30
|
import { appendVp9CpuUsedArg } from "./vp9Options.js";
|
|
31
|
+
import { appendSoftwareEncoderThreadsArg } from "./softwareEncoderThreads.js";
|
|
31
32
|
import { appendRenderProvenanceArgs } from "../utils/renderProvenance.js";
|
|
32
33
|
import { appendLockedGopArgs, lockedGopCodecParams, resolveLockedGopSize } from "./chunkEncoder.js";
|
|
33
34
|
export function createFrameReorderBuffer(startFrame, endFrame) {
|
|
@@ -180,6 +181,7 @@ export function buildStreamingArgs(options, outputPath, gpuEncoder = null) {
|
|
|
180
181
|
else {
|
|
181
182
|
const encoderName = codec === "h264" ? "libx264" : "libx265";
|
|
182
183
|
args.push("-c:v", encoderName, "-preset", preset);
|
|
184
|
+
appendSoftwareEncoderThreadsArg(args);
|
|
183
185
|
if (bitrate)
|
|
184
186
|
args.push("-b:v", bitrate);
|
|
185
187
|
else
|
|
@@ -27,7 +27,7 @@ import { isFfmpegFilterAvailable } from "../utils/psnrFilterAvailability.js";
|
|
|
27
27
|
import { DEFAULT_CONFIG } from "../config.js";
|
|
28
28
|
import { unwrapTemplate } from "../utils/htmlTemplate.js";
|
|
29
29
|
import { FRAME_FILENAME_PREFIX, gcExtractionCache, gcSweepDue, lookupCacheEntry, partialCacheEntryDir, publishCacheEntry, readContentSha256, readKeyStat, rehydrateCacheEntry, touchCacheEntry, } from "./extractionCache.js";
|
|
30
|
-
import { framePathsFromDirectory } from "./extractedFrameIndex.js";
|
|
30
|
+
import { framePathsFromDirectory, frameSequencePath } from "./extractedFrameIndex.js";
|
|
31
31
|
export { resolveProjectRelativeSrc };
|
|
32
32
|
/**
|
|
33
33
|
* The single source of truth for the source-video frame-extraction allow-list.
|
|
@@ -571,7 +571,7 @@ outputDirOverride) {
|
|
|
571
571
|
}
|
|
572
572
|
const format = resolveFrameFormat(metadata, options.format);
|
|
573
573
|
const framePattern = `${FRAME_FILENAME_PREFIX}%05d.${format}`;
|
|
574
|
-
const outputPattern =
|
|
574
|
+
const outputPattern = frameSequencePath(videoOutputDir, framePattern);
|
|
575
575
|
// Forced-SDR extraction tone-maps HDR with zscale, the Studio proxy policy. A macOS ffmpeg
|
|
576
576
|
// without zscale falls back to VideoToolbox, whose frames stay HDR and are tone-mapped by
|
|
577
577
|
// Chrome instead (darker), and the render warns once.
|
|
@@ -946,15 +946,10 @@ export function resolveVideoExtractionWindow(video, metadata, timelineEnd) {
|
|
|
946
946
|
export function resolveVideoExtractionDuration(video, metadata, timelineEnd) {
|
|
947
947
|
return resolveVideoExtractionWindow(video, metadata, timelineEnd).durationSeconds;
|
|
948
948
|
}
|
|
949
|
-
/**
|
|
950
|
-
* Codecs whose bitstream is allowed to carry an alpha channel. Default the
|
|
951
|
-
* extraction path to PNG output for these regardless of `metadata.hasAlpha`
|
|
952
|
-
* so a missed sidecar tag doesn't silently strip transparency. Opaque content
|
|
953
|
-
* encoded in one of these codecs pays a small file-size cost on the cached
|
|
954
|
-
* frames but stays correct on the rare case where alpha IS present and the
|
|
955
|
-
* tag was missed.
|
|
956
|
-
*/
|
|
949
|
+
/** Codecs whose bitstream can carry alpha: decoded with the alpha-aware decoder whether or not a tag says so. */
|
|
957
950
|
const ALPHA_CAPABLE_CODECS = new Set(["vp9", "vp8", "prores"]);
|
|
951
|
+
/** Their alpha rides a side channel ffprobe can miss, so frames stay PNG; ProRes alpha shows in its pixel format. */
|
|
952
|
+
const SIDECAR_ALPHA_CODECS = new Set(["vp9", "vp8"]);
|
|
958
953
|
export function codecMayHaveAlpha(codec) {
|
|
959
954
|
return ALPHA_CAPABLE_CODECS.has((codec ?? "").toLowerCase());
|
|
960
955
|
}
|
|
@@ -962,7 +957,7 @@ export function decoderForCodec(codec) {
|
|
|
962
957
|
return alphaDecoderFor(codec) ?? (codec ?? "").toLowerCase();
|
|
963
958
|
}
|
|
964
959
|
export function resolveFrameFormat(metadata, requested) {
|
|
965
|
-
if (metadata.hasAlpha ||
|
|
960
|
+
if (metadata.hasAlpha || SIDECAR_ALPHA_CODECS.has((metadata.videoCodec ?? "").toLowerCase()))
|
|
966
961
|
return "png";
|
|
967
962
|
if (requested === "png" || requested === "jpg")
|
|
968
963
|
return requested;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/engine",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.189",
|
|
4
4
|
"description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"linkedom": "^0.18.12",
|
|
51
51
|
"puppeteer": "^25.8.0",
|
|
52
52
|
"puppeteer-core": "^25.8.0",
|
|
53
|
-
"@hyperframes/core": "^0.8.
|
|
54
|
-
"@hyperframes/parsers": "^0.8.
|
|
53
|
+
"@hyperframes/core": "^0.8.189",
|
|
54
|
+
"@hyperframes/parsers": "^0.8.189"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/node": "^25.0.10",
|