feat(lint,player): fast-capture lint rule + player media sync (#1921)

* feat(engine): drawElementImage capture service

* feat(engine): 3D projection + compositor-effect risk gate

* fix(engine): gate filter drop-shadow wherever blur gates (review)

detectCssEffectRisk documented drop-shadow as a ~29dB damage case but only
detected blur( in its three scan paths — a drop-shadow comp stayed on the
fast path despite the gate's own correctness contract. Detect drop-shadow(
in computed styles, stylesheet rules, and GSAP tween vars, pinned by a
focused test that runs the real page-side closure against a DOM shim
(computed / stylesheet / tween coverage + blur regression + effect-free
null).

Addresses miguel-heygen's blocker on #1918.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(engine): frame-capture core — fast-capture routing, worker-encode, dedup extension

# Conflicts:
#	packages/engine/src/services/screenshotService.ts

* fix(engine): document HF_FORCE_DRAWELEMENT as diagnostic-only; make armStaticDedup idempotent (review)

Addresses miguel-heygen's blockers on #1919:

- HF_FORCE_DRAWELEMENT promoted from a stale "SCRATCH/Uncommitted" comment to
  a documented diagnostic flag: it exists for upstream-Chromium repro work
  (gate-vs-API isolation, crbug 521861819 149-vs-151) and R&D on gated effect
  classes; renders under it may be damaged BY DESIGN since it bypasses gates
  whose thresholds encode measured damage. Never production; the safety-net
  blank guard also stands down under it so diagnostic frames arrive unmodified.
- armStaticDedup is now idempotent: the drawElement init path arms dedup
  before canvas injection, then initializeSession called it again — the
  second run overwrote the armed state with skipReason="capture_mode"
  (captureMode is "drawelement" by then), producing contradictory telemetry
  (armed frames + a skip reason), and re-ran the verification seeks on the
  fallback path. It now no-ops once staticFrames or a skip decision exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(producer): fast-capture render stages + remote bg-image localizer

* feat(lint,player): fast-capture lint rule + player media sync

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-06 16:22:35 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 1d0dbcd3b2
commit 992a9b6607
6 changed files with 185 additions and 9 deletions
@@ -1,3 +1,4 @@
// fallow-ignore-file code-duplication
import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
@@ -607,6 +608,78 @@ describe("composition rules", () => {
});
});
describe("missing_data_no_timeline", () => {
it("warns when root has no timeline registration and no data-no-timeline", async () => {
const html = `<!DOCTYPE html><html><body>
<div data-composition-id="c1" data-width="320" data-height="180" data-duration="5"></div>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_data_no_timeline");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
it("does not warn when data-no-timeline is present (boolean form)", async () => {
const html = `<!DOCTYPE html><html><body>
<div data-composition-id="c1" data-no-timeline data-width="320" data-height="180" data-duration="5"></div>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeUndefined();
});
it("does not warn when a script registers window.__timelines[id]", async () => {
const html = `<!DOCTYPE html><html><body>
<div data-composition-id="c1" data-width="320" data-height="180" data-duration="5"></div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeUndefined();
});
it("does not warn when there is no root composition-id", async () => {
const html = `<!DOCTYPE html><html><body><p>hello</p></body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeUndefined();
});
it("does not false-positive when data-no-timeline appears only inside an attribute value", async () => {
// Regression: /\bdata-no-timeline\b/ matched substrings inside values
const html = `<!DOCTYPE html><html><body>
<div data-composition-id="c1" title="add data-no-timeline here" data-width="320" data-height="180" data-duration="5"></div>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeDefined();
});
it("does not suppress when a hyphenated variant like data-no-timeline-start is present", async () => {
// Regression: /\bdata-no-timeline\b/ matched data-no-timeline-start because
// hyphen is a non-word char and \b fires between 'e' and '-'
const html = `<!DOCTYPE html><html><body>
<div data-composition-id="c1" data-no-timeline-start="0" data-width="320" data-height="180" data-duration="5"></div>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeDefined();
});
it("does not warn for sub-compositions", async () => {
const html = `<template><div data-composition-id="c1" data-width="320" data-height="180" data-duration="5"></div></template>`;
const result = await lintHyperframeHtml(html, { isSubComposition: true });
expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeUndefined();
});
it("does not warn when composition has external scripts (cannot scan for timeline registration)", async () => {
const html = `<!DOCTYPE html><html><body>
<div data-composition-id="c1" data-width="320" data-height="180" data-duration="5"></div>
<script src="app.js"></script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeUndefined();
});
});
describe("root_composition_missing_data_duration (removed)", () => {
// The rule was a static proxy for the runtime's loop-inflation Infinity
// emission, but lint cannot observe GSAP timeline duration statically and
+36
View File
@@ -495,6 +495,42 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
return findings;
},
// missing_data_no_timeline
// The producer polls window.__timelines[id] with a 45-second timeout waiting
// for GSAP timeline registration. Compositions that never call
// window.__timelines[id] = tl stall for 45 s every render. Adding
// data-no-timeline to the root element tells the producer to skip the poll.
({ rootTag, rootCompositionId, scripts, rawSource, options }) => {
if (options.isSubComposition) return [];
if (!rootCompositionId || !rootTag) return [];
// readAttr only matches valued attrs (attr="..."); data-no-timeline is
// typically boolean (no value). Strip quoted attribute values first to
// avoid matching attr names that appear inside other values
// (e.g. title="add data-no-timeline here"), then check with a boundary
// that rejects hyphenated variants (data-no-timeline-start has '-' next,
// not a word-break char).
const tagNoValues = rootTag.raw.replace(/"[^"]*"|'[^']*'/g, '""');
if (/(?:^|\s)data-no-timeline(?=[\s>=/]|$)/i.test(tagNoValues)) return [];
// Can't scan external script files for timeline registration; skip to avoid
// false positives on compositions that register via a bundled JS file.
if (/<script\b[^>]*\bsrc\s*=/i.test(rawSource)) return [];
const registersTimeline = scripts.some((s) => s.content.includes("window.__timelines["));
if (registersTimeline) return [];
return [
{
code: "missing_data_no_timeline",
severity: "warning",
message:
"This composition has no `window.__timelines` registration but is missing `data-no-timeline`. " +
"The producer polls for timeline registration for up to 45 seconds before timing out, " +
"adding 45 s to every render.",
fixHint:
'Add `data-no-timeline` to the root element to skip the poll: `<div data-composition-id="..." data-no-timeline ...>`.',
snippet: truncateSnippet(rootTag.raw),
},
];
},
// requestanimationframe_in_composition
({ scripts, rawSource, options }) => {
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];