@hyperframes/lint 0.8.73 → 0.8.75
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 +242 -0
- package/dist/browser.js.map +1 -1
- package/dist/index.js +242 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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"]);
|