@hyperframes/lint 0.8.73 → 0.8.74

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
@@ -2254,6 +2254,7 @@ import {
2254
2254
  readAuthoredDurationSeconds,
2255
2255
  resolveMediaDuration
2256
2256
  } from "@hyperframes/parsers/media-duration";
2257
+ var INSPECTION_VIEWPORT_CAP = 4096;
2257
2258
  var MAX_COMPOSITION_LINES = 300;
2258
2259
  var MAX_TIMED_ELEMENTS_PER_TRACK = 3;
2259
2260
  var TRACK_DENSITY_EXEMPT_TAGS = /* @__PURE__ */ new Set(["audio", "script", "style", "video"]);
@@ -2376,6 +2377,81 @@ function rootClassStyledSelectors(styles, rootClasses) {
2376
2377
  }
2377
2378
  return offenders;
2378
2379
  }
2380
+ var ZOOM_DECLARATION = /(?<![\w-])zoom\s*:\s*([^;}]+)/gi;
2381
+ function isIdentityZoom(rawValue) {
2382
+ const value = rawValue.trim().replace(/!\s*important\s*$/i, "").trim().toLowerCase();
2383
+ return value === "" || value === "normal" || value === "unset" || value === "initial" || value === "revert" || value === "1" || value === "1.0" || value === "100%";
2384
+ }
2385
+ function firstRescalingZoom(css) {
2386
+ ZOOM_DECLARATION.lastIndex = 0;
2387
+ let match;
2388
+ while ((match = ZOOM_DECLARATION.exec(css)) !== null) {
2389
+ const value = (match[1] ?? "").trim();
2390
+ if (!isIdentityZoom(value)) return value.replace(/!\s*important\s*$/i, "").trim();
2391
+ }
2392
+ return null;
2393
+ }
2394
+ function targetsCanvasRoot(selector, rootId, rootClasses) {
2395
+ const leftmost = selector.trim().split(/[\s>+~]+/)[0] ?? "";
2396
+ const bare = leftmost.toLowerCase();
2397
+ if (bare === "html" || bare === "body" || bare === ":root" || bare === "*") return true;
2398
+ if (rootId && leftmostCompoundId(selector) === rootId) return true;
2399
+ return leftmostCompoundClasses(selector).some((cls) => rootClasses.includes(cls));
2400
+ }
2401
+ function inlineCanvasZoomHits(rootTag, tags) {
2402
+ const hits = [];
2403
+ const htmlTag = findHtmlTag(tags);
2404
+ const bodyTag = tags.find((tag) => tag.name.toLowerCase() === "body");
2405
+ for (const [label, tag] of [
2406
+ ["the root element's inline style", rootTag],
2407
+ ["<html>'s inline style", htmlTag],
2408
+ ["<body>'s inline style", bodyTag]
2409
+ ]) {
2410
+ if (!tag) continue;
2411
+ const inline = readAttr(tag.raw, "style");
2412
+ const value = inline ? firstRescalingZoom(inline) : null;
2413
+ if (value) hits.push({ where: label, value, snippet: truncateSnippet(tag.raw) });
2414
+ }
2415
+ return hits;
2416
+ }
2417
+ function firstCanvasSelector(header, rootId, rootClasses) {
2418
+ for (const selector of header.split(",")) {
2419
+ const trimmed = selector.trim();
2420
+ if (trimmed && targetsCanvasRoot(trimmed, rootId, rootClasses)) return trimmed;
2421
+ }
2422
+ return null;
2423
+ }
2424
+ function canvasZoomInRule(header, body, rootId, rootClasses) {
2425
+ const trimmedHeader = header.trim();
2426
+ if (!trimmedHeader || trimmedHeader.startsWith("@")) return null;
2427
+ const value = firstRescalingZoom(body);
2428
+ if (!value) return null;
2429
+ const selector = firstCanvasSelector(trimmedHeader, rootId, rootClasses);
2430
+ if (!selector) return null;
2431
+ return {
2432
+ where: `\`${selector}\``,
2433
+ value,
2434
+ snippet: truncateSnippet(`${selector} { zoom: ${value} }`)
2435
+ };
2436
+ }
2437
+ function stylesheetCanvasZoomHits(styles, rootId, rootClasses) {
2438
+ const hits = [];
2439
+ for (const style of styles) {
2440
+ const ruleWithBody = /([^{}]+)\{([^{}]*)\}/g;
2441
+ let match;
2442
+ while ((match = ruleWithBody.exec(stripCssComments(style.content))) !== null) {
2443
+ const hit = canvasZoomInRule(match[1] ?? "", match[2] ?? "", rootId, rootClasses);
2444
+ if (hit) hits.push(hit);
2445
+ }
2446
+ }
2447
+ return hits;
2448
+ }
2449
+ function canvasZoomHits(rootTag, tags, styles, rootId, rootClasses) {
2450
+ return [
2451
+ ...inlineCanvasZoomHits(rootTag, tags),
2452
+ ...stylesheetCanvasZoomHits(styles, rootId, rootClasses)
2453
+ ];
2454
+ }
2379
2455
  function collectDeclaredVariableIds(htmlTagRaw) {
2380
2456
  const declared = /* @__PURE__ */ new Set();
2381
2457
  const raw = readJsonAttr(htmlTagRaw, "data-composition-variables");
@@ -2414,6 +2490,29 @@ function isInsideInertTemplate(tag, tags) {
2414
2490
  (candidate) => candidate.name === "template" && candidate.closeIndex != null && tag.index > candidate.index && tag.index < candidate.closeIndex
2415
2491
  );
2416
2492
  }
