mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
fix(producer): stop JPEG frame capture adding colour noise to graded footage (#5514)
* fix(producer): stop JPEG frame capture adding colour noise to graded footage Standard and high renders captured page frames as JPEG at quality 95 (the probe session and distributed chunks at 80). Chrome's encoder turns graded colour and grain into 8x8 chroma blotches at those settings, and they survive the final encode. Capture now uses quality 100 for standard and high, from one owner that the in-process render, the probe session and distributed chunks all read. Draft keeps 80. The disk estimate for JPEG frames doubles to match the larger files. * fix(producer): key resume and disk estimate on the capture quality Segment resume now fingerprints the capture jpeg quality, so a resume across a quality change re-renders instead of splicing old-quality segments. The disk estimate allows for larger frames at any jpeg quality above draft's 80 instead of only at exactly 100. * fix(producer): keep segment manifest comment share at base * fix(producer): fit capture quality comment in four lines
This commit is contained in:
@@ -69,6 +69,7 @@ import { applyRenderWarningPolicy } from "../renderOrchestrator.js";
|
||||
import { runEncodeStage } from "../render/stages/encodeStage.js";
|
||||
import { runCaptureStage } from "../render/stages/captureStage.js";
|
||||
import { resolveVideoCaptureBeyondViewport } from "../render/captureBeyondViewport.js";
|
||||
import { resolveCaptureJpegQuality } from "../render/captureImageFormat.js";
|
||||
import { createCapturePlan } from "../render/capturePlan.js";
|
||||
import {
|
||||
type ChunkSliceJson,
|
||||
@@ -819,7 +820,10 @@ export async function renderChunk(
|
||||
height: plan.dimensions.height,
|
||||
fps: { num: plan.dimensions.fpsNum, den: plan.dimensions.fpsDen },
|
||||
format: plan.dimensions.format === "mp4" ? "jpeg" : "png",
|
||||
quality: plan.dimensions.format === "mp4" ? 80 : undefined,
|
||||
quality:
|
||||
plan.dimensions.format === "mp4"
|
||||
? resolveCaptureJpegQuality(job.config.quality)
|
||||
: undefined,
|
||||
deviceScaleFactor: encoder.deviceScaleFactor,
|
||||
// Re-inject the controller's snapshotted variables so the chunk's
|
||||
// first capture sees the same `window.__hfVariables` the in-process
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { frameFileExtension } from "@hyperframes/engine";
|
||||
import { resolveCaptureImageFormat } from "./captureImageFormat.js";
|
||||
import { resolveCaptureImageFormat, resolveCaptureJpegQuality } from "./captureImageFormat.js";
|
||||
|
||||
describe("resolveCaptureImageFormat", () => {
|
||||
it("captures JPEG for an ordinary opaque render", () => {
|
||||
@@ -27,3 +27,14 @@ describe("resolveCaptureImageFormat", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCaptureJpegQuality", () => {
|
||||
it("captures standard and high renders at quality 100 so graded colour survives", () => {
|
||||
expect(resolveCaptureJpegQuality("standard")).toBe(100);
|
||||
expect(resolveCaptureJpegQuality("high")).toBe(100);
|
||||
});
|
||||
|
||||
it("keeps draft renders on the smaller, faster frames", () => {
|
||||
expect(resolveCaptureJpegQuality("draft")).toBe(80);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MotionBlurOptions } from "@hyperframes/engine";
|
||||
import type { EngineConfig, MotionBlurOptions } from "@hyperframes/engine";
|
||||
|
||||
/**
|
||||
* The format a render captures its frames in.
|
||||
@@ -19,3 +19,11 @@ export function resolveCaptureImageFormat(input: {
|
||||
}): "jpeg" | "png" {
|
||||
return input.needsAlpha || input.motionBlur ? "png" : "jpeg";
|
||||
}
|
||||
|
||||
/**
|
||||
* Below 100, Chrome's JPEG turns graded colour and grain into 8x8 chroma blotches that survive
|
||||
* the final encode. Draft keeps the smaller, faster frames.
|
||||
*/
|
||||
export function resolveCaptureJpegQuality(quality: EngineConfig["quality"]): number {
|
||||
return quality === "draft" ? 80 : 100;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ const hashInput: SegmentPlanHashInput = {
|
||||
outputWidth: 1920,
|
||||
outputHeight: 1080,
|
||||
motionBlur: "",
|
||||
captureQuality: 80,
|
||||
};
|
||||
|
||||
describe("computeSegmentPlanHash", () => {
|
||||
@@ -68,6 +69,7 @@ describe("computeSegmentPlanHash", () => {
|
||||
expect(computeSegmentPlanHash({ ...hashInput, motionBlur: '{"samplesPerFrame":32}' })).not.toBe(
|
||||
a,
|
||||
);
|
||||
expect(computeSegmentPlanHash({ ...hashInput, captureQuality: 100 })).not.toBe(a);
|
||||
});
|
||||
|
||||
it("does not collide across adjacent field boundaries", () => {
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface SegmentPlanHashInput {
|
||||
outputHeight: number;
|
||||
/** Serialised motion-blur options, or "" when off; sample count and shutter change every pixel. */
|
||||
motionBlur: string;
|
||||
captureQuality: number | undefined;
|
||||
}
|
||||
|
||||
const MANIFEST_FILENAME = "segments.json";
|
||||
@@ -70,6 +71,7 @@ export function computeSegmentPlanHash(input: SegmentPlanHashInput): string {
|
||||
input.useGpu ? "gpu" : "cpu",
|
||||
`${input.outputWidth}x${input.outputHeight}`,
|
||||
input.motionBlur,
|
||||
input.captureQuality === undefined ? "" : String(input.captureQuality),
|
||||
].join(" ");
|
||||
return createHash("sha256").update(ordered).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
@@ -80,6 +80,12 @@ describe("disk capture capacity", () => {
|
||||
expect(estimateDiskCaptureBytes(10, captureOptions)).toBe(100_000);
|
||||
});
|
||||
|
||||
it("allows for the larger frames of jpeg above draft quality", () => {
|
||||
expect(estimateDiskCaptureBytes(10, { ...captureOptions, quality: 80 })).toBe(100_000);
|
||||
expect(estimateDiskCaptureBytes(10, { ...captureOptions, quality: 95 })).toBe(200_000);
|
||||
expect(estimateDiskCaptureBytes(10, { ...captureOptions, quality: 100 })).toBe(200_000);
|
||||
});
|
||||
|
||||
it("fails before capture when estimated frames exceed available headroom", () => {
|
||||
expect(() =>
|
||||
assertDiskCaptureHeadroom("/render/captured-frames", 10, pngOptions, () => 800_000),
|
||||
|
||||
@@ -142,8 +142,10 @@ export function shouldAllowAdaptiveCaptureRetry(
|
||||
return workerCount > 1;
|
||||
}
|
||||
|
||||
/** Jpeg frames are far smaller than raw pixels; this divisor is a conservative bound. */
|
||||
/** Jpeg frames are far smaller than raw pixels; these divisors are conservative bounds. */
|
||||
const JPEG_RAW_RGBA_DIVISOR = 8;
|
||||
/** Above draft's quality 80; quality-100 jpeg of grainy 4K footage measures about 1/6 of raw RGBA. */
|
||||
const HIGH_QUALITY_JPEG_RAW_RGBA_DIVISOR = 4;
|
||||
/** Frames captured before the measured projection replaces the static estimate. */
|
||||
const DISK_PROJECTION_SAMPLE_FRAMES = 10;
|
||||
|
||||
@@ -156,7 +158,12 @@ export function estimateDiskCaptureBytes(
|
||||
const outputHeight = Math.ceil(captureOptions.height * scale);
|
||||
const rawBytes = Math.ceil(totalFrames) * outputWidth * outputHeight * 4;
|
||||
return frameFileExtension(captureOptions.format) === "jpg"
|
||||
? Math.ceil(rawBytes / JPEG_RAW_RGBA_DIVISOR)
|
||||
? Math.ceil(
|
||||
rawBytes /
|
||||
((captureOptions.quality ?? 80) > 80
|
||||
? HIGH_QUALITY_JPEG_RAW_RGBA_DIVISOR
|
||||
: JPEG_RAW_RGBA_DIVISOR),
|
||||
)
|
||||
: rawBytes;
|
||||
}
|
||||
|
||||
|
||||
@@ -927,6 +927,26 @@ describe("runProbeStage — render variable threading", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("runProbeStage — capture jpeg quality", () => {
|
||||
// A sequential disk render reuses this session for every frame, so its quality is the render's.
|
||||
for (const [preset, expected] of [
|
||||
["draft", 80],
|
||||
["standard", 100],
|
||||
["high", 100],
|
||||
] as const) {
|
||||
it(`captures ${preset} renders at jpeg quality ${expected}`, async () => {
|
||||
capturedOptions.length = 0;
|
||||
const { runProbeStage } = await import("./probeStage.js");
|
||||
const input = makeProbeInput({ stageForceScreenshot: false });
|
||||
input.job.config.quality = preset;
|
||||
|
||||
await runProbeStage(input);
|
||||
|
||||
expect(capturedOptions[0]).toMatchObject({ format: "jpeg", quality: expected });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("runProbeStage — decimal duration frame count", () => {
|
||||
it("reproduces all 14 cumulative final-frame indices from the field report", async () => {
|
||||
const { runProbeStage } = await import("./probeStage.js");
|
||||
|
||||
@@ -71,7 +71,7 @@ import {
|
||||
import type { RenderJob } from "../../renderOrchestrator.js";
|
||||
import { isActionableProbeFailure } from "./probeFailures.js";
|
||||
import { preflightCompositionAssetMediaTypes } from "../../assetMediaType.js";
|
||||
import { resolveCaptureImageFormat } from "../captureImageFormat.js";
|
||||
import { resolveCaptureImageFormat, resolveCaptureJpegQuality } from "../captureImageFormat.js";
|
||||
|
||||
export interface ProbeStageInput {
|
||||
projectDir: string;
|
||||
@@ -379,7 +379,8 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
|
||||
height,
|
||||
fps: job.config.fps,
|
||||
format: captureFormat,
|
||||
quality: captureFormat === "png" ? undefined : 80,
|
||||
quality:
|
||||
captureFormat === "png" ? undefined : resolveCaptureJpegQuality(job.config.quality),
|
||||
variables: job.config.variables,
|
||||
deviceScaleFactor,
|
||||
motionBlur,
|
||||
|
||||
@@ -211,7 +211,10 @@ import { runCaptureHdrStage } from "./render/stages/captureHdrStage.js";
|
||||
import { runEncodeStage } from "./render/stages/encodeStage.js";
|
||||
import { runAssembleStage } from "./render/stages/assembleStage.js";
|
||||
import { shouldUseLayeredComposite } from "./hdrCompositor.js";
|
||||
import { resolveCaptureImageFormat } from "./render/captureImageFormat.js";
|
||||
import {
|
||||
resolveCaptureImageFormat,
|
||||
resolveCaptureJpegQuality,
|
||||
} from "./render/captureImageFormat.js";
|
||||
import { assertMotionBlurSupported } from "./render/motionBlurRoute.js";
|
||||
|
||||
export function sampleDirectoryBytes(dir: string): number {
|
||||
@@ -3471,7 +3474,8 @@ async function executeRenderPipeline(input: {
|
||||
height,
|
||||
fps: job.config.fps,
|
||||
format: captureImageFormat,
|
||||
quality: captureImageFormat === "png" ? undefined : job.config.quality === "draft" ? 80 : 95,
|
||||
quality:
|
||||
captureImageFormat === "png" ? undefined : resolveCaptureJpegQuality(job.config.quality),
|
||||
variables: job.config.variables,
|
||||
deviceScaleFactor,
|
||||
motionBlur: job.config.motionBlur,
|
||||
@@ -4438,6 +4442,7 @@ async function executeRenderPipeline(input: {
|
||||
outputWidth: captureCompositionWidth ?? width,
|
||||
outputHeight: captureCompositionHeight ?? height,
|
||||
motionBlur: job.config.motionBlur ? JSON.stringify(job.config.motionBlur) : "",
|
||||
captureQuality: captureOptions.quality,
|
||||
});
|
||||
const segmentDir = segmentDirFor(join(projectDir, "renders"), segmentPlanHash);
|
||||
let completedSegments: ReadonlySet<number> = new Set<number>();
|
||||
|
||||
Reference in New Issue
Block a user