mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-04 04:46:59 +00:00
147 lines
5.3 KiB
TypeScript
147 lines
5.3 KiB
TypeScript
import type { BrowserGpuMode } from "../browser/gpuPolicy.js";
|
|
import { c } from "./colors.js";
|
|
|
|
export function formatBytes(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
export function formatDuration(ms: number): string {
|
|
const seconds = ms / 1000;
|
|
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
|
const minutes = Math.floor(seconds / 60);
|
|
const remaining = seconds - minutes * 60;
|
|
return `${minutes}m ${remaining.toFixed(1)}s`;
|
|
}
|
|
|
|
/**
|
|
* Build the detail portion of the render-complete summary (everything after the
|
|
* file size). The output video length is shown as the primary figure, with the
|
|
* wall-clock render time explicitly labeled "rendered in" so the two are never
|
|
* confused (users were comparing the render time to ffprobe's media duration).
|
|
* Directory (png-sequence) output has no single muxed video, so it shows a frame
|
|
* count instead, or just the render time when neither is known. An HLS playlist
|
|
* directory does play as one continuous video, so it reports a duration.
|
|
*/
|
|
export function formatRenderSummaryDetail(input: {
|
|
elapsedMs: number;
|
|
outputDurationSeconds?: number;
|
|
isDirectory: boolean;
|
|
frameCount?: number;
|
|
playlistDirectory?: boolean;
|
|
}): string {
|
|
const middle =
|
|
input.isDirectory && !input.playlistDirectory
|
|
? input.frameCount != null
|
|
? `${input.frameCount} frames`
|
|
: undefined
|
|
: input.outputDurationSeconds != null && input.outputDurationSeconds > 0
|
|
? `${formatDuration(input.outputDurationSeconds * 1000)} video`
|
|
: undefined;
|
|
const renderTime = `rendered in ${formatDuration(input.elapsedMs)}`;
|
|
return [middle, renderTime].filter(Boolean).join(" · ");
|
|
}
|
|
|
|
type PipelineStageKey =
|
|
| "compileMs"
|
|
| "videoExtractMs"
|
|
| "audioProcessMs"
|
|
| "browserProbeMs"
|
|
| "captureSetupMs"
|
|
| "captureFrameMs"
|
|
| "encodeMs"
|
|
| "assembleMs";
|
|
|
|
const PIPELINE_STAGES: ReadonlyArray<readonly [PipelineStageKey, string]> = [
|
|
["compileMs", "compile"],
|
|
["videoExtractMs", "extract"],
|
|
["audioProcessMs", "audio"],
|
|
["browserProbeMs", "probe"],
|
|
["captureSetupMs", "setup"],
|
|
["captureFrameMs", "capture"],
|
|
["encodeMs", "encode"],
|
|
["assembleMs", "assemble"],
|
|
];
|
|
|
|
/** Session mode wins unless it is the aggregator's empty `"unknown"` sentinel. */
|
|
export function resolvePrintedCaptureMode(
|
|
sessionMode?: string,
|
|
observabilityCaptureMode?: string,
|
|
): string | undefined {
|
|
if (sessionMode && sessionMode !== "unknown") return sessionMode;
|
|
return observabilityCaptureMode;
|
|
}
|
|
|
|
/** Capture path, gpu mode, and stage timings for the render summary. */
|
|
export function formatRenderPipelineDetail(input: {
|
|
captureMode?: string;
|
|
browserGpuMode?: BrowserGpuMode | string;
|
|
streamingEncode?: boolean;
|
|
stages: Record<string, number | undefined>;
|
|
}): string | undefined {
|
|
const parts: string[] = [];
|
|
if (input.captureMode) parts.push(`${input.captureMode} capture`);
|
|
if (input.browserGpuMode) parts.push(`${input.browserGpuMode} gpu`);
|
|
for (const [key, stageLabel] of PIPELINE_STAGES) {
|
|
const ms = input.stages[key];
|
|
if (ms == null) continue;
|
|
const label =
|
|
key === "encodeMs" && input.streamingEncode ? "encode (during capture)" : stageLabel;
|
|
parts.push(`${label} ${formatDuration(ms)}`);
|
|
}
|
|
return parts.length > 0 ? parts.join(" · ") : undefined;
|
|
}
|
|
|
|
/**
|
|
* Why a Linux render stayed on screenshot after automatic or hardware GPU selection.
|
|
* Silent when software was requested (--docker, --no-browser-gpu) or off Linux.
|
|
*/
|
|
export function formatScreenshotFallbackHint(input: {
|
|
captureMode?: string;
|
|
browserGpuMode?: BrowserGpuMode | string;
|
|
requestedGpuMode?: BrowserGpuMode;
|
|
platform: NodeJS.Platform;
|
|
}): string | undefined {
|
|
if (
|
|
input.platform !== "linux" ||
|
|
(input.requestedGpuMode !== "auto" && input.requestedGpuMode !== "hardware")
|
|
) {
|
|
return undefined;
|
|
}
|
|
if (input.captureMode !== "screenshot") return undefined;
|
|
if (input.browserGpuMode === "hardware") {
|
|
return (
|
|
"Screenshot capture (slower): BeginFrame did not run. Needs chrome-headless-shell and no " +
|
|
"--resolution upscale. A BeginFrame probe timeout can occur when browsers start together " +
|
|
"on one GPU; fewer --workers may help."
|
|
);
|
|
}
|
|
if (input.browserGpuMode !== "software" || input.requestedGpuMode !== "auto") return undefined;
|
|
return (
|
|
"Screenshot capture (slower): BeginFrame did not run. Needs chrome-headless-shell and no " +
|
|
"--resolution upscale. Heavy compositions can stall on software GL."
|
|
);
|
|
}
|
|
|
|
export function label(name: string, value: string): string {
|
|
const pad = 14 - name.length;
|
|
return ` ${c.dim(name)}${" ".repeat(Math.max(1, pad))}${c.bold(value)}`;
|
|
}
|
|
|
|
export function errorBox(title: string, hint?: string, suggestion?: string): void {
|
|
console.error(`\n${c.error("\u2717")} ${c.bold(title)}`);
|
|
if (hint) {
|
|
// Indent EVERY hint line, not just the first \u2014 a multi-line hint (e.g. the
|
|
// NO_TOKEN numbered setup list) otherwise had line 1 indented and the rest
|
|
// flush-left, mangling the list. Single-line hints are unchanged.
|
|
const indented = hint
|
|
.split("\n")
|
|
.map((line) => ` ${line}`)
|
|
.join("\n");
|
|
console.error(`\n${c.dim(indented)}`);
|
|
}
|
|
if (suggestion) console.error(` ${c.accent(suggestion)}`);
|
|
console.error();
|
|
}
|