@hyperframes/lint 0.7.59 → 0.7.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2421,6 +2421,27 @@ var MAX_COMPOSITION_LINES = 300;
2421
2421
  var MAX_TIMED_ELEMENTS_PER_TRACK = 3;
2422
2422
  var TRACK_DENSITY_EXEMPT_TAGS = /* @__PURE__ */ new Set(["audio", "script", "style", "video"]);
2423
2423
  var CAPTION_CUE_TOKEN = /^(?:caption(?:[-_](?:group|word|line|block|cue|text))?|subtitle(?:[-_](?:group|line|cue|text))?|cg-.+)$/i;
2424
+ var HEAVY_OVERLAY_ELEMENT_COUNT_WARN = 25;
2425
+ var HEAVY_OVERLAY_EXEMPT_TAGS = /* @__PURE__ */ new Set([
2426
+ "audio",
2427
+ "body",
2428
+ "br",
2429
+ "defs",
2430
+ "head",
2431
+ "hr",
2432
+ "html",
2433
+ "link",
2434
+ "meta",
2435
+ "script",
2436
+ "source",
2437
+ "style",
2438
+ "template",
2439
+ "title",
2440
+ "use",
2441
+ "video"
2442
+ ]);
2443
+ var HEAVY_OVERLAY_CSS_PATTERN = /(?:filter\s*:[^;}]*\bblur\s*\()|(?:clip-path\s*:(?!\s*(?:none|inherit|initial|unset)\b)\s*[^;}]+)|(?:radial-gradient\s*\()/i;
2444
+ var INLINE_STYLE_DISPLAY_NONE_PATTERN = /(?:^|;)\s*display\s*:\s*none\b/i;
2424
2445
  var OVERLAP_EPSILON_SECONDS = 1e-6;
2425
2446
  function countPhysicalLines(source) {
2426
2447
  if (source.length === 0) return 0;
@@ -2479,6 +2500,33 @@ function leftmostCompoundClasses(selector) {
2479
2500
  const leftmost = selector.trim().split(/[\s>+~]+/)[0] ?? "";
2480
2501
  return (leftmost.match(/\.([\w-]+)/g) ?? []).map((c) => c.slice(1));
2481
2502
  }
2503
+ function leftmostCompoundId(selector) {
2504
+ const leftmost = selector.trim().split(/[\s>+~]+/)[0] ?? "";
2505
+ return leftmost.match(/#([\w-]+)/)?.[1] ?? null;
2506
+ }
2507
+ function collectHeavyOverlayHooks(styles) {
2508
+ const classes = /* @__PURE__ */ new Set();
2509
+ const ids = /* @__PURE__ */ new Set();
2510
+ for (const style of styles) {
2511
+ const noComments = style.content.replace(/\/\*[\s\S]*?\*\//g, "");
2512
+ const ruleWithBody = /([^{}]+)\{([^{}]*)\}/g;
2513
+ let m;
2514
+ while ((m = ruleWithBody.exec(noComments)) !== null) {
2515
+ const header = (m[1] ?? "").trim();
2516
+ const body = m[2] ?? "";
2517
+ if (!header || header.startsWith("@")) continue;
2518
+ if (!HEAVY_OVERLAY_CSS_PATTERN.test(body)) continue;
2519
+ for (const sel of header.split(",")) {
2520
+ const trimmed = sel.trim();
2521
+ if (!trimmed) continue;
2522
+ for (const cls of leftmostCompoundClasses(trimmed)) classes.add(cls);
2523
+ const idToken = leftmostCompoundId(trimmed);
2524
+ if (idToken) ids.add(idToken);
2525
+ }
2526
+ }
2527
+ }
2528
+ return { classes, ids };
2529
+ }
2482
2530
  function rootClassStyledSelectors(styles, rootClasses) {
2483
2531
  const offenders = [];
2484
2532
  for (const style of styles) {
@@ -3229,6 +3277,55 @@ ${allInlineStyles}`.replace(/\/\*[\s\S]*?\*\//g, "");
3229
3277
  ];
3230
3278
  }
3231
3279
  return [];
3280
+ },
3281
+ // composition_heavy_overlay_count_high
3282
+ // Field signal ts=1784040753 (#hyperframes-cli-feedback): a composition
3283
+ // with ~40 heavy overlay DOM elements — `filter:blur`, oversized
3284
+ // `radial-gradient`, and `clip-path` animations — captures solid-black for
3285
+ // the first ~half of the render, recovering near the end. Reproduces
3286
+ // identically via drawElement AND forced --no-browser-gpu screenshot
3287
+ // capture AND `snapshot`, so the offender is the capture layer itself, not
3288
+ // encoder/mux. Independent of duration (padding the timeline grows the bad
3289
+ // zone proportionally, doesn't shift it). Reporter's workaround was to
3290
+ // split into per-transition mini compositions + FFmpeg concat.
3291
+ //
3292
+ // Presence alone matters: opacity:0 and visibility:hidden overlays still
3293
+ // contribute to the capture-layer regression, so they're counted-in. The
3294
+ // only escape hatch is `display: none` — an element removed from the render
3295
+ // tree can't feed the compositor. Warn at 25, well below the observed
3296
+ // 40-element repro, to give authors lead time before hitting the bug.
3297
+ // fallow-ignore-next-line complexity
3298
+ ({ tags, styles, rawSource, options }) => {
3299
+ if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
3300
+ const { classes: heavyClassTokens, ids: heavyIds } = collectHeavyOverlayHooks(styles);
3301
+ let heavyCount = 0;
3302
+ for (const tag of tags) {
3303
+ if (HEAVY_OVERLAY_EXEMPT_TAGS.has(tag.name)) continue;
3304
+ if (isCompositionRootOrMount(tag.raw)) continue;
3305
+ const styleAttr = readJsonAttr(tag.raw, "style") ?? "";
3306
+ if (styleAttr && INLINE_STYLE_DISPLAY_NONE_PATTERN.test(styleAttr)) continue;
3307
+ let heavy = false;
3308
+ if (styleAttr && HEAVY_OVERLAY_CSS_PATTERN.test(styleAttr)) heavy = true;
3309
+ if (!heavy && (heavyClassTokens.size > 0 || heavyIds.size > 0)) {
3310
+ const classList = (readAttr(tag.raw, "class") || "").split(/\s+/).filter(Boolean);
3311
+ if (classList.some((cls) => heavyClassTokens.has(cls))) heavy = true;
3312
+ if (!heavy) {
3313
+ const idValue = readAttr(tag.raw, "id");
3314
+ if (idValue && heavyIds.has(idValue)) heavy = true;
3315
+ }
3316
+ }
3317
+ if (heavy) heavyCount += 1;
3318
+ }
3319
+ if (heavyCount < HEAVY_OVERLAY_ELEMENT_COUNT_WARN) return [];
3320
+ const splitTarget = options.isSubComposition ? "Split this sub-composition further into per-transition mini-compositions" : "Split coherent scenes / transitions into separate .html files under compositions/";
3321
+ return [
3322
+ {
3323
+ code: "composition_heavy_overlay_count_high",
3324
+ severity: "warning",
3325
+ message: `This composition has ${heavyCount} elements carrying "heavy overlay" CSS (filter:blur, radial-gradient, or clip-path). Field signal: a composition with ~40 such elements \u2014 including opacity:0 / visibility:hidden ones \u2014 captures solid-black for the first ~half of the render, recovering near the end. Reproduces identically via drawElement, forced screenshot capture, and snapshot, so the capture layer itself is the offender (not encoder/mux). Independent of duration. Presence alone matters; only display:none elements are excluded here.`,
3326
+ fixHint: `${splitTarget} and concat the pieces (FFmpeg or the runtime's slideshow) so each capture only sees a small subset of heavy overlays at once. Even hidden overlays (opacity:0 / visibility:hidden) contribute \u2014 either remove truly unused ones from the source or scope them into their own per-transition sub-composition. If an overlay is genuinely inert for the whole clip, use display:none so it never enters the render tree. Field ref ts=1784040753 (#hyperframes-cli-feedback).`
3327
+ }
3328
+ ];
3232
3329
  }
3233
3330
  ];
3234
3331