Skip to content

Commit b2e7d76

Browse files
authored
Merge pull request #2875 from heygen-com/feat/de-short-inversion
feat(producer): short-comp DE inversion band — baseline release (telemetry only, routing off)
2 parents f81ac74 + 4dbf0d9 commit b2e7d76

11 files changed

Lines changed: 828 additions & 23 deletions

File tree

packages/cli/src/commands/render.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1109,7 +1109,6 @@ let deParallelRouterTrialFiredThisProcess = false;
11091109
* resetting outside a test process where many independent test cases share
11101110
* one imported module instance.
11111111
*/
1112-
// fallow-ignore-next-line unused-export
11131112
export function __resetDeParallelRouterTrialStateForTests(): void {
11141113
deParallelRouterTrialManagedByUs = false;
11151114
deParallelRouterTrialFiredThisProcess = false;
@@ -1433,6 +1432,9 @@ function trackRenderMetrics(
14331432
deClampReason: perf?.drawElement?.clampReason,
14341433
deWorkerInversion: perf?.drawElement?.workerInversion,
14351434
dePreInversionWorkers: perf?.drawElement?.preInversionWorkers,
1435+
compositionElementCount: perf?.drawElement?.compositionElementCount,
1436+
compositionElementCountSource: perf?.drawElement?.compositionElementCountSource,
1437+
deShortBand: perf?.drawElement?.shortBand,
14361438
deParallelRouter: perf?.drawElement?.parallelRouter,
14371439
dePreRouterWorkers: perf?.drawElement?.preRouterWorkers,
14381440
deGateReason: perf?.drawElement?.gateReason,

packages/cli/src/telemetry/events.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ export interface RenderObservabilityTelemetryPayload {
7272
// the more authoritative perfSummary value wins when both are present.
7373
captureDeWorkerInversion?: string;
7474
captureDePreInversionWorkers?: number;
75+
captureCompositionElementCount?: number;
76+
captureCompositionElementCountSource?: string;
77+
captureDeShortBand?: string;
7578
captureDeParallelRouter?: string;
7679
captureDeGpuRenderer?: string;
7780
captureDePreRouterWorkers?: number;
@@ -130,6 +133,9 @@ function renderObservabilityEventProperties(props: RenderObservabilityTelemetryP
130133
capture_memory_exhaustion_detected: props.captureMemoryExhaustionDetected,
131134
de_worker_inversion: props.captureDeWorkerInversion,
132135
de_pre_inversion_workers: props.captureDePreInversionWorkers,
136+
composition_element_count: props.captureCompositionElementCount,
137+
composition_element_count_source: props.captureCompositionElementCountSource,
138+
de_short_band: props.captureDeShortBand,
133139
de_parallel_router: props.captureDeParallelRouter,
134140
gpu_renderer: props.captureDeGpuRenderer,
135141
de_pre_router_workers: props.captureDePreRouterWorkers,
@@ -204,6 +210,9 @@ export function trackRenderComplete(
204210
deClampReason?: string;
205211
deWorkerInversion?: string;
206212
dePreInversionWorkers?: number;
213+
compositionElementCount?: number;
214+
compositionElementCountSource?: string;
215+
deShortBand?: string;
207216
deParallelRouter?: string;
208217
dePreRouterWorkers?: number;
209218
deGateReason?: string;
@@ -303,6 +312,9 @@ export function trackRenderComplete(
303312
de_clamp_reason: props.deClampReason,
304313
de_worker_inversion: props.deWorkerInversion,
305314
de_pre_inversion_workers: props.dePreInversionWorkers,
315+
composition_element_count: props.compositionElementCount,
316+
composition_element_count_source: props.compositionElementCountSource,
317+
de_short_band: props.deShortBand,
306318
de_parallel_router: props.deParallelRouter,
307319
de_pre_router_workers: props.dePreRouterWorkers,
308320
de_gate_reason: props.deGateReason,

packages/cli/src/telemetry/renderObservability.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ export function renderObservabilityTelemetryPayload(
4242
captureMemoryExhaustionDetected: capture.memoryExhaustionDetected,
4343
captureDeWorkerInversion: capture.deWorkerInversion,
4444
captureDePreInversionWorkers: capture.dePreInversionWorkers,
45+
captureCompositionElementCount: capture.compositionElementCount,
46+
captureCompositionElementCountSource: capture.compositionElementCountSource,
47+
captureDeShortBand: capture.deShortBand,
4548
captureDeParallelRouter: capture.deParallelRouter,
4649
captureDeGpuRenderer: capture.deGpuRenderer,
4750
captureDePreRouterWorkers: capture.dePreRouterWorkers,

packages/engine/src/services/frameCapture.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3786,6 +3786,8 @@ export function getCapturePerfSummary(session: CaptureSession): CapturePerfSumma
37863786
p95TotalMs: percentileOf(session.capturePerf.frameMs, 0.95),
37873787
p99TotalMs: percentileOf(session.capturePerf.frameMs, 0.99),
37883788
subTimelineWaitOutcome: session.subTimelineWaitOutcome,
3789+
initDurationMs: session.initTelemetry?.initDurationMs,
3790+
initTweenCount: session.initTelemetry?.tweenCount,
37893791
warnings: cloneCaptureWarnings(session.warnings),
37903792
staticDedupReused: session.staticDedupCount ?? 0,
37913793
staticDedupEnabled: session.staticDedupEnabled ?? false,

packages/engine/src/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,18 @@ export interface CapturePerfSummary {
241241
p99TotalMs: number;
242242
/** Sub-composition timeline wait outcome (absent pre-init). */
243243
subTimelineWaitOutcome?: SubTimelineWaitOutcome;
244+
/**
245+
* Session init telemetry, mirrored from the `[FrameCapture:INIT]` console
246+
* line so PARALLEL workers report it too: worker sessions' console buffers
247+
* only propagate to the orchestrator on failure, which left the
248+
* multi-worker path — the short-comp band's entire population — with 0%
249+
* coverage of the motion axis (`observability_init_tween_count`) in fleet
250+
* telemetry. Riding the perf summary reuses the one channel that already
251+
* flows back per worker on success.
252+
*/
253+
initDurationMs?: number;
254+
/** GSAP tween count at init — the motion-axis signal for capture routing analysis. */
255+
initTweenCount?: number;
244256
/** Correctness warnings observed before or during capture. */
245257
warnings?: CaptureWarning[];
246258
/**

packages/producer/src/services/render/observability.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,3 +407,34 @@ describe("RenderObservabilityRecorder", () => {
407407
);
408408
});
409409
});
410+
411+
describe("init observability fallback (parallel workers)", () => {
412+
const makeRecorder = () =>
413+
new RenderObservabilityRecorder({ renderJobId: "render-par", pipelineStartMs: Date.now() });
414+
415+
it("uses the structured fallback when the console has no INIT line — the parallel success path", () => {
416+
const summary = makeRecorder().summary({
417+
lastBrowserConsole: ["[FrameCapture:NAV] page.goto start"],
418+
capture: { forceScreenshot: false, captureMode: "screenshot" },
419+
initFallback: { initDurationMs: 850, tweenCount: 1200 },
420+
});
421+
expect(summary.init).toEqual({ initDurationMs: 850, tweenCount: 1200 });
422+
});
423+
424+
it("max-merges console INIT lines over the fallback, matching multi-session semantics", () => {
425+
const summary = makeRecorder().summary({
426+
lastBrowserConsole: ["[FrameCapture:INIT] complete initDurationMs=1234 tweenCount=42"],
427+
capture: { forceScreenshot: false, captureMode: "screenshot" },
428+
initFallback: { initDurationMs: 850, tweenCount: 1200 },
429+
});
430+
expect(summary.init).toEqual({ initDurationMs: 1234, tweenCount: 1200 });
431+
});
432+
433+
it("stays undefined when neither source has anything", () => {
434+
const summary = makeRecorder().summary({
435+
lastBrowserConsole: [],
436+
capture: { forceScreenshot: false, captureMode: "screenshot" },
437+
});
438+
expect(summary.init).toBeUndefined();
439+
});
440+
});

packages/producer/src/services/render/observability.ts

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,43 @@ export interface RenderCaptureObservability {
7070
deWorkerInversion?: "inverted" | "reverted";
7171
/** Worker count the resolver would have used absent the inversion; undefined if it never fired. */
7272
dePreInversionWorkers?: number;
73+
/**
74+
* Element count for the short-comp band gate (`resolveCompositionElementCount`):
75+
* the LIVE DOM size from the already-running probe session when one is
76+
* initialized, falling back to a static scan of the compiled HTML
77+
* (`countElementTags`) otherwise. Live is authoritative — a static scan
78+
* cannot see elements a composition's own script creates at runtime.
79+
*
80+
* Emitted on every render, not just inverted ones — this is the variable the
81+
* short-comp inversion band is gated on, and the fleet distribution of it is
82+
* unknown. Without it there is no way to tell whether the 2500 ceiling opens
83+
* the band for most short comps or almost none, and no way to re-derive the
84+
* threshold from real content instead of synthetic sweeps.
85+
*/
86+
compositionElementCount?: number;
87+
/**
88+
* Provenance of `compositionElementCount`: "live" (measured from the probe
89+
* session's real DOM — sees runtime-generated elements) or "static" (source
90+
* markup scan, which does not). The probe is CONDITIONAL, so this is not a
91+
* detail: only a `live` count may open the short band, and the fleet rate of
92+
* "static" sizes the population a future conditional-probe-launch would
93+
* unlock for the band.
94+
*/
95+
compositionElementCountSource?: "live" | "static";
96+
/**
97+
* Short-comp band decision, emitted only when the band is DECISIVE — every
98+
* other inversion-eligibility condition passed and only the floor (250 vs
99+
* 900) differed. "applied": the element count cleared the ceiling too, so
100+
* with routing enabled (HF_DE_SHORT_BAND_ROUTE) this render inverts; in the
101+
* baseline release the same value is the COUNTERFACTUAL "would have
102+
* inverted". "skipped_elements": the element ceiling was the only blocker.
103+
* Unset: the band could not have affected this render (ineligible for some
104+
* other reason, or already inverting at 900+). The selector is computed
105+
* identically before and after the routing flip, and the skipped/oversize
106+
* renders form the concurrent control for the difference-in-differences
107+
* read — that is the entire point of the field.
108+
*/
109+
deShortBand?: "applied" | "skipped_elements" | "unmeasured";
73110
/** DE parallel-router outcome: "routed" (fired, held) | "reverted" (fired, self-verify retry rolled back). */
74111
deParallelRouter?: "routed" | "reverted";
75112
/**
@@ -246,20 +283,26 @@ function readUnsignedIntAfter(line: string, prefix: string): number | undefined
246283
return digits > 0 ? value : undefined;
247284
}
248285

249-
function summarizeInitObservability(lines: string[]): RenderInitObservability | undefined {
250-
let initDurationMs: number | undefined;
251-
let tweenCount: number | undefined;
286+
/** Max of two optional readings — multiple worker/session INIT records can appear; keep the worst. */
287+
function maxReading(current: number | undefined, next: number | undefined): number | undefined {
288+
if (next === undefined) return current;
289+
return current === undefined ? next : Math.max(current, next);
290+
}
291+
292+
function summarizeInitObservability(
293+
lines: string[],
294+
fallback?: RenderInitObservability,
295+
): RenderInitObservability | undefined {
296+
// Console parsing only sees THIS process's session buffer, so parallel
297+
// workers' INIT lines never reach it — their init telemetry arrives
298+
// structured via the per-worker perf summaries instead. Seed with that and
299+
// let the console parse (same max semantics) refine it.
300+
let initDurationMs: number | undefined = fallback?.initDurationMs;
301+
let tweenCount: number | undefined = fallback?.tweenCount;
252302
for (const line of lines) {
253303
if (!line.includes("[FrameCapture:INIT]")) continue;
254-
const duration = readUnsignedIntAfter(line, "initDurationMs=");
255-
const tweens = readUnsignedIntAfter(line, "tweenCount=");
256-
// Multiple worker/session INIT records can appear; keep the worst observed startup cost.
257-
if (duration !== undefined) {
258-
initDurationMs = initDurationMs === undefined ? duration : Math.max(initDurationMs, duration);
259-
}
260-
if (tweens !== undefined) {
261-
tweenCount = tweenCount === undefined ? tweens : Math.max(tweenCount, tweens);
262-
}
304+
initDurationMs = maxReading(initDurationMs, readUnsignedIntAfter(line, "initDurationMs="));
305+
tweenCount = maxReading(tweenCount, readUnsignedIntAfter(line, "tweenCount="));
263306
}
264307
if (initDurationMs === undefined && tweenCount === undefined) return undefined;
265308
return { initDurationMs, tweenCount };
@@ -378,6 +421,8 @@ export class RenderObservabilityRecorder {
378421
summary(input: {
379422
lastBrowserConsole: string[];
380423
capture: RenderCaptureObservability;
424+
/** Structured init telemetry from per-worker perf summaries — the only success-path channel parallel workers have (their console buffers propagate on failure only). */
425+
initFallback?: RenderInitObservability;
381426
extraction?: RenderExtractionObservability;
382427
compositionHash?: string;
383428
}): RenderObservabilitySummary {
@@ -392,7 +437,7 @@ export class RenderObservabilityRecorder {
392437
browserDiagnostics: summarizeBrowserDiagnostics(input.lastBrowserConsole),
393438
capture: { ...input.capture },
394439
extraction: input.extraction ? { ...input.extraction } : undefined,
395-
init: summarizeInitObservability(input.lastBrowserConsole),
440+
init: summarizeInitObservability(input.lastBrowserConsole, input.initFallback),
396441
};
397442
}
398443

packages/producer/src/services/render/perfSummary.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@ export interface DrawElementPerfInput {
8080
workerInversion?: "inverted" | "reverted";
8181
/** Auto-resolved worker count before the inversion pinned it to 1 (set only when the inversion fired). */
8282
preInversionWorkers?: number;
83+
/** Rough compiled-composition element count — gate variable for the short-comp inversion band. */
84+
compositionElementCount?: number;
85+
/** Provenance of the element count: "live" (probe DOM, trusted to gate) | "static" (source scan, not). */
86+
compositionElementCountSource?: "live" | "static";
87+
/** Short-comp band decision when the band was DECISIVE: "applied" (inverts once HF_DE_SHORT_BAND_ROUTE is on; counterfactual in the baseline release) | "skipped_elements" (element ceiling was the only blocker); unset when the band could not have affected this render. */
88+
shortBand?: "applied" | "skipped_elements" | "unmeasured";
8389
parallelRouter?: "routed" | "reverted";
8490
/** Auto-resolved worker count before the router pinned it to 3 (set only when the router fired). */
8591
preRouterWorkers?: number;
@@ -121,6 +127,9 @@ function aggregateDrawElement(
121127
clampReason: de.clampReason,
122128
workerInversion: de.workerInversion ?? "none",
123129
preInversionWorkers: de.preInversionWorkers,
130+
compositionElementCount: de.compositionElementCount,
131+
compositionElementCountSource: de.compositionElementCountSource,
132+
shortBand: de.shortBand,
124133
parallelRouter: de.parallelRouter ?? "none",
125134
preRouterWorkers: de.preRouterWorkers,
126135
gateReason: gateReasons.length > 0 ? gateReasons.join("|") : undefined,

packages/producer/src/services/render/stages/probeStage.ts

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,37 @@ function hasRuntimeInsertedMedia(html: string): boolean {
184184
);
185185
}
186186

187+
/**
188+
* Does this render need a browser probe at all?
189+
*
190+
* Extracted as a pure predicate because whether a probe runs decides whether
191+
* a LIVE DOM element count is available downstream, and the short-comp
192+
* inversion band fails closed without one (see
193+
* `resolveCompositionElementCount` / `resolveDeShortBand`). Notably NONE of
194+
* these conditions fire for a known-duration, media-free composition that
195+
* builds thousands of `div`/`span` nodes in its own init script —
196+
* `hasRuntimeInsertedMedia` matches only `createElement("video"|"audio")` —
197+
* so that shape is measured statically and must never reach the band's
198+
* `applied` cohort (review finding, R4).
199+
*/
200+
export function probeRequiresBrowser(args: {
201+
durationSeconds: number;
202+
unresolvedCompositionCount: number;
203+
hasAutoStart: boolean;
204+
hasScriptedAudio: boolean;
205+
hasVariableMedia: boolean;
206+
hasInsertedMedia: boolean;
207+
}): boolean {
208+
return (
209+
args.durationSeconds <= 0 ||
210+
args.unresolvedCompositionCount > 0 ||
211+
args.hasAutoStart ||
212+
args.hasScriptedAudio ||
213+
args.hasVariableMedia ||
214+
args.hasInsertedMedia
215+
);
216+
}
217+
187218
export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageResult> {
188219
const {
189220
projectDir,
@@ -217,13 +248,14 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
217248
);
218249
const hasVariableMedia = hasVariableBoundMedia(compiled.html, job.config.variables);
219250
const hasInsertedMedia = hasRuntimeInsertedMedia(compiled.html);
220-
const needsBrowser =
221-
composition.duration <= 0 ||
222-
compiled.unresolvedCompositions.length > 0 ||
223-
hasAutoStart ||
224-
hasScriptedAudio ||
225-
hasVariableMedia ||
226-
hasInsertedMedia;
251+
const needsBrowser = probeRequiresBrowser({
252+
durationSeconds: composition.duration,
253+
unresolvedCompositionCount: compiled.unresolvedCompositions.length,
254+
hasAutoStart,
255+
hasScriptedAudio,
256+
hasVariableMedia,
257+
hasInsertedMedia,
258+
});
227259

228260
if (needsBrowser) {
229261
const reasons = [];

0 commit comments

Comments
 (0)