diff --git a/packages/producer/src/services/render/observability.ts b/packages/producer/src/services/render/observability.ts
index 0996f6000..d802ae12f 100644
--- a/packages/producer/src/services/render/observability.ts
+++ b/packages/producer/src/services/render/observability.ts
@@ -71,7 +71,11 @@ export interface RenderCaptureObservability {
/** Worker count the resolver would have used absent the inversion; undefined if it never fired. */
dePreInversionWorkers?: number;
/**
- * Rough element count of the compiled composition (`countElementTags`).
+ * Element count for the short-comp band gate (`resolveCompositionElementCount`):
+ * the LIVE DOM size from the already-running probe session when one is
+ * initialized, falling back to a static scan of the compiled HTML
+ * (`countElementTags`) otherwise. Live is authoritative — a static scan
+ * cannot see elements a composition's own script creates at runtime.
*
* Emitted on every render, not just inverted ones — this is the variable the
* short-comp inversion band is gated on, and the fleet distribution of it is
diff --git a/packages/producer/src/services/renderOrchestrator.test.ts b/packages/producer/src/services/renderOrchestrator.test.ts
index 6f3a198e5..fe83d25f7 100644
--- a/packages/producer/src/services/renderOrchestrator.test.ts
+++ b/packages/producer/src/services/renderOrchestrator.test.ts
@@ -37,6 +37,7 @@ import {
countElementTags,
envInt,
mergeWorkerInitObservability,
+ resolveCompositionElementCount,
resolveDeShortBand,
shouldPreferParallelDrawElement,
shouldPreferSingleWorkerDrawElement,
@@ -1896,6 +1897,46 @@ describe("shouldPreferSingleWorkerDrawElement (DE priority inversion)", () => {
});
});
+ describe("resolveCompositionElementCount", () => {
+ // Review finding (R3): a static scan of SOURCE markup cannot see DOM a
+ // composition's own script creates at runtime (document.createElement) —
+ // an unbounded undercount no regex can close. style-10-prod's real
+ // per-transcript-word caption generator is exactly this shape: 2 source
+ // tags, thousands of live nodes after init. These pin the fix: prefer the
+ // live count from an initialized probe session, static scan only as
+ // fallback.
+ it("uses the live DOM count from an initialized probe session, ignoring the (much smaller) source scan", async () => {
+ const session = { isInitialized: true, page: { evaluate: async () => 40001 } };
+ expect(await resolveCompositionElementCount(session, "
")).toBe(40001);
+ });
+
+ it("falls back to the static scan when there is no probe session", async () => {
+ expect(await resolveCompositionElementCount(null, "
")).toBe(2);
+ });
+
+ it("falls back to the static scan when the probe session is not yet initialized", async () => {
+ const session = { isInitialized: false, page: { evaluate: async () => 999 } };
+ expect(await resolveCompositionElementCount(session, "")).toBe(1);
+ });
+
+ it("falls back to the static scan when page.evaluate throws (detached frame, mid-navigation)", async () => {
+ const session = {
+ isInitialized: true,
+ page: {
+ evaluate: async () => {
+ throw new Error("Execution context was destroyed");
+ },
+ },
+ };
+ expect(await resolveCompositionElementCount(session, "
")).toBe(2);
+ });
+
+ it("falls back to the static scan when evaluate resolves a non-finite value", async () => {
+ const session = { isInitialized: true, page: { evaluate: async () => Number.NaN } };
+ expect(await resolveCompositionElementCount(session, "")).toBe(1);
+ });
+ });
+
describe("envInt", () => {
afterEach(() => {
delete process.env.HF_TEST_ENV_INT;
diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts
index 03fe724e4..6b88a9b29 100644
--- a/packages/producer/src/services/renderOrchestrator.ts
+++ b/packages/producer/src/services/renderOrchestrator.ts
@@ -1270,9 +1270,13 @@ export function envInt(name: string, fallback: number): number {
* literal closing marker (``, a void name at a word boundary, or `/>`), so
* ordinary JS comparisons and divisions don't qualify — verified by test.
*
- * These semantics are FROZEN while the short-band baseline is being read —
- * the fleet distribution recorded by the baseline release must be measured
- * by the same counter that later gates routing, or the baseline is invalid.
+ * FALLBACK ONLY as of the live-DOM fix below — a string scan of the SOURCE
+ * markup cannot see elements a composition's own script creates at runtime
+ * (`document.createElement`), which is an unbounded undercount no regex can
+ * close: `style-10-prod`'s per-transcript-word caption generator measures 2
+ * source tags against thousands of live nodes after init (review finding).
+ * `resolveCompositionElementCount` prefers the initialized probe session's
+ * live count and uses this only when no such session exists.
*/
export function countElementTags(html: string): number {
const matches = html.match(
@@ -1281,6 +1285,41 @@ export function countElementTags(html: string): number {
return matches === null ? 0 : matches.length;
}
+/**
+ * Element count for the short-comp band's gate — prefers the LIVE DOM of the
+ * probe session that's already running for every render at this point in the
+ * pipeline (its Chrome is reused for capture on the common single-worker
+ * path, so this costs one extra CDP round-trip, not a browser launch) over
+ * the static `countElementTags` scan of `compiled.html`. The live count is
+ * the only one that sees runtime-generated DOM — a composition whose script
+ * builds its own elements after load (the caption-word-span pattern above)
+ * is otherwise measured as near-empty regardless of how the static scanner
+ * is tuned, admitting an arbitrarily large live DOM below the routing
+ * ceiling (review finding, R3).
+ *
+ * These semantics are FROZEN while the short-band baseline is being read —
+ * the fleet distribution recorded by the baseline release must be measured
+ * by the same resolver that later gates routing, or the baseline is invalid.
+ */
+export async function resolveCompositionElementCount(
+ probeSession: Pick | null,
+ html: string,
+): Promise {
+ if (probeSession?.isInitialized) {
+ try {
+ const liveCount = await probeSession.page.evaluate(
+ () => document.querySelectorAll("*").length,
+ );
+ if (typeof liveCount === "number" && Number.isFinite(liveCount)) return liveCount;
+ } catch {
+ // Probe page evaluate can fail (navigation mid-flight, detached frame,
+ // page crash) — fall through to the static scan rather than block the
+ // render on a routing-gate measurement.
+ }
+ }
+ return countElementTags(html);
+}
+
/**
* Max-merge init telemetry across per-worker capture perf summaries — the
* success-path channel for PARALLEL renders, whose worker console buffers
@@ -2539,7 +2578,10 @@ async function executeRenderPipeline(input: {
// 900 floor stands, unchanged.
const deShortBandMinFrames = envInt("HF_DE_SHORT_MIN_FRAMES", 250);
const deShortBandMaxElements = envInt("HF_DE_SHORT_MAX_ELEMENTS", 2500);
- const compositionElementCount = countElementTags(compiled.html);
+ const compositionElementCount = await resolveCompositionElementCount(
+ probeSession,
+ compiled.html,
+ );
// HF_DE_SHORT_MAX_ELEMENTS=0 is the documented kill switch (symmetric
// with HF_DE_SHORT_MIN_FRAMES=0, which disables via the predicate's own
// minFrames > 0 guard). Gated explicitly here too — without it, a fired