@hyperframes/lint 0.8.106 → 0.8.108

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
@@ -113,6 +113,28 @@ function readAttr(tagSource, attr) {
113
113
  const match = tagSource.match(new RegExp(`(?<![\\w-])${escaped}\\s*=\\s*["']([^"']+)["']`, "i"));
114
114
  return match?.[1] || null;
115
115
  }
116
+ function hasAttrName(tagSource, attr) {
117
+ const escaped = attr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
118
+ const attrs = tagSource.replace(/^<\s*[a-z][\w:-]*/i, "");
119
+ return new RegExp(`(?:^|\\s)${escaped}(?:\\s*=|\\s|/?>)`, "i").test(attrs);
120
+ }
121
+ function isAudibleVideoTag(tagSource) {
122
+ if (hasAttrName(tagSource, "muted")) return false;
123
+ if (!hasAttrName(tagSource, "data-has-audio")) return true;
124
+ const declared = readAttr(tagSource, "data-has-audio");
125
+ return declared === "true";
126
+ }
127
+ function mediaTimeWindow(tagSource) {
128
+ const start = Number(readAttr(tagSource, "data-start"));
129
+ const duration = Number(readAttr(tagSource, "data-duration"));
130
+ if (!readAttr(tagSource, "data-start") || !readAttr(tagSource, "data-duration")) return null;
131
+ if (!Number.isFinite(start) || !Number.isFinite(duration)) return null;
132
+ return { start, end: start + duration };
133
+ }
134
+ function mediaWindowsOverlap(a, b) {
135
+ if (!a || !b) return true;
136
+ return a.start < b.end && b.start < a.end;
137
+ }
116
138
  function readDecodedAttr(tagSource, attr) {
117
139
  if (!tagSource) return null;
118
140
  let value = null;
@@ -1370,11 +1392,6 @@ function tweensVolumeInSameCall(script, id) {
1370
1392
  function escapeRegExp(value) {
1371
1393
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1372
1394
  }
1373
- function hasAttrName(tagSource, attr) {
1374
- const escaped = escapeRegExp(attr);
1375
- const attrs = tagSource.replace(/^<\s*[a-z][\w:-]*/i, "");
1376
- return new RegExp(`(?:^|\\s)${escaped}(?:\\s*=|\\s|/?>)`, "i").test(attrs);
1377
- }
1378
1395
  var IMAGE_SRC_EXT = /* @__PURE__ */ new Set([
1379
1396
  "jpg",
1380
1397
  "jpeg",
@@ -1775,14 +1792,15 @@ var mediaRules = [
1775
1792
  if (tag.name !== "video") continue;
1776
1793
  const hasMuted = hasAttrName(tag.raw, "muted");
1777
1794
  const hasDeclaredAudio = readAttr(tag.raw, "data-has-audio") === "true";
1778
- if (!hasMuted && !hasDeclaredAudio && readAttr(tag.raw, "data-start")) {
1795
+ const declaresAudioState = hasAttrName(tag.raw, "data-has-audio");
1796
+ if (!hasMuted && !declaresAudioState && readAttr(tag.raw, "data-start")) {
1779
1797
  const elementId = readAttr(tag.raw, "id") || void 0;
1780
1798
  findings.push({
1781
1799
  code: "video_missing_muted",
1782
1800
  severity: "error",
1783
- message: `<video${elementId ? ` id="${elementId}"` : ""}> has data-start but is not muted. Mark audible videos with data-has-audio="true"; otherwise keep video muted and use a separate <audio> element for sound.`,
1801
+ message: `<video${elementId ? ` id="${elementId}"` : ""}> has data-start but declares neither muted nor data-has-audio. If the file has sound, add data-has-audio="true" (the sound stays on this clip). If it is silent, add muted.`,
1784
1802
  elementId,
1785
- fixHint: 'Add the `muted` attribute for silent video, or add data-has-audio="true" when the video track should contribute audio.',
1803
+ fixHint: 'Add data-has-audio="true" when the file has sound (recommended \u2014 keeps picture and sound on one clip), or add `muted` for silent footage.',
1786
1804
  snippet: truncateSnippet(tag.raw)
1787
1805
  });
1788
1806
  }
@@ -2008,31 +2026,31 @@ var mediaRules = [
2008
2026
  // <audio> pointing to the same file, which causes double playback at runtime
2009
2027
  ({ tags }) => {
2010
2028
  const findings = [];
2011
- const videoSources = /* @__PURE__ */ new Map();
2012
- const audioSources = /* @__PURE__ */ new Map();
2029
+ const videos = [];
2030
+ const audios = [];
2013
2031
  for (const tag of tags) {
2014
2032
  if (!readAttr(tag.raw, "data-start")) continue;
2015
2033
  const src = readAttr(tag.raw, "src");
2016
2034
  if (!src) continue;
2017
- const elementId = readAttr(tag.raw, "id") || void 0;
2035
+ const source = { id: readAttr(tag.raw, "id") || void 0, raw: tag.raw };
2018
2036
  if (tag.name === "video") {
2019
- const isMuted = hasAttrName(tag.raw, "muted");
2020
- if (!isMuted) {
2021
- videoSources.set(src, { id: elementId, raw: tag.raw });
2022
- }
2037
+ if (isAudibleVideoTag(tag.raw)) videos.push([src, source]);
2023
2038
  } else if (tag.name === "audio") {
2024
- audioSources.set(src, { id: elementId, raw: tag.raw });
2039
+ audios.push([src, source]);
2025
2040
  }
2026
2041
  }
2027
- for (const [src, audioInfo] of audioSources) {
2028
- const videoInfo = videoSources.get(src);
2029
- if (!videoInfo) continue;
2042
+ for (const [src, audioInfo] of audios) {
2043
+ const match = videos.find(
2044
+ ([videoSrc, video]) => videoSrc === src && mediaWindowsOverlap(mediaTimeWindow(video.raw), mediaTimeWindow(audioInfo.raw))
2045
+ );
2046
+ if (!match) continue;
2047
+ const videoInfo = match[1];
2030
2048
  findings.push({
2031
2049
  code: "video_audio_double_source",
2032
2050
  severity: "error",
2033
- message: `<audio${audioInfo.id ? ` id="${audioInfo.id}"` : ""}> and <video${videoInfo.id ? ` id="${videoInfo.id}"` : ""}> both point to the same source. The unmuted video already provides audio \u2014 the duplicate <audio> will cause double playback and echo.`,
2051
+ message: `<audio${audioInfo.id ? ` id="${audioInfo.id}"` : ""}> and <video${videoInfo.id ? ` id="${videoInfo.id}"` : ""}> both point to the same source at the same time. The unmuted video already provides audio \u2014 the duplicate <audio> will cause double playback and echo.`,
2034
2052
  elementId: audioInfo.id,
2035
- fixHint: "Either mute the video (add `muted` attribute) and keep the separate <audio>, or remove the <audio> element and let the video provide its own audio track.",
2053
+ fixHint: "Remove the <audio> element and let the video carry its own sound (recommended), or mute the video (add `muted`) and keep the separate <audio> when picture and sound must be cut independently.",
2036
2054
  snippet: truncateSnippet(audioInfo.raw)
2037
2055
  });
2038
2056
  }
@@ -2141,7 +2159,7 @@ function findCarveUngroupedSourcesFindings(ctx) {
2141
2159
  var AUDIO_GROUP_TIMING_ATTRS = ["data-start", "data-duration", "data-track-index"];
2142
2160
  function findAudioGroupNoMembersFindings(ctx) {
2143
2161
  const memberGroupIds = new Set(
2144
- ctx.tags.filter((tag) => tag.name === "audio").map((tag) => readAttr(tag.raw, "data-audio-group")).filter((id) => Boolean(id))
2162
+ ctx.tags.filter((tag) => tag.name === "audio" || tag.name === "video" && isAudibleVideoTag(tag.raw)).map((tag) => readAttr(tag.raw, "data-audio-group")).filter((id) => Boolean(id))
2145
2163
  );
2146
2164
  if (memberGroupIds.size === 0) return [];
2147
2165
  const mayHaveCrossFileMembers = ctx.tags.some(
@@ -6616,7 +6634,10 @@ function lintProjectAudioFiles(projectDir, htmlSources) {
6616
6634
  }
6617
6635
  if (audioFiles.length === 0) return findings;
6618
6636
  const hasAudioElement = htmlSources.some(({ html }) => /<audio\b/i.test(html));
6619
- if (!hasAudioElement) {
6637
+ const hasAudibleVideo = htmlSources.some(
6638
+ ({ html }) => (html.match(/<video\b[^>]*>/gi) ?? []).some(isAudibleVideoTag)
6639
+ );
6640
+ if (!hasAudioElement && !hasAudibleVideo) {
6620
6641
  findings.push({
6621
6642
  code: "audio_file_without_element",
6622
6643
  severity: "warning",
@@ -6766,10 +6787,11 @@ function lintDuplicateAudioTracks(htmlSources) {
6766
6787
  const tracks = [];
6767
6788
  const seen = /* @__PURE__ */ new Set();
6768
6789
  for (const { html } of htmlSources) {
6769
- const audioTagRe = /<audio\b[^>]*>/gi;
6790
+ const soundTagRe = /<(?:audio|video)\b[^>]*>/gi;
6770
6791
  let match;
6771
- while ((match = audioTagRe.exec(html)) !== null) {
6792
+ while ((match = soundTagRe.exec(html)) !== null) {
6772
6793
  const tag = match[0];
6794
+ if (/^<video/i.test(tag) && !isAudibleVideoTag(tag)) continue;
6773
6795
  const trackStr = extractAttr(tag, "data-track-index");
6774
6796
  const startStr = extractAttr(tag, "data-start");
6775
6797
  const durStr = extractAttr(tag, "data-duration");
@@ -6793,7 +6815,7 @@ function lintDuplicateAudioTracks(htmlSources) {
6793
6815
  findings.push({
6794
6816
  code: "duplicate_audio_track",
6795
6817
  severity: "warning",
6796
- message: `Multiple <audio> elements on track ${a.trackIndex} overlap (${a.src} at ${a.start}-${Number.isFinite(a.end) ? a.end.toFixed(1) : "end"}s, ${b.src} at ${b.start}-${Number.isFinite(b.end) ? b.end.toFixed(1) : "end"}s). This causes layered audio playback.`,
6818
+ message: `Multiple audible <audio>/<video> elements on track ${a.trackIndex} overlap (${a.src} at ${a.start}-${Number.isFinite(a.end) ? a.end.toFixed(1) : "end"}s, ${b.src} at ${b.start}-${Number.isFinite(b.end) ? b.end.toFixed(1) : "end"}s). This causes layered audio playback.`,
6797
6819
  fixHint: "Use non-overlapping time windows or different track indices."
6798
6820
  });
6799
6821
  }