@hyperframes/lint 0.8.40 → 0.8.41

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
@@ -5379,7 +5379,7 @@ import { existsSync as existsSync2, readFileSync, readdirSync } from "fs";
5379
5379
  import { dirname, extname, join as join2, relative, resolve } from "path";
5380
5380
  import { rewriteAssetPath as rewriteAssetPath2 } from "@hyperframes/parsers/asset-paths";
5381
5381
  import { checkSubCompositionUsability } from "@hyperframes/parsers/sub-composition-validity";
5382
- import { parseHTML as parseHTML2 } from "linkedom";
5382
+ import { parseHTML as parseHTML3 } from "linkedom";
5383
5383
  import {
5384
5384
  cleanAssetUrl as cleanAssetUrl2,
5385
5385
  collectSubCompositionSrcs,
@@ -5396,6 +5396,7 @@ import { execFile } from "child_process";
5396
5396
  import { existsSync } from "fs";
5397
5397
  import { join } from "path";
5398
5398
  import { rewriteAssetPath } from "@hyperframes/parsers/asset-paths";
5399
+ import { parseNumeric } from "@hyperframes/parsers/composition-contract";
5399
5400
  import { findFfBinary } from "@hyperframes/parsers/ff-binaries";
5400
5401
  import {
5401
5402
  cleanAssetUrl,
@@ -5404,6 +5405,7 @@ import {
5404
5405
  maskNonScannableRanges,
5405
5406
  resolveExistingLocalAsset
5406
5407
  } from "@hyperframes/parsers/asset-resolution";
5408
+ import { parseHTML as parseHTML2 } from "linkedom";
5407
5409
  var PROBE_TIMEOUT_MS = 4e3;
5408
5410
  var PROBE_CONCURRENCY = 8;
5409
5411
  function execFileAsync(file, args) {
@@ -5414,6 +5416,29 @@ function execFileAsync(file, args) {
5414
5416
  });
5415
5417
  });
5416
5418
  }
5419
+ async function mapWithProbeConcurrency(entries, probe) {
5420
+ const results = new Array(entries.length);
5421
+ let nextIndex = 0;
5422
+ const workerCount = Math.min(PROBE_CONCURRENCY, entries.length);
5423
+ const runWorker = async () => {
5424
+ for (; ; ) {
5425
+ const index = nextIndex++;
5426
+ if (index >= entries.length) return;
5427
+ const entry = entries[index];
5428
+ if (entry !== void 0) results[index] = await probe(entry);
5429
+ }
5430
+ };
5431
+ await Promise.all(Array.from({ length: workerCount }, runWorker));
5432
+ return results;
5433
+ }
5434
+ function resolveLocalVideoReference(projectDir, rawSrc, compSrcPath) {
5435
+ if (isUnresolvedAssetPlaceholder(rawSrc)) return null;
5436
+ const src = cleanAssetUrl(rawSrc);
5437
+ if (!src || isRemoteOrInlineUrl(src)) return null;
5438
+ const rootRelative = compSrcPath ? rewriteAssetPath(compSrcPath, src, (path) => existsSync(join(projectDir, path))) : src;
5439
+ const asset = resolveExistingLocalAsset(projectDir, rootRelative);
5440
+ return asset ? { resolved: asset.resolved, src } : null;
5441
+ }
5417
5442
  function hasHevcStream(json) {
5418
5443
  if (typeof json !== "object" || json === null) return false;
5419
5444
  const streams = Reflect.get(json, "streams");
@@ -5451,35 +5476,143 @@ function collectLocalVideoCandidates(projectDir, htmlSources) {
5451
5476
  let match;
5452
5477
  while ((match = re.exec(scannable)) !== null) {
5453
5478
  const rawSrc = match[2] ?? "";
5454
- if (isUnresolvedAssetPlaceholder(rawSrc)) continue;
5455
- const src = cleanAssetUrl(rawSrc);
5456
- if (!src) continue;
5457
- if (isRemoteOrInlineUrl(src)) continue;
5458
- const rootRelative = compSrcPath ? rewriteAssetPath(compSrcPath, src, (path) => existsSync(join(projectDir, path))) : src;
5459
- const resolvedAsset = resolveExistingLocalAsset(projectDir, rootRelative);
5460
- if (!resolvedAsset) continue;
5461
- if (!candidates.has(resolvedAsset.resolved)) candidates.set(resolvedAsset.resolved, src);
5479
+ const reference = resolveLocalVideoReference(projectDir, rawSrc, compSrcPath);
5480
+ if (!reference || candidates.has(reference.resolved)) continue;
5481
+ candidates.set(reference.resolved, reference.src);
5462
5482
  }
5463
5483
  }
5464
5484
  return candidates;
5465
5485
  }