2493
+ var NEGATIVE_Z_INDEX = /(?<![\w-])z-index\s*:\s*-(?!0(?:\s*[;}]|\s*$))([\d.]+)/g;
2494
+ function cssOwnerSelector(content, matchIndex) {
2495
+ const blockStart = content.lastIndexOf("{", matchIndex);
2496
+ if (blockStart === -1) return void 0;
2497
+ const previousBlockEnd = content.lastIndexOf("}", blockStart);
2498
+ const lines = content.slice(previousBlockEnd + 1, blockStart).trim().split("\n");
2499
+ return lines[lines.length - 1]?.trim() || void 0;
2500
+ }
2501
+ function elementSelector(tag) {
2502
+ const elementId = readAttr(tag.raw, "id");
2503
+ return elementId ? `#${elementId}` : void 0;
2504
+ }
2505
+ function negativeZIndexFinding(match, selector) {
2506
+ const level = match[1] ?? "";
2507
+ return {
2508
+ code: "negative_z_index",
2509
+ severity: "warning",
2510
+ ...selector ? { selector } : {},
2511
+ message: `\`z-index: -${level}\` paints the element behind its nearest stacking context's own content. With no stacking-context ancestor that context is the composition root itself, so an opaque background there hides it entirely: in the DOM, laid out, and absent from the picture. A transparent root leaves it visible. Siblings at \`z-index: 0\` or above are unaffected.`,
2512
+ fixHint: "Give the element's parent a stacking context - `isolation: isolate` is the cheapest, and `transform`, `filter`, `opacity` below 1, `contain: paint` and `will-change` all work too. The negative-z child then paints above that parent's background and renders normally. Or express paint order through DOM order - an earlier sibling paints behind a later one - and raise the elements that should sit in front rather than lowering this one.",
2513
+ snippet: truncateSnippet(match[0])
2514
+ };
2515
+ }
2417
2516
  var compositionRules = [
2418
2517
  // duplicate_composition_id catches meta-tag/root collisions that create duplicate composition entries.
2419
2518
  ({ tags }) => {
@@ -2704,6 +2803,59 @@ var compositionRules = [
2704
2803
  }
2705
2804
  ];
2706
2805
  },
