mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
* feat(engine): encoders cap peak bitrate and place keyframes on a set interval * style(engine): shorten the rate-cap comment * style(engine): drop the doc lines the comment ratchet flagged * feat(producer): mp4 renders honour a keyframe interval and a peak bitrate cap on every encoder path * style(producer): shorten the delivery encoder file headers * style(producer): drop a comment line from the segment manifest * style(producer): shorten the integration test header * test: delivery encoder tests follow the ffprobe argv contract and run in the integration lane * feat: the render request, server and Lambda plan carry the keyframe interval and peak bitrate cap * fix(studio): the Studio render route passes the keyframe interval and bitrate cap to the encoder * chore(studio-server): drop a comment the helper's name already says * feat(cli): render and lambda render take --keyframe-interval, --max-bitrate and --buffer-size
415 lines
15 KiB
TypeScript
415 lines
15 KiB
TypeScript
// fallow-ignore-file code-duplication
|
|
import { mkdirSync, readFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import type { CanvasResolution, OutputResolutionIssueKind } from "@hyperframes/core";
|
|
import { c } from "../../ui/colors.js";
|
|
import { errorBox, formatBytes } from "../../ui/format.js";
|
|
import { formatLintStartupMessage } from "../../utils/lintFormat.js";
|
|
import {
|
|
hasDefinitiveEntryMismatch,
|
|
lintProject,
|
|
shouldBlockRender,
|
|
type ProjectLintResult,
|
|
} from "../../utils/lintProject.js";
|
|
import { normalizeErrorMessage } from "../../utils/errorMessage.js";
|
|
import { failCommand, setCommandExitCode } from "../../utils/commandResult.js";
|
|
import {
|
|
reportVariableIssues,
|
|
resolveVariablesArg,
|
|
validateVariablesAgainstProject,
|
|
} from "../../utils/variables.js";
|
|
import { trackRenderPreflightRejected } from "../../telemetry/events.js";
|
|
import { applyRenderEnvironment, renderOutputDirectory, type RenderPlan } from "./plan.js";
|
|
import type { RenderOptions, SingleRenderResult } from "../render.js";
|
|
import type { RenderCancellationScope } from "../../utils/renderCancellation.js";
|
|
import { runRenderSetupWorker } from "../../utils/cancellableProcess.js";
|
|
|
|
type RenderExecutor = (
|
|
projectDir: string,
|
|
outputPath: string,
|
|
options: RenderOptions,
|
|
cancellation?: RenderCancellationScope,
|
|
) => Promise<SingleRenderResult>;
|
|
|
|
type ResolutionPreflight = (
|
|
compositionHtml: string,
|
|
outputResolution: CanvasResolution | undefined,
|
|
modes: { alphaRequested: boolean; hdrRequested: boolean; aspectAgnostic?: boolean },
|
|
) => Promise<{ message: string; kind: OutputResolutionIssueKind } | undefined>;
|
|
|
|
export interface RenderExecutionDependencies {
|
|
renderDocker: RenderExecutor;
|
|
renderLocal: RenderExecutor;
|
|
checkResolution: ResolutionPreflight;
|
|
}
|
|
|
|
// Exported only through render.ts so command tests can lock the user-facing guidance.
|
|
export function renderLintContinuationHint(strictErrors: boolean): string {
|
|
return strictErrors
|
|
? " Continuing render despite lint warnings. Use --strict-all to block warnings."
|
|
: " Continuing render despite lint issues. Use --strict to block errors.";
|
|
}
|
|
|
|
function renderLintShouldAbort(
|
|
strictErrors: boolean,
|
|
strictAll: boolean,
|
|
lintResult: ProjectLintResult,
|
|
definitiveEntryMismatch: boolean,
|
|
): boolean {
|
|
return (
|
|
definitiveEntryMismatch ||
|
|
shouldBlockRender(strictErrors, strictAll, lintResult.totalErrors, lintResult.totalWarnings)
|
|
);
|
|
}
|
|
|
|
/** Execute a validated plan. Output and process lifecycle stay outside parsing. */
|
|
export async function executeRenderPlan(
|
|
plan: RenderPlan,
|
|
dependencies: RenderExecutionDependencies,
|
|
cancellation?: RenderCancellationScope,
|
|
): Promise<void> {
|
|
assertRenderActive(cancellation);
|
|
applyRenderEnvironment(plan);
|
|
if (!plan.batchPath) mkdirSync(renderOutputDirectory(plan), { recursive: true });
|
|
|
|
let batchModule: typeof import("../batchRender.js") | undefined;
|
|
let preparedBatch: import("../batchRender.js").PreparedBatchRender | undefined;
|
|
if (plan.batchPath) {
|
|
batchModule = await import("../batchRender.js");
|
|
try {
|
|
preparedBatch = batchModule.prepareBatchRender({
|
|
batchPath: plan.batchPath,
|
|
outputTemplate: plan.batchOutputTemplate,
|
|
indexPath: plan.renderTarget,
|
|
strictVariables: plan.strictVariables,
|
|
quiet: plan.quiet || plan.batchJson,
|
|
json: plan.batchJson,
|
|
});
|
|
} catch (error: unknown) {
|
|
batchModule.exitBatchRenderInputError(error);
|
|
}
|
|
}
|
|
|
|
if (!plan.useDocker) await refuseLowDiskBeforeBrowserDownload(plan);
|
|
const browserPath = plan.useDocker
|
|
? undefined
|
|
: await ensureRenderBrowser(plan, cancellation?.signal);
|
|
assertRenderActive(cancellation);
|
|
await runRenderLint(plan, lintProject, cancellation?.signal);
|
|
assertRenderActive(cancellation);
|
|
await runResolutionPreflight(plan, dependencies.checkResolution);
|
|
assertRenderActive(cancellation);
|
|
|
|
if (plan.batchPath && batchModule && preparedBatch) {
|
|
await executeBatchRender(
|
|
plan,
|
|
browserPath,
|
|
batchModule,
|
|
preparedBatch,
|
|
dependencies,
|
|
cancellation,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const variables = resolveVariablesArg(plan.variablesArg, plan.variablesFileArg);
|
|
if (variables && Object.keys(variables).length > 0) {
|
|
const issues = validateVariablesAgainstProject(plan.renderTarget, variables);
|
|
reportVariableIssues(issues, { strict: plan.strictVariables, quiet: plan.quiet });
|
|
}
|
|
|
|
const options = renderOptionsFromPlan(plan, browserPath, variables);
|
|
const execute = plan.useDocker ? dependencies.renderDocker : dependencies.renderLocal;
|
|
await execute(plan.project.dir, plan.outputPath, options, cancellation);
|
|
}
|
|
|
|
/**
|
|
* The plan -> RenderOptions hop for a single render. Pure and exported so the
|
|
* flags that cross it are pinned by a test: `--resume` / `--keep-segments`
|
|
* were once dropped exactly here and nothing but a manual gate noticed.
|
|
*/
|
|
export function renderOptionsFromPlan(
|
|
plan: RenderPlan,
|
|
browserPath: string | undefined,
|
|
variables: RenderOptions["variables"],
|
|
): RenderOptions {
|
|
const options: RenderOptions = {
|
|
fps: plan.fps,
|
|
quality: plan.quality,
|
|
authoringSkill: plan.authoringSkill,
|
|
authoringSkillSource: plan.authoringSkillSource,
|
|
authoringSkillInvalid: plan.invalidAuthoringSkill,
|
|
hfEnvOverrides: plan.hfEnvOverrides,
|
|
catalogUsage: plan.catalogUsage,
|
|
format: plan.format,
|
|
gifLoop: plan.gifLoop,
|
|
gifFpsCapped: plan.gifFpsCapped,
|
|
hlsSegmentSeconds: plan.hlsSegmentSeconds,
|
|
workers: plan.workers,
|
|
gpu: plan.useGpu,
|
|
browserGpuMode: plan.browserGpuMode,
|
|
hdrMode: plan.hdrMode,
|
|
crf: plan.crf,
|
|
vp9CpuUsed: plan.vp9CpuUsed,
|
|
videoBitrate: plan.videoBitrate,
|
|
keyframeIntervalSeconds: plan.keyframeIntervalSeconds,
|
|
maxBitrate: plan.maxBitrate,
|
|
bufferSize: plan.bufferSize,
|
|
videoFrameFormat: plan.videoFrameFormat,
|
|
pageMotionBlur: plan.pageMotionBlur,
|
|
quiet: plan.quiet,
|
|
browserPath,
|
|
debug: plan.debug,
|
|
resumeSegments: plan.resumeSegments,
|
|
keepSegments: plan.keepSegments,
|
|
bestEffort: plan.bestEffort,
|
|
variables,
|
|
entryFile: plan.entryFile,
|
|
outputResolution: plan.outputResolution,
|
|
outputResolutionAspectAgnostic: plan.outputResolutionAspectAgnostic,
|
|
outputResolutionRaw: plan.outputResolutionRaw,
|
|
pageNavigationTimeoutMs: plan.pageNavigationTimeoutMs,
|
|
protocolTimeout: plan.protocolTimeout,
|
|
playerReadyTimeout: plan.playerReadyTimeout,
|
|
exitAfterComplete: true,
|
|
manageDeParallelRouterBreaker: true,
|
|
};
|
|
if (plan.useDocker) {
|
|
options.pageSideCompositing = plan.pageSideCompositing;
|
|
options.experimentalFastCapture = plan.experimentalFastCapture;
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function assertRenderActive(cancellation?: RenderCancellationScope): void {
|
|
cancellation?.checkAncestors();
|
|
cancellation?.signal.throwIfAborted();
|
|
}
|
|
|
|
async function refuseLowDiskBeforeBrowserDownload(plan: RenderPlan): Promise<void> {
|
|
const { checkDisk } = await import("../../browser/preflight.js");
|
|
await (await import("../../browser/manager.js")).sweepKilledInstalls();
|
|
const paths = new Set([tmpdir(), renderOutputDirectory(plan)]);
|
|
const low = [...paths].map((path) => checkDisk(path)).filter((outcome) => !outcome.ok);
|
|
if (low.length === 0) return;
|
|
for (const check of low) errorBox(check.title ?? "Low disk space", check.detail, check.hint);
|
|
failCommand();
|
|
}
|
|
|
|
async function ensureRenderBrowser(plan: RenderPlan, signal?: AbortSignal): Promise<string> {
|
|
const { ensureBrowser } = await import("../../browser/manager.js");
|
|
let browserSpinner:
|
|
| {
|
|
start: (message?: string) => void;
|
|
message: (message: string) => void;
|
|
stop: (message?: string) => void;
|
|
}
|
|
| undefined;
|
|
try {
|
|
if (plan.effectiveQuiet) {
|
|
return (await ensureBrowser({ preferManagedChrome: true, signal })).executablePath;
|
|
}
|
|
const clack = await import("@clack/prompts");
|
|
browserSpinner = clack.spinner();
|
|
browserSpinner.start("Checking browser...");
|
|
const info = await ensureBrowser({
|
|
preferManagedChrome: true,
|
|
signal,
|
|
onProgress: (downloaded, total) => {
|
|
if (total <= 0) return;
|
|
const pct = Math.floor((downloaded / total) * 100);
|
|
browserSpinner?.message(
|
|
`Downloading Chrome... ${c.progress(pct + "%")} ${c.dim("(" + formatBytes(downloaded) + " / " + formatBytes(total) + ")")}`,
|
|
);
|
|
},
|
|
});
|
|
browserSpinner.stop(c.dim(`Browser: ${info.source}`));
|
|
return info.executablePath;
|
|
} catch (error: unknown) {
|
|
browserSpinner?.stop(c.error("Browser not available"));
|
|
if (signal?.aborted) signal.throwIfAborted();
|
|
errorBox(
|
|
"Chrome not found",
|
|
normalizeErrorMessage(error),
|
|
"Run: npx hyperframes browser ensure",
|
|
);
|
|
failCommand();
|
|
}
|
|
}
|
|
|
|
// fallow-ignore-next-line complexity
|
|
export async function runRenderLint(
|
|
plan: RenderPlan,
|
|
runLint: (projectDir: string, entryFile?: string) => Promise<ProjectLintResult> = lintProject,
|
|
signal?: AbortSignal,
|
|
): Promise<void> {
|
|
// lintProject's explicit-entry contract is an absolute source path;
|
|
// entryFile remains project-relative for the producer.
|
|
const explicitEntry = plan.entryFile ? plan.renderTarget : undefined;
|
|
const lintResult =
|
|
signal && runLint === lintProject
|
|
? await runRenderLintInOwnedProcess(plan.project.dir, explicitEntry, signal)
|
|
: await runLint(plan.project.dir, explicitEntry);
|
|
if (lintResult.totalErrors === 0 && lintResult.totalWarnings === 0) return;
|
|
const definitiveEntryMismatch = hasDefinitiveEntryMismatch(lintResult);
|
|
const willAbort = renderLintShouldAbort(
|
|
plan.strictErrors,
|
|
plan.strictAll,
|
|
lintResult,
|
|
definitiveEntryMismatch,
|
|
);
|
|
presentRenderLintFindings(lintResult, plan.effectiveQuiet, plan.lintVerbose || willAbort);
|
|
if (willAbort) {
|
|
presentRenderLintAbort(plan, definitiveEntryMismatch);
|
|
failCommand();
|
|
}
|
|
presentRenderLintContinuation(plan);
|
|
}
|
|
|
|
async function runRenderLintInOwnedProcess(
|
|
projectDir: string,
|
|
entryFile: string | undefined,
|
|
signal: AbortSignal,
|
|
): Promise<ProjectLintResult> {
|
|
return runRenderSetupWorker<ProjectLintResult>(
|
|
"lint",
|
|
{ projectDir, entryFile },
|
|
{
|
|
signal,
|
|
maxBufferBytes: 8 * 1024 * 1024,
|
|
},
|
|
);
|
|
}
|
|
|
|
function presentRenderLintFindings(
|
|
lintResult: Awaited<ReturnType<typeof lintProject>>,
|
|
quiet: boolean,
|
|
lintVerbose: boolean,
|
|
): void {
|
|
if (quiet) return;
|
|
console.log("");
|
|
for (const line of formatLintStartupMessage(
|
|
lintResult,
|
|
lintVerbose ? { kind: "verbose", options: { errorsFirst: true } } : { kind: "summary" },
|
|
))
|
|
console.log(line);
|
|
}
|
|
|
|
function presentRenderLintAbort(plan: RenderPlan, definitiveEntryMismatch: boolean): void {
|
|
if (plan.effectiveQuiet) return;
|
|
console.log("");
|
|
console.log(
|
|
c.error(
|
|
definitiveEntryMismatch
|
|
? " Aborting render because the default index.html entry is blank."
|
|
: ` Aborting render due to lint issues (${plan.strictAll ? "--strict-all" : "--strict"} mode).`,
|
|
),
|
|
);
|
|
console.log("");
|
|
}
|
|
|
|
function presentRenderLintContinuation(plan: RenderPlan): void {
|
|
if (plan.effectiveQuiet) return;
|
|
console.log(c.dim(renderLintContinuationHint(plan.strictErrors)));
|
|
console.log("");
|
|
}
|
|
|
|
async function runResolutionPreflight(
|
|
plan: RenderPlan,
|
|
checkResolution: ResolutionPreflight,
|
|
): Promise<void> {
|
|
if (!plan.outputResolution) return;
|
|
let issue: { message: string; kind: OutputResolutionIssueKind } | undefined;
|
|
try {
|
|
issue = await checkResolution(readFileSync(plan.renderTarget, "utf8"), plan.outputResolution, {
|
|
alphaRequested:
|
|
plan.format === "webm" || plan.format === "mov" || plan.format === "png-sequence",
|
|
hdrRequested: plan.hdrMode === "force-hdr",
|
|
aspectAgnostic: plan.outputResolutionAspectAgnostic,
|
|
});
|
|
} catch {
|
|
// Unreadable input is surfaced by the render pipeline with full context.
|
|
}
|
|
if (!issue) return;
|
|
trackRenderPreflightRejected({ kind: issue.kind });
|
|
errorBox("Output resolution incompatible", issue.message);
|
|
failCommand();
|
|
}
|
|
|
|
export function batchRowRenderOptions(
|
|
plan: RenderPlan,
|
|
browserPath: string | undefined,
|
|
): RenderOptions {
|
|
const batchQuiet = plan.quiet || plan.batchJson;
|
|
return {
|
|
fps: plan.fps,
|
|
quality: plan.quality,
|
|
authoringSkill: plan.authoringSkill,
|
|
authoringSkillSource: plan.authoringSkillSource,
|
|
authoringSkillInvalid: plan.invalidAuthoringSkill,
|
|
hfEnvOverrides: plan.hfEnvOverrides,
|
|
catalogUsage: plan.catalogUsage,
|
|
format: plan.format,
|
|
gifLoop: plan.gifLoop,
|
|
gifFpsCapped: plan.gifFpsCapped,
|
|
hlsSegmentSeconds: plan.hlsSegmentSeconds,
|
|
workers: plan.workers,
|
|
gpu: plan.useGpu,
|
|
browserGpuMode: plan.browserGpuMode,
|
|
hdrMode: plan.hdrMode,
|
|
crf: plan.crf,
|
|
vp9CpuUsed: plan.vp9CpuUsed,
|
|
videoBitrate: plan.videoBitrate,
|
|
keyframeIntervalSeconds: plan.keyframeIntervalSeconds,
|
|
maxBitrate: plan.maxBitrate,
|
|
bufferSize: plan.bufferSize,
|
|
videoFrameFormat: plan.videoFrameFormat,
|
|
pageMotionBlur: plan.pageMotionBlur,
|
|
quiet: batchQuiet,
|
|
browserPath,
|
|
entryFile: plan.entryFile,
|
|
outputResolution: plan.outputResolution,
|
|
outputResolutionAspectAgnostic: plan.outputResolutionAspectAgnostic,
|
|
outputResolutionRaw: plan.outputResolutionRaw,
|
|
pageNavigationTimeoutMs: plan.pageNavigationTimeoutMs,
|
|
protocolTimeout: plan.protocolTimeout,
|
|
playerReadyTimeout: plan.playerReadyTimeout,
|
|
debug: plan.debug,
|
|
resumeSegments: plan.resumeSegments,
|
|
keepSegments: plan.keepSegments,
|
|
bestEffort: plan.bestEffort,
|
|
exitAfterComplete: false,
|
|
throwOnError: true,
|
|
skipFeedback: true,
|
|
desktopHint: false,
|
|
manageDeParallelRouterBreaker: plan.batchConcurrency <= 1,
|
|
};
|
|
}
|
|
|
|
async function executeBatchRender(
|
|
plan: RenderPlan,
|
|
browserPath: string | undefined,
|
|
batchModule: typeof import("../batchRender.js"),
|
|
preparedBatch: import("../batchRender.js").PreparedBatchRender,
|
|
dependencies: RenderExecutionDependencies,
|
|
cancellation?: RenderCancellationScope,
|
|
): Promise<void> {
|
|
const batchQuiet = plan.quiet || plan.batchJson;
|
|
const renderOptionsBase = batchRowRenderOptions(plan, browserPath);
|
|
const manifest = await batchModule.runBatchRender({
|
|
prepared: preparedBatch,
|
|
concurrency: plan.batchConcurrency,
|
|
failFast: plan.batchFailFast,
|
|
quiet: batchQuiet,
|
|
json: plan.batchJson,
|
|
renderOne: (row) => {
|
|
assertRenderActive(cancellation);
|
|
const options: RenderOptions = { ...renderOptionsBase, variables: row.variables };
|
|
if (plan.useDocker) options.pageSideCompositing = plan.pageSideCompositing;
|
|
const execute = plan.useDocker ? dependencies.renderDocker : dependencies.renderLocal;
|
|
return execute(plan.project.dir, row.outputPath, options, cancellation);
|
|
},
|
|
});
|
|
if (manifest.failed > 0) setCommandExitCode(1);
|
|
}
|