5486
+ function collectVideoElements(html) {
5487
+ const { document } = parseHTML2(html);
5488
+ const roots = [document];
5489
+ const videos = [];
5490
+ for (let index = 0; index < roots.length; index++) {
5491
+ const root = roots[index];
5492
+ if (!root) continue;
5493
+ videos.push(...root.querySelectorAll("video"));
5494
+ for (const template of root.querySelectorAll("template")) {
5495
+ roots.push(template.content);
5496
+ }
5497
+ }
5498
+ return videos;
5499
+ }
5500
+ function readPositiveNumber(raw) {
5501
+ const value = parseNumeric(raw);
5502
+ return value !== null && value > 0 ? value : null;
5503
+ }
5504
+ function readNonNegativeNumber(raw) {
5505
+ const value = parseNumeric(raw);
5506
+ return value !== null && value >= 0 ? value : null;
5507
+ }
5508
+ function readFiniteVideoSlot(video, file) {
5509
+ if (video.hasAttribute("loop")) return null;
5510
+ if (video.hasAttribute("data-var-src")) return null;
5511
+ if (video.hasAttribute("data-playback-start")) return null;
5512
+ if (readPositiveNumber(video.getAttribute("data-duration")) === null) return null;
5513
+ const mediaStart = readNonNegativeNumber(video.getAttribute("data-media-start"));
5514
+ if (mediaStart === null) return null;
5515
+ return {
5516
+ rawSrc: video.getAttribute("src") ?? "",
5517
+ file,
5518
+ ...video.id ? { elementId: video.id } : {},
5519
+ mediaStart
5520
+ };
5521
+ }
5522
+ function collectLocalFiniteVideoSlots(projectDir, htmlSources) {
5523
+ const slotsByPath = /* @__PURE__ */ new Map();
5524
+ for (const { html, compSrcPath } of htmlSources) {
5525
+ for (const video of collectVideoElements(html)) {
5526
+ const slot = readFiniteVideoSlot(video, compSrcPath ?? "index.html");
5527
+ if (!slot) continue;
5528
+ const reference = resolveLocalVideoReference(projectDir, slot.rawSrc, compSrcPath);
5529
+ if (!reference) continue;
5530
+ const slots = slotsByPath.get(reference.resolved) ?? [];
5531
+ slots.push({
5532
+ src: reference.src,
5533
+ file: slot.file,
5534
+ ...slot.elementId ? { elementId: slot.elementId } : {},
5535
+ mediaStart: slot.mediaStart
5536
+ });
5537
+ slotsByPath.set(reference.resolved, slots);
5538
+ }
5539
+ }
5540
+ return slotsByPath;
5541
+ }
5542
+ function parsePositiveDuration(value) {
5543
+ const duration = typeof value === "number" || typeof value === "string" ? Number(value) : NaN;
5544
+ return Number.isFinite(duration) && duration > 0 ? duration : null;
5545
+ }
5546
+ function readPlayableVideoDuration(stdout) {
5547
+ try {
5548
+ const metadata = JSON.parse(stdout);
5549
+ if (typeof metadata !== "object" || metadata === null) return null;
5550
+ const streams = Reflect.get(metadata, "streams");
5551
+ const stream = Array.isArray(streams) ? streams.find((candidate) => typeof candidate === "object" && candidate !== null) : null;
5552
+ const streamDuration = stream ? parsePositiveDuration(Reflect.get(stream, "duration")) : null;
5553
+ if (streamDuration !== null) return streamDuration;
5554
+ const format = Reflect.get(metadata, "format");
5555
+ return typeof format === "object" && format !== null ? parsePositiveDuration(Reflect.get(format, "duration")) : null;
5556
+ } catch {
5557
+ return null;
5558
+ }
5559
+ }
5560
+ async function probePlayableVideoDuration(ffprobePath, filePath) {
5561
+ try {
5562
+ return readPlayableVideoDuration(
5563
+ await execFileAsync(ffprobePath, [
5564
+ "-v",
5565
+ "error",
5566
+ "-select_streams",
5567
+ "v:0",
5568
+ "-show_entries",
5569
+ "stream=duration:format=duration",
5570
+ "-of",
5571
+ "json",
5572
+ "--",
5573
+ filePath
5574
+ ])
5575
+ );
5576
+ } catch {
5577
+ return null;
5578
+ }
5579
+ }
5580
+ async function lintVideoMediaStartPastEof(projectDir, htmlSources) {
5581
+ const slotsByPath = collectLocalFiniteVideoSlots(projectDir, htmlSources);
5582
+ if (slotsByPath.size === 0) return [];
5583
+ const ffprobePath = findFfBinary("ffprobe", { configuredMustExist: true });
5584
+ if (!ffprobePath) return [];
5585
+ const entries = [...slotsByPath.entries()];
5586
+ const durations = await mapWithProbeConcurrency(
5587
+ entries,
5588
+ (entry) => probePlayableVideoDuration(ffprobePath, entry[0])
5589
+ );
5590
+ const findings = [];
5591
+ for (const [index, [, slots]] of entries.entries()) {
5592
+ const sourceDuration = durations[index];
5593
+ if (sourceDuration == null) continue;
5594
+ for (const slot of slots) {
5595
+ if (slot.mediaStart < sourceDuration) continue;
5596
+ findings.push({
5597
+ code: "video_media_start_at_or_past_eof",
5598
+ severity: "warning",
5599
+ message: `Video "${slot.src}" starts at ${slot.mediaStart}s, at or past its ${sourceDuration}s playable source duration. The video will hold its final frame for the explicit slot.`,
5600
+ file: slot.file,
5601
+ ...slot.elementId ? { elementId: slot.elementId } : {},
5602
+ fixHint: `Trim data-media-start below ${sourceDuration}s if this final-frame hold is unintended.`
5603
+ });
5604
+ }
5605
+ }
5606
+ return findings;
5607
+ }
5466
5608
  async function lintHevcPreviewCodec(candidates) {
5467
5609
  if (candidates.size === 0) return [];
5468
5610
  const ffprobePath = findFfBinary("ffprobe", { configuredMustExist: true });
5469
5611
  if (!ffprobePath) return [];
5470
5612
  const entries = [...candidates.entries()];
5471
- const isHevc = new Array(entries.length).fill(false);
5472
- let nextIndex = 0;
5473
- const workerCount = Math.min(PROBE_CONCURRENCY, entries.length);
5474
- await Promise.all(
5475
- Array.from({ length: workerCount }, async () => {
5476
- while (nextIndex < entries.length) {
5477
- const index = nextIndex++;
5478
- const entry = entries[index];
5479
- if (!entry) break;
5480
- isHevc[index] = await probeIsHevc(ffprobePath, entry[0]);
5481
- }
5482
- })
5613
+ const isHevc = await mapWithProbeConcurrency(
5614
+ entries,
5615
+ (entry) => probeIsHevc(ffprobePath, entry[0])
5483
5616
  );
5484
5617
  const hevcSrcs = entries.filter((_, i) => isHevc[i]).map(([, src]) => src);
5485
5618
  if (hevcSrcs.length === 0) return [];
@@ -5496,7 +5629,7 @@ async function lintHevcPreviewCodec(candidates) {
5496
5629
 
5497
5630
  // src/project.ts
5498
5631
  function parseSubCompHtml(html) {
5499
- return parseHTML2(html).document;
5632
+ return parseHTML3(html).document;
5500
5633
  }
5501
5634
  function querySelectorAllIncludingTemplates(root, selector) {
5502
5635
  const matches = [...root.querySelectorAll(selector)];
@@ -5534,7 +5667,7 @@ function collectLocalStylesheets(projectDir, document, compSrcPath) {
5534
5667
  }
5535
5668
  function collectExternalStyles(projectDir, html, compSrcPath) {
5536
5669
  const styles = [];
5537
- const { document } = parseHTML2(html);
5670
+ const { document } = parseHTML3(html);
5538
5671
  for (const { href, content } of collectLocalStylesheets(projectDir, document, compSrcPath)) {
5539
5672
  styles.push({ href, content });
5540
5673
  }
@@ -5542,7 +5675,7 @@ function collectExternalStyles(projectDir, html, compSrcPath) {
5542
5675
  }
5543
5676
  function collectCssSources(projectDir, html, compSrcPath) {
5544
5677
  const sources = [];
5545
- const { document } = parseHTML2(html);
5678
+ const { document } = parseHTML3(html);
5546
5679
  for (const style of querySelectorAllIncludingTemplates(document, "style")) {
5547
5680
  sources.push({ content: style.textContent ?? "" });
5548
5681
  }
@@ -5640,6 +5773,7 @@ async function lintProject(projectDir, entryFile) {
5640
5773
  ...!entryFile ? lintBlankRootWithStandaloneComposition(rootHtml, allHtmlSources) : [],
5641
5774
  ...lintDuplicateAudioTracks(allHtmlSources),
5642
5775
  ...lintMissingOrEmptySubComposition(projectDir, rootHtml),
5776
+ ...await lintVideoMediaStartPastEof(projectDir, allHtmlSources),
5643
5777
  ...await lintHevcPreviewCodec(collectLocalVideoCandidates(projectDir, allHtmlSources))
5644
5778
  ];
5645
5779
  if (projectFindings.length > 0) {
@@ -5661,7 +5795,7 @@ async function lintProject(projectDir, entryFile) {
5661
5795
  return { results, totalErrors, totalWarnings, totalInfos };
5662
5796
  }
5663
5797
  function lintBlankRootWithStandaloneComposition(rootHtml, htmlSources) {
5664
- const { document: rootDocument } = parseHTML2(rootHtml);
5798
+ const { document: rootDocument } = parseHTML3(rootHtml);
5665
5799
  const root = rootDocument.querySelector("body [data-composition-id]");
5666
5800
  if (!root || root.querySelector("*:not(script):not(style):not(link):not(meta):not(template)")) {
5667
5801
  return [];
@@ -5669,7 +5803,7 @@ function lintBlankRootWithStandaloneComposition(rootHtml, htmlSources) {
5669
5803
  const standaloneCandidates = [];
5670
5804
  for (const source of htmlSources) {
5671
5805
  if (!source.compSrcPath) continue;
5672
- const { document } = parseHTML2(source.html);
5806
+ const { document } = parseHTML3(source.html);
5673
5807
  const composition = document.querySelector("body [data-composition-id]");
5674
5808
  if (!composition) continue;
5675
5809
  const authoredTimedContent = Array.from(