2806
+ // negative_z_index
2807
+ // An element at a negative z-index is silently absent from both `snapshot`
2808
+ // and `render`, while siblings differing only in the sign of z-index render
2809
+ // exactly (heygen-com/hyperframes#4366). lint, validate and render all exit 0
2810
+ // and report nothing, so the first suspicion falls on the author's own CSS.
2811
+ // NOT A RENDERER DEFECT -- ORDINARY CSS PAINTING ORDER, measured at 0.8.72.
2812
+ // The element is PAINTED; it is simply painted beneath something opaque. Remove
2813
+ // every opaque background above it and the same `z-index: -1` band renders at
2814
+ // full coverage, identically to the same band with `z-index` deleted. A negative-z
2815
+ // descendant paints at step 2 of its nearest stacking context -- above that
2816
+ // context root's own background, but below the context's positioned in-flow
2817
+ // content -- so a `position: relative` composition root with an opaque background
2818
+ // paints over it. That is the shape #4366 reports; nothing is dropped and the
2819
+ // renderer has no say in it.
2820
+ //
2821
+ // Kept as a warning because authors hit it and nothing explains why: the element
2822
+ // is in the DOM, laid out, and invisible. The message therefore names the CAUSE
2823
+ // and the remedy rather than implying the tool failed.
2824
+ //
2825
+ // THE CONDITION IS LOAD-BEARING AND THE MESSAGE STATES IT. The element is only
2826
+ // dropped when its nearest ancestor stacking context is the composition root,
2827
+ // which is the shape #4366 reports. Give any ancestor a stacking context and it
2828
+ // renders correctly: measured at 0.8.72 on ONE frame carrying the same
2829
+ // `z-index: -1` band under six triggers -- isolation:isolate, transform,
2830
+ // opacity below 1, filter, contain:paint, will-change -- all six PRESENT at full
2831
+ // coverage, against the no-stacking-context control ABSENT at zero coverage.
2832
+ // That is ordinary CSS painting order: a negative-z child paints above its
2833
+ // stacking context root's own background, so only the root case is hidden.
2834
+ //
2835
+ // This rule matches CSS TEXT and does not resolve the cascade, so it cannot know
2836
+ // whether an ancestor forms a stacking context and fires on both shapes. That is
2837
+ // why the message is CONDITIONAL rather than an assertion of absence: an
2838
+ // unconditional "silently dropped" is false for every isolated case, and a lint
2839
+ // message that overclaims is how authors learn to disregard the rule.
2840
+ ({ tags, styles }) => {
2841
+ const findings = [];
2842
+ for (const style of styles) {
2843
+ const content = stripCssComments(style.content);
2844
+ NEGATIVE_Z_INDEX.lastIndex = 0;
2845
+ let match;
2846
+ while ((match = NEGATIVE_Z_INDEX.exec(content)) !== null) {
2847
+ findings.push(negativeZIndexFinding(match, cssOwnerSelector(content, match.index)));
2848
+ }
2849
+ }
2850
+ for (const tag of tags) {
2851
+ const inline = readAttr(tag.raw, "style");
2852
+ if (!inline) continue;
2853
+ NEGATIVE_Z_INDEX.lastIndex = 0;
2854
+ const match = NEGATIVE_Z_INDEX.exec(inline);
2855
+ if (match) findings.push(negativeZIndexFinding(match, elementSelector(tag)));
2856
+ }
2857
+ return findings;
2858
+ },
2707
2859
  // requestanimationframe_in_composition
2708
2860
  ({ scripts, rawSource, options }) => {
2709
2861
  if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
@@ -3123,6 +3275,96 @@ ${allInlineStyles}`.replace(/\/\*[\s\S]*?\*\//g, "");
3123
3275
  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).`
3124
3276
  }
3125
3277
  ];
