@hyperframes/lint 0.8.5 → 0.8.7
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.d.ts +2 -0
- package/dist/browser.js +106 -138
- package/dist/browser.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +137 -138
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ type HyperframeLintFinding = {
|
|
|
8
8
|
elementId?: string;
|
|
9
9
|
fixHint?: string;
|
|
10
10
|
snippet?: string;
|
|
11
|
+
/** Optional standalone entry that command-specific guidance can act on. */
|
|
12
|
+
suggestedComposition?: string;
|
|
11
13
|
};
|
|
12
14
|
/**
|
|
13
15
|
* Where a single lint pass spent its time. Attributed per rule-source module
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@ var TIMELINE_REGISTRY_ASSIGN_PATTERN = /window\.__timelines(?:\[[^\]]+\]|\.[A-Za
|
|
|
7
7
|
var WINDOW_TIMELINE_ASSIGN_PATTERN = /window\.__timelines(?:\[\s*(?:["']([^"']+)["']|[A-Za-z_$][\w$.]*)\s*\]|\.\s*([A-Za-z_$][\w$]*))\s*=\s*([A-Za-z_$][\w$]*)/i;
|
|
8
8
|
var INVALID_SCRIPT_CLOSE_PATTERN = /<script[^>]*>[\s\S]*?<\s*\/\s*script(?!>)/i;
|
|
9
9
|
var TIMELINE_REGISTRY_KEY_PATTERN = /window\.__timelines(?:\[\s*["']([^"']+)["']\s*\]|\.\s*([A-Za-z_$][\w$]*))\s*=/g;
|
|
10
|
-
var
|
|
10
|
+
var TIMELINE_REGISTRY_OBJECT_OPEN_PATTERN = /window\.__timelines\s*=\s*\{/i;
|
|
11
11
|
var TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN = /(?:["']([^"']+)["']|([A-Za-z_$][\w$]*))\s*:\s*[A-Za-z_$][\w$]*/g;
|
|
12
12
|
function parseHtmlStructure(source) {
|
|
13
13
|
const tags = [];
|
|
@@ -135,18 +135,48 @@ function extractTimelineRegistryKeys(source) {
|
|
|
135
135
|
const key = match[1] ?? match[2];
|
|
136
136
|
if (key) keys.add(key);
|
|
137
137
|
}
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
138
|
+
for (const entry of readTimelineRegistryTopLevelKeys(source)) keys.add(entry);
|
|
139
|
+
return [...keys];
|
|
140
|
+
}
|
|
141
|
+
function findMatchingBrace(source, bodyStart) {
|
|
142
|
+
let depth = 1;
|
|
143
|
+
for (let i = bodyStart; i < source.length; i += 1) {
|
|
144
|
+
if (source[i] === "{") depth += 1;
|
|
145
|
+
else if (source[i] === "}" && (depth -= 1) === 0) return i;
|
|
146
|
+
}
|
|
147
|
+
return source.length;
|
|
148
|
+
}
|
|
149
|
+
function blankNestedBraceGroups(body) {
|
|
150
|
+
let out = "";
|
|
151
|
+
let depth = 0;
|
|
152
|
+
for (const ch of body) {
|
|
153
|
+
if (ch === "{") depth += 1;
|
|
154
|
+
else if (ch === "}") depth = Math.max(0, depth - 1);
|
|
155
|
+
else if (depth === 0) {
|
|
156
|
+
out += ch;
|
|
157
|
+
continue;
|
|
147
158
|
}
|
|
159
|
+
out += " ";
|
|
148
160
|
}
|
|
149
|
-
return
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
function readTimelineRegistryTopLevelKeys(source) {
|
|
164
|
+
const open = TIMELINE_REGISTRY_OBJECT_OPEN_PATTERN.exec(source);
|
|
165
|
+
if (!open) return [];
|
|
166
|
+
const bodyStart = open.index + open[0].length;
|
|
167
|
+
const body = source.slice(bodyStart, findMatchingBrace(source, bodyStart));
|
|
168
|
+
const flattened = blankNestedBraceGroups(body);
|
|
169
|
+
const keys = [];
|
|
170
|
+
const entryPattern = new RegExp(
|
|
171
|
+
TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN.source,
|
|
172
|
+
TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN.flags
|
|
173
|
+
);
|
|
174
|
+
let entry;
|
|
175
|
+
while ((entry = entryPattern.exec(flattened)) !== null) {
|
|
176
|
+
const key = entry[1] ?? entry[2];
|
|
177
|
+
if (key) keys.push(key);
|
|
178
|
+
}
|
|
179
|
+
return keys;
|
|
150
180
|
}
|
|
151
181
|
function getInlineScriptSyntaxError(source) {
|
|
152
182
|
if (!source.trim()) return null;
|
|
@@ -158,6 +188,12 @@ function getInlineScriptSyntaxError(source) {
|
|
|
158
188
|
return String(error);
|
|
159
189
|
}
|
|
160
190
|
}
|
|
191
|
+
function stripStringLiterals(source) {
|
|
192
|
+
return source.replace(
|
|
193
|
+
/(['"])(?:\\.|(?!\1)[^\\\n])*\1?/g,
|
|
194
|
+
(literal) => literal[0] + " ".repeat(Math.max(0, literal.length - 1))
|
|
195
|
+
);
|
|
196
|
+
}
|
|
161
197
|
function stripJsComments(source) {
|
|
162
198
|
let out = "";
|
|
163
199
|
let i = 0;
|
|
@@ -392,54 +428,8 @@ function describeStudioElement(tag) {
|
|
|
392
428
|
parts.push(">");
|
|
393
429
|
return parts.join("");
|
|
394
430
|
}
|
|
395
|
-
var HEAD_BLOCKS_TO_IGNORE_PATTERN = /<(?:style|script|template|title|noscript)\b[^>]*>[\s\S]*?<\/(?:style|script|template|title|noscript)(?:\s[^>]*)?>/gi;
|
|
396
|
-
var HTML_TAG_PATTERN = /<[^>]+>/g;
|
|
397
|
-
var HEAD_CONTENT_PATTERN = /<head\b[^>]*>([\s\S]*?)(?:<\/head>|<body\b|$)/gi;
|
|
398
|
-
var AFTER_HEAD_BEFORE_BODY_PATTERN = /<\/head(?:\s[^>]*)?>([\s\S]*?)(?=<body\b|$)/gi;
|
|
399
|
-
var STRAY_HEAD_CLOSE_PATTERN = /<\/(?:style|script)(?:\s[^>]*)?>/i;
|
|
400
|
-
var MARKDOWN_CODE_FENCE_PATTERN = /```[^\r\n`]*(?:\r?\n|$)[\s\S]*?```/i;
|
|
401
|
-
var ORPHAN_CSS_AT_RULE_PATTERN = /(?:^|\s)@(?:container|font-face|keyframes|layer|media|page|property|scope|supports)[^{<]*\{[\s\S]*?:[\s\S]*?\}/i;
|
|
402
|
-
var ORPHAN_CSS_RULE_PATTERN = /(?:^|\s)(?:\/\*[\s\S]*?\*\/\s*)?(?:@[a-z-]+[^{}<]*|[.#][\w-]+[^{}<]*|[a-z][\w-]*(?:\s+[.#:[\w-][^{}<]*)?)\s*\{[^{}]*:[^{}]*\}/i;
|
|
403
431
|
var VISIBLE_MARKUP_COMMENT_PATTERN = /\/\*[\s\S]*?\*\//g;
|
|
404
432
|
var VISIBLE_MARKUP_COMMENT_PROTECTED_BLOCK_PATTERN = /<(style|script|template|title|noscript|pre|code|textarea|text)\b[^>]*>[\s\S]*?<\/\1(?:\s[^>]*)?>/gi;
|
|
405
|
-
function findCodeFenceLeak(headWithoutValidBlocks) {
|
|
406
|
-
return MARKDOWN_CODE_FENCE_PATTERN.exec(headWithoutValidBlocks)?.[0] ?? null;
|
|
407
|
-
}
|
|
408
|
-
function findOrphanCssLeak(headContent) {
|
|
409
|
-
const residualText = headContent.replace(HEAD_BLOCKS_TO_IGNORE_PATTERN, " ").replace(HTML_TAG_PATTERN, " ");
|
|
410
|
-
return ORPHAN_CSS_AT_RULE_PATTERN.exec(residualText)?.[0] ?? ORPHAN_CSS_RULE_PATTERN.exec(residualText)?.[0] ?? null;
|
|
411
|
-
}
|
|
412
|
-
function findStrayCloseLeak(headWithoutValidBlocks) {
|
|
413
|
-
return STRAY_HEAD_CLOSE_PATTERN.exec(headWithoutValidBlocks)?.[0] ?? null;
|
|
414
|
-
}
|
|
415
|
-
function findLeakedTextInHeadContent(headContent) {
|
|
416
|
-
const withoutValidBlocks = headContent.replace(HEAD_BLOCKS_TO_IGNORE_PATTERN, " ");
|
|
417
|
-
return findCodeFenceLeak(withoutValidBlocks) ?? findOrphanCssLeak(headContent) ?? findStrayCloseLeak(withoutValidBlocks);
|
|
418
|
-
}
|
|
419
|
-
function findLeakedTextInHead(rawSource) {
|
|
420
|
-
const headMatches = [...rawSource.matchAll(HEAD_CONTENT_PATTERN)];
|
|
421
|
-
for (const match of headMatches) {
|
|
422
|
-
const leakedText = findLeakedTextInHeadContent(match[1] ?? "");
|
|
423
|
-
if (leakedText) return leakedText;
|
|
424
|
-
}
|
|
425
|
-
return null;
|
|
426
|
-
}
|
|
427
|
-
function findLeakedTextBetweenHeadAndBody(rawSource) {
|
|
428
|
-
const boundaryMatches = [...rawSource.matchAll(AFTER_HEAD_BEFORE_BODY_PATTERN)];
|
|
429
|
-
for (const match of boundaryMatches) {
|
|
430
|
-
const leakedText = findLeakedTextInHeadContent(match[1] ?? "");
|
|
431
|
-
if (leakedText) return leakedText;
|
|
432
|
-
}
|
|
433
|
-
return null;
|
|
434
|
-
}
|
|
435
|
-
function findLeakedTextBeforeCompositionRoot(source, rootTag) {
|
|
436
|
-
if (!rootTag || rootTag.name === "body") return null;
|
|
437
|
-
const bodyOpenMatch = /<body\b[^>]*>/i.exec(source);
|
|
438
|
-
const prefixStart = bodyOpenMatch ? bodyOpenMatch.index + bodyOpenMatch[0].length : 0;
|
|
439
|
-
const prefixEnd = rootTag.index;
|
|
440
|
-
if (prefixEnd <= prefixStart) return null;
|
|
441
|
-
return findLeakedTextInHeadContent(source.slice(prefixStart, prefixEnd));
|
|
442
|
-
}
|
|
443
433
|
function findProtectedVisibleMarkupRanges(source) {
|
|
444
434
|
const ranges = [];
|
|
445
435
|
for (const match of source.matchAll(VISIBLE_MARKUP_COMMENT_PROTECTED_BLOCK_PATTERN)) {
|
|
@@ -499,6 +489,7 @@ var coreRules = [
|
|
|
499
489
|
return findings;
|
|
500
490
|
},
|
|
501
491
|
// root_missing_composition_id + root_missing_dimensions
|
|
492
|
+
// fallow-ignore-next-line complexity
|
|
502
493
|
({ rootTag }) => {
|
|
503
494
|
const findings = [];
|
|
504
495
|
if (!rootTag || !readDecodedAttr(rootTag.raw, "data-composition-id")) {
|
|
@@ -523,20 +514,6 @@ var coreRules = [
|
|
|
523
514
|
}
|
|
524
515
|
return findings;
|
|
525
516
|
},
|
|
526
|
-
// head_leaked_text
|
|
527
|
-
({ source, rootTag }) => {
|
|
528
|
-
const snippet = findLeakedTextInHead(source) ?? findLeakedTextBetweenHeadAndBody(source) ?? findLeakedTextBeforeCompositionRoot(source, rootTag);
|
|
529
|
-
if (!snippet) return [];
|
|
530
|
-
return [
|
|
531
|
-
{
|
|
532
|
-
code: "head_leaked_text",
|
|
533
|
-
severity: "error",
|
|
534
|
-
message: "Detected leaked code or CSS text around the document `<head>` or before the composition root. Browsers render this as visible text in the video.",
|
|
535
|
-
fixHint: "Move CSS into a single `<style>...</style>` block and remove stray close tags, markdown fences, or code text from `<head>`, the `</head>`/`<body>` boundary, or the pre-root body prefix.",
|
|
536
|
-
snippet: truncateSnippet(snippet)
|
|
537
|
-
}
|
|
538
|
-
];
|
|
539
|
-
},
|
|
540
517
|
// visible_markup_comment
|
|
541
518
|
({ source }) => {
|
|
542
519
|
const snippet = findVisibleMarkupCommentLeak(source);
|
|
@@ -552,6 +529,7 @@ var coreRules = [
|
|
|
552
529
|
];
|
|
553
530
|
},
|
|
554
531
|
// missing_timeline_registry + timeline_registry_missing_init
|
|
532
|
+
// fallow-ignore-next-line complexity
|
|
555
533
|
({ source, rawSource, rootTag, options }) => {
|
|
556
534
|
if (options.isSubComposition || rawSource.trimStart().toLowerCase().startsWith("<template")) {
|
|
557
535
|
return [];
|
|
@@ -730,7 +708,10 @@ var coreRules = [
|
|
|
730
708
|
hint: "Remove time-dependent code. Use GSAP timeline position instead of wall-clock time."
|
|
731
709
|
},
|
|
732
710
|
{
|
|
733
|
-
|
|
711
|
+
// Zero-arg only. `new Date(<fixed timestamp>)` is fully deterministic and is how
|
|
712
|
+
// a composition labels a fixed date on an axis or card; the hint ("remove
|
|
713
|
+
// time-dependent code") cannot be applied to it without deleting the label.
|
|
714
|
+
pattern: /new\s+Date\s*\(\s*\)/,
|
|
734
715
|
label: "new Date()",
|
|
735
716
|
hint: "Remove time-dependent code. Use GSAP timeline position instead of wall-clock time."
|
|
736
717
|
},
|
|
@@ -751,15 +732,20 @@ var coreRules = [
|
|
|
751
732
|
},
|
|
752
733
|
{
|
|
753
734
|
// GSAP string form: "random(...)" / "+=random(...)" — re-rolls at tween init.
|
|
735
|
+
// `scansStrings` because here the string IS the executed value: GSAP parses it.
|
|
736
|
+
// Every other pattern above matches executable code, so a match inside a string
|
|
737
|
+
// literal is inert text and must not be reported.
|
|
754
738
|
pattern: /["'`](?:[+-]=)?random\(\s*[-\d[]/,
|
|
739
|
+
scansStrings: true,
|
|
755
740
|
label: '"random(...)" tween value',
|
|
756
741
|
hint: "GSAP random string values re-roll at tween init and each render worker initializes independently. Use fixed values or precompute with a seeded PRNG."
|
|
757
742
|
}
|
|
758
743
|
];
|
|
759
744
|
for (const script of scripts) {
|
|
760
|
-
const
|
|
761
|
-
|
|
762
|
-
|
|
745
|
+
const withoutComments = stripJsComments(script.content);
|
|
746
|
+
const executable = stripStringLiterals(withoutComments);
|
|
747
|
+
for (const { pattern, label, hint, scansStrings } of patterns) {
|
|
748
|
+
if (pattern.test(scansStrings ? withoutComments : executable)) {
|
|
763
749
|
findings.push({
|
|
764
750
|
code: "non_deterministic_code",
|
|
765
751
|
severity: "error",
|
|
@@ -1318,7 +1304,9 @@ var mediaRules = [
|
|
|
1318
1304
|
// audio_volume_double_automation
|
|
1319
1305
|
findVolumeDoubleAutomationFindings,
|
|
1320
1306
|
// audio_volume_tween_overrides_gain
|
|
1321
|
-
findVolumeTweenOverridesGainFindings
|
|
1307
|
+
findVolumeTweenOverridesGainFindings,
|
|
1308
|
+
// audio_carve_ungrouped_sources
|
|
1309
|
+
findCarveUngroupedSourcesFindings
|
|
1322
1310
|
];
|
|
1323
1311
|
function findVolumeTweenOverridesGainFindings(ctx) {
|
|
1324
1312
|
const boosted = ctx.tags.filter((tag) => isMediaTag(tag.name)).map((tag) => ({ tag, volume: Number(readAttr(tag.raw, "data-volume") ?? "1") })).filter((entry) => Number.isFinite(entry.volume) && entry.volume !== 1).filter((entry) => !readDecodedAttr(entry.tag.raw, "data-automation")).map((entry) => ({ ...entry, id: readAttr(entry.tag.raw, "id") })).filter((entry) => Boolean(entry.id));
|
|
@@ -1358,6 +1346,40 @@ function findVolumeDoubleAutomationFindings(ctx) {
|
|
|
1358
1346
|
}
|
|
1359
1347
|
return findings;
|
|
1360
1348
|
}
|
|
1349
|
+
function findCarveUngroupedSourcesFindings(ctx) {
|
|
1350
|
+
const groupIds = new Set(
|
|
1351
|
+
ctx.tags.filter((tag) => tag.name === "hf-audio-group").map((tag) => readAttr(tag.raw, "id"))
|
|
1352
|
+
);
|
|
1353
|
+
const findings = [];
|
|
1354
|
+
for (const tag of ctx.tags) {
|
|
1355
|
+
const raw = readDecodedAttr(tag.raw, "data-fx-carve");
|
|
1356
|
+
if (raw === null) continue;
|
|
1357
|
+
const trimmed = raw.trim();
|
|
1358
|
+
if (!trimmed.startsWith("{")) continue;
|
|
1359
|
+
let parsed;
|
|
1360
|
+
try {
|
|
1361
|
+
parsed = JSON.parse(trimmed);
|
|
1362
|
+
} catch {
|
|
1363
|
+
continue;
|
|
1364
|
+
}
|
|
1365
|
+
const sources = parsed.sources;
|
|
1366
|
+
if (!Array.isArray(sources)) continue;
|
|
1367
|
+
const clipIds = sources.filter(
|
|
1368
|
+
(id) => typeof id === "string" && !groupIds.has(id)
|
|
1369
|
+
);
|
|
1370
|
+
if (clipIds.length < 2) continue;
|
|
1371
|
+
const elementId = readAttr(tag.raw, "id") || void 0;
|
|
1372
|
+
findings.push({
|
|
1373
|
+
code: "audio_carve_ungrouped_sources",
|
|
1374
|
+
severity: "warning",
|
|
1375
|
+
message: `${elementId ? `#${elementId}'s` : "This"} carve names ${clipIds.length} voice clips directly (${clipIds.join(", ")}) instead of a group.`,
|
|
1376
|
+
elementId,
|
|
1377
|
+
fixHint: "Group the voice clips and carve against the group \u2014 a hand-rolled clip list silently rots when a clip is added.",
|
|
1378
|
+
snippet: truncateSnippet(tag.raw)
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
return findings;
|
|
1382
|
+
}
|
|
1361
1383
|
|
|
1362
1384
|
// src/rules/gsap.ts
|
|
1363
1385
|
async function loadParseGsapScript() {
|
|
@@ -2160,9 +2182,9 @@ ${right.raw}`)
|
|
|
2160
2182
|
(candidate) => targetedSelectorTokens(firstVisible.targetSelector).has(candidate)
|
|
2161
2183
|
) || selectors[0] || tag.name;
|
|
2162
2184
|
const laterHidden = visibilityWindows.some(
|
|
2163
|
-
(win) => win.position >= firstVisible.position && isHiddenGsapState(win.propertyValues)
|
|
2185
|
+
(win) => win !== firstVisible && win.method !== "from" && win.position >= firstVisible.position && isHiddenGsapState(win.propertyValues)
|
|
2164
2186
|
);
|
|
2165
|
-
if (
|
|
2187
|
+
if (!laterHidden) continue;
|
|
2166
2188
|
reportedVisibleOverlayKeys.add(overlayKey);
|
|
2167
2189
|
findings.push({
|
|
2168
2190
|
code: "gsap_fullscreen_overlay_starts_visible",
|
|
@@ -3111,7 +3133,12 @@ var captionRules = [
|
|
|
3111
3133
|
severity: "warning",
|
|
3112
3134
|
selector: (selector ?? "").trim(),
|
|
3113
3135
|
message: `Caption selector "${(selector ?? "").trim()}" has white-space: nowrap but no max-width. Long phrases will clip off-screen.`,
|
|
3114
|
-
fixHint:
|
|
3136
|
+
fixHint: (
|
|
3137
|
+
// Deliberately does NOT say `overflow: hidden`: caption words are scaled
|
|
3138
|
+
// above 1.0x, and clipping them is exactly what caption_overflow_clips_scaled_words
|
|
3139
|
+
// errors on. Recommending it here made this warning's own fix produce an error.
|
|
3140
|
+
"Add max-width: 1600px (landscape) or max-width: 900px (portrait). Keep overflow visible so scaled emphasis words are not clipped."
|
|
3141
|
+
)
|
|
3115
3142
|
});
|
|
3116
3143
|
}
|
|
3117
3144
|
}
|
|
@@ -3266,7 +3293,6 @@ var HEAVY_OVERLAY_EXEMPT_TAGS = /* @__PURE__ */ new Set([
|
|
|
3266
3293
|
]);
|
|
3267
3294
|
var HEAVY_OVERLAY_CSS_PATTERN = /(?:filter\s*:[^;}]*\bblur\s*\()|(?:clip-path\s*:(?!\s*(?:none|inherit|initial|unset)\b)\s*[^;}]+)|(?:radial-gradient\s*\()/i;
|
|
3268
3295
|
var INLINE_STYLE_DISPLAY_NONE_PATTERN = /(?:^|;)\s*display\s*:\s*none\b/i;
|
|
3269
|
-
var OVERLAP_EPSILON_SECONDS = 1e-6;
|
|
3270
3296
|
function readTagTiming(rawTag) {
|
|
3271
3297
|
return readClipTiming({ getAttribute: (name) => readAttr(rawTag, name) });
|
|
3272
3298
|
}
|
|
@@ -3631,64 +3657,6 @@ var compositionRules = [
|
|
|
3631
3657
|
}
|
|
3632
3658
|
return findings;
|
|
3633
3659
|
},
|
|
3634
|
-
// overlapping_clips_same_track
|
|
3635
|
-
// fallow-ignore-next-line complexity
|
|
3636
|
-
({ tags }) => {
|
|
3637
|
-
const findings = [];
|
|
3638
|
-
const trackMap = /* @__PURE__ */ new Map();
|
|
3639
|
-
for (const tag of tags) {
|
|
3640
|
-
const trackStr = readAttr(tag.raw, COMPOSITION_ATTRIBUTES.trackIndex);
|
|
3641
|
-
if (!trackStr) continue;
|
|
3642
|
-
const timing = readTagTiming(tag.raw);
|
|
3643
|
-
const { start, duration } = timing;
|
|
3644
|
-
const track = trackStr;
|
|
3645
|
-
if (start == null || duration == null) continue;
|
|
3646
|
-
const clips = trackMap.get(track) || [];
|
|
3647
|
-
clips.push({
|
|
3648
|
-
start,
|
|
3649
|
-
end: start + duration,
|
|
3650
|
-
elementId: readAttr(tag.raw, "id") || void 0,
|
|
3651
|
-
snippet: truncateSnippet(tag.raw) || ""
|
|
3652
|
-
});
|
|
3653
|
-
trackMap.set(track, clips);
|
|
3654
|
-
}
|
|
3655
|
-
for (const [track, clips] of trackMap) {
|
|
3656
|
-
clips.sort((a, b) => a.start - b.start);
|
|
3657
|
-
for (let i = 0; i < clips.length - 1; i++) {
|
|
3658
|
-
const current = clips[i];
|
|
3659
|
-
const next = clips[i + 1];
|
|
3660
|
-
if (!current || !next) continue;
|
|
3661
|
-
if (current.end - next.start > OVERLAP_EPSILON_SECONDS) {
|
|
3662
|
-
findings.push({
|
|
3663
|
-
code: "overlapping_clips_same_track",
|
|
3664
|
-
severity: "error",
|
|
3665
|
-
message: `Track ${track}: clip ending at ${current.end}s overlaps with clip starting at ${next.start}s. Overlapping clips on the same track cause rendering conflicts.`,
|
|
3666
|
-
fixHint: "Adjust data-start or data-duration so clips on the same track do not overlap, or move one clip to a different data-track-index."
|
|
3667
|
-
});
|
|
3668
|
-
}
|
|
3669
|
-
}
|
|
3670
|
-
}
|
|
3671
|
-
return findings;
|
|
3672
|
-
},
|
|
3673
|
-
// root_composition_missing_data_start
|
|
3674
|
-
({ rootTag, options }) => {
|
|
3675
|
-
const findings = [];
|
|
3676
|
-
if (options.isSubComposition) return findings;
|
|
3677
|
-
if (!rootTag) return findings;
|
|
3678
|
-
const compId = readDecodedAttr(rootTag.raw, "data-composition-id");
|
|
3679
|
-
if (!compId) return findings;
|
|
3680
|
-
const hasStart = readAttr(rootTag.raw, "data-start") !== null;
|
|
3681
|
-
if (!hasStart) {
|
|
3682
|
-
findings.push({
|
|
3683
|
-
code: "root_composition_missing_data_start",
|
|
3684
|
-
severity: "error",
|
|
3685
|
-
message: `Root composition "${compId}" is missing data-start. The runtime needs data-start="0" on the root element to begin playback.`,
|
|
3686
|
-
fixHint: 'Add data-start="0" to the root composition element.',
|
|
3687
|
-
snippet: truncateSnippet(rootTag.raw)
|
|
3688
|
-
});
|
|
3689
|
-
}
|
|
3690
|
-
return findings;
|
|
3691
|
-
},
|
|
3692
3660
|
// standalone_composition_wrapped_in_template
|
|
3693
3661
|
({ rawSource, options }) => {
|
|
3694
3662
|
const findings = [];
|
|
@@ -4996,6 +4964,7 @@ async function lintProject(projectDir, entryFile) {
|
|
|
4996
4964
|
...lintMissingLocalAsset(projectDir, allHtmlSources),
|
|
4997
4965
|
...lintTextureMaskAssetNotFound(projectDir, allHtmlSources),
|
|
4998
4966
|
...!entryFile ? lintMultipleRootCompositions(projectDir) : [],
|
|
4967
|
+
...!entryFile ? lintBlankRootWithStandaloneComposition(rootHtml, allHtmlSources) : [],
|
|
4999
4968
|
...lintDuplicateAudioTracks(allHtmlSources),
|
|
5000
4969
|
...lintMissingOrEmptySubComposition(projectDir, rootHtml),
|
|
5001
4970
|
...await lintHevcPreviewCodec(collectLocalVideoCandidates(projectDir, allHtmlSources))
|
|
@@ -5018,6 +4987,36 @@ async function lintProject(projectDir, entryFile) {
|
|
|
5018
4987
|
}
|
|
5019
4988
|
return { results, totalErrors, totalWarnings, totalInfos };
|
|
5020
4989
|
}
|
|
4990
|
+
function lintBlankRootWithStandaloneComposition(rootHtml, htmlSources) {
|
|
4991
|
+
const { document: rootDocument } = parseHTML(rootHtml);
|
|
4992
|
+
const root = rootDocument.querySelector("body [data-composition-id]");
|
|
4993
|
+
if (!root || root.querySelector("*:not(script):not(style):not(link):not(meta):not(template)")) {
|
|
4994
|
+
return [];
|
|
4995
|
+
}
|
|
4996
|
+
const standaloneCandidates = [];
|
|
4997
|
+
for (const source of htmlSources) {
|
|
4998
|
+
if (!source.compSrcPath) continue;
|
|
4999
|
+
const { document } = parseHTML(source.html);
|
|
5000
|
+
const composition = document.querySelector("body [data-composition-id]");
|
|
5001
|
+
if (!composition) continue;
|
|
5002
|
+
const authoredTimedContent = Array.from(
|
|
5003
|
+
composition.querySelectorAll(
|
|
5004
|
+
".clip, [data-start], [data-end], video, audio, img, svg, canvas"
|
|
5005
|
+
)
|
|
5006
|
+
).some((element) => !element.hasAttribute("data-composition-src"));
|
|
5007
|
+
if (authoredTimedContent) standaloneCandidates.push(source.compSrcPath);
|
|
5008
|
+
}
|
|
5009
|
+
if (standaloneCandidates.length === 0) return [];
|
|
5010
|
+
return [
|
|
5011
|
+
{
|
|
5012
|
+
code: "blank_root_with_standalone_composition",
|
|
5013
|
+
severity: "error",
|
|
5014
|
+
message: `The default index.html composition has no renderable content, but ${standaloneCandidates.join(", ")} contains a standalone timed composition. Default check, snapshot, preview, render, and publish commands open index.html, so they will capture or publish only its background.`,
|
|
5015
|
+
fixHint: `Move the authored composition into index.html, or mount it from index.html with data-composition-src and the sub-composition <template> contract. If the separate file is intentional, render it explicitly with --composition ${standaloneCandidates[0]}.`,
|
|
5016
|
+
suggestedComposition: standaloneCandidates[0]
|
|
5017
|
+
}
|
|
5018
|
+
];
|
|
5019
|
+
}
|
|
5021
5020
|
function lintProjectAudioFiles(projectDir, htmlSources) {
|
|
5022
5021
|
const findings = [];
|
|
5023
5022
|
let audioFiles;
|