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