3278
+ },
3279
+ // root_zoom_rescales_a_fixed_canvas
3280
+ //
3281
+ // The capture frame is sized from the root's data-width/data-height, which are
3282
+ // LAYOUT pixels. CSS `zoom` on the canvas itself (the root, or html/body above
3283
+ // it) rescales the painted content inside that frame without changing the frame,
3284
+ // so the two disagree and the author gets neither of the two things they might
3285
+ // have meant.
3286
+ //
3287
+ // Measured at 0.8.71 on an 800x400 composition holding two 100x100 boxes at
3288
+ // left:100 and left:600, rendered to a PNG sequence and the boxes located by
3289
+ // colour:
3290
+ //
3291
+ // root zoom:0.5 canvas stays 800x400; boxes paint at (50,25) and (300,25),
3292
+ // each 50x50 -- the composition occupies the top-left quarter
3293
+ // and the rest of every frame is dead space.
3294
+ // root zoom:2 canvas stays 800x400; the left:100 box paints at (200,100)
3295
+ // at 200x200, and the left:600 box renders ZERO PIXELS --
3296
+ // scaled to x=1200, outside a frame that is still 800 wide.
3297
+ //
3298
+ // The second case is the one worth an error: content that is inside the declared
3299
+ // composition silently does not exist in the output, with nothing else in lint,
3300
+ // check or the render log naming it.
3301
+ //
3302
+ // Deliberately NOT flagged: `zoom` on a descendant. Measured in the same run --
3303
+ // a child at left:100 with zoom:2 paints at (200,100) at 200x200, which is
3304
+ // exactly what CSS `zoom` specifies. It is honoured, not ignored, so a rule that
3305
+ // fired on every `zoom` would be flagging correct authoring.
3306
+ ({ rootTag, tags, styles }) => {
3307
+ if (!rootTag) return [];
3308
+ const rootId = readAttr(rootTag.raw, "id");
3309
+ const rootClasses = (readAttr(rootTag.raw, "class") || "").split(/\s+/).filter(Boolean);
3310
+ const hits = canvasZoomHits(rootTag, tags, styles, rootId, rootClasses);
3311
+ const hit = hits[0];
3312
+ if (!hit) return [];
3313
+ const enlarging = !hit.value.startsWith("-") && parseFloat(hit.value) > (hit.value.includes("%") ? 100 : 1);
3314
+ return [
3315
+ {
3316
+ code: "root_zoom_rescales_a_fixed_canvas",
3317
+ severity: "error",
3318
+ message: `\`zoom: ${hit.value}\` on ${hit.where} rescales the painted composition inside a frame that does not rescale with it. The capture frame is sized from the root's data-width/data-height in LAYOUT pixels, and \`zoom\` changes only what is painted inside it, so ` + (enlarging ? `content past the frame's edge renders zero pixels \u2014 it is inside the declared composition and absent from the output, with nothing else reporting it.` : `the composition paints into part of the frame and the remainder of every output frame is dead space.`),
3319
+ fixHint: `Author the composition at its real size \u2014 set data-width/data-height (and the root's width/height) to the dimensions you want \u2014 and drop the canvas-level \`zoom\`. To scale the OUTPUT without changing layout, pass \`--output-resolution\` to render, which supersamples. \`zoom\` on descendants is fine and is not flagged: it is honoured exactly as specified.`,
3320
+ snippet: hit.snippet
3321
+ }
3322
+ ];
3323
+ },
3324
+ // composition_exceeds_inspection_viewport_cap
3325
+ //
3326
+ // packages/cli/src/utils/compositionViewport.ts caps a parsed data-width /
3327
+ // data-height at MAX_VIEWPORT_DIMENSION (4096) with a plain Math.min and no
3328
+ // warning. captureCompositionFrame resolves its viewport through that helper,
3329
+ // and check, validate, snapshot, compare and layout all capture through it;
3330
+ // layout and motionShot carry their own independent Math.min(..., 4096).
3331
+ //
3332
+ // `render` does NOT go through it, and that asymmetry is the whole finding.
3333
+ // Measured at 0.8.71 on a 5000x400 composition with 100x100 boxes at left:100
3334
+ // and left:4500:
3335
+ //
3336
+ // render -> 5000x400 output, both boxes present.
3337
+ // snapshot -> 4096x400 frame, the left:4500 box ABSENT.
3338
+ //
3339
+ // So the video is correct and the tools an author would reach for to check it
3340
+ // silently cannot see the last 904 px. The failure direction is the awkward
3341
+ // one: `check` comes back clean on a region it never rendered, and someone
3342
+ // debugging a missing element through `snapshot` chases a difference that
3343
+ // exists only in the instrument.
3344
+ //
3345
+ // A warning, not an error: the deliverable is fine, and a composition wider
3346
+ // than 4096 can be entirely deliberate.
3347
+ ({ rootTag }) => {
3348
+ if (!rootTag) return [];
3349
+ const over = [];
3350
+ for (const attr of ["data-width", "data-height"]) {
3351
+ const raw = readAttr(rootTag.raw, attr);
3352
+ if (!raw) continue;
3353
+ const value = Number.parseInt(raw, 10);
3354
+ if (Number.isFinite(value) && value > INSPECTION_VIEWPORT_CAP) {
3355
+ over.push(`${attr}=${value}`);
3356
+ }
3357
+ }
3358
+ if (over.length === 0) return [];
3359
+ return [
3360
+ {
3361
+ code: "composition_exceeds_inspection_viewport_cap",
3362
+ severity: "warning",
3363
+ message: `${over.join(" and ")} exceeds the ${INSPECTION_VIEWPORT_CAP}px viewport cap that check, validate, snapshot, compare and layout clamp to. render is unaffected and produces the full size, so the video is correct \u2014 but every inspection command captures a ${INSPECTION_VIEWPORT_CAP}px-wide frame, and anything beyond that is missing from what they report. Measured at 0.8.71: a 5000x400 composition renders 5000x400 with all content, while snapshot returns 4096x400 with the element at left:4500 absent.`,
3364
+ fixHint: `Keep the authored size if the output needs it, and verify the region past ${INSPECTION_VIEWPORT_CAP}px from a render rather than from check/snapshot/compare \u2014 a clean inspection result does not cover it. If the large canvas is only there to gain resolution, author at the layout size and pass \`--output-resolution\` to render instead, which supersamples without changing layout.`,
3365
+ snippet: truncateSnippet(rootTag.raw)
3366
+ }
3367
+ ];
3126
3368
  }
3127
3369
  ];
3128
3370
  var CLIP_MEDIA_TAGS = /* @__PURE__ */ new Set(["img", "video", "audio